[
  {
    "path": ".claude/rules/content-scripts.md",
    "content": "---\nglobs: [\"src/pages/content/**\", \"public/contentStyle.css\"]\n---\n\n# Content Script Rules\n\n## CSS\n- All injected CSS classes MUST be prefixed with `gv-` (e.g., `.gv-rtl`, `.gv-pm-trigger`)\n- Styles go in `public/contentStyle.css`\n- Support both light and dark themes: use `.theme-host.light-theme` / `.theme-host.dark-theme` overrides, NOT `@media (prefers-color-scheme)`\n- RTL layout: use `body.gv-rtl` selector for RTL overrides (see `src/core/utils/rtl.ts`)\n\n## Storage\n- Content scripts use `chrome.storage` / `browser.storage` directly via ExtGlobal — this is the exception to the \"use StorageService\" rule\n- StorageService is for popup/background/options contexts\n\n## DOM Injection\n- Each content script sub-module in `src/pages/content/` is self-contained\n- Bridge between Gemini UI and Extension — Gemini DOM structure may change without notice\n- Safari has limitations: cloud sync, watermark removal, image export are disabled. Check `isSafari()` guards.\n- Extension context can be invalidated after update/reload. Use `isExtensionContextInvalidatedError()`.\n\n## Material Symbols Icons\n- When adding new icons in popup, add the icon name to `icon_names=` in the Google Fonts URL in `src/pages/popup/index.html`\n"
  },
  {
    "path": ".claude/rules/high-complexity.md",
    "content": "---\nglobs: [\"src/core/services/StorageService.ts\", \"src/core/services/DataBackupService.ts\", \"src/core/services/GoogleDriveSyncService.ts\", \"src/core/services/AccountIsolationService.ts\", \"src/features/folder/**\", \"src/features/export/**\"]\n---\n\n# High-Complexity Modules — Edit with Caution\n\n| Module | Risk | Notes |\n|--------|------|-------|\n| `StorageService` | Sync/local/session logic + migration. Single source of truth for persistence. | Do not modify lightly. |\n| `DataBackupService` | Multi-layer backup. Race conditions during unload. | Critical for data safety. |\n| `GoogleDriveSyncService` | OAuth2 cloud sync (folders, prompts, starred). | Requires OAuth2 identity. |\n| `AccountIsolationService` | Hard account isolation for multi-account. | Integrates with Drive sync. |\n| `features/folder` | Drag-and-drop + cloud sync UI. DOM manipulation + state sync. | Watch for infinite loops. |\n| `features/export` | JSON/MD/PDF/Image export + Deep Research. | Fragile to Gemini UI changes. Multi-browser compat. |\n\n## Before modifying these modules\n1. Read the entire file first — not just the section you plan to change\n2. List all existing features that might be affected\n3. Ensure zero destructiveness to user data\n4. Run full test suite after changes\n"
  },
  {
    "path": ".claude/rules/i18n.md",
    "content": "---\nglobs: [\"src/locales/**\"]\n---\n\n# i18n / Translation Rules\n\n## 10 Locales (ALL must be updated together)\n`en`, `ar`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`, `zh`, `zh_TW`\n\n## When adding a new translation key\n1. Add the key to ALL 10 `src/locales/*/messages.json` files\n2. English (`en`) is the source — write it first, then translate to all others\n3. Keys are flat strings in JSON, no nesting\n\n## When removing a translation key\n- Remove from ALL 10 locale files\n\n## Quality\n- Translations should be natural, not machine-literal\n- Arabic (`ar`) is RTL — ensure UI handles it (see `src/core/utils/rtl.ts`)\n"
  },
  {
    "path": ".claude/rules/typescript.md",
    "content": "---\nglobs: [\"src/**/*.ts\", \"src/**/*.tsx\"]\n---\n\n# TypeScript Coding Standards\n\n## DOs\n- Prefer plain objects with interfaces/types for data structures\n- Use `map`, `filter`, `reduce` for immutability\n- Use `private`/`protected` in classes\n- Use `unknown` + narrowing (Zod or custom guards) for type safety\n- Use named exports: `export function X`\n- Functional React: hooks at top level, strictly functional components\n\n## DON'Ts\n- **No `any` type.** Use `unknown` if you must, then narrow it.\n- **No global variables** outside defined Services.\n- **No `chrome.storage` in UI components** (`src/components/`, `src/pages/popup/`). Use `StorageService`.\n- **No God Components.** Business logic belongs in `features/*/services/` or custom hooks, not UI files.\n- **No magic strings.** Use constants or enums (StorageKeys, CSS classes).\n- **No `console.log` in production.** Use `LoggerService` for critical info.\n\n## Testing (Vitest + jsdom)\n- Chrome `chrome` object is globally mocked in `src/tests/setup.ts`\n- Mock specific storage: `(chrome.storage.sync.get as any).mockResolvedValue({ key: 'val' })`\n- Run: `bun run test`, `bun run test <filename>`, `bun run test:coverage`\n"
  },
  {
    "path": ".claude/settings.json",
    "content": "{\n  \"hooks\": {\n    \"PostToolUse\": [\n      {\n        \"matcher\": \"Task\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"entire hooks claude-code post-task\"\n          }\n        ]\n      },\n      {\n        \"matcher\": \"TodoWrite\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"entire hooks claude-code post-todo\"\n          }\n        ]\n      }\n    ],\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Task\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"entire hooks claude-code pre-task\"\n          }\n        ]\n      }\n    ],\n    \"SessionEnd\": [\n      {\n        \"matcher\": \"\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"entire hooks claude-code session-end\"\n          }\n        ]\n      }\n    ],\n    \"SessionStart\": [\n      {\n        \"matcher\": \"\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"entire hooks claude-code session-start\"\n          }\n        ]\n      }\n    ],\n    \"Stop\": [\n      {\n        \"matcher\": \"\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"entire hooks claude-code stop\"\n          }\n        ]\n      }\n    ],\n    \"UserPromptSubmit\": [\n      {\n        \"matcher\": \"\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"entire hooks claude-code user-prompt-submit\"\n          }\n        ]\n      }\n    ]\n  },\n  \"permissions\": {\n    \"deny\": [\n      \"Read(./.entire/metadata/**)\"\n    ]\n  }\n}\n"
  },
  {
    "path": ".claude/skills/safari-release/SKILL.md",
    "content": "---\nname: safari-release\ndescription: Build Safari extension with update check enabled, guide user through Xcode export, and create DMG for distribution. Use when user wants to release a new Safari version or create a Safari DMG.\nuser-invocable: true\n---\n\n# Safari Release Workflow\n\nBuild the Safari extension for manual distribution and create a signed DMG.\n\n## Steps\n\n### 1. Read version from package.json\n\nRead `package.json` to get the current version number. Store it as `VERSION` for later steps.\n\n### 2. Build Safari with update check enabled\n\nRun the following command:\n\n```bash\nENABLE_SAFARI_UPDATE_CHECK=true bun run build:safari\n```\n\nWait for the build to complete successfully. If it fails, report the error and stop.\n\n### 3. Prompt user for Xcode export\n\nTell the user:\n\n> Safari build complete (`dist_safari/`). Please complete the following steps in Xcode:\n>\n> 1. Open the Xcode project (if not already open)\n> 2. **Product → Archive**\n> 3. **Window → Organizer** → select the archive → **Distribute App**\n> 4. Export the signed `Gemini Voyager.app` into `safari/Models/dmg_source/`\n>\n> Let me know when you're done exporting.\n\n**Wait for the user to confirm** before proceeding. Do NOT continue until the user says they're done.\n\n### 4. Verify the exported app exists\n\nCheck that `safari/Models/dmg_source/Gemini Voyager.app` exists:\n\n```bash\nls \"safari/Models/dmg_source/Gemini Voyager.app\"\n```\n\nIf it doesn't exist, ask the user to check their export path.\n\n### 5. Create DMG\n\nRun `create-dmg` in the `safari/Models` directory:\n\n```bash\ncd safari/Models && create-dmg \\\n  --volname \"Gemini Voyager\" \\\n  --window-size 600 400 \\\n  --icon-size 100 \\\n  --icon \"Gemini Voyager.app\" 175 190 \\\n  --app-drop-link 425 190 \\\n  \"voyager-v${VERSION}.dmg\" \\\n  dmg_source\n```\n\n### 6. Verify and report\n\nConfirm the DMG was created at `safari/Models/voyager-v${VERSION}.dmg` and report success to the user.\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\ntrim_trailing_whitespace = true\n\n[*.md]\ntrim_trailing_whitespace = false\n"
  },
  {
    "path": ".entire/.gitignore",
    "content": "tmp/\nsettings.local.json\nmetadata/\nlogs/\n"
  },
  {
    "path": ".entire/settings.json",
    "content": "{\n  \"strategy\": \"manual-commit\",\n  \"enabled\": true,\n  \"telemetry\": false\n}\n"
  },
  {
    "path": ".gitattributes",
    "content": "# Auto detect text files and normalize line endings to LF\n* text=auto eol=lf\n\n# Explicitly declare file types\n*.ts text eol=lf\n*.tsx text eol=lf\n*.js text eol=lf\n*.jsx text eol=lf\n*.json text eol=lf\n*.md text eol=lf\n*.css text eol=lf\n*.html text eol=lf\n*.yml text eol=lf\n*.yaml text eol=lf\n\n# Binary files (don't modify)\n*.png binary\n*.jpg binary\n*.jpeg binary\n*.gif binary\n*.ico binary\n*.woff binary\n*.woff2 binary\n*.ttf binary\n*.eot binary\nbun.lockb binary\n"
  },
  {
    "path": ".github/CONTRIBUTING.md",
    "content": "> **🌐 语言 / Language**: [中文](#贡献指南) | [English](#contributing-to-gemini-voyager) | [Español](CONTRIBUTING_ES.md) | [Français](CONTRIBUTING_FR.md) | [日本語](CONTRIBUTING_JA.md)\n\n---\n\n# 贡献指南\n\n> [!CAUTION]\n> **本项目暂时不接受任何新功能的 PR。** 如果你有一个很想做的功能，请按以下流程操作：\n>\n> 1. **先开一个 Issue 与维护者讨论**你的想法和方案\n> 2. **等待维护者同意，并确定了一个好的实现方案后**，再开始编码并提交 PR\n>\n> 未经讨论直接提交的新功能 PR 将被直接关闭，不予审核。感谢理解。\n\n> [!IMPORTANT]\n> **项目状态：低频维护。** 回复较慢。优先处理带测试的 PR。\n\n感谢你考虑为 Voyager 做出贡献！🚀\n\n本文档提供贡献的指南和说明。我们欢迎错误修复、文档改进和翻译等贡献。关于新功能，请务必先通过 Issue 进行讨论。\n\n## 🚫 AI 政策\n\n**本项目拒绝接受任何未经人工复核的 AI 生成的 PR。**\n\n虽然 AI 是很好的辅助工具，但“懒惰”的复制粘贴贡献会浪费维护者的时间。\n\n- **缺乏逻辑解释** 或缺少必要测试的 PR 将将被拒绝。\n- 你必须理解并对你提交的每一行代码负责。\n- **Git 协作能力**：你应熟悉 GitHub 和 Git 的基本工作流，确保能在 AI Agent 的辅助下正确进行开源协作。如果你对此尚不熟悉，建议先学习相关知识，请保持 PR 中的 Git 历史整洁，避免出现混乱的提交记录。\n\n## 目录\n\n- [快速开始](#快速开始)\n- [认领 Issue](#认领-issue)\n- [开发环境设置](#开发环境设置)\n- [进行更改](#进行更改)\n- [提交 Pull Request](#提交-pull-request)\n- [代码风格](#代码风格)\n- [添加 Gem 支持](#添加-gem-支持)\n- [许可证](#许可证)\n\n---\n\n## 快速开始\n\n### 前置要求\n\n- **Bun** 1.0+（必需）\n- 用于测试的 Chromium 内核浏览器（Chrome、Edge、Brave 等）\n- **Firefox：必须进行测试。**\n- **Safari：作为可选项目**。如果有环境请进行测试；或者由 AI/自行判断该功能是否为 Safari 不支持的功能，并请予以标注。\n\n### 快速启动\n\n```bash\n# 克隆仓库\ngit clone https://github.com/Nagi-ovo/gemini-voyager.git\ncd gemini-voyager\n\n# 安装依赖\nbun install\n\n# 启动开发模式\nbun run dev\n```\n\n---\n\n## 认领 Issue\n\n为避免重复工作并协调贡献：\n\n### 1. 检查现有工作\n\n在开始之前，检查 issue 的 **Assignees** 部分，确认是否已有人被分配。\n\n### 2. 认领 Issue\n\n在任何未分配的 issue 上评论 `/claim`，机器人将自动将你分配为负责人。\n\n### 3. 取消认领\n\n如果你无法继续处理某个 issue，评论 `/unclaim` 即可释放它供他人处理。\n\n### 4. 贡献意愿复选框\n\n创建 issue 时，你可以勾选\"我愿意贡献代码\"复选框，表明你有兴趣实现该功能或修复。\n\n---\n\n## 开发环境设置\n\n### 安装依赖\n\n```bash\nbun install\n```\n\n### 可用命令\n\n| 命令                  | 描述                             |\n| --------------------- | -------------------------------- |\n| `bun run dev`         | 启动 Chrome 开发模式（热重载）   |\n| `bun run dev:firefox` | 启动 Firefox 开发模式            |\n| `bun run dev:safari`  | 启动 Safari 开发模式（仅 macOS） |\n| `bun run build`       | Chrome 生产构建                  |\n| `bun run build:all`   | 所有浏览器生产构建               |\n| `bun run lint`        | 运行 ESLint 并自动修复           |\n| `bun run typecheck`   | 运行 TypeScript 类型检查         |\n| `bun run test`        | 运行测试套件                     |\n\n### 加载扩展\n\n1. 运行 `bun run dev` 启动开发构建\n2. 打开 Chrome，访问 `chrome://extensions/`\n3. 启用\"开发者模式\"\n4. 点击\"加载已解压的扩展程序\"，选择 `dist_chrome` 文件夹\n\n---\n\n## 进行更改\n\n### 开始之前\n\n1. **从 `main` 创建分支**：\n\n   ```bash\n   git checkout -b feature/your-feature-name\n   # 或\n   git checkout -b fix/your-bug-fix\n   ```\n\n2. **关联 Issue** - 在实现一个新功能时，请**务必先开启一个 Issue 进行讨论**。未经讨论直接提交的新功能 PR 将被关闭。在提交 PR 时，请链接该 Issue。\n\n### 提交前检查清单\n\n提交前，请务必运行：\n\n```bash\nbun run lint       # 修复代码风格问题\nbun run format     # 格式化代码\nbun run typecheck  # 检查类型\nbun run build      # 验证构建成功\nbun run test       # 运行测试\n```\n\n并确保：\n\n1. 你的更改实现了预期功能。\n2. 你的更改没有影响现有的原有功能。\n\n---\n\n## 测试策略\n\n我们遵循“基于 ROI”的测试策略：**测逻辑，不测 DOM。**\n\n1. **必须测 (Logic)**：核心服务（Storage, Backup）、数据解析和工具函数。必须使用 TDD。\n2. **建议测 (State)**：复杂的 UI 状态（如文件夹 Reducer）。\n3. **跳过 (Fragile)**：直接 DOM 操作（Content Scripts）和纯 UI 组件。请使用防御性编程代替。\n\n---\n\n## 提交 Pull Request\n\n### PR 指南\n\n1. **标题**：使用清晰的描述性标题（如 \"feat: add dark mode toggle\" 或 \"fix: timeline scroll sync\"）\n2. **描述**：解释你做了什么更改以及原因\n3. **用户影响**：描述用户将如何受到影响\n4. **可视化证据（严格）**：对于任何 UI 修改或新功能，**必须**提供截图或屏幕录制。**没有截图 = 不予审核/回复。**\n5. **Issue 引用**：链接相关 issue（如 \"Closes #123\"）\n6. **测试与逻辑**：PR 必须包含单元测试并清晰解释修改逻辑。不接受没有上下文的“魔法”修复。\n\n### 提交信息格式\n\n遵循 [Conventional Commits](https://www.conventionalcommits.org/)：\n\n- `feat:` - 新功能\n- `fix:` - 错误修复\n- `docs:` - 文档更改\n- `chore:` - 维护任务\n- `refactor:` - 代码重构\n- `test:` - 添加或更新测试\n\n---\n\n## 代码风格\n\n### 通用指南\n\n- **优先使用提前返回**而非嵌套条件\n- **使用描述性名称** - 避免缩写\n- **避免魔法数字** - 使用命名常量\n- **匹配现有风格** - 一致性优于偏好\n\n### TypeScript 约定\n\n- **PascalCase**：类、接口、类型、枚举、React 组件\n- **camelCase**：函数、变量、方法\n- **UPPER_SNAKE_CASE**：常量\n\n### 导入顺序\n\n1. React 及相关导入\n2. 第三方库\n3. 内部绝对导入（`@/...`）\n4. 相对导入（`./...`）\n5. 仅类型导入\n\n---\n\n## 添加 Gem 支持\n\n如需为新 Gem（官方 Google Gems 或自定义 Gems）添加支持：\n\n1. 打开 `src/pages/content/folder/gemConfig.ts`\n2. 在 `GEM_CONFIG` 数组中添加新条目：\n\n```typescript\n{\n  id: 'your-gem-id',           // URL 中的 ID：/gem/your-gem-id/...\n  name: 'Your Gem Name',       // 显示名称\n  icon: 'material_icon_name',  // Google Material Symbols 图标\n}\n```\n\n### 查找 Gem ID\n\n- 打开与该 Gem 的对话\n- 检查 URL：`https://gemini.google.com/app/gem/[GEM_ID]/...`\n- 在配置中使用 `[GEM_ID]` 部分\n\n### 选择图标\n\n使用有效的 [Google Material Symbols](https://fonts.google.com/icons) 图标名称：\n\n| 图标           | 用途           |\n| -------------- | -------------- |\n| `auto_stories` | 学习、教育     |\n| `lightbulb`    | 创意、头脑风暴 |\n| `work`         | 职业、专业     |\n| `code`         | 编程、技术     |\n| `analytics`    | 数据、分析     |\n\n---\n\n## 项目范围\n\nVoyager 通过以下功能增强 Gemini AI 聊天体验：\n\n- 时间线导航\n- 文件夹组织\n- 指令宝库\n- 聊天导出\n- UI 自定义\n\n**不在范围内**：网站爬取、网络拦截、账户自动化。\n\n---\n\n## 获取帮助\n\n- 💬 [GitHub Discussions](https://github.com/Nagi-ovo/gemini-voyager/discussions) - 提问\n- 🐛 [Issues](https://github.com/Nagi-ovo/gemini-voyager/issues) - 报告错误\n- 📖 [文档](https://gemini-voyager.vercel.app/) - 阅读文档\n\n---\n\n## 许可证\n\n提交贡献即表示你同意你的贡献将采用 [GPLv3 许可证](../LICENSE)。\n\n---\n\n# Contributing to Voyager\n\n> [!CAUTION]\n> **This project is currently NOT accepting PRs for new features.** If you have a feature you'd really like to build, please follow this process:\n>\n> 1. **Open an Issue first** to discuss your idea and approach with the maintainer\n> 2. **Wait for approval and a solid implementation plan** before writing any code or submitting a PR\n>\n> New feature PRs submitted without prior discussion will be closed without review. Thank you for understanding.\n\n> [!IMPORTANT]\n> **Project Status: Low Maintenance.** Expect delays in response. PRs with tests are prioritized.\n\nThank you for considering contributing to Voyager! 🚀\n\nThis document provides guidelines and instructions for contributing. We welcome bug fixes, documentation improvements, and translations. For new features, please discuss via an Issue first.\n\n## 🚫 AI Policy\n\n**We explicitly reject AI-generated PRs that have not been manually verified.**\n\nWhile AI tools are great assistants, \"lazy\" copy-paste contributions waste maintainer time.\n\n- **Low-quality AI PRs** will be closed immediately without discussion.\n- **PRs without explanation** of the logic or missing necessary tests will be rejected.\n- You must understand and take responsibility for every line of code you submit.\n- **Workflow Proficiency**: You should be familiar with GitHub and Git workflows and able to collaborate correctly using AI tools. If you are new to this, please learn the basics first to ensure a clean Git history in your PRs.\n\n## Table of Contents\n\n- [Getting Started](#getting-started)\n- [Claiming an Issue](#claiming-an-issue)\n- [Development Setup](#development-setup)\n- [Making Changes](#making-changes)\n- [Submitting a Pull Request](#submitting-a-pull-request)\n- [Code Style](#code-style)\n- [Adding Gem Support](#adding-gem-support)\n- [License](#license)\n\n---\n\n## Getting Started\n\n### Prerequisites\n\n- **Bun** 1.0+ (Required)\n- A Chromium-based browser for testing (Chrome, Edge, Brave, etc.)\n- **Firefox: Mandatory testing.**\n- **Safari: Optional.** If you have the environment, please test it. Alternatively, let AI or use your own judgment to determine if the feature is unsupported on Safari and label it accordingly.\n\n### Quick Start\n\n```bash\n# Clone the repository\ngit clone https://github.com/Nagi-ovo/gemini-voyager.git\ncd gemini-voyager\n\n# Install dependencies\nbun install\n\n# Start development mode\nbun run dev\n```\n\n---\n\n## Claiming an Issue\n\nTo avoid duplicate work and coordinate contributions:\n\n### 1. Check for Existing Work\n\nBefore starting, check if the issue is already assigned to someone by looking at the **Assignees** section.\n\n### 2. Claim an Issue\n\nComment `/claim` on any unassigned issue to automatically assign yourself. A bot will confirm the assignment.\n\n### 3. Unclaim if Needed\n\nIf you can no longer work on an issue, comment `/unclaim` to release it for others.\n\n### 4. Contribution Checkbox\n\nWhen creating issues, you can check the \"I am willing to contribute code\" checkbox to indicate your interest in implementing the feature or fix.\n\n---\n\n## Development Setup\n\n### Install Dependencies\n\n```bash\nbun install\n```\n\n### Available Commands\n\n| Command               | Description                                   |\n| --------------------- | --------------------------------------------- |\n| `bun run dev`         | Start Chrome development mode with hot reload |\n| `bun run dev:firefox` | Start Firefox development mode                |\n| `bun run dev:safari`  | Start Safari development mode (macOS only)    |\n| `bun run build`       | Production build for Chrome                   |\n| `bun run build:all`   | Production build for all browsers             |\n| `bun run lint`        | Run ESLint with auto-fix                      |\n| `bun run typecheck`   | Run TypeScript type checking                  |\n| `bun run test`        | Run test suite                                |\n\n### Loading the Extension\n\n1. Run `bun run dev` to start the development build\n2. Open Chrome and go to `chrome://extensions/`\n3. Enable \"Developer mode\"\n4. Click \"Load unpacked\" and select the `dist_chrome` folder\n\n---\n\n## Making Changes\n\n### Before You Start\n\n1. **Create a branch** from `main`:\n\n   ```bash\n   git checkout -b feature/your-feature-name\n   # or\n   git checkout -b fix/your-bug-fix\n   ```\n\n2. **Link Issues** - When implementing a new feature, you **must first open an Issue for discussion**. PRs for new features submitted without prior discussion will be closed. When submitting a PR, please link that Issue.\n\n### Pre-Commit Checklist\n\nBefore submitting, always run:\n\n```bash\nbun run lint       # Fix linting issues\nbun run format     # Format code\nbun run typecheck  # Check types\nbun run build      # Verify build succeeds\nbun run test       # Run tests\n```\n\nEnsure that:\n\n1. Your changes achieve the desired functionality.\n2. Your changes do not negatively affect existing features.\n\n---\n\n## Testing Strategy\n\nWe follow a \"ROI-based\" testing strategy: **Test Logic, Not DOM.**\n\n1. **Must Have (Logic)**: Core services (Storage, Backup), Data parsers, and Utils. TDD is required here.\n2. **Should Have (State)**: Complex UI state (e.g., Folder reducer).\n3. **Skip (Fragile)**: Direct DOM manipulation (Content Scripts) and pure UI components. Use defensive programming instead.\n\n---\n\n## Submitting a Pull Request\n\n### PR Guidelines\n\n1. **Title**: Use a clear, descriptive title (e.g., \"feat: add dark mode toggle\" or \"fix: timeline scroll sync\")\n2. **Description**: Explain what changes you made and why\n3. **User Impact**: Describe how users will be affected\n4. **Visual Proof (Strict)**: For ANY UI changes or new features, you **MUST** provide screenshots or screen recordings. **No screenshot = No review/reply.**\n5. **Issue Reference**: Link related issues (e.g., \"Closes #123\")\n6. **Tests & Logic**: PRs must include unit tests and a clear explanation of the logic. \"Magic\" fixes without context are not accepted.\n\n### Commit Message Format\n\nFollow [Conventional Commits](https://www.conventionalcommits.org/):\n\n- `feat:` - New features\n- `fix:` - Bug fixes\n- `docs:` - Documentation changes\n- `chore:` - Maintenance tasks\n- `refactor:` - Code refactoring\n- `test:` - Adding or updating tests\n\n---\n\n## Code Style\n\n### General Guidelines\n\n- **Prefer early returns** over nested conditionals\n- **Use descriptive names** - avoid abbreviations\n- **Avoid magic numbers** - use named constants\n- **Match existing style** - consistency over preference\n\n### TypeScript Conventions\n\n- **PascalCase**: Classes, interfaces, types, enums, React components\n- **camelCase**: Functions, variables, methods\n- **UPPER_SNAKE_CASE**: Constants\n\n### Import Order\n\n1. React & React-related imports\n2. Third-party libraries\n3. Internal absolute imports (`@/...`)\n4. Relative imports (`./...`)\n5. Type-only imports\n\n```typescript\nimport React, { useState } from 'react';\n\nimport { marked } from 'marked';\n\nimport { Button } from '@/components/ui/Button';\nimport { StorageService } from '@/core/services/StorageService';\nimport type { FolderData } from '@/core/types/folder';\n\nimport { parseData } from './parser';\n```\n\n---\n\n## Adding Gem Support\n\nTo add support for a new Gem (official Google Gems or custom Gems):\n\n1. Open `src/pages/content/folder/gemConfig.ts`\n2. Add a new entry to the `GEM_CONFIG` array:\n\n```typescript\n{\n  id: 'your-gem-id',           // From URL: /gem/your-gem-id/...\n  name: 'Your Gem Name',       // Display name\n  icon: 'material_icon_name',  // Google Material Symbols icon\n}\n```\n\n### Finding the Gem ID\n\n- Open a conversation with the Gem\n- Check the URL: `https://gemini.google.com/app/gem/[GEM_ID]/...`\n- Use the `[GEM_ID]` portion in your configuration\n\n### Choosing an Icon\n\nUse valid [Google Material Symbols](https://fonts.google.com/icons) icon names:\n\n| Icon           | Use Case               |\n| -------------- | ---------------------- |\n| `auto_stories` | Learning, Education    |\n| `lightbulb`    | Ideas, Brainstorming   |\n| `work`         | Career, Professional   |\n| `code`         | Programming, Technical |\n| `analytics`    | Data, Analysis         |\n\n---\n\n## Project Scope\n\nVoyager enhances the Gemini AI chat experience with:\n\n- Timeline navigation\n- Folder organization\n- Prompt vault\n- Chat export\n- UI customization\n\n**Out of scope**: Site scraping, network interception, account automation.\n\n---\n\n## Getting Help\n\n- 💬 [GitHub Discussions](https://github.com/Nagi-ovo/gemini-voyager/discussions) - Ask questions\n- 🐛 [Issues](https://github.com/Nagi-ovo/gemini-voyager/issues) - Report bugs\n- 📖 [Documentation](https://gemini-voyager.vercel.app/) - Read the docs\n\n---\n\n## License\n\nBy contributing, you agree that your contributions will be licensed under the [GPLv3 License](../LICENSE).\n"
  },
  {
    "path": ".github/CONTRIBUTING_ES.md",
    "content": "# Guía de Contribución\n\n> [!CAUTION]\n> **Este proyecto actualmente NO acepta PRs para nuevas funcionalidades.** Si tienes una funcionalidad que realmente te gustaría desarrollar, sigue este proceso:\n>\n> 1. **Abre un Issue primero** para discutir tu idea y enfoque con el mantenedor\n> 2. **Espera la aprobación y un plan de implementación sólido** antes de escribir código o enviar un PR\n>\n> Los PRs de nuevas funcionalidades enviados sin discusión previa serán cerrados sin revisión. Gracias por tu comprensión.\n\n> [!IMPORTANT]\n> **Estado del proyecto: Mantenimiento bajo.** Espere retrasos en las respuestas. Se priorizan los PR con pruebas.\n\n¡Gracias por considerar contribuir a Voyager! 🚀\n\nEste documento proporciona pautas e instrucciones para contribuir. Damos la bienvenida a correcciones de errores, mejoras en la documentación y traducciones. Para nuevas funcionalidades, por favor discútelo primero mediante un Issue.\n\n## 🚫 Política de IA\n\n**Rechazamos explícitamente los PR generados por IA que no hayan sido verificados manualmente.**\n\nAunque las herramientas de IA son grandes asistentes, las contribuciones de \"copiar y pegar\" sin revisión hacen perder tiempo a los mantenedores.\n\n- **Los PR de IA de baja calidad** se cerrarán inmediatamente sin discusión.\n- **Los PR sin explicación** de la lógica o que carezcan de las pruebas necesarias serán rechazados.\n- Debes entender y asumir la responsabilidad de cada línea de código que envíes.\n\n## Tabla de Contenidos\n\n- [Comenzando](#comenzando)\n- [Reclamar un Problema](#reclamar-un-problema)\n- [Configuración de Desarrollo](#configuración-de-desarrollo)\n- [Realizando Cambios](#realizando-cambios)\n- [Enviar un Pull Request](#enviar-un-pull-request)\n- [Estilo de Código](#estilo-de-código)\n- [Agregar Soporte para Gem](#agregar-soporte-para-gem)\n- [Licencia](#licencia)\n\n---\n\n## Comenzando\n\n### Requisitos Previos\n\n- **Bun** 1.0+ (Requerido)\n- Un navegador basado en Chromium para pruebas (Chrome, Edge, Brave, etc.)\n\n### Inicio Rápido\n\n```bash\n# Clonar el repositorio\ngit clone https://github.com/Nagi-ovo/gemini-voyager.git\ncd gemini-voyager\n\n# Instalar dependencias\nbun install\n\n# Iniciar modo de desarrollo\nbun run dev\n```\n\n---\n\n## Reclamar un Problema\n\nPara evitar trabajo duplicado y coordinar contribuciones:\n\n### 1. Verificar Trabajo Existente\n\nAntes de comenzar, verifica si el problema ya está asignado a alguien mirando la sección **Assignees**.\n\n### 2. Reclamar un Problema\n\nComenta `/claim` en cualquier problema no asignado para asignártelo automáticamente. Un bot confirmará la asignación.\n\n### 3. Liberar si es Necesario\n\nSi ya no puedes trabajar en un problema, comenta `/unclaim` para liberarlo para otros.\n\n### 4. Casilla de Verificación de Contribución\n\nAl crear problemas, puedes marcar la casilla \"I am willing to contribute code\" para indicar tu interés en implementar la funcionalidad o corrección.\n\n---\n\n## Configuración de Desarrollo\n\n### Instalar Dependencias\n\n```bash\nbun install\n```\n\n### Comandos Disponibles\n\n| Comando               | Descripción                                           |\n| --------------------- | ----------------------------------------------------- |\n| `bun run dev`         | Iniciar modo desarrollo Chrome con recarga automática |\n| `bun run dev:firefox` | Iniciar modo desarrollo Firefox                       |\n| `bun run dev:safari`  | Iniciar modo desarrollo Safari (solo macOS)           |\n| `bun run build`       | Compilación de producción para Chrome                 |\n| `bun run build:all`   | Compilación de producción para todos los navegadores  |\n| `bun run lint`        | Ejecutar ESLint con corrección automática             |\n| `bun run typecheck`   | Ejecutar comprobación de tipos TypeScript             |\n| `bun run test`        | Ejecutar conjunto de pruebas                          |\n\n### Cargar la Extensión\n\n1. Ejecuta `bun run dev` para iniciar la compilación de desarrollo\n2. Abre Chrome y ve a `chrome://extensions/`\n3. Habilita el \"Modo de desarrollador\"\n4. Haz clic en \"Cargar descomprimida\" y selecciona la carpeta `dist_chrome`\n\n---\n\n## Realizando Cambios\n\n### Antes de Empezar\n\n1. **Crea una rama** desde `main`:\n\n   ```bash\n   git checkout -b feature/nombre-de-tu-funcionalidad\n   # o\n   git checkout -b fix/tu-correccion-de-error\n   ```\n\n2. **Vincular Issues** - Al implementar una nueva funcionalidad, **primero debes abrir un Issue de discusión**. Los PR de nuevas funcionalidades enviados sin discusión previa serán cerrados. Al enviar un PR, por favor enlaza ese Issue.\n3. **Mantén los cambios enfocados** - una funcionalidad o corrección por PR\n\n### Lista de Verificación Pre-Commit\n\nAntes de enviar, ejecuta siempre:\n\n```bash\nbun run lint       # Corregir problemas de linting\nbun run format     # Formatear código\nbun run typecheck  # Comprobar tipos\nbun run build      # Verificar que la compilación tiene éxito\nbun run test       # Ejecutar pruebas\n```\n\nAsegúrate de que:\n\n1. Tus cambios logran la funcionalidad deseada.\n2. Tus cambios no afectan negativamente a las funciones existentes.\n\n---\n\n## Estrategia de Pruebas\n\nSeguimos una estrategia de pruebas basada en el ROI: **Prueba la lógica, no el DOM.**\n\n1. **Imprescindible (Lógica)**: Servicios principales (Almacenamiento, Copia de seguridad), analizadores de datos y utilidades. Aquí se requiere TDD.\n2. **Recomendable (Estado)**: Estado de UI complejo (ej: Reducer de carpetas).\n3. **Omitir (Frágil)**: Manipulación directa del DOM (Content Scripts) y componentes de UI puros. Usa programación defensiva en su lugar.\n\n---\n\n## Enviar un Pull Request\n\n### Pautas de PR\n\n1. **Título**: Usa un título claro y descriptivo (ej: \"feat: add dark mode toggle\" o \"fix: timeline scroll sync\")\n2. **Descripción**: Explica qué cambios hiciste y por qué\n3. **Impacto en el Usuario**: Describe cómo se verán afectados los usuarios\n4. **Prueba Visual (Estricto)**: Para CUALQUIER cambio de UI o nueva funcionalidad, **DEBES** proporcionar capturas de pantalla o grabaciones. **Sin captura = Sin revisión/respuesta.**\n5. **Referencia de Problema**: Enlaza problemas relacionados (ej: \"Closes #123\")\n\n### Formato de Mensaje de Commit\n\nSigue [Conventional Commits](https://www.conventionalcommits.org/):\n\n- `feat:` - Nuevas funcionalidades\n- `fix:` - Corrección de errores\n- `docs:` - Cambios en documentación\n- `chore:` - Tareas de mantenimiento\n- `refactor:` - Refactorización de código\n- `test:` - Agregar o actualizar pruebas\n\n---\n\n## Estilo de Código\n\n### Pautas Generales\n\n- **Prefiere retornos tempranos** sobre condicionales anidados\n- **Usa nombres descriptivos** - evita abreviaciones\n- **Evita números mágicos** - usa constantes con nombre\n- **Sigue el estilo existente** - consistencia sobre preferencia\n\n### Convenciones TypeScript\n\n- **PascalCase**: Clases, interfaces, tipos, enums, componentes React\n- **camelCase**: Funciones, variables, métodos\n- **UPPER_SNAKE_CASE**: Constantes\n\n### Orden de Importación\n\n1. React e importaciones relacionadas\n2. Bibliotecas de terceros\n3. Importaciones absolutas internas (`@/...`)\n4. Importaciones relativas (`./...`)\n5. Importaciones solo de tipo\n\n```typescript\nimport React, { useState } from 'react';\n\nimport { marked } from 'marked';\n\nimport { Button } from '@/components/ui/Button';\nimport { StorageService } from '@/core/services/StorageService';\nimport type { FolderData } from '@/core/types/folder';\n\nimport { parseData } from './parser';\n```\n\n---\n\n## Agregar Soporte para Gem\n\nPara agregar soporte para un nuevo Gem (Gems oficiales de Google o Gems personalizados):\n\n1. Abre `src/pages/content/folder/gemConfig.ts`\n2. Agrega una nueva entrada al array `GEM_CONFIG`:\n\n```typescript\n{\n  id: 'your-gem-id',           // De la URL: /gem/your-gem-id/...\n  name: 'Your Gem Name',       // Nombre para mostrar\n  icon: 'material_icon_name',  // Icono de Google Material Symbols\n}\n```\n\n### Encontrar el ID del Gem\n\n- Abre una conversación con el Gem\n- Verifica la URL: `https://gemini.google.com/app/gem/[GEM_ID]/...`\n- Usa la parte `[GEM_ID]` en tu configuración\n\n### Elegir un Icono\n\nUsa nombres de iconos válidos de [Google Material Symbols](https://fonts.google.com/icons):\n\n| Icono          | Caso de Uso            |\n| -------------- | ---------------------- |\n| `auto_stories` | Aprendizaje, Educación |\n| `lightbulb`    | Ideas, Lluvia de ideas |\n| `work`         | Carrera, Profesional   |\n| `code`         | Programación, Técnica  |\n| `analytics`    | Datos, Análisis        |\n\n---\n\n## Alcance del Proyecto\n\nVoyager mejora la experiencia de chat de Gemini AI con:\n\n- Navegación por línea de tiempo\n- Organización de carpetas\n- Bóveda de prompts\n- Exportación de chat\n- Personalización de UI\n\n**Fuera de alcance**: Scraping de sitios, intercepción de red, automatización de cuentas.\n\n---\n\n## Obtener Ayuda\n\n- 💬 [GitHub Discussions](https://github.com/Nagi-ovo/gemini-voyager/discussions) - Haz preguntas\n- 🐛 [Issues](https://github.com/Nagi-ovo/gemini-voyager/issues) - Reporta errores\n- 📖 [Documentación](https://gemini-voyager.vercel.app/) - Lee la documentación\n\n---\n\n## Licencia\n\nAl contribuir, aceptas que tus contribuciones se licenciarán bajo la [Licencia GPLv3](../LICENSE).\n"
  },
  {
    "path": ".github/CONTRIBUTING_FR.md",
    "content": "# Guide de Contribution\n\n> [!CAUTION]\n> **Ce projet n'accepte actuellement PAS les PRs pour de nouvelles fonctionnalités.** Si vous souhaitez vraiment développer une fonctionnalité, veuillez suivre ce processus :\n>\n> 1. **Ouvrez d'abord un Issue** pour discuter de votre idée et de votre approche avec le mainteneur\n> 2. **Attendez l'approbation et un plan d'implémentation solide** avant d'écrire du code ou de soumettre une PR\n>\n> Les PRs de nouvelles fonctionnalités soumises sans discussion préalable seront fermées sans examen. Merci de votre compréhension.\n\n> [!IMPORTANT]\n> **Statut du projet : Maintenance réduite.** Attendez-vous à des délais de réponse. Les PR avec tests sont prioritaires.\n\nMerci d'envisager de contribuer à Voyager ! 🚀\n\nCe document fournit des directives et des instructions pour contribuer. Nous accueillons les corrections de bugs, les améliorations de la documentation et les traductions. Pour les nouvelles fonctionnalités, veuillez d'abord en discuter via un Issue.\n\n## 🚫 Politique IA\n\n**Nous rejetons explicitement les PR générées par l'IA qui n'ont pas été vérifiées manuellement.**\n\nBien que les outils d'IA soient d'excellents assistants, les contributions \"paresseuses\" de copier-coller font perdre du temps aux mainteneurs.\n\n- **Les PR d'IA de mauvaise qualité** seront fermées immédiatement sans discussion.\n- **Les PR sans explication** de la logique ou manquant de tests nécessaires seront rejetées.\n- Vous devez comprendre et assumer la responsabilité de chaque ligne de code que vous soumettez.\n\n## Table des Matières\n\n- [Commencer](#commencer)\n- [Réclamer un Ticket](#réclamer-un-ticket)\n- [Configuration de Développement](#configuration-de-développement)\n- [Apporter des Modifications](#apporter-des-modifications)\n- [Soumettre une Pull Request](#soumettre-une-pull-request)\n- [Style de Code](#style-de-code)\n- [Ajouter le Support d'un Gem](#ajouter-le-support-dun-gem)\n- [Licence](#licence)\n\n---\n\n## Commencer\n\n### Prérequis\n\n- **Bun** 1.0+ (Requis)\n- Un navigateur basé sur Chromium pour les tests (Chrome, Edge, Brave, etc.)\n\n### Démarrage Rapide\n\n```bash\n# Cloner le dépôt\ngit clone https://github.com/Nagi-ovo/gemini-voyager.git\ncd gemini-voyager\n\n# Installer les dépendances\nbun install\n\n# Démarrer le mode développement\nbun run dev\n```\n\n---\n\n## Réclamer un Ticket\n\nPour éviter le travail en double et coordonner les contributions :\n\n### 1. Vérifier le Travail Existant\n\nAvant de commencer, vérifiez si le ticket est déjà assigné à quelqu'un en regardant la section **Assignees**.\n\n### 2. Réclamer un Ticket\n\nCommentez `/claim` sur n'importe quel ticket non assigné pour vous l'assigner automatiquement. Un bot confirmera l'assignation.\n\n### 3. Libérer si Nécessaire\n\nSi vous ne pouvez plus travailler sur un ticket, commentez `/unclaim` pour le libérer pour d'autres.\n\n### 4. Case à Cocher de Contribution\n\nLors de la création de tickets, vous pouvez cocher la case \"I am willing to contribute code\" pour indiquer votre intérêt à implémenter la fonctionnalité ou le correctif.\n\n---\n\n## Configuration de Développement\n\n### Installer les Dépendances\n\n```bash\nbun install\n```\n\n### Commandes Disponibles\n\n| Commande              | Description                                           |\n| --------------------- | ----------------------------------------------------- |\n| `bun run dev`         | Démarrer le mode dev Chrome avec rechargement à chaud |\n| `bun run dev:firefox` | Démarrer le mode dev Firefox                          |\n| `bun run dev:safari`  | Démarrer le mode dev Safari (macOS uniquement)        |\n| `bun run build`       | Build de production pour Chrome                       |\n| `bun run build:all`   | Build de production pour tous les navigateurs         |\n| `bun run lint`        | Exécuter ESLint avec correction automatique           |\n| `bun run typecheck`   | Exécuter la vérification de type TypeScript           |\n| `bun run test`        | Exécuter la suite de tests                            |\n\n### Charger l'Extension\n\n1. Exécutez `bun run dev` pour démarrer le build de développement\n2. Ouvrez Chrome et allez sur `chrome://extensions/`\n3. Activez le \"Mode développeur\"\n4. Cliquez sur \"Charger l'extension non empaquetée\" et sélectionnez le dossier `dist_chrome`\n\n---\n\n## Apporter des Modifications\n\n### Avant de Commencer\n\n1. **Créez une branche** depuis `main` :\n\n   ```bash\n   git checkout -b feature/nom-de-votre-fonctionnalite\n   # ou\n   git checkout -b fix/votre-correction-de-bug\n   ```\n\n2. **Lier les Issues** - Lors de l'implémentation d'une nouvelle fonctionnalité, vous devez **d'abord ouvrir un Issue pour discussion**. Les PR pour de nouvelles fonctionnalités soumises sans discussion préalable seront fermées. Lors de la soumission d'une PR, veuillez lier cet Issue.\n\n3. **Gardez les modifications ciblées** - une fonctionnalité ou correction par PR\n\n### Liste de Contrôle Pré-Commit\n\nAvant de soumettre, exécutez toujours :\n\n```bash\nbun run lint       # Corriger les problèmes de linting\nbun run format     # Formater le code\nbun run typecheck  # Vérifier les types\nbun run build      # Vérifier que le build réussit\nbun run test       # Exécuter les tests\n```\n\nAssurez-vous que :\n\n1. Vos modifications réalisent la fonctionnalité souhaitée.\n2. Vos modifications n'affectent pas négativement les fonctionnalités existantes.\n\n---\n\n## Stratégie de Test\n\nNous suivons une stratégie de test basée sur le ROI : **Testez la logique, pas le DOM.**\n\n1. **Indispensable (Logique)** : Services principaux (Stockage, Sauvegarde), analyseurs de données et utilitaires. Le TDD est requis ici.\n2. **Recommandé (État)** : État d'interface utilisateur complexe (ex: Reducer de dossiers).\n3. **Ignorer (Fragile)** : Manipulation directe du DOM (Scripts de contenu) et composants d'interface utilisateur purs. Utilisez plutôt la programmation défensive.\n\n---\n\n## Soumettre une Pull Request\n\n### Directives de PR\n\n1. **Titre** : Utilisez un titre clair et descriptif (ex: \"feat: add dark mode toggle\" ou \"fix: timeline scroll sync\")\n2. **Description** : Expliquez quels changements vous avez effectués et pourquoi\n3. **Impact Utilisateur** : Décrivez comment les utilisateurs seront affectés\n4. **Preuve Visuelle (Strict)** : Pour TOUT changement d'interface ou nouvelle fonctionnalité, vous **DEVEZ** fournir des captures d'écran ou des enregistrements. **Pas de capture = Pas de revue/réponse.**\n5. **Référence de Ticket** : Liez les tickets associés (ex: \"Closes #123\")\n\n### Format du Message de Commit\n\nSuivez [Conventional Commits](https://www.conventionalcommits.org/) :\n\n- `feat:` - Nouvelles fonctionnalités\n- `fix:` - Corrections de bugs\n- `docs:` - Changements de documentation\n- `chore:` - Tâches de maintenance\n- `refactor:` - Refactorisation de code\n- `test:` - Ajout ou mise à jour de tests\n\n---\n\n## Style de Code\n\n### Directives Générales\n\n- **Préférez les retours anticipés** aux conditionnelles imbriquées\n- **Utilisez des noms descriptifs** - évitez les abréviations\n- **Évitez les nombres magiques** - utilisez des constantes nommées\n- **Respectez le style existant** - la cohérence prime sur la préférence\n\n### Conventions TypeScript\n\n- **PascalCase** : Classes, interfaces, types, énumérations, composants React\n- **camelCase** : Fonctions, variables, méthodes\n- **UPPER_SNAKE_CASE** : Constantes\n\n### Ordre d'Importation\n\n1. React et imports liés\n2. Bibliothèques tierces\n3. Imports absolus internes (`@/...`)\n4. Imports relatifs (`./...`)\n5. Imports de type uniquement\n\n```typescript\nimport React, { useState } from 'react';\n\nimport { marked } from 'marked';\n\nimport { Button } from '@/components/ui/Button';\nimport { StorageService } from '@/core/services/StorageService';\nimport type { FolderData } from '@/core/types/folder';\n\nimport { parseData } from './parser';\n```\n\n---\n\n## Ajouter le Support d'un Gem\n\nPour ajouter le support d'un nouveau Gem (Gems officiels Google ou Gems personnalisés) :\n\n1. Ouvrez `src/pages/content/folder/gemConfig.ts`\n2. Ajoutez une nouvelle entrée au tableau `GEM_CONFIG` :\n\n```typescript\n{\n  id: 'votre-id-gem',          // Depuis l'URL : /gem/votre-id-gem/...\n  name: 'Nom de Votre Gem',    // Nom d'affichage\n  icon: 'material_icon_name',  // Nom de l'icône Google Material Symbols\n}\n```\n\n### Trouver l'ID du Gem\n\n- Ouvrez une conversation avec le Gem\n- Vérifiez l'URL : `https://gemini.google.com/app/gem/[GEM_ID]/...`\n- Utilisez la partie `[GEM_ID]` dans votre configuration\n\n### Choisir une Icône\n\nUtilisez des noms d'icônes valides de [Google Material Symbols](https://fonts.google.com/icons) :\n\n| Icône          | Cas d'Utilisation        |\n| -------------- | ------------------------ |\n| `auto_stories` | Apprentissage, Éducation |\n| `lightbulb`    | Idées, Brainstorming     |\n| `work`         | Carrière, Professionnel  |\n| `code`         | Programmation, Technique |\n| `analytics`    | Données, Analyse         |\n\n---\n\n## Portée du Projet\n\nVoyager améliore l'expérience de chat Gemini AI avec :\n\n- Navigation par chronologie\n- Organisation par dossiers\n- Coffre-fort de prompts\n- Exportation de chat\n- Personnalisation de l'interface utilisateur\n\n**Hors de portée** : Scraping de site, interception réseau, automatisation de compte.\n\n---\n\n## Obtenir de l'Aide\n\n- 💬 [GitHub Discussions](https://github.com/Nagi-ovo/gemini-voyager/discussions) - Poser des questions\n- 🐛 [Issues](https://github.com/Nagi-ovo/gemini-voyager/issues) - Signaler des bugs\n- 📖 [Documentation](https://gemini-voyager.vercel.app/) - Lire la documentation\n\n---\n\n## Licence\n\nEn contribuant, vous acceptez que vos contributions soient licenciées sous la [Licence GPLv3](../LICENSE).\n"
  },
  {
    "path": ".github/CONTRIBUTING_JA.md",
    "content": "# 貢献ガイド\n\n> [!CAUTION]\n> **本プロジェクトは現在、新機能の PR を受け付けていません。** どうしても実装したい機能がある場合は、以下のプロセスに従ってください：\n>\n> 1. **まず Issue を作成して**、メンテナーとアイデアやアプローチについて議論してください\n> 2. **承認と確実な実装計画が決まってから**、コードを書いて PR を提出してください\n>\n> 事前の議論なしに提出された新機能の PR は、レビューなしにクローズされます。ご理解のほどよろしくお願いいたします。\n\n> [!IMPORTANT]\n> **プロジェクトの状態: 低頻度メンテナンス。** 返信が遅れる可能性があります。テスト付きのPRが優先されます。\n\nVoyager への貢献をご検討いただきありがとうございます！🚀\n\nこのドキュメントでは、貢献のためのガイドラインと手順を説明します。バグ修正、ドキュメントの改善、翻訳などの貢献を歓迎します。新機能については、まず Issue で議論してください。\n\n## 🚫 AI ポリシー\n\n**手動で検証されていない AI 生成の PR は明示的に拒否します。**\n\nAI ツールは優れたアシスタントですが、「怠惰な」コピー＆ペーストの貢献はメンテナの時間を浪費します。\n\n- **低品質な AI PR** は、議論なしに即座にクローズされます。\n- ロジックの**説明がない PR** や、必要なテストが不足している PR は拒否されます。\n- あなたは提出するすべてのコード行を理解し、責任を負う必要があります。\n\n## 目次\n\n- [はじめに](#はじめに)\n- [Issue の担当](#issue-の担当)\n- [開発環境のセットアップ](#開発環境のセットアップ)\n- [変更の実施](#変更の実施)\n- [Pull Request の送信](#pull-request-の送信)\n- [コードスタイル](#コードスタイル)\n- [Gem サポートの追加](#gem-サポートの追加)\n- [ライセンス](#ライセンス)\n\n---\n\n## はじめに\n\n### 前提条件\n\n- **Bun** 1.0+（必須）\n- テスト用の Chromium ベースのブラウザ（Chrome, Edge, Brave など）\n\n### クイックスタート\n\n```bash\n# リポジトリをクローン\ngit clone https://github.com/Nagi-ovo/gemini-voyager.git\ncd gemini-voyager\n\n# 依存関係をインストール\nbun install\n\n# 開発モードを開始\nbun run dev\n```\n\n---\n\n## Issue の担当\n\n重複作業を避け、貢献を調整するために：\n\n### 1. 既存の作業を確認\n\n開始する前に、Issue の **Assignees** セクションを見て、すでに誰かが担当していないか確認してください。\n\n### 2. Issue を担当する\n\n未割り当ての Issue に `/claim` とコメントすると、自動的にあなた自身が担当者に割り当てられます。ボットが割り当てを確認します。\n\n### 3. 必要に応じて担当を解除\n\nIssue に取り組めなくなった場合は、`/unclaim` とコメントして、他の人のために解放してください。\n\n### 4. 貢献のチェックボックス\n\nIssue を作成する際、「I am willing to contribute code」チェックボックスをオンにして、機能の実装や修正に興味があることを示すことができます。\n\n---\n\n## 開発環境のセットアップ\n\n### 依存関係のインストール\n\n```bash\nbun install\n```\n\n### 利用可能なコマンド\n\n| コマンド              | 説明                                      |\n| --------------------- | ----------------------------------------- |\n| `bun run dev`         | Chrome 開発モードを開始（ホットリロード） |\n| `bun run dev:firefox` | Firefox 開発モードを開始                  |\n| `bun run dev:safari`  | Safari 開発モードを開始（macOS のみ）     |\n| `bun run build`       | Chrome 用のプロダクションビルド           |\n| `bun run build:all`   | 全ブラウザ用のプロダクションビルド        |\n| `bun run lint`        | ESLint を実行して自動修正                 |\n| `bun run typecheck`   | TypeScript の型チェックを実行             |\n| `bun run test`        | テストスイートを実行                      |\n\n### 拡張機能の読み込み\n\n1. `bun run dev` を実行して開発ビルドを開始します\n2. Chrome を開き、`chrome://extensions/` に移動します\n3. 「デベロッパー モード」を有効にします\n4. 「パッケージ化されていない拡張機能を読み込む」をクリックし、`dist_chrome` フォルダを選択します\n\n---\n\n## 変更の実施\n\n### 作業を始める前に\n\n1. `main` から**ブランチを作成**します：\n\n   ```bash\n   git checkout -b feature/your-feature-name\n   # または\n   git checkout -b fix/your-bug-fix\n   ```\n\n2. **Issue をリンクする** - 新機能の実装については、**まず議論のために Issue を提出する必要があります**。事前の議論なしに提出された新機能の PR はクローズされます。PR を送信する際は、その Issue をリンクしてください。\n\n3. **変更を集中させる** - PR ごとに1つの機能または修正\n\n### コミット前チェックリスト\n\n送信する前に、必ず以下を実行してください：\n\n```bash\nbun run lint       # リンティングの問題を修正\nbun run format     # コードの整形\nbun run typecheck  # 型をチェック\nbun run build      # ビルドが成功することを確認\nbun run test       # テストを実行\n```\n\n以下を確認してください：\n\n1. 変更内容が期待通りに機能すること。\n2. 既存の機能に影響を与えていないこと。\n\n---\n\n## テスト戦略\n\n私たちは「ROI に基づく」テスト戦略に従います：**DOM ではなくロジックをテストしてください。**\n\n1. **必須 (Logic)**: コアサービス (ストレージ、バックアップ)、データパーサー、ユーティリティ。ここでは TDD が必須です。\n2. **推奨 (State)**: 複雑な UI 状態 (例: フォルダ Reducer)。\n3. **スキップ (Fragile)**: 直接的な DOM 操作 (Content Scripts) や純粋な UI コンポーネント。代わりに防御的プログラミングを使用してください。\n\n---\n\n## Pull Request の送信\n\n### PR ガイドライン\n\n1. **タイトル**: 明確で説明的なタイトルを使用してください（例：\"feat: add dark mode toggle\" または \"fix: timeline scroll sync\"）\n2. **説明**: どのような変更を行ったか、およびその理由を説明してください\n3. **ユーザーへの影響**: ユーザーにどのような影響があるかを説明してください\n4. **視覚的証拠 (厳格)**: UI の変更や新機能については、**必ず**スクリーンショットまたは画面録画を提供してください。**スクリーンショットなし = レビュー/返信しません。**\n5. **Issue の参照**: 関連する Issue をリンクしてください（例：\"Closes #123\"）\n\n### コミットメッセージの形式\n\n[Conventional Commits](https://www.conventionalcommits.org/) に従ってください：\n\n- `feat:` - 新機能\n- `fix:` - バグ修正\n- `docs:` - ドキュメントの変更\n- `chore:` - メンテナンス作業\n- `refactor:` - コードのリファクタリング\n- `test:` - テストの追加または更新\n\n---\n\n## コードスタイル\n\n### 一般的なガイドライン\n\n- ネストされた条件分岐よりも**早期リターンを優先**\n- **説明的な名前を使用** - 略語は避ける\n- **マジックナンバーを避ける** - 名前付き定数を使用\n- **既存のスタイルに合わせる** - 好みよりも一貫性\n\n### TypeScript の規約\n\n- **PascalCase**: クラス、インターフェース、型、Enum、React コンポーネント\n- **camelCase**: 関数、変数、メソッド\n- **UPPER_SNAKE_CASE**: 定数\n\n### インポートの順序\n\n1. React および関連するインポート\n2. サードパーティライブラリ\n3. 内部の絶対インポート（`@/...`）\n4. 相対インポート（`./...`）\n5. 型のみのインポート\n\n```typescript\nimport React, { useState } from 'react';\n\nimport { marked } from 'marked';\n\nimport { Button } from '@/components/ui/Button';\nimport { StorageService } from '@/core/services/StorageService';\nimport type { FolderData } from '@/core/types/folder';\n\nimport { parseData } from './parser';\n```\n\n---\n\n## Gem サポートの追加\n\n新しい Gem（公式 Google Gems またはカスタム Gems）のサポートを追加するには：\n\n1. `src/pages/content/folder/gemConfig.ts` を開きます\n2. `GEM_CONFIG` 配列に新しいエントリを追加します：\n\n```typescript\n{\n  id: 'your-gem-id',           // URL から取得: /gem/your-gem-id/...\n  name: 'Your Gem Name',       // 表示名\n  icon: 'material_icon_name',  // Google Material Symbols アイコン\n}\n```\n\n### Gem ID の見つけ方\n\n- Gem との会話を開きます\n- URL を確認します: `https://gemini.google.com/app/gem/[GEM_ID]/...`\n- 設定で `[GEM_ID]` の部分を使用します\n\n### アイコンの選択\n\n有効な [Google Material Symbols](https://fonts.google.com/icons) アイコン名を使用してください：\n\n| アイコン       | 使用例               |\n| -------------- | -------------------- |\n| `auto_stories` | 学習、教育           |\n| `lightbulb`    | アイデア、ブレスト   |\n| `work`         | キャリア、専門職     |\n| `code`         | プログラミング、技術 |\n| `analytics`    | データ、分析         |\n\n---\n\n## プロジェクトの範囲\n\nVoyager は、以下の機能で Gemini AI チャット体験を向上させます：\n\n- タイムラインナビゲーション\n- フォルダ整理\n- プロンプトヴォルト\n- チャットのエクスポート\n- UI カスタマイズ\n\n**範囲外**: サイトのスクレイピング、ネットワーク傍受、アカウントの自動化。\n\n---\n\n## ヘルプを得る\n\n- 💬 [GitHub Discussions](https://github.com/Nagi-ovo/gemini-voyager/discussions) - 質問する\n- 🐛 [Issues](https://github.com/Nagi-ovo/gemini-voyager/issues) - バグを報告する\n- 📖 [ドキュメント](https://gemini-voyager.vercel.app/) - ドキュメントを読む\n\n---\n\n## ライセンス\n\n貢献することにより、あなたの貢献が [GPLv3 ライセンス](../LICENSE) の下でライセンスされることに同意したものとみなされます。\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: Nagi-ovo\nbuy_me_a_coffee: Nag1ovo\ncustom: ['https://afdian.com/a/nagi-ovo']\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: '🐛 反馈缺陷 Bug Report'\ndescription: '反馈一个问题缺陷 | Report a bug'\ntitle: '[Bug] '\nlabels: '🐛 Bug'\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ### ⚠️ 项目维护状态 | Maintenance Status\n\n        **本项目目前处于低频维护模式。 | This project is currently in Low Maintenance mode.**\n\n        - 我们将优先处理带有测试代码的 Pull Request，而非 Issue。 | We prioritize Pull Requests with tests over Issues.\n        - 对于未提供清晰复现步骤或环境信息的非核心 Bug，可能会被直接关闭。 | Non-critical bugs without minimal reproduction steps may be closed directly.\n        - 维护者精力有限，回复可能会有显著延迟，请谅解。 | Please be patient as response times will be significantly delayed.\n  - type: checkboxes\n    id: checklist\n    attributes:\n      label: '⚠️ 前置检查 | Essential Checklist'\n      description: '请确认您已完成以下操作 | Please confirm you have done the following'\n      options:\n        - label: '我已在 Issue 中搜索过类似问题，确认这不是一个重复的问题 / I have searched existing issues'\n          required: true\n        - label: '我理解如果未提供清晰的复现步骤或环境信息，Issue 可能会被关闭 / I understand my issue may be closed if repro steps are missing'\n          required: true\n        - label: '我确认使用的是插件的最新版本 / I am using the latest version of the extension'\n          required: true\n  - type: dropdown\n    attributes:\n      label: '💻 系统环境 | Operating System'\n      options:\n        - Windows\n        - macOS\n        - Ubuntu\n        - Other Linux\n        - Other\n    validations:\n      required: true\n  - type: dropdown\n    attributes:\n      label: '🌐 浏览器 | Browser'\n      description: 'Which browser are you using?'\n      options:\n        - Chrome\n        - Edge\n        - Safari\n        - Firefox\n        - Other\n    validations:\n      required: true\n  - type: input\n    attributes:\n      label: '📦 扩展版本 | Extension Version'\n      description: 'Which version of Gemini Voyager are you using? (e.g., 1.1.3)'\n      placeholder: '1.1.3'\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: '🐛 问题描述 | Bug Description'\n      description: 'A clear and concise description of the bug.'\n      placeholder: 'Describe what happened...'\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: '🚦 期望结果 | Expected Behavior'\n      description: 'A clear and concise description of what you expected to happen.'\n      placeholder: 'Describe what you expected...'\n  - type: textarea\n    attributes:\n      label: '📷 复现步骤 | Recurrence Steps'\n      description: 'Steps to reproduce the behavior.'\n      placeholder: |\n        1. Go to '...'\n        2. Click on '...'\n        3. See error\n  - type: textarea\n    attributes:\n      label: '📸 截图 | Screenshots'\n      description: 'REQUIRED for UI issues. If we cannot see it, we will not fix it. / UI 问题必填。如果看不到问题，我们将不会修复。'\n      placeholder: 'Drag and drop images here or paste image URLs'\n  - type: textarea\n    attributes:\n      label: '📝 补充信息 | Additional Information'\n      description: 'If your problem needs further explanation, please add more information here.'\n      placeholder: 'Any additional context, error messages, or console logs...'\n  - type: checkboxes\n    attributes:\n      label: '💻 贡献意愿 | Contribution'\n      options:\n        - label: '我愿意为该问题贡献修复代码 / I am willing to contribute a fix for this bug'\n        - label: '💡 其他贡献者可评论 `/claim` 认领此 Issue / Other contributors can comment `/claim` to claim'\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: '💬 讨论区 Discussions'\n    url: https://github.com/Nagi-ovo/gemini-voyager/discussions\n    about: '讨论想法或提问，而非提交 Bug 或 Feature | For general discussions, questions, and ideas'\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: '🌠 功能需求 Feature Request'\ndescription: '需求或建议 | Suggest an idea for Gemini Voyager'\ntitle: '[Feature] '\nlabels: '🌠 Feature Request'\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ### ⚠️ 项目维护状态 | Maintenance Status\n\n        **本项目目前处于低频维护模式。 | This project is currently in Low Maintenance mode.**\n\n        - 无法保证能够投入精力，及时实现新的功能需求。 | The maintainer cannot guarantee they will be able to dedicate time to implement new features in a timely manner.\n        - 我们非常**欢迎并鼓励社区贡献**！ | However, we **welcome and encourage community contributions**!\n        - 如果您希望该功能被实现，提交高质量的 Pull Request 是最佳途径。但在提交 PR 前，**请务必先在此 Issue 中进行讨论**。未经讨论直接提交的新功能 PR 将被关闭。 | The best way to see this feature realized is to submit a Pull Request. However, before submitting a PR, **you MUST first discuss it in this Issue**. PRs for new features submitted without prior discussion will be closed.\n  - type: textarea\n    attributes:\n      label: '🥰 需求描述 | Feature Description'\n      description: 'Please add a clear and concise description of the problem you are seeking to solve with this feature request.'\n      placeholder: \"Is your feature request related to a problem? Please describe.\\nExample: I'm always frustrated when...\"\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: '🧐 解决方案 | Proposed Solution'\n      description: \"Describe the solution you'd like in a clear and concise manner.\"\n      placeholder: 'Describe what you want to happen...'\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: '🔄 替代方案 | Alternatives Considered'\n      description: 'Describe any alternative solutions or features you have considered.'\n      placeholder: 'Describe alternative solutions...'\n  - type: textarea\n    attributes:\n      label: '📝 补充信息 | Additional Information'\n      description: 'REQUIRED for UI features: Please provide mockups or screenshots. / UI 功能必填：请提供设计图或截图。'\n      placeholder: 'No screenshot = No reply (for UI features).'\n  - type: checkboxes\n    attributes:\n      label: '💻 贡献意愿 | Contribution'\n      options:\n        - label: '我愿意为该功能贡献代码 / I am willing to contribute code for this feature'\n        - label: '💡 其他贡献者可评论 `/claim` 认领此 Issue / Other contributors can comment `/claim` to claim'\n"
  },
  {
    "path": ".github/README_AR.md",
    "content": "<div align=\"center\" dir=\"rtl\">\n  <img src=\"../docs/public/assets/promotion/Promo-Banner.png\" alt=\"promotion\"/>\n  <h3>اجعل تجربتك مع Gemini™ ملكك حقاً ✨</h3>\n  <p>\n    تصفح المحادثات بجدول زمني أنيق، ونظم الدردشات في مجلدات، وقم ببناء مستودع المطالبات الخاص بك.<br>\n    <b>إنه الترقية المفقودة لـ Google Gemini.</b>\n  </p>\n  \n  <p>\n    <img src=\"https://img.shields.io/badge/Chrome-✓-4285F4?style=flat-square&logo=googlechrome&logoColor=white\" alt=\"Chrome\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoftedge&logoColor=white\" alt=\"Edge\">\n    <img src=\"https://img.shields.io/badge/Firefox-✓-FF7139?style=flat-square&logo=firefox&logoColor=white\" alt=\"Firefox\">\n    <img src=\"https://img.shields.io/badge/Safari-✓-000000?style=flat-square&logo=safari&logoColor=white\" alt=\"Safari\">\n    <img src=\"https://img.shields.io/badge/Opera-✓-FF1B2D?style=flat-square&logo=opera&logoColor=white\" alt=\"Opera\">\n    <img src=\"https://img.shields.io/badge/Brave-✓-FB542B?style=flat-square&logo=brave&logoColor=white\" alt=\"Brave\">\n  </p>\n  <p>\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub stars\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub forks\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Latest version\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub downloads\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store users\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store rating\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons users\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons rating\">\n  </p>\n  <p>\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Gemini Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </p>\n  <p align=\"center\">\n    ✨ نحن متواجدون الآن على Product Hunt! يسعدنا سماع آرائكم. ❤️\n  </p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://voyager.nagi.fun/ar\">📖 التوثيق</a> • \n  <a href=\"../README.md\">English</a> • \n  <a href=\"./README_ZH.md\">简体中文</a> •\n  <a href=\"./README_ZH_TW.md\">繁體中文</a> •\n  <a href=\"./README_JA.md\">日本語</a> •\n  <a href=\"./README_FR.md\">Français</a> •\n  <a href=\"./README_ES.md\">Español</a> •\n  <a href=\"./README_PT.md\">Português</a> •\n  <a href=\"./README_RU.md\">Русский</a> •\n  <a href=\"./README_KO.md\">한국어</a>\n</p>\n\n<p align=\"center\">\n    <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n  </p>\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024507762483277927?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/x-recommendation.png\" alt=\"KOL Recommendation\" width=\"500\">\n  </a>\n  <br>\n  <b>🎉 نوصي به بشدة من قبل كبار المؤثرين في مجال التكنولوجيا والمجتمع!</b>\n</p>\n\n> [!IMPORTANT]\n> **إشعار تغيير الاسم**: بسبب مخاوف تتعلق بالعلامات التجارية وحقوق النشر، تم تغيير اسم هذا الامتداد رسمياً إلى **Voyager**. ومع ذلك، بسبب بطء شديد في عملية مراجعة Chrome Web Store، لم يتم الموافقة على تغيير الاسم خلال 7 أيام — وهو غير متاح مؤقتاً على Chrome Web Store.\n\n> [!NOTE]\n> إذا كان Voyager مفيداً لك، فشاركْه على X أو Reddit أو YouTube إلخ. كل مشاركة تساعد المزيد من الناس على اكتشاف المشروع وتحسين تجربة Gemini. شكراً.\n\n---\n\n## 👋 لماذا Voyager؟\n\nنحن نحب Gemini، لكننا تمنينا أحياناً لو كان لديه المزيد من \"التنظيم\".\n\nلهذا السبب قمنا ببناء **Voyager**. إنه ليس مجرد أداة؛ إنه رفيق يساعدك في الحفاظ على تنظيم محادثاتك مع الذكاء الاصطناعي وجعلها سهلة الوصول ومنتجة. سواء كنت باحثاً تدير عشرات الخيوط، أو مطوراً يحفظ مقتطفات برمجية، أو مجرد شخص يحب النظام، فإن Voyager مصمم لك.\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024509398601597412?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/try-voyager.png\" alt=\"Try Voyager\" width=\"500\">\n  </a>\n  <br>\n  <i>خلال المشكلة التي حدثت في 18 فبراير حيث تسبب تطبيق Google Gemini في جعل المحادثات التاريخية لبعض المستخدمين غير قابلة للوصول، تمكن مستخدمو Voyager من الاستمرار في رؤية محادثاتهم المحفوظة في مجلداتهم.</i>\n</p>\n\n---\n\n## ✨ الميزات\n\n### 🌌 الميزات الأساسية (Gemini & AI Studio)\n\n- **📂 [تنظيم المجلدات](https://voyager.nagi.fun/ar/guide/folders)**: نظم دردشاتك في تسلسل هرمي للمجلدات مع دعم **السحب والإفلات** و **مزامنة Google Drive**.\n  - **Gemini**: يدعم **وضع عزل الحساب** و **ألوان المجلدات المخصصة**.\n- **💡 [مستودع المطالبات](https://voyager.nagi.fun/ar/guide/prompts)**: احفظ وأعد استخدام أفضل مطالباتك في Gemini و AI Studio و[المواقع المخصصة](https://voyager.nagi.fun/ar/guide/custom-websites).\n- **☁️ [المزامنة السحابية](https://voyager.nagi.fun/ar/guide/cloud-sync)**: قم بمزامنة المجلدات ومستودع المطالبات مع Google Drive.\n- **📐 نسخ الصيغ**: نسخ بنقرة واحدة لأكواد المصدر LaTeX و MathML (Word).\n- **🌦️ التأثيرات البصرية**: أضف أجواء موسمية مع **الثلج** أو **المطر السينمائي** أو **بتلات الساكورا المتساقطة** من لوحة الإعدادات.\n\n### ✨ ميزات Gemini الحصرية\n\n- **📍 [تصفح الجدول الزمني](https://voyager.nagi.fun/ar/guide/timeline)**: عقد بصرية للتنقل بين الرسائل، وتمييز اللحظات الرئيسية، وإدارة فروع المحادثة.\n- **💾 [تصدير الدردشة](https://voyager.nagi.fun/ar/guide/export)**: صَدّر المحادثات إلى تنسيقات JSON أو Markdown أو PDF مع تضمين الصور.\n- **🧜‍♀️ [رسم Mermaid](https://voyager.nagi.fun/ar/guide/mermaid)**: عرض تلقائي للمخططات الانسيابية ومخططات التتابع وغيرها من رسوم Mermaid.\n- **📝 [إصلاح عرض Markdown](https://voyager.nagi.fun/ar/guide/markdown-fix)**: إصلاح تلقائي لتنسيق Markdown العريض الذي تعطل بسبب عناصر HTML التي أدرجها Gemini.\n- **🍌 [NanoBanana](https://voyager.nagi.fun/ar/guide/nanobanana)**: إزالة العلامة المائية بدون فقدان الجودة للصور التي ينتجها Gemini.\n- **🔬 [البحث العميق](https://voyager.nagi.fun/ar/guide/deep-research)**: استخرج عمليات التفكير وروابط البحث من جلسات البحث العميق.\n- **🛠️ أدوات القوة**:\n  - **[الحذف الجماعي](https://voyager.nagi.fun/ar/guide/batch-delete)**: تنظيف السجل الخاص بك دفعة واحدة.\n  - **[الرد مع اقتباس](https://voyager.nagi.fun/ar/guide/quote-reply)**: الرد مع السياق عن طريق تحديد النص.\n  - **[مزامنة عنوان علامة التبويب](https://voyager.nagi.fun/ar/guide/tab-title)**: مزامنة عنوان علامة تبويب المتصفح تلقائياً مع عنوان الدردشة.\n  - **[منع التمرير التلقائي](https://voyager.nagi.fun/ar/guide/prevent-auto-scroll)**: يمنع الصفحة من النزول تلقائياً إلى الأسفل.\n  - **[طي الإدخال](https://voyager.nagi.fun/ar/guide/input-collapse)**: منطقة إدخال قابلة للطي تلقائياً لتوفير مساحة قراءة أكبر.\n  - **[النموذج الافتراضي](https://voyager.nagi.fun/ar/guide/default-model)**: تعيين النموذج المفضل لديك كنموذج افتراضي.\n  - **[إخفاء العناصر الأخيرة والـ Gems](https://voyager.nagi.fun/ar/guide/recents-hider)**: إخفاء قائمة \"الأخيرة\" في الشريط الجانبي لتقليل التشتت.\n\n### 🎨 التخصيص\n\n- افتح نافذة الإضافة المنبثقة وابحث عن **التأثيرات البصرية** للتبديل بين `إيقاف`، `ثلج`، `ساكورا`، و`مطر`.\n- التأثيرات تُعرض كطبقات خفيفة بملء الشاشة ولا تعيق التفاعل مع الصفحة.\n- عند تبديل التأثيرات أو إيقافها، تتلاشى الجسيمات بشكل طبيعي بدلاً من الاختفاء المفاجئ.\n\n---\n\n## 📥 التثبيت\n\n> ⚠️ ملاحظة: مدير المطالبات هو الميزة الوحيدة التي تدعم Gemini للمؤسسات.\n\n<div align=\"center\">\n  <a href=\"https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=readme&utm_campaign=organic_growth&utm_content=ar\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Chrome%20Web%20Store-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white\" alt=\"Chrome Web Store\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Microsoft%20Edge-0078D7?style=for-the-badge&logo=microsoftedge&logoColor=white\" alt=\"Microsoft Edge Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://addons.mozilla.org/firefox/addon/gemini-voyager/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Firefox Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager/releases/latest/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Safari-000000?style=for-the-badge&logo=safari&logoColor=white\" alt=\"Safari تنزيل\" height=\"36\">\n  </a>\n</div>\n\n<p align=\"center\">\n  <sub>سوق <b>Chrome</b> الإلكتروني يعمل أيضاً على Edge و Opera و Brave و Vivaldi و Arc ومتصفحات Chromium الأخرى.</sub>\n</p>\n\n> **حالة المتجر:** Chrome ✅ · Firefox ✅ · Edge ✅ · Safari ✅\n\nلـ **التثبيت اليدوي** أو **بناء التطوير**، يرجى الرجوع إلى [دليل التثبيت](https://voyager.nagi.fun/ar/guide/installation).\n\n---\n\n## ☕ دعم هذا المشروع\n\n<div align=\"center\">\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" />\n  </a>\n</div>\n\nإذا كان Voyager يسهل حياتك، فكر في دعوتي لتناول القهوة. يساعد ذلك في استمرار التحديثات! سيتم إدراج الداعمين في قسم الشكر الخاص بنا. ❤️\n\n<div align=\"center\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" height=\"36\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" height=\"36\">\n  </a>\n  \n  <p><b>أو الدعم عبر WeChat / Alipay / Afdian:</b></p>\n  <table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n    <tr>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/wechat-sponsor.png\" alt=\"WeChat Pay\" height=\"160\"><br>\n        <sub><b>WeChat Pay</b></sub>\n      </td>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/alipay-sponsor.jpg\" alt=\"Alipay\" height=\"160\"><br>\n        <sub><b>Alipay</b></sub>\n      </td>\n      <td align=\"center\">\n        <a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n          <picture>\n            <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n            <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n            <img alt=\"Nagi-ovo's Profile\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n          </picture>\n        </a><br>\n        <sub><b>Afdian</b></sub>\n      </td>\n    </tr>\n  </table>\n</div>\n\n### 🎙️ أداة موصى بها: Typeless\n\nأوصي بشدة بـ **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)**، وهي أداة تحويل الصوت إلى نص بالذكاء الاصطناعي استخدمتها على نطاق واسع أثناء تطوير Voyager. لقد وفرت لي الكثير من الوقت وزادت من إنتاجيتي بشكل كبير.\n\n> 🎁 **[انضم عبر رابط الإحالة الخاص بي](https://www.typeless.com/?via=gemini-voyager)** (الكود: _`gemini-voyager`_) للحصول على **5 دولارات رصيد مجاني**. ❤️\n\n---\n\n## 🤝 المساهمة والتطوير\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\nنرحب بالمساهمات!\n\n- **Issues**: استخدم نماذجنا لـ [تقرير الأخطاء](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/bug_report.md) أو [طلب الميزات](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml).\n- **Pull Requests**: تحقق من [CONTRIBUTING.md](./CONTRIBUTING.md).\n\nشكراً لمساعدتك في جعل Voyager أفضل! ❤️\n\n### ❤️ شكر خاص\n\nشكر خاص لجميع المساهمين على مساهماتهم في Voyager ❤️\n\n<a href=\"https://github.com/Nagi-ovo/gemini-voyager/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=Nagi-ovo/gemini-voyager&max=200&columns=14\" />\n</a>\n\n---\n\n## 🌟 الاعتمادات\n\n- **[DeepSeek Voyager](https://github.com/Azurboy/deepseek-voyager)** - نسخة مشتقة من Voyager مخصصة لـ DeepSeek.\n\n- **[claude-nexus](https://github.com/Qiuner/claude-nexus)** - إضافة لتحسين Claude.ai مستوحاة من Voyager، تتضمن التنقّل عبر الخط الزمني، وإدارة المجلدات، ومكتبة المطالبات والمزيد، مع توافق كامل لاستيراد/تصدير المطالبات مع Voyager!\n\n- **[ChatGPT Conversation Timeline](https://github.com/Reborn14/chatgpt-conversation-timeline)** - مصدر الإلهام الأصلي للتنقل في الجدول الزمني للمحادثة.\n\n- **[Ophel Atlas](https://github.com/urzeye/ophel)** - إضافة متصفح تحوّل محادثات الذكاء الاصطناعي إلى مستندات منظمة وقابلة للبحث، مع إنشاء تلقائي للمخططات وإدارة المحادثات ومكتبة للأوامر، وتدعم منصات ذكاء اصطناعي متعددة.\n\n---\n\n<div align=\"center\">\n  <a href=\"https://www.star-history.com/#Nagi-ovo/gemini-voyager&type=date&legend=top-left\">\n   <picture>\n     <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&theme=dark&legend=top-left\" />\n     <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n     <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n   </picture>\n  </a>\n  <p>صنع بكل ❤️ بواسطة Jesse Zhang</p>\n  <sub>رخصة GPLv3 © 2025</sub>\n</div>\n"
  },
  {
    "path": ".github/README_ES.md",
    "content": "<div align=\"center\">\n  <img src=\"../docs/public/assets/promotion/Promo-Banner.png\" alt=\"promotion\"/>\n  <h3>Haz que tu experiencia con Gemini™ sea verdaderamente tuya ✨</h3>\n  <p>\n    Navegación elegante por línea de tiempo, organización de chats con carpetas y tu propio depósito de prompts.<br>\n    <b>Es la pieza que le faltaba a Google Gemini.</b>\n  </p>\n  \n  <p>\n    <img src=\"https://img.shields.io/badge/Chrome-✓-4285F4?style=flat-square&logo=googlechrome&logoColor=white\" alt=\"Chrome\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoftedge&logoColor=white\" alt=\"Edge\">\n    <img src=\"https://img.shields.io/badge/Firefox-✓-FF7139?style=flat-square&logo=firefox&logoColor=white\" alt=\"Firefox\">\n    <img src=\"https://img.shields.io/badge/Safari-✓-000000?style=flat-square&logo=safari&logoColor=white\" alt=\"Safari\">\n    <img src=\"https://img.shields.io/badge/Opera-✓-FF1B2D?style=flat-square&logo=opera&logoColor=white\" alt=\"Opera\">\n    <img src=\"https://img.shields.io/badge/Brave-✓-FB542B?style=flat-square&logo=brave&logoColor=white\" alt=\"Brave\">\n  </p>\n  <p>\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub stars\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub forks\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Latest version\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub downloads\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store users\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store rating\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons users\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons rating\">\n  </p>\n  <p>\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Gemini Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </p>\n  <p align=\"center\">\n    ✨ ¡Estamos en Product Hunt! Nos encantaría conocer tu opinión. ❤️\n  </p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://voyager.nagi.fun/es\">📖 Documentación</a> • \n  <a href=\"../README.md\">English</a> • \n  <a href=\"./README_ZH.md\">简体中文</a> •\n  <a href=\"./README_ZH_TW.md\">繁體中文</a> •\n  <a href=\"./README_JA.md\">日本語</a> •\n  <a href=\"./README_FR.md\">Français</a> •\n  <a href=\"./README_PT.md\">Português</a> •\n  <a href=\"./README_RU.md\">Русский</a> •\n  <a href=\"./README_AR.md\">العربية</a> •\n  <a href=\"./README_KO.md\">한국어</a>\n</p>\n\n<p align=\"center\">\n    <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n  </p>\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024507762483277927?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/x-recommendation.png\" alt=\"KOL Recommendation\" width=\"500\">\n  </a>\n  <br>\n  <b>🎉 ¡Altamente recomendado por los principales influencers tecnológicos!</b>\n</p>\n\n> [!IMPORTANT]\n> **Aviso de cambio de nombre**: Debido a problemas de marcas y derechos de autor, esta extensión ha sido renombrada oficialmente a **Voyager**. Sin embargo, debido a la lentitud del proceso de revisión de Chrome Web Store, el cambio de nombre no fue aprobado en 7 días — está temporalmente no disponible en Chrome Web Store.\n\n> [!NOTE]\n> Si Voyager te resulta útil, compártelo en X, Reddit, YouTube, etc. Cada difusión ayuda a que más personas descubran el proyecto y mejoren la experiencia con Gemini. Gracias.\n\n---\n\n## 👋 ¿Por qué Voyager?\n\nNos encanta Gemini, pero a veces desearíamos que tuviera un poco más de estructura.\n\nPor eso creamos **Voyager**. No es solo una herramienta; es un compañero que te ayuda a mantener tus conversaciones con IA organizadas, accesibles y productivas. Ya seas un investigador que maneja docenas de hilos, un desarrollador que guarda fragmentos de código, o simplemente alguien que ama el orden, Voyager está diseñado para ti.\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024509398601597412?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/try-voyager.png\" alt=\"Try Voyager\" width=\"500\">\n  </a>\n  <br>\n  <i>Durante el problema del 18 de febrero en el que la aplicación Google Gemini hizo inaccesibles las conversaciones históricas de algunos usuarios, los usuarios de Voyager aún pudieron ver sus conversaciones guardadas en sus carpetas.</i>\n</p>\n\n---\n\n## ✨ Funcionalidades\n\n### 🌌 Núcleo Común (Gemini & AI Studio)\n\n- **📂 [Organización por Carpetas](https://voyager.nagi.fun/es/guide/folders)**: Organiza tus chats en una jerarquía de dos niveles con soporte para **arrastrar y soltar** y **sincronización con Google Drive**.\n  - **Gemini**: Soporta **Modo de Aislamiento de Cuenta** y **Colores de Carpeta Personalizados**.\n- **💡 [Depósito de Prompts](https://voyager.nagi.fun/es/guide/prompts)**: Guarda y reutiliza tus mejores prompts en Gemini, AI Studio y [sitios web personalizados](https://voyager.nagi.fun/es/guide/custom-websites).\n- **☁️ [Sincronización en la Nube](https://voyager.nagi.fun/es/guide/cloud-sync)**: Sincroniza tus carpetas y depósito de prompts con Google Drive.\n- **📐 Copia de Fórmulas**: Copia en un clic los códigos fuente LaTeX y MathML (Word).\n- **🌦️ Efectos Visuales**: Añade un ambiente estacional con **nieve**, **lluvia cinematográfica** o **pétalos de sakura** desde el panel de configuración.\n\n### ✨ Funciones Exclusivas de Gemini\n\n- **📍 [Navegación de Línea de Tiempo](https://voyager.nagi.fun/es/guide/timeline)**: Nodos visuales para saltar entre mensajes, destacar momentos clave y gestionar ramas de conversación.\n- **💾 [Exportación de Chat](https://voyager.nagi.fun/es/guide/export)**: Exporta conversaciones a JSON, Markdown o PDF con imágenes incluidas.\n- **🧜‍♀️ [Renderizado Mermaid](https://voyager.nagi.fun/es/guide/mermaid)**: Renderizado automático de diagramas de flujo, diagramas de secuencia y otros gráficos Mermaid.\n- **📝 [Corrección de Renderizado Markdown](https://voyager.nagi.fun/es/guide/markdown-fix)**: Corrige automáticamente la sintaxis de negrita de Markdown dañada por los elementos HTML inyectados por Gemini.\n- **🍌 [NanoBanana](https://voyager.nagi.fun/es/guide/nanobanana)**: Eliminación de marca de agua sin pérdida para imágenes generadas por Gemini.\n- **🔬 [Deep Research](https://voyager.nagi.fun/es/guide/deep-research)**: Extrae procesos de pensamiento y enlaces de investigación de las sesiones de Deep Research.\n- **🛠️ Herramientas de Productividad**:\n  - **[Eliminación por Lote](https://voyager.nagi.fun/es/guide/batch-delete)**: Limpia tu historial de forma masiva.\n  - **[Respuesta con Cita](https://voyager.nagi.fun/es/guide/quote-reply)**: Responde con contexto seleccionando texto.\n  - **[Sincronización de Título de Pestaña](https://voyager.nagi.fun/es/guide/tab-title)**: Sincroniza automáticamente el título de la pestaña del navegador.\n  - **[Evitar desplazamiento automático](https://voyager.nagi.fun/es/guide/prevent-auto-scroll)**: Intercepta el comportamiento de salto no deseado al enviar un mensaje.\n  - **[Colapso de Entrada](https://voyager.nagi.fun/es/guide/input-collapse)**: Área de entrada auto-colapsable para más espacio de lectura.\n  - **[Modelo Predeterminado](https://voyager.nagi.fun/es/guide/default-model)**: Establece tu modelo preferido por defecto.\n  - **[Ocultar elementos recientes y Gems](https://voyager.nagi.fun/es/guide/recents-hider)**: Oculta la lista \"Recientes\" en la barra lateral para reducir las distracciones.\n\n### 🎨 Personalización\n\n- Abre el popup de la extensión y busca **Efectos Visuales** para cambiar entre `Apagado`, `Nieve`, `Sakura` y `Lluvia`.\n- Los efectos se renderizan como capas ligeras a pantalla completa y no bloquean la interacción con la página.\n- Al cambiar de efecto o desactivarlo, las partículas se desvanecen naturalmente en lugar de desaparecer abruptamente.\n\n---\n\n## 📥 Instalación\n\n> ⚠️ Nota: El Administrador de Prompts es la única función que admite Gemini para Empresas.\n\n<div align=\"center\">\n  <a href=\"https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=readme&utm_campaign=organic_growth&utm_content=es\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Chrome%20Web%20Store-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white\" alt=\"Chrome Web Store\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Microsoft%20Edge-0078D7?style=for-the-badge&logo=microsoftedge&logoColor=white\" alt=\"Microsoft Edge Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://addons.mozilla.org/firefox/addon/gemini-voyager/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Firefox Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager/releases/latest/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Safari-000000?style=for-the-badge&logo=safari&logoColor=white\" alt=\"Safari Descargar\" height=\"36\">\n  </a>\n</div>\n\n<p align=\"center\">\n  <sub><b>Chrome Web Store</b> también funciona en Edge, Opera, Brave, Vivaldi, Arc y otros navegadores Chromium.</sub>\n</p>\n\n> **Estado de la Tienda:** Chrome ✅ · Firefox ✅ · Edge ✅ · Safari ✅\n\nPara **instalación manual** o **compilaciones de desarrollo**, consulta la [Guía de Instalación](https://voyager.nagi.fun/es/guide/installation).\n\n---\n\n## ☕ Apoya este Proyecto\n\n<div align=\"center\">\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" />\n  </a>\n</div>\n\nSi Voyager te facilita la vida, considera invitarme a un café. ¡Ayuda a mantener las actualizaciones! Los patrocinadores aparecerán en nuestra sección de Agradecimientos Especiales. ❤️\n\n<div align=\"center\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Invítame%20a%20un%20café-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" height=\"36\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Patrocíname-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" height=\"36\">\n  </a>\n  \n  <p><b>O apoya a través de WeChat / Alipay / Afdian:</b></p>\n  <table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n    <tr>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/wechat-sponsor.png\" alt=\"WeChat Pay\" height=\"160\"><br>\n        <sub><b>WeChat Pay</b></sub>\n      </td>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/alipay-sponsor.jpg\" alt=\"Alipay\" height=\"160\"><br>\n        <sub><b>Alipay</b></sub>\n      </td>\n      <td align=\"center\">\n        <a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n          <picture>\n            <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n            <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n            <img alt=\"Nagi-ovo's Profile\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n          </picture>\n        </a><br>\n        <sub><b>Afdian</b></sub>\n      </td>\n    </tr>\n  </table>\n</div>\n\n### 🎙️ Herramienta recomendada: Typeless\n\nRecomiendo encarecidamente **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)**, una herramienta de voz a texto con IA que utilicé extensamente durante el desarrollo de Voyager. Integrarlo en mi flujo de trabajo diario me ha ahorrado muchísimo tiempo.\n\n> 🎁 **[Únete a través de mi enlace de referido](https://www.typeless.com/?via=gemini-voyager)** (Código: _`gemini-voyager`_) para obtener **$5 de crédito gratis**. ❤️\n\n---\n\n## 🤝 Contribución y Desarrollo\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\n¡Damos la bienvenida a las contribuciones!\n\n- **Issues**: Usa nuestras plantillas de [informe de errores](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/bug_report.md) o [solicitud de funcionalidades](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml).\n- **Pull Requests**: Revisa [CONTRIBUTING.md](./CONTRIBUTING.md).\n\n¡Gracias por ayudar a que Voyager sea mejor! ❤️\n\n### ❤️ Agradecimientos Especiales\n\nUn agradecimiento especial a todos los colaboradores por sus contribuciones a Voyager ❤️\n\n<a href=\"https://github.com/Nagi-ovo/gemini-voyager/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=Nagi-ovo/gemini-voyager&max=200&columns=14\" />\n</a>\n\n---\n\n## 🌟 Créditos\n\n- **[DeepSeek Voyager](https://github.com/Azurboy/deepseek-voyager)** - Un fork de Voyager adaptado para DeepSeek.\n\n- **[claude-nexus](https://github.com/Qiuner/claude-nexus)** - Una extensión de mejora para Claude.ai inspirada en Voyager, con navegación de línea de tiempo, gestión de carpetas, biblioteca de prompts y más, con compatibilidad total de importación/exportación de prompts con Voyager.\n\n- **[ChatGPT Conversation Timeline](https://github.com/Reborn14/chatgpt-conversation-timeline)** - La fuente original de inspiración para la navegación por línea de tiempo.\n\n- **[Ophel Atlas](https://github.com/urzeye/ophel)** - Una extensión de navegador que transforma las conversaciones de IA en documentos organizados y buscables, con esquemas autogenerados, gestión de conversaciones y biblioteca de prompts, compatible con múltiples plataformas de IA.\n\n---\n\n<div align=\"center\">\n  <a href=\"https://www.star-history.com/#Nagi-ovo/gemini-voyager&type=date&legend=top-left\">\n   <picture>\n     <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&theme=dark&legend=top-left\" />\n     <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n     <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n   </picture>\n  </a>\n  <p>Hecho con ❤️ por Jesse Zhang</p>\n  <sub>GPLv3 License © 2026</sub>\n</div>\n"
  },
  {
    "path": ".github/README_FR.md",
    "content": "<div align=\"center\">\n  <img src=\"../docs/public/assets/promotion/Promo-Banner.png\" alt=\"promotion\"/>\n  <h3>Personnalisez votre expérience Gemini™ ✨</h3>\n  <p>\n    Navigation temporelle élégante, organisation des chats par dossiers, et coffre-fort de prompts personnel.<br>\n    <b>C'est l'extension indispensable pour Google Gemini.</b>\n  </p>\n  \n  <p>\n    <img src=\"https://img.shields.io/badge/Chrome-✓-4285F4?style=flat-square&logo=googlechrome&logoColor=white\" alt=\"Chrome\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoftedge&logoColor=white\" alt=\"Edge\">\n    <img src=\"https://img.shields.io/badge/Firefox-✓-FF7139?style=flat-square&logo=firefox&logoColor=white\" alt=\"Firefox\">\n    <img src=\"https://img.shields.io/badge/Safari-✓-000000?style=flat-square&logo=safari&logoColor=white\" alt=\"Safari\">\n    <img src=\"https://img.shields.io/badge/Opera-✓-FF1B2D?style=flat-square&logo=opera&logoColor=white\" alt=\"Opera\">\n    <img src=\"https://img.shields.io/badge/Brave-✓-FB542B?style=flat-square&logo=brave&logoColor=white\" alt=\"Brave\">\n  </p>\n  <p>\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub stars\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub forks\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Latest version\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub downloads\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store users\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store rating\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons users\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons rating\">\n  </p>\n  <p>\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Gemini Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </p>\n  <p align=\"center\">\n    ✨ Nous sommes en direct sur Product Hunt ! Vos retours sont les bienvenus. ❤️\n  </p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://voyager.nagi.fun/fr\">📖 Documentation</a> • \n  <a href=\"../README.md\">English</a> • \n  <a href=\"./README_ZH.md\">简体中文</a> •\n  <a href=\"./README_ZH_TW.md\">繁體中文</a> •\n  <a href=\"./README_JA.md\">日本語</a> •\n  <a href=\"./README_ES.md\">Español</a> •\n  <a href=\"./README_PT.md\">Português</a> •\n  <a href=\"./README_RU.md\">Русский</a> •\n  <a href=\"./README_AR.md\">العربية</a> •\n  <a href=\"./README_KO.md\">한국어</a>\n</p>\n\n<p align=\"center\">\n    <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n  </p>\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024507762483277927?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/x-recommendation.png\" alt=\"KOL Recommendation\" width=\"500\">\n  </a>\n  <br>\n  <b>🎉 Fortement recommandé par les meilleurs influenceurs tech !</b>\n</p>\n\n> [!IMPORTANT]\n> **Avis de changement de nom** : En raison de problèmes de marque et de droits d'auteur, cette extension a été officiellement renommée **Voyager**. Cependant, en raison de la lenteur extrême du processus de révision du Chrome Web Store, le changement de nom n'a pas été approuvé dans les 7 jours — elle est temporairement indisponible sur le Chrome Web Store.\n\n> [!NOTE]\n> Si Voyager vous est utile, partagez-le sur X, Reddit, YouTube, etc. Chaque partage aide plus de personnes à découvrir le projet et à améliorer l'expérience Gemini. Merci.\n\n---\n\n## 👋 Pourquoi Voyager ?\n\nNous adorons Gemini, mais nous aimerions parfois qu'il soit un peu plus \"ordonné\".\n\nC'est pourquoi nous avons créé **Voyager**. Plus qu'un simple outil, c'est un compagnon qui vous aide à garder vos conversations IA organisées, accessibles et productives. Que vous soyez un chercheur gérant des dizaines de fils, un développeur sauvegardant des extraits de code, ou simplement quelqu'un qui aime l'ordre, Voyager est fait pour vous.\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024509398601597412?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/try-voyager.png\" alt=\"Try Voyager\" width=\"500\">\n  </a>\n  <br>\n  <i>Lors du problème du 18 février où l'application Google Gemini a rendu inaccessibles les conversations historiques de certains utilisateurs, les utilisateurs de Voyager ont toujours pu voir leurs conversations enregistrées dans leurs dossiers.</i>\n</p>\n\n---\n\n## ✨ Fonctionnalités\n\n### 🌌 Noyau Commun (Gemini & AI Studio)\n\n- **📂 [Dossiers](https://voyager.nagi.fun/fr/guide/folders)** : Organisez vos chats dans une hiérarchie à deux niveaux avec support du **glisser-déposer** et **synchronisation Google Drive**.\n  - **Gemini** : Support du **mode d'isolation de compte** et **couleurs de dossiers personnalisées**.\n- **💡 [Coffre-fort de Prompts](https://voyager.nagi.fun/fr/guide/prompts)** : Enregistrez et réutilisez vos meilleurs prompts sur Gemini, AI Studio et [sites personnalisés](https://voyager.nagi.fun/fr/guide/custom-websites).\n- **☁️ [Sincronisation Cloud](https://voyager.nagi.fun/fr/guide/cloud-sync)** : Synchronisez vos dossiers et coffre-fort de prompts avec Google Drive.\n- **📐 Copie de Formules**: Copie en un clic des codes sources LaTeX et MathML (Word).\n- **🌦️ Effets Visuels** : Ajoutez une ambiance saisonnière avec **neige**, **pluie cinématique** ou **pétales de sakura** depuis le panneau de paramètres.\n\n### ✨ Fonctions Exclusives Gemini\n\n- **📍 [Navigation Temporelle](https://voyager.nagi.fun/fr/guide/timeline)** : Des nœuds visuels pour naviguer entre les messages, marquer les moments clés et gérer les branches de conversation.\n- **💾 [Export de Chat](https://voyager.nagi.fun/fr/guide/export)** : Exportez vos conversations en JSON, Markdown ou PDF avec les images incluses.\n- **🧜‍♀️ [Rendu Mermaid](https://voyager.nagi.fun/fr/guide/mermaid)**: Rendu automatique des organigrammes, diagrammes de séquence et autres graphiques Mermaid.\n- **📝 [Correction du Rendu Markdown](https://voyager.nagi.fun/fr/guide/markdown-fix)**: Répare automatiquement la syntaxe Markdown grasse corrompue par les éléments HTML injectés par Gemini.\n- **🍌 [NanoBanana](https://voyager.nagi.fun/fr/guide/nanobanana)** : Suppression sans perte du filigrane Gemini sur les images générées.\n- **🔬 [Deep Research](https://voyager.nagi.fun/fr/guide/deep-research)** : Extrayez les processus de réflexion et les liens de recherche des sessions Deep Research.\n- **🛠️ Outils de Productivité** :\n  - **[Suppression par Lot](https://voyager.nagi.fun/fr/guide/batch-delete)** : Nettoyage massif de votre historique.\n  - **[Réponse avec Citation](https://voyager.nagi.fun/fr/guide/quote-reply)** : Répondez avec contexte en sélectionnant simplement du texte.\n  - **[Synchro Titre Onglet](https://voyager.nagi.fun/fr/guide/tab-title)** : Synchronisation automatique du titre de l'onglet avec le titre du chat.\n  - **[Empêcher le défilement auto](https://voyager.nagi.fun/fr/guide/prevent-auto-scroll)** : Intercepte le saut incontrôlé vers le bas de la page.\n  - **[Réduction Entrée](https://voyager.nagi.fun/fr/guide/input-collapse)** : Zone de saisie auto-réductible pour plus d'espace de lecture.\n  - **[Modèle par Défaut](https://voyager.nagi.fun/fr/guide/default-model)** : Définissez votre modèle préféré par défaut.\n  - **[Masquer les éléments récents et Gems](https://voyager.nagi.fun/fr/guide/recents-hider)** : Masquer la liste \"Récents\" dans la barre latérale pour réduire les distractions.\n\n### 🎨 Personnalisation\n\n- Ouvrez le popup de l'extension et recherchez **Effets Visuels** pour basculer entre `Désactivé`, `Neige`, `Sakura` et `Pluie`.\n- Les effets sont rendus sous forme de couches légères plein écran qui ne bloquent pas l'interaction avec la page.\n- Lors du changement d'effet ou de la désactivation, les particules s'estompent naturellement au lieu de disparaître brusquement.\n\n---\n\n## 📥 Installation\n\n> ⚠️ Note : Le Gestionnaire de Prompts est la seule fonctionnalité supportant Gemini for Enterprise.\n\n<div align=\"center\">\n  <a href=\"https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=readme&utm_campaign=organic_growth&utm_content=fr\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Chrome%20Web%20Store-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white\" alt=\"Chrome Web Store\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Microsoft%20Edge-0078D7?style=for-the-badge&logo=microsoftedge&logoColor=white\" alt=\"Microsoft Edge Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://addons.mozilla.org/firefox/addon/gemini-voyager/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Firefox Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager/releases/latest/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Safari-000000?style=for-the-badge&logo=safari&logoColor=white\" alt=\"Safari Télécharger\" height=\"36\">\n  </a>\n</div>\n\n<p align=\"center\">\n  <sub>Le <b>Chrome Web Store</b> fonctionne aussi sur Edge, Opera, Brave, Vivaldi, Arc et autres navigateurs Chromium.</sub>\n</p>\n\n> **Statut des Stores :** Chrome ✅ · Firefox ✅ · Edge ✅ · Safari ✅\n\nPour une **installation manuelle** ou des **builds de développement**, veuillez vous référer au [Guide d'Installation](https://voyager.nagi.fun/fr/guide/installation).\n\n---\n\n## ☕ Soutenir le Projet\n\n<div align=\"center\">\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" />\n  </a>\n</div>\n\nSi Voyager facilite votre vie, considérez m'offrir un café. Cela aide à maintenir les mises à jour ! Les donateurs seront cités dans notre section remerciements. ❤️\n\n<div align=\"center\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" height=\"36\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" height=\"36\">\n  </a>\n  \n  <p><b>Ou via WeChat / Alipay / Afdian :</b></p>\n  <table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n    <tr>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/wechat-sponsor.png\" alt=\"WeChat Pay\" height=\"160\"><br>\n        <sub><b>WeChat Pay</b></sub>\n      </td>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/alipay-sponsor.jpg\" alt=\"Alipay\" height=\"160\"><br>\n        <sub><b>Alipay</b></sub>\n      </td>\n      <td align=\"center\">\n        <a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n          <picture>\n            <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n            <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n            <img alt=\"Nagi-ovo's Profile\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n          </picture>\n        </a><br>\n        <sub><b>Afdian</b></sub>\n      </td>\n    </tr>\n  </table>\n</div>\n\n### 🎙️ Outil recommandé : Typeless\n\nJe recommande vivement **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)**, un outil de synthèse vocale IA que j'ai utilisé intensivement durant le développement. Il booste considérablement la productivité.\n\n> 🎁 **[Rejoignez via mon lien](https://www.typeless.com/?via=gemini-voyager)** (Code : _`gemini-voyager`_) pour obtenir **5$ de crédits gratuits**. ❤️\n\n---\n\n## 🤝 Contribution & Développement\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\nToute contribution est la bienvenue !\n\n- **Issues** : Utilisez nos templates de [bug report](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/bug_report.md) ou [feature request](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml).\n- **Pull Requests** : Consultez [CONTRIBUTING.md](./CONTRIBUTING.md).\n\nMerci d'aider à rendre Voyager meilleur ! ❤️\n\n### ❤️ Remerciements Spéciaux\n\nUn grand merci à tous les contributeurs pour leurs contributions à Voyager ❤️\n\n<a href=\"https://github.com/Nagi-ovo/gemini-voyager/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=Nagi-ovo/gemini-voyager&max=200&columns=14\" />\n</a>\n\n---\n\n## 🌟 Crédits\n\n- **[DeepSeek Voyager](https://github.com/Azurboy/deepseek-voyager)** - Un fork de Voyager adapté pour DeepSeek.\n\n- **[claude-nexus](https://github.com/Qiuner/claude-nexus)** - Une extension d’amélioration pour Claude.ai inspirée de Voyager, avec navigation par timeline, gestion de dossiers, bibliothèque de prompts, et une compatibilité totale d’import/export des prompts avec Voyager !\n\n- **[ChatGPT Conversation Timeline](https://github.com/Reborn14/chatgpt-conversation-timeline)** - La source d'inspiration originale pour la navigation temporelle.\n\n- **[Ophel Atlas](https://github.com/urzeye/ophel)** - Une extension de navigateur qui transforme les conversations IA en documents organisés et consultables, avec génération automatique de plans, gestion des conversations et bibliothèque de prompts, compatible avec plusieurs plateformes d'IA.\n\n---\n\n<div align=\"center\">\n  <a href=\"https://www.star-history.com/#Nagi-ovo/gemini-voyager&type=date&legend=top-left\">\n   <picture>\n     <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&theme=dark&legend=top-left\" />\n     <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n     <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n   </picture>\n  </a>\n  <p>Fait avec ❤️ par Jesse Zhang</p>\n  <sub>GPLv3 License © 2026</sub>\n</div>\n"
  },
  {
    "path": ".github/README_JA.md",
    "content": "<div align=\"center\">\n  <img src=\"../docs/public/assets/promotion/Promo-Banner-jp.png\" alt=\"promotion\"/>\n  <h3>Gemini™ 体験を自分好みに ✨</h3>\n  <p>\n    エレガントなタイムライン、フォルダ管理、そしてプロンプト管理。<br>\n    <b>Gemini に足りなかった「最後のピース」がここにあります。</b>\n  </p>\n  \n  <p>\n    <img src=\"https://img.shields.io/badge/Chrome-✓-4285F4?style=flat-square&logo=googlechrome&logoColor=white\" alt=\"Chrome\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoftedge&logoColor=white\" alt=\"Edge\">\n    <img src=\"https://img.shields.io/badge/Firefox-✓-FF7139?style=flat-square&logo=firefox&logoColor=white\" alt=\"Firefox\">\n    <img src=\"https://img.shields.io/badge/Safari-✓-000000?style=flat-square&logo=safari&logoColor=white\" alt=\"Safari\">\n    <img src=\"https://img.shields.io/badge/Opera-✓-FF1B2D?style=flat-square&logo=opera&logoColor=white\" alt=\"Opera\">\n    <img src=\"https://img.shields.io/badge/Brave-✓-FB542B?style=flat-square&logo=brave&logoColor=white\" alt=\"Brave\">\n  </p>\n  <p>\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Star\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Fork\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"最新バージョン\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub ダウンロード数\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome ユーザー数\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome 評価\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox ユーザー数\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox 評価\">\n  </p>\n  <p>\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Gemini Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </p>\n  <p align=\"center\">\n    ✨ Product Hunt に登場しました！応援よろしくお願いします。❤️\n  </p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://voyager.nagi.fun/ja\">📖 ドキュメント</a> • \n  <a href=\"../README.md\">English</a> • \n  <a href=\"./README_ZH.md\">简体中文</a> •\n  <a href=\"./README_ZH_TW.md\">繁體中文</a> •\n  <a href=\"./README_FR.md\">Français</a> •\n  <a href=\"./README_ES.md\">Español</a> •\n  <a href=\"./README_PT.md\">Português</a> •\n  <a href=\"./README_RU.md\">Русский</a> •\n  <a href=\"./README_AR.md\">العربية</a> •\n  <a href=\"./README_KO.md\">한국어</a>\n</p>\n\n<p align=\"center\">\n    <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n  </p>\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024507762483277927?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/x-recommendation.png\" alt=\"KOL Recommendation\" width=\"500\">\n  </a>\n  <br>\n  <b>🎉 トップテック KOL やコミュニティから強く推奨されています！</b>\n</p>\n\n> [!IMPORTANT]\n> **名称変更のお知らせ**：商標・著作権上の問題により、本拡張機能は正式に **Voyager** へ改名されました。ただし、Chrome ウェブストアの審査が非常に遅いため、7 日以内に名称変更が承認されず、現在 Chrome Web Store では一時的にご利用いただけない状態です。\n\n> [!NOTE]\n> もし Voyager が役に立っているなら、X、YouTube、Reddit などで共有してもらえると嬉しいです。シェアが増えるほど、このプロジェクトをより多くの人に届けられ、Gemini の体験改善にもつながります。ありがとう。\n\n---\n\n## 👋 Voyager とは？\n\n私たちは Gemini が大好きですが、時にはもう少し「秩序」が欲しいと感じることがあります。\n\nそれが **Voyager** を開発した理由です。これは単なるツールではなく、AI との会話を整理し、アクセスしやすく、生産的にするためのパートナーです。多くのスレッドを扱う研究者、コードを保存したい開発者、あるいは単に整理整頓が好きな方、Voyager はあなたのための拡張機能です。\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024509398601597412?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/try-voyager.png\" alt=\"Try Voyager\" width=\"500\">\n  </a>\n  <br>\n  <i>2 月 18 日に Google Gemini App が一部のユーザーの履歴会話にアクセスできなくなる問題を引き起こした際、Voyager のユーザーは引き続きフォルダ内に保存された会話を見ることができました。</i>\n</p>\n\n---\n\n## ✨ 主な機能\n\n### 🌌 共通コア (Gemini & AI Studio)\n\n- **📂 [フォルダ管理](https://voyager.nagi.fun/ja/guide/folders)**: **階層構造**、**ドラッグ＆ドロップによる並べ替え**、**Google ドライブ同期**をサポート。\n  - **Gemini**: **マルチアカウント分離モード**と**カスタムフォルダカラー**をサポート。\n- **💡 [プロンプト管理](https://voyager.nagi.fun/ja/guide/prompts)**: プロンプトを保存して再利用。Gemini、AI Studio、[カスタムサイト](https://voyager.nagi.fun/ja/guide/custom-websites)で使用可能。\n- **☁️ [クラウド同期](https://voyager.nagi.fun/ja/guide/cloud-sync)**: フォルダとプロンプトを Google ドライブに同期します。\n- **📐 数式コピー**: LaTeX および MathML (Word) のソースコードを一クリックでコピー。\n- **🌦️ ビジュアルエフェクト**: **雪**、**映画的な雨**、**桜の花びら**で季節の雰囲気を演出。設定パネルから切り替え可能。\n\n### ✨ Gemini 専用機能\n\n- **📍 [タイムライン](https://voyager.nagi.fun/ja/guide/timeline)**: 会話構造を可視化し、メッセージ間を瞬時に移動。重要なメッセージのスター保存も可能。\n- **💾 [エクスポート](https://voyager.nagi.fun/ja/guide/export)**: 会話を JSON、Markdown、PDF 形式で保存（画像込み）。\n- **🧜‍♀️ [Mermaid レンダリング](https://voyager.nagi.fun/ja/guide/mermaid)**: フローチャート、シーケンス図、その他の Mermaid チャートを自動レンダリングします。\n- **📝 [Markdown レンダリングの修正](https://voyager.nagi.fun/ja/guide/markdown-fix)**: Gemini が挿入した HTML 要素によって壊れた Markdown の太字構文を自動的に修正します。\n- **🍌 [NanoBanana](https://voyager.nagi.fun/ja/guide/nanobanana)**: Gemini で生成された画像からウォーターマークを自動除去。\n- **🔬 [Deep Research](https://voyager.nagi.fun/ja/guide/deep-research)**: 思考プロセスやリサーチリンクを Markdown で抽出。\n- **🛠️ パワーツール**:\n  - **[一括削除](https://voyager.nagi.fun/ja/guide/batch-delete)**: 履歴をまとめてクリーンアップ。\n  - **[引用返信](https://voyager.nagi.fun/ja/guide/quote-reply)**: テキストを選択してワンクリックで引用。\n  - **[タブタイトルの同期](https://voyager.nagi.fun/ja/guide/tab-title)**: タブの名前をチャットのタイトルに自動変更。\n  - **[自動スクロール防止](https://voyager.nagi.fun/ja/guide/prevent-auto-scroll)**: 新しいプロンプトを送信する際の迷惑なジャンプ動作を阻止します。\n  - **[入力欄の自動非表示](https://voyager.nagi.fun/ja/guide/input-collapse)**: 未入力時に折りたたんで表示スペースを確保。\n  - **[デフォルトモデル](https://voyager.nagi.fun/ja/guide/default-model)**: 新しい対話に使用するデフォルトのモデルを設定します。\n  - **[最近の項目と Gem を非表示](https://voyager.nagi.fun/ja/guide/recents-hider)**: サイドバーの「最近」リストを非表示にして、集中力を高めます。\n\n### 🎨 パーソナライズ\n\n- 拡張機能のポップアップを開き、**ビジュアルエフェクト** で `オフ`、`雪`、`桜`、`雨` を切り替えられます。\n- エフェクトは軽量なフルスクリーンオーバーレイとしてレンダリングされ、ページの操作を妨げません。\n- エフェクトを切り替えたりオフにしたりすると、パーティクルは突然消えるのではなく、自然にフェードアウトします。\n\n---\n\n## 📥 インストール\n\n> ⚠️ 注意：プロンプト管理のみが Gemini for Enterprise をサポートしています。\n\n<div align=\"center\">\n  <a href=\"https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=readme&utm_campaign=organic_growth&utm_content=ja\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Chrome%20ストア-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white\" alt=\"Chrome Web Store\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Microsoft%20Edge-0078D7?style=for-the-badge&logo=microsoftedge&logoColor=white\" alt=\"Microsoft Edge Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://addons.mozilla.org/firefox/addon/gemini-voyager/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Firefox%20アドオン-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Firefox Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager/releases/latest/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Safari-000000?style=for-the-badge&logo=safari&logoColor=white\" alt=\"Safari ダウンロード\" height=\"36\">\n  </a>\n</div>\n\n<p align=\"center\">\n  <sub><b>Chrome ストア版</b>は Edge, Opera, Brave, Vivaldi, Arc などの Chromium 系ブラウザでも動作します。</sub>\n</p>\n\n> **ストア状況：** Chrome ✅ · Firefox ✅ · Edge ✅ · Safari ✅\n\n**手動インストール**や**開発用ビルド**については、[インストールガイド](https://voyager.nagi.fun/ja/guide/installation)を参照してください。\n\n---\n\n## ☕ 支援のお願い\n\n<div align=\"center\">\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" />\n  </a>\n</div>\n\nVoyager がお役に立ちましたら、コーヒー一杯分のご支援をいただけますと幸いです。アップデートの継続に繋がります！❤️\n\n<div align=\"center\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" height=\"36\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" height=\"36\">\n  </a>\n  \n  <p><b>WeChat / Alipay / Afdian でも支援可能です：</b></p>\n  <table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n    <tr>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/wechat-sponsor.png\" alt=\"WeChat Pay\" height=\"160\"><br>\n        <sub><b>WeChat Pay</b></sub>\n      </td>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/alipay-sponsor.jpg\" alt=\"Alipay\" height=\"160\"><br>\n        <sub><b>Alipay</b></sub>\n      </td>\n      <td align=\"center\">\n        <a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n          <picture>\n            <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n            <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n            <img alt=\"Nagi-ovo's Profile\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n          </picture>\n        </a><br>\n        <sub><b>Afdian</b></sub>\n      </td>\n    </tr>\n  </table>\n</div>\n\n### 🎙️ おすすめツール：Typeless\n\nVoyager の開発中、私は AI 音声入力ツール **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)** を愛用していました。日々のワークフローに欠かせない、生産性を劇的に向上させてくれるツールです。\n\n> 🎁 **[こちらのリンク](https://www.typeless.com/?via=gemini-voyager)**（招待コード：_`gemini-voyager`_）から登録すると、**5 ドルの無料クレジット**がもらえます。❤️\n\n---\n\n## 🤝 貢献と開発\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\nバグ報告、機能提案、ドキュメントの改善など、あらゆる貢献を歓迎します！\n\n- **Issues**: [バグ報告](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/bug_report.md) または [機能提案](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml) テンプレートを使用してください。\n- **Pull Requests**: [CONTRIBUTING.md](./CONTRIBUTING.md) をご確認ください。\n\n<details>\n<summary>開発環境の構築</summary>\n\n```bash\n# 依存関係のインストール (Bun 推奨)\nbun i\n\n# 開発モード (ホットリロード対応)\nbun run dev:chrome\nbun run dev:firefox\nbun run dev:safari\n\n# ビルド\nbun run build:all\n```\n\n**Safari での開発**: 詳細は [safari/README.md](../safari/README.md) を参照してください。\n\n</details>\n\nVoyager をより良くするために協力してくださり、ありがとうございます！❤️\n\n### ❤️ スペシャルサンクス\n\nVoyager に貢献してくださったすべてのコントリビューターに感謝します ❤️\n\n<a href=\"https://github.com/Nagi-ovo/gemini-voyager/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=Nagi-ovo/gemini-voyager&max=200&columns=14\" />\n</a>\n\n---\n\n## 🌟 クレジット\n\n- **[DeepSeek Voyager](https://github.com/Azurboy/deepseek-voyager)** - Voyager を DeepSeek 向けに移植したプロジェクト。\n\n- **[claude-nexus](https://github.com/Qiuner/claude-nexus)** - Voyager に着想を得た Claude.ai 向け拡張機能。タイムラインナビゲーション、フォルダ管理、プロンプトライブラリなどを備え、Voyager のプロンプトのインポート／エクスポートと完全互換です！\n\n- **[ChatGPT Conversation Timeline](https://github.com/Reborn14/chatgpt-conversation-timeline)** - オリジナルの ChatGPT 向け拡張機能。このプロジェクトのインスピレーションの源です。\n\n- **[Ophel Atlas](https://github.com/urzeye/ophel)** - AI チャットを整理された検索可能なドキュメントに変換するブラウザ拡張機能。アウトラインの自動生成、会話管理、プロンプトライブラリを備え、複数の AI プラットフォームに対応しています。\n\n---\n\n<div align=\"center\">\n  <a href=\"https://www.star-history.com/#Nagi-ovo/gemini-voyager&type=date&legend=top-left\">\n   <picture>\n     <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&theme=dark&legend=top-left\" />\n     <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n     <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n   </picture>\n  </a>\n  <p>Made with ❤️ by Jesse Zhang</p>\n  <sub>GPLv3 License © 2026</sub>\n</div>\n"
  },
  {
    "path": ".github/README_KO.md",
    "content": "<div align=\"center\">\n  <img src=\"../docs/public/assets/promotion/Promo-Banner-KO.png\" alt=\"promotion\"/>\n  <h3>Gemini™ 경험을 진정으로 당신의 것으로 만드세요 ✨</h3>\n  <p>\n    우아한 타임라인으로 대화를 탐색하고, 폴더로 채팅을 정리하며, 자신만의 프롬프트 저장소를 구축하세요.<br>\n    <b>Google Gemini 를 위한 필수 강화 도구입니다.</b>\n  </p>\n  \n  <p>\n    <img src=\"https://img.shields.io/badge/Chrome-✓-4285F4?style=flat-square&logo=googlechrome&logoColor=white\" alt=\"Chrome\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoftedge&logoColor=white\" alt=\"Edge\">\n    <img src=\"https://img.shields.io/badge/Firefox-✓-FF7139?style=flat-square&logo=firefox&logoColor=white\" alt=\"Firefox\">\n    <img src=\"https://img.shields.io/badge/Safari-✓-000000?style=flat-square&logo=safari&logoColor=white\" alt=\"Safari\">\n    <img src=\"https://img.shields.io/badge/Opera-✓-FF1B2D?style=flat-square&logo=opera&logoColor=white\" alt=\"Opera\">\n    <img src=\"https://img.shields.io/badge/Brave-✓-FB542B?style=flat-square&logo=brave&logoColor=white\" alt=\"Brave\">\n  </p>\n  <p>\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub stars\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub forks\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Latest version\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub downloads\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store users\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store rating\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons users\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons rating\">\n  </p>\n  <p>\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Gemini Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </p>\n  <p align=\"center\">\n    ✨ Product Hunt 에 출시되었습니다! 여러분의 의견과 피드백을 환영합니다. ❤️\n  </p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://voyager.nagi.fun/ko\">📖 문서</a> • \n  <a href=\"../README.md\">English</a> •\n  <a href=\"./README_ZH.md\">简体中文</a> •\n  <a href=\"./README_ZH_TW.md\">繁體中文</a> •\n  <a href=\"./README_JA.md\">日本語</a> •\n  <a href=\"./README_FR.md\">Français</a> •\n  <a href=\"./README_ES.md\">Español</a> •\n  <a href=\"./README_PT.md\">Português</a> •\n  <a href=\"./README_RU.md\">Русский</a> •\n  <a href=\"./README_AR.md\">العربية</a>\n</p>\n\n<p align=\"center\">\n    <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n  </p>\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024507762483277927?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/x-recommendation.png\" alt=\"KOL Recommendation\" width=\"500\">\n  </a>\n  <br>\n  <b>🎉 주요 기술 KOL 및 커뮤니티에서 적극 권장합니다!</b>\n</p>\n\n> [!IMPORTANT]\n> **이름 변경 안내**: 상표 및 저작권 문제로 인해 이 확장 프로그램이 공식적으로 **Voyager**로 이름이 변경되었습니다. 하지만 Chrome 웹 스토어의 심사 속도가 매우 느려 7일 이내에 이름 변경이 승인되지 않아, 현재 Chrome Web Store에서 일시적으로 이용할 수 없는 상태입니다.\n\n> [!NOTE]\n> Voyager 가 도움이 되었다면 X, YouTube, Reddit 등에서 공유해 주세요. 공유가 늘수록 더 많은 사용자가 프로젝트를 발견하고 Gemini 사용 경험도 함께 좋아집니다. 감사합니다.\n\n---\n\n## 👋 왜 Voyager 인가요?\n\n우리는 Gemini 를 사랑하지만, 때로는 조금 더 구조화된 정리가 필요하다고 느꼈습니다.\n\n그래서 우리는 **Voyager**를 만들었습니다. 이것은 단순한 도구가 아니라, AI 대화를 정리하고 액세스 가능하며 생산적으로 유지하도록 돕는 동반자입니다. 수십 개의 스레드를 관리하는 연구자이든, 코드 스니펫을 저장하는 개발자이든, 단순히 정리를 좋아하는 사람이든 Voyager 는 당신을 위해 설계되었습니다.\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024509398601597412?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/try-voyager.png\" alt=\"Try Voyager\" width=\"500\">\n  </a>\n  <br>\n  <i>2 월 18 일 Google Gemini 앱으로 인해 일부 사용자의 과거 대화에 접근할 수 없는 문제가 발생했을 때, Voyager 사용자는 여전히 폴더에 저장된 대화를 볼 수 있었습니다.</i>\n</p>\n\n---\n\n## ✨ 주요 기능\n\n### 🌌 공통 (Gemini & AI Studio)\n\n- **📂 [폴더 관리](https://voyager.nagi.fun/ko/guide/folders)**: 드래그 앤 드롭을 지원하는 2 단계 폴더 계층 구조로 대화를 정리하세요.\n  - **Gemini**: **계정 분리 모드** 및 **사용자 지정 폴더 색상**을 지원합니다.\n- **💡 [프롬프트 저장소](https://voyager.nagi.fun/ko/guide/prompts)**: Gemini, AI Studio 및 [사용자 지정 웹사이트](https://voyager.nagi.fun/ko/guide/custom-websites)에서 프롬프트를 저장하고 재사용하세요.\n- **☁️ [클라우드 동기화](https://voyager.nagi.fun/ko/guide/cloud-sync)**: 폴더와 프롬프트를 Google Drive 에 동기화하세요.\n- **📐 수식 복사**: LaTeX 및 MathML (Word) 소스 코드를 클릭 한 번으로 복사하세요.\n- **🌦️ 시각 효과**: 설정 패널에서 **눈**, **시네마틱 비**, **벚꽃잎** 효과를 전환하여 계절 분위기를 연출하세요.\n\n### ✨ Gemini 전용 기능\n\n- **📍 [타임라인 탐색](https://voyager.nagi.fun/ko/guide/timeline)**: 시각적 노드를 통해 메시지 간 이동, 주요 순간 별표 표시, 대화 브랜치 관리를 수행하세요.\n- **💾 [대화 내보내기](https://voyager.nagi.fun/ko/guide/export)**: 이미지를 포함하여 JSON, Markdown 또는 PDF 로 대화를 내보내세요.\n- **🧜‍♀️ [Mermaid 렌더링](https://voyager.nagi.fun/ko/guide/mermaid)**: 플로우차트, 시퀀스 다이어그램 및 기타 Mermaid 차트를 자동으로 렌더링합니다.\n- **📝 [Markdown 렌더링 수정](https://voyager.nagi.fun/ko/guide/markdown-fix)**: Gemini 가 삽입한 HTML 요소로 인해 깨진 굵게 표시 (bold) 구문을 자동으로 수정합니다.\n- **🍌 [NanoBanana](https://voyager.nagi.fun/ko/guide/nanobanana)**: Gemini 에서 생성된 이미지의 워터마크를 무손실로 제거합니다.\n- **🔬 [Deep Research](https://voyager.nagi.fun/ko/guide/deep-research)**: Deep Research 세션에서 생각 과정과 연구 링크를 추출합니다.\n- **🛠️ 파워 툴**:\n  - **[일괄 삭제](https://voyager.nagi.fun/ko/guide/batch-delete)**: 여러 대화를 한꺼번에 삭제합니다.\n  - **[인용 답장](https://voyager.nagi.fun/ko/guide/quote-reply)**: 텍스트를 선택하여 문맥과 함께 답장합니다.\n  - **[탭 제목 동기화](https://voyager.nagi.fun/ko/guide/tab-title)**: 브라우저 탭 제목을 자동으로 동기화합니다.\n  - **[자동 스크롤 방지](https://voyager.nagi.fun/ko/guide/prevent-auto-scroll)**: 새로운 프롬프트를 전송할 때 발생하는 원치 않는 점프 동작을 차단합니다.\n  - **[입력창 접기](https://voyager.nagi.fun/ko/guide/input-collapse)**: 더 많은 읽기 공간을 위해 자동으로 확장되는 입력 영역을 제공합니다.\n  - **[기본 모델](https://voyager.nagi.fun/ko/guide/default-model)**: 좋아하는 모델을 기본값으로 설정하세요.\n  - **[최근 항목 숨기기](https://voyager.nagi.fun/ko/guide/recents-hider)**: 사이드바에서 \"최근\" 목록을 숨겨 산만함을 줄입니다.\n\n### 🎨 개인화\n\n- 확장 프로그램 팝업을 열고 **시각 효과**에서 `끄기`, `눈`, `벚꽃`, `비`를 전환할 수 있습니다.\n- 효과는 가벼운 전체 화면 오버레이로 렌더링되며, 페이지 상호작용을 차단하지 않습니다.\n- 효과를 전환하거나 끄면 파티클이 갑자기 사라지는 대신 자연스럽게 사라집니다.\n\n---\n\n## 📥 설치\n\n> ⚠️ 참고: 프롬프트 관리자는 Gemini Enterprise 를 지원하는 유일한 기능입니다.\n\n<div align=\"center\">\n  <a href=\"https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=readme&utm_campaign=organic_growth&utm_content=ko\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Chrome%20Web%20Store-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white\" alt=\"Chrome Web Store\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Microsoft%20Edge-0078D7?style=for-the-badge&logo=microsoftedge&logoColor=white\" alt=\"Microsoft Edge Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://addons.mozilla.org/firefox/addon/gemini-voyager/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Firefox Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager/releases/latest/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Safari-000000?style=for-the-badge&logo=safari&logoColor=white\" alt=\"Safari 다운로드\" height=\"36\">\n  </a>\n</div>\n\n<p align=\"center\">\n  <sub><b>Chrome 웹 스토어</b>는 Edge, Opera, Brave, Vivaldi, Arc 및 기타 Chromium 브라우저에서도 작동합니다.</sub>\n</p>\n\n> **스토어 상태:** Chrome ✅ · Firefox ✅ · Edge ✅ · Safari ✅\n\n**수동 설치** 또는 **개발 빌드**에 대해서는 [설치 가이드](https://voyager.nagi.fun/ko/guide/installation)를 참조하세요.\n\n---\n\n## ☕ 프로젝트 후원하기\n\n<div align=\"center\">\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" />\n  </a>\n</div>\n\nVoyager 가 도움이 되었다면 커피 한 잔을 후원해 주세요. 지속적인 업데이트에 큰 도움이 됩니다! 후원자분들은 특별 감사 섹션에 기재됩니다. ❤️\n\n<div align=\"center\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" height=\"36\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" height=\"36\">\n  </a>\n  \n  <p><b>또는 WeChat / Alipay / Afdian 을 통해 후원하기:</b></p>\n  <table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n    <tr>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/wechat-sponsor.png\" alt=\"WeChat Pay\" height=\"160\"><br>\n        <sub><b>WeChat Pay</b></sub>\n      </td>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/alipay-sponsor.jpg\" alt=\"Alipay\" height=\"160\"><br>\n        <sub><b>Alipay</b></sub>\n      </td>\n      <td align=\"center\">\n        <a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n          <picture>\n            <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n            <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n            <img alt=\"Nagi-ovo's Profile\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n          </picture>\n        </a><br>\n        <sub><b>Afdian</b></sub>\n      </td>\n    </tr>\n  </table>\n</div>\n\n### 🎙️ 추천 도구: Typeless\n\nVoyager 개발 중에 광범위하게 사용한 AI 음성 - 텍스트 변환 도구인 **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)**를 강력히 추천합니다. 이를 매일의 워크플로우에 통합함으로써 엄청난 시간을 절약하고 생산성을 크게 높일 수 있었습니다.\n\n> 🎁 **[제 추천 링크를 통해 가입](https://www.typeless.com/?via=gemini-voyager)** (코드: _`gemini-voyager`_) 하시면 **$5 무료 크레딧**을 받으실 수 있습니다. 이는 제가 이 프로젝트를 계속 유지 관리할 수 있는 크레딧을 제공하며, 저의 작업을 지원하는 무료 방법입니다! ❤️\n\n---\n\n## 💬 커뮤니티\n\n<table>\n  <tr>\n    <td align=\"center\" width=\"50%\" valign=\"top\">\n      <a href=\"https://x.com/Nag1ovo/status/2012610584722731188\" target=\"_blank\">\n        <img src=\"https://img.shields.io/badge/Follow%20on-𝕏-000000?style=for-the-badge&logo=x&logoColor=white\" alt=\"Follow on X\" height=\"36\">\n      </a>\n      <br><br>\n      <b>업데이트 팔로우</b><br>\n      <sub>최신 소식을 받아보세요.</sub>\n    </td>\n    <td align=\"center\" width=\"50%\" valign=\"top\">\n      <a href=\"https://discord.gg/TEUFxdMbGb\" target=\"_blank\">\n        <img src=\"https://img.shields.io/discord/1463273957120675973?style=for-the-badge&logo=discord&logoColor=white&label=Join%20Discord\" alt=\"Discord\" height=\"36\">\n      </a>\n      <br><br>\n      <b>커뮤니티 가입</b><br>\n      <sub>다른 사용자들과 대화하고, 프롬프트를 공유하고, 도움을 받으세요.</sub>\n    </td>\n  </tr>\n</table>\n\n---\n\n## 🤝 기여 및 개발\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\n여러분의 기여를 환영합니다! 버그 보고, 기능 제안, 문서 개선 또는 코드 제출 등 무엇이든 환영합니다:\n\n- **이슈**: [버그 보고](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/bug_report.md) 또는 [기능 제안](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml) 템플릿을 사용하세요.\n- **풀 리퀘스트**: 가이드라인은 [CONTRIBUTING.md](../.github/CONTRIBUTING.md)를 확인하세요.\n\n<details>\n<summary>개발 설정</summary>\n\n```bash\n# 의존성 설치 (Bun 권장)\nbun i\n\n# 개발 모드 (자동 새로고침 지원)\nbun run dev:chrome   # Chrome 및 Chromium 브라우저\nbun run dev:firefox  # Firefox\nbun run dev:safari   # Safari (macOS 필요)\n\n# 프로덕션 빌드\nbun run build:chrome   # Chrome\nbun run build:firefox  # Firefox\nbun run build:safari   # Safari\nbun run build:all      # 모든 브라우저\n```\n\n**Safari 개발**: 추가 빌드 단계는 [safari/README.md](../safari/README.md) 를 참조하세요.\n\n</details>\n\nVoyager 를 더 좋게 만들 수 있도록 도와주셔서 감사합니다! ❤️\n\n### ❤️ 특별 감사\n\nVoyager에 기여해 주신 모든 기여자분들께 특별히 감사드립니다 ❤️\n\n<a href=\"https://github.com/Nagi-ovo/gemini-voyager/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=Nagi-ovo/gemini-voyager&max=200&columns=14\" />\n</a>\n\n---\n\n## 🌟 크레딧\n\n- **[DeepSeek Voyager](https://github.com/Azurboy/deepseek-voyager)** - Voyager 를 DeepSeek 에 맞게 조정한 포크 프로젝트로, DeepSeek 사용자에게 타임라인 탐색 및 채팅 관리 기능을 제공합니다!\n\n- **[claude-nexus](https://github.com/Qiuner/claude-nexus)** - Voyager에서 영감을 받은 Claude.ai 향상 확장 프로그램으로, 타임라인 탐색, 폴더 관리, 프롬프트 라이브러리 등 다양한 기능을 제공하며 Voyager 프롬프트 가져오기/내보내기와 완전 호환됩니다!\n\n- **[ChatGPT Conversation Timeline](https://github.com/Reborn14/chatgpt-conversation-timeline)** - 이 프로젝트에 영감을 준 ChatGPT 용 오리지널 타임라인 탐색 확장 프로그램입니다. Voyager 는 타임라인 개념을 Gemini 에 맞게 조정하고 폴더 관리, 프롬프트 저장소, 채팅 내보내기 등 광범위한 새로운 기능을 추가했습니다.\n\n- **[Ophel Atlas](https://github.com/urzeye/ophel)** - AI 대화를 체계적이고 검색 가능한 문서로 변환하는 브라우저 확장 프로그램입니다. 자동 개요 생성, 대화 관리, 프롬프트 라이브러리를 제공하며 여러 AI 플랫폼을 지원합니다.\n\n---\n\n<div align=\"center\">\n  <a href=\"https://www.star-history.com/#Nagi-ovo/gemini-voyager&type=date&legend=top-left\">\n   <picture>\n     <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&theme=dark&legend=top-left\" />\n     <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n     <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n   </picture>\n  </a>\n  <p>Made with ❤️ by Jesse Zhang</p>\n  <sub>GPLv3 License © 2026</sub>\n</div>\n"
  },
  {
    "path": ".github/README_PT.md",
    "content": "<div align=\"center\">\n  <img src=\"../docs/public/assets/promotion/Promo-Banner.png\" alt=\"promotion\"/>\n  <h3>Torne a sua experiência Gemini™ verdadeiramente sua ✨</h3>\n  <p>\n    Navegação elegante na linha do tempo, organização de chats com pastas e o seu próprio cofre de prompts.<br>\n    <b>É o \"power-up\" que faltava no Google Gemini.</b>\n  </p>\n  \n  <p>\n    <img src=\"https://img.shields.io/badge/Chrome-✓-4285F4?style=flat-square&logo=googlechrome&logoColor=white\" alt=\"Chrome\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoftedge&logoColor=white\" alt=\"Edge\">\n    <img src=\"https://img.shields.io/badge/Firefox-✓-FF7139?style=flat-square&logo=firefox&logoColor=white\" alt=\"Firefox\">\n    <img src=\"https://img.shields.io/badge/Safari-✓-000000?style=flat-square&logo=safari&logoColor=white\" alt=\"Safari\">\n    <img src=\"https://img.shields.io/badge/Opera-✓-FF1B2D?style=flat-square&logo=opera&logoColor=white\" alt=\"Opera\">\n    <img src=\"https://img.shields.io/badge/Brave-✓-FB542B?style=flat-square&logo=brave&logoColor=white\" alt=\"Brave\">\n  </p>\n  <p>\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub stars\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub forks\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Latest version\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub downloads\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store users\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store rating\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons users\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons rating\">\n  </p>\n  <p>\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Gemini Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </p>\n  <p align=\"center\">\n    ✨ Estamos no Product Hunt! Gostaríamos muito de ouvir o seu feedback. ❤️\n  </p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://voyager.nagi.fun/pt\">📖 Documentação</a> • \n  <a href=\"../README.md\">English</a> • \n  <a href=\"./README_ZH.md\">简体中文</a> •\n  <a href=\"./README_ZH_TW.md\">繁體中文</a> •\n  <a href=\"./README_JA.md\">日本語</a> •\n  <a href=\"./README_FR.md\">Français</a> •\n  <a href=\"./README_ES.md\">Español</a> •\n  <a href=\"./README_RU.md\">Русский</a> •\n  <a href=\"./README_AR.md\">العربية</a> •\n  <a href=\"./README_KO.md\">한국어</a>\n</p>\n\n<p align=\"center\">\n    <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n  </p>\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024507762483277927?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/x-recommendation.png\" alt=\"KOL Recommendation\" width=\"500\">\n  </a>\n  <br>\n  <b>🎉 Altamente recomendado pelos principais influenciadores de tecnologia!</b>\n</p>\n\n> [!IMPORTANT]\n> **Aviso de mudança de nome**: Devido a problemas de marcas registradas e direitos autorais, esta extensão foi oficialmente renomeada para **Voyager**. No entanto, devido ao processo extremamente lento de revisão do Chrome Web Store, a mudança de nome não foi aprovada em 7 dias — está temporariamente indisponível no Chrome Web Store.\n\n> [!NOTE]\n> Se o Voyager for útil para você, compartilhe no X, Reddit, YouTube, etc. Cada partilha ajuda mais pessoas a descobrir o projeto e a melhorar a experiência com Gemini. Obrigado.\n\n---\n\n## 👋 Porquê o Voyager?\n\nNós adoramos o Gemini, mas às vezes desejaríamos que ele tivesse apenas um pouco mais de \"estrutura\".\n\nFoi por isso que criámos o **Voyager**. Não é apenas uma ferramenta; é um companheiro que o ajuda a manter as suas conversas com IA organizadas, acessíveis e produtivas. Quer seja um investigador que lida com dezenas de threads, um programador que guarda snippets de código, ou apenas alguém que adora ordem, o Voyager foi desenhado para si.\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024509398601597412?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/try-voyager.png\" alt=\"Try Voyager\" width=\"500\">\n  </a>\n  <br>\n  <i>Durante o problema de 18 de fevereiro, em que o Google Gemini App tornou as conversas históricas de alguns usuários inacessíveis, os usuários do Voyager ainda conseguiram ver suas conversas salvas em suas pastas.</i>\n</p>\n\n---\n\n## ✨ Funcionalidades\n\n<div align=\"center\">\n  <img src=\"../docs/public/assets/teaser.png\" alt=\"teaser\"/>\n</div>\n\nPara um guia completo, visite a nossa [Documentação](https://voyager.nagi.fun/pt).\n\n### 🌌 Núcleo Comum (Gemini & AI Studio)\n\n- **📂 [Organização por Pastas](https://voyager.nagi.fun/pt/guide/folders)**: Organize os seus chats numa hierarquia de dois níveis com suporte para **arrastar e largar** e **sincronização com o Google Drive**.\n  - **Gemini**: Suporta o **Modo de Isolamento de Conta** e **Cores de Pastas Personalizadas**.\n- **💡 [Cofre de Prompts](https://voyager.nagi.fun/pt/guide/prompts)**: Guarde e reutilize os seus melhores prompts no Gemini, AI Studio e [sites personalizados](https://voyager.nagi.fun/pt/guide/custom-websites).\n- **☁️ [Sincronização na Nuvem](https://voyager.nagi.fun/pt/guide/cloud-sync)**: Sincronize as suas pastas e cofre de prompts com o Google Drive.\n- **📐 Cópia de Fórmulas**: Cópia com um clique de códigos-fonte LaTeX e MathML (Word).\n- **🌦️ Efeitos Visuais**: Adicione um ambiente sazonal com **neve**, **chuva cinematográfica** ou **pétalas de sakura** a partir do painel de configurações.\n\n### ✨ Recursos Exclusivos do Gemini\n\n- **📍 [Navegação na Linha do Tempo](https://voyager.nagi.fun/pt/guide/timeline)**: Nós visuais para saltar entre mensagens, marcar momentos importantes e gerir ramos da conversa.\n- **💾 [Exportação de Chat](https://voyager.nagi.fun/pt/guide/export)**: Exporte conversas para JSON, Markdown ou PDF com imagens incluídas.\n- **🧜‍♀️ [Renderização Mermaid](https://voyager.nagi.fun/pt/guide/mermaid)**: Renderização automática de fluxogramas, diagramas de sequência e outros gráficos Mermaid.\n- **📝 [Correção de Renderização Markdown](https://voyager.nagi.fun/pt/guide/markdown-fix)**: Corrige automaticamente a sintaxe de negrito do Markdown quebrada pelos elementos HTML injetados pelo Gemini.\n- **🍌 [NanoBanana](https://voyager.nagi.fun/pt/guide/nanobanana)**: Remoção de marca de água sem perdas para imagens geradas pelo Gemini.\n- **🔬 [Deep Research](https://voyager.nagi.fun/pt/guide/deep-research)**: Extraia processos de pensamento e links de pesquisa de sessões de Deep Research.\n- **🛠️ Ferramentas de Produtividade**:\n  - **[Exclusão em Lote](https://voyager.nagi.fun/pt/guide/batch-delete)**: Limpe o seu histórico em massa.\n  - **[Resposta com Citação](https://voyager.nagi.fun/pt/guide/quote-reply)**: Responda com contexto selecionando o texto.\n  - **[Sincronização do Título da Aba](https://voyager.nagi.fun/pt/guide/tab-title)**: Sincroniza automaticamente o título da aba do navegador.\n  - **[Prevenir rolamento automático](https://voyager.nagi.fun/pt/guide/prevent-auto-scroll)**: Intercepta o comportamento de salto indesejado ao enviar uma mensagem.\n  - **[Colapso de Entrada](https://voyager.nagi.fun/pt/guide/input-collapse)**: Área de entrada auto-colapsável para mais espaço de leitura.\n  - **[Modelo Padrão](https://voyager.nagi.fun/pt/guide/default-model)**: Defina o seu modelo favorito como padrão.\n  - **[Ocultar itens recentes e Gems](https://voyager.nagi.fun/pt/guide/recents-hider)**: Oculta a lista \"Recentes\" na barra lateral para reduzir distrações.\n\n### 🎨 Personalização\n\n- Abra o popup da extensão e encontre **Efeitos Visuais** para alternar entre `Desligado`, `Neve`, `Sakura` e `Chuva`.\n- Os efeitos são renderizados como sobreposições leves em tela cheia e não bloqueiam a interação com a página.\n- Ao trocar de efeito ou desativar, as partículas desaparecem naturalmente em vez de sumir abruptamente.\n\n---\n\n## 📥 Instalação\n\n> ⚠️ Nota: O Gestor de Prompts é a única funcionalidade que suporta o Gemini for Enterprise.\n\n<div align=\"center\">\n  <a href=\"https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=readme&utm_campaign=organic_growth&utm_content=pt\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Chrome%20Web%20Store-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white\" alt=\"Chrome Web Store\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Microsoft%20Edge-0078D7?style=for-the-badge&logo=microsoftedge&logoColor=white\" alt=\"Microsoft Edge Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://addons.mozilla.org/firefox/addon/gemini-voyager/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Firefox Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager/releases/latest/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Safari-000000?style=for-the-badge&logo=safari&logoColor=white\" alt=\"Safari Descarregar\" height=\"36\">\n  </a>\n</div>\n\n<p align=\"center\">\n  <sub>A <b>Chrome Web Store</b> também funciona no Edge, Opera, Brave, Vivaldi, Arc e outros navegadores Chromium.</sub>\n</p>\n\n> **Estado da Loja:** Chrome ✅ · Firefox ✅ · Edge ✅ · Safari ✅\n\nPara **instalação manual** ou **builds de desenvolvimento**, consulte o [Guia de Instalação](https://voyager.nagi.fun/pt/guide/installation).\n\n---\n\n## ☕ Apoie este Projeto\n\n<div align=\"center\">\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" />\n  </a>\n</div>\n\nSe o Voyager facilita a sua vida, considere pagar-me um café. Ajuda a manter as atualizações! Os patrocinadores serão destacados na nossa secção de Agradecimentos Especiais. ❤️\n\n<div align=\"center\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Pague-me%20um%20café-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" height=\"36\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Patrocine-me%20no%20GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" height=\"36\">\n  </a>\n  \n  <p><b>Ou apoie via WeChat / Alipay / Afdian:</b></p>\n  <table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n    <tr>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/wechat-sponsor.png\" alt=\"WeChat Pay\" height=\"160\"><br>\n        <sub><b>WeChat Pay</b></sub>\n      </td>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/alipay-sponsor.jpg\" alt=\"Alipay\" height=\"160\"><br>\n        <sub><b>Alipay</b></sub>\n      </td>\n      <td align=\"center\">\n        <a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n          <picture>\n            <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n            <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n            <img alt=\"Nagi-ovo's Profile\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n          </picture>\n        </a><br>\n        <sub><b>Afdian</b></sub>\n      </td>\n    </tr>\n  </table>\n</div>\n\n### 🎙️ Ferramenta Recomendada: Typeless\n\nRecomendo vivamente o **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)**, uma ferramenta de voz para texto com IA que utilizei extensivamente durante o desenvolvimento do Voyager. Poupou-me imenso tempo e aumentou significativamente a minha produtividade.\n\n> 🎁 **[Registe-se através do meu link](https://www.typeless.com/?via=gemini-voyager)** (Código: _`gemini-voyager`_) para obter **5$ de créditos gratuitos**. ❤️\n\n---\n\n## 🤝 Contribuição e Desenvolvimento\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\nDamos as boas-vindas a contribuições!\n\n- **Issues**: Use os nossos templates de [relatório de erros](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/bug_report.md) ou [pedido de funcionalidade](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml).\n- **Pull Requests**: Consulte [CONTRIBUTING.md](./CONTRIBUTING.md).\n\nObrigado por ajudar a tornar o Voyager melhor! ❤️\n\n### ❤️ Agradecimentos Especiais\n\nUm agradecimento especial a todos os colaboradores pelas suas contribuições ao Voyager ❤️\n\n<a href=\"https://github.com/Nagi-ovo/gemini-voyager/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=Nagi-ovo/gemini-voyager&max=200&columns=14\" />\n</a>\n\n---\n\n## 🌟 Créditos\n\n- **[DeepSeek Voyager](https://github.com/Azurboy/deepseek-voyager)** - Um fork do Voyager adaptado para o DeepSeek.\n\n- **[claude-nexus](https://github.com/Qiuner/claude-nexus)** - Uma extensão de aprimoramento para Claude.ai inspirada no Voyager, com navegação por linha do tempo, gerenciamento de pastas, biblioteca de prompts e muito mais, com compatibilidade total de importação/exportação de prompts com o Voyager!\n\n- **[ChatGPT Conversation Timeline](https://github.com/Reborn14/chatgpt-conversation-timeline)** - A fonte original de inspiração para a navegação na linha do tempo.\n\n- **[Ophel Atlas](https://github.com/urzeye/ophel)** - Uma extensão de navegador que transforma conversas de IA em documentos organizados e pesquisáveis, com geração automática de esquemas, gestão de conversas e biblioteca de prompts, compatível com múltiplas plataformas de IA.\n\n---\n\n<div align=\"center\">\n  <a href=\"https://www.star-history.com/#Nagi-ovo/gemini-voyager&type=date&legend=top-left\">\n   <picture>\n     <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&theme=dark&legend=top-left\" />\n     <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n     <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n   </picture>\n  </a>\n  <p>Feito com ❤️ por Jesse Zhang</p>\n  <sub>GPLv3 License © 2026</sub>\n</div>\n"
  },
  {
    "path": ".github/README_RU.md",
    "content": "<div align=\"center\">\n  <img src=\"../docs/public/assets/promotion/Promo-Banner.png\" alt=\"promotion\"/>\n  <h3>Сделайте ваш Gemini™ по-настоящему вашим ✨</h3>\n  <p>\n    Элегантная навигация по таймлайну, организация чатов по папкам и собственное хранилище промптов.<br>\n    <b>Это недостающий элемент для Google Gemini.</b>\n  </p>\n  \n  <p>\n    <img src=\"https://img.shields.io/badge/Chrome-✓-4285F4?style=flat-square&logo=googlechrome&logoColor=white\" alt=\"Chrome\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoftedge&logoColor=white\" alt=\"Edge\">\n    <img src=\"https://img.shields.io/badge/Firefox-✓-FF7139?style=flat-square&logo=firefox&logoColor=white\" alt=\"Firefox\">\n    <img src=\"https://img.shields.io/badge/Safari-✓-000000?style=flat-square&logo=safari&logoColor=white\" alt=\"Safari\">\n    <img src=\"https://img.shields.io/badge/Opera-✓-FF1B2D?style=flat-square&logo=opera&logoColor=white\" alt=\"Opera\">\n    <img src=\"https://img.shields.io/badge/Brave-✓-FB542B?style=flat-square&logo=brave&logoColor=white\" alt=\"Brave\">\n  </p>\n  <p>\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub stars\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub forks\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Latest version\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub downloads\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store users\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store rating\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons users\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons rating\">\n  </p>\n  <p>\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Gemini Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </p>\n  <p align=\"center\">\n    ✨ Мы на Product Hunt! Будем рады вашим отзывам. ❤️\n  </p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://voyager.nagi.fun/ru\">📖 Документация</a> • \n  <a href=\"../README.md\">English</a> • \n  <a href=\"./README_ZH.md\">简体中文</a> •\n  <a href=\"./README_ZH_TW.md\">繁體中文</a> •\n  <a href=\"./README_JA.md\">日本語</a> •\n  <a href=\"./README_FR.md\">Français</a> •\n  <a href=\"./README_ES.md\">Español</a> •\n  <a href=\"./README_PT.md\">Português</a> •\n  <a href=\"./README_AR.md\">العربية</a> •\n  <a href=\"./README_KO.md\">한국어</a>\n</p>\n\n<p align=\"center\">\n    <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n  </p>\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024507762483277927?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/x-recommendation.png\" alt=\"KOL Recommendation\" width=\"500\">\n  </a>\n  <br>\n  <b>🎉 Настоятельно рекомендуется ведущими технологическими лидерами мнений!</b>\n</p>\n\n> [!IMPORTANT]\n> **Уведомление о переименовании**: В связи с проблемами товарных знаков и авторских прав это расширение официально переименовано в **Voyager**. Однако из-за крайне медленного процесса проверки Chrome Web Store обновление названия не было одобрено в течение 7 дней — расширение временно недоступно в Chrome Web Store.\n\n> [!NOTE]\n> Если Voyager вам полезен, поделитесь им в X, Reddit, YouTube и т.д. Каждый репост помогает большему числу людей узнать о проекте и улучшать опыт использования Gemini. Спасибо.\n\n---\n\n## 👋 Почему Voyager?\n\nМы любим Gemini, но иногда хочется, чтобы в нем было чуть больше порядка.\n\nИменно поэтому мы создали **Voyager**. Это не просто инструмент, а помощник, который помогает организовать ваши диалоги с ИИ, сделать их доступными и продуктивными. Будь вы исследователем, ведущим десятки веток, разработчиком, сохраняющим фрагменты кода, или просто любителем порядка — Voyager создан для вас.\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024509398601597412?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/try-voyager.png\" alt=\"Try Voyager\" width=\"500\">\n  </a>\n  <br>\n  <i>Во время сбоя 18 февраля, когда приложение Google Gemini сделало исторические разговоры некоторых пользователей недоступными, пользователи Voyager по-прежнему могли видеть свои сохраненные разговоры в своих папках.</i>\n</p>\n\n---\n\n## ✨ Возможности\n\n### 🌌 Общие функции (Gemini & AI Studio)\n\n- **📂 [Организация по папкам](https://voyager.nagi.fun/ru/guide/folders)**: Группируйте чаты в двухуровневую иерархию папок с поддержкой **перетаскивания** и **синхронизации с Google Drive**.\n  - **Gemini**: Поддержка **режима изоляции аккаунтов** и **пользовательских цветов папок**.\n- **💡 [Хранилище промптов](https://voyager.nagi.fun/ru/guide/prompts)**: Сохраняйте и повторно используйте лучшие промпты в Gemini, AI Studio и на [любых сайтах](https://voyager.nagi.fun/ru/guide/custom-websites).\n- **☁️ [Облачная синхронизация](https://voyager.nagi.fun/ru/guide/cloud-sync)**: Синхронизируйте папки и хранилище промптов с Google Drive.\n- **📐 Копирование формул**: Копирование исходного кода LaTeX и MathML (Word) в один клик.\n- **🌦️ Визуальные эффекты**: Добавьте сезонную атмосферу с **снегом**, **кинематографическим дождём** или **падающими лепестками сакуры** из панели настроек.\n\n### ✨ Эксклюзивные функции Gemini\n\n- **📍 [Навигация по таймлайну](https://voyager.nagi.fun/ru/guide/timeline)**: Визуальные узлы для быстрого перехода между сообщениями, отметки важных моментов и управления ветками диалога.\n- **💾 [Экспорт чатов](https://voyager.nagi.fun/ru/guide/export)**: Сохраняйте диалоги в форматах JSON, Markdown или PDF вместе с изображениями.\n- **🧜‍♀️ [Рендеринг Mermaid](https://voyager.nagi.fun/ru/guide/mermaid)**: Автоматический рендеринг блок-схем, диаграмм последовательности и других графиков Mermaid.\n- **📝 [Исправление рендеринга Markdown](https://voyager.nagi.fun/ru/guide/markdown-fix)**: Автоматическое исправление синтаксиса жирного шрифта Markdown, нарушенного вставленными Gemini HTML-элементами.\n- **🍌 [NanoBanana](https://voyager.nagi.fun/ru/guide/nanobanana)**: Автоматическое удаление водяных знаков с изображений, созданных Gemini, без потери качества.\n- **🔬 [Deep Research](https://voyager.nagi.fun/ru/guide/deep-research)**: Извлечение цепочек рассуждений и ссылок из сессий Deep Research.\n- **🛠️ Инструменты продуктивности**:\n  - **[Пакетное удаление](https://voyager.nagi.fun/ru/guide/batch-delete)**: Массовая очистка истории диалогов.\n  - **[Ответ с цитированием](https://voyager.nagi.fun/ru/guide/quote-reply)**: Цитирование выделенного текста при ответе.\n  - **[Синхронизация заголовка](https://voyager.nagi.fun/ru/guide/tab-title)**: Автоматическое обновление названия вкладки браузера.\n  - **[Предотвращение автопрокрутки](https://voyager.nagi.fun/ru/guide/prevent-auto-scroll)**: Блокирует прыжок страницы при отправке сообщения.\n  - **[Сворачивание ввода](https://voyager.nagi.fun/ru/guide/input-collapse)**: Сворачивание пустого поля ввода для увеличения области чтения.\n  - **[Модель по умолчанию](https://voyager.nagi.fun/ru/guide/default-model)**: Установите вашу любимую модель по умолчанию.\n  - **[Скрытие недавних элементов и Gems](https://voyager.nagi.fun/ru/guide/recents-hider)**: Скройте список «Недавние» на боковой панели, чтобы не отвлекаться.\n\n### 🎨 Персонализация\n\n- Откройте всплывающее окно расширения и найдите **Визуальные эффекты**, чтобы переключаться между `Выключено`, `Снег`, `Сакура` и `Дождь`.\n- Эффекты отображаются как лёгкие полноэкранные наложения и не блокируют взаимодействие со страницей.\n- При переключении или отключении эффекта частицы плавно исчезают, а не пропадают мгновенно.\n\n---\n\n## 📥 Установка\n\n> ⚠️ Примечание: Prompt Manager — единственная функция, поддерживающая Gemini для предприятий.\n\n<div align=\"center\">\n  <a href=\"https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=readme&utm_campaign=organic_growth&utm_content=ru\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Chrome%20Web%20Store-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white\" alt=\"Chrome Web Store\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Microsoft%20Edge-0078D7?style=for-the-badge&logo=microsoftedge&logoColor=white\" alt=\"Microsoft Edge Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://addons.mozilla.org/firefox/addon/gemini-voyager/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Firefox Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager/releases/latest/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Safari-000000?style=for-the-badge&logo=safari&logoColor=white\" alt=\"Safari Скачать\" height=\"36\">\n  </a>\n</div>\n\n<p align=\"center\">\n  <sub>Версия для <b>Chrome Web Store</b> также работает в Edge, Opera, Brave, Vivaldi, Arc и других браузерах на базе Chromium.</sub>\n</p>\n\n> **Статус в магазинах:** Chrome ✅ · Firefox ✅ · Edge ✅ · Safari ✅\n\nДля **ручной установки** или **сборки для разработки**, пожалуйста, обратитесь к [Руководству по установке](https://voyager.nagi.fun/ru/guide/installation).\n\n---\n\n## ☕ Поддержать проект\n\n<div align=\"center\">\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" />\n  </a>\n</div>\n\nЕсли Voyager делает вашу жизнь проще, вы можете угостить меня кофе. Это помогает продолжать работу над обновлениями! Спонсоры будут отмечены в разделе благодарностей. ❤️\n\n<div align=\"center\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" height=\"36\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" height=\"36\">\n  </a>\n  \n  <p><b>Или через WeChat / Alipay / Afdian:</b></p>\n  <table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n    <tr>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/wechat-sponsor.png\" alt=\"WeChat Pay\" height=\"160\"><br>\n        <sub><b>WeChat Pay</b></sub>\n      </td>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/alipay-sponsor.jpg\" alt=\"Alipay\" height=\"160\"><br>\n        <sub><b>Alipay</b></sub>\n      </td>\n      <td align=\"center\">\n        <a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n          <picture>\n            <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n            <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n            <img alt=\"Nagi-ovo's Profile\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n          </picture>\n        </a><br>\n        <sub><b>Afdian</b></sub>\n      </td>\n    </tr>\n  </table>\n</div>\n\n### 🎙️ Рекомендуемый инструмент: Typeless\n\nЯ настоятельно рекомендую **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)** — инструмент для перевода голоса в текст с ИИ, который я активно использовал во время разработки Voyager.\n\n> 🎁 **[Присоединяйтесь по моей ссылке](https://www.typeless.com/?via=gemini-voyager)** (Код: _`gemini-voyager`_), чтобы получить **$5 бесплатных бонусов**. ❤️\n\n---\n\n## 🤝 Участие в разработке\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\nМы приветствуем любую помощь!\n\n- **Issues**: Используйте наши шаблоны для [отчетов об ошибках](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/bug_report.md) или [предложений новых функций](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml).\n- **Pull Requests**: Ознакомьтесь с [CONTRIBUTING.md](./CONTRIBUTING.md).\n\nСпасибо, что помогаете делать Voyager лучше! ❤️\n\n### ❤️ Особая Благодарность\n\nОсобая благодарность всем участникам за их вклад в Voyager ❤️\n\n<a href=\"https://github.com/Nagi-ovo/gemini-voyager/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=Nagi-ovo/gemini-voyager&max=200&columns=14\" />\n</a>\n\n---\n\n## 🌟 Благодарности\n\n- **[DeepSeek Voyager](https://github.com/Azurboy/deepseek-voyager)** - Форк Voyager, адаптированный для DeepSeek.\n\n- **[claude-nexus](https://github.com/Qiuner/claude-nexus)** - Расширение для улучшения Claude.ai, вдохновлённое Voyager: навигация по таймлайну, управление папками, библиотека промптов и многое другое, с полной совместимостью импорта/экспорта промптов с Voyager!\n\n- **[ChatGPT Conversation Timeline](https://github.com/Reborn14/chatgpt-conversation-timeline)** - Оригинальное расширение с таймлайном для ChatGPT, вдохновившее этот проект.\n\n- **[Ophel Atlas](https://github.com/urzeye/ophel)** - Расширение браузера, которое превращает диалоги с ИИ в организованные и доступные для поиска документы с автоматическим созданием оглавлений, управлением диалогами и библиотекой промптов, поддерживающее несколько платформ ИИ.\n\n---\n\n<div align=\"center\">\n  <a href=\"https://www.star-history.com/#Nagi-ovo/gemini-voyager&type=date&legend=top-left\">\n   <picture>\n     <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&theme=dark&legend=top-left\" />\n     <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n     <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n   </picture>\n  </a>\n  <p>Сделано с ❤️ Jesse Zhang</p>\n  <sub>GPLv3 License © 2026</sub>\n</div>\n"
  },
  {
    "path": ".github/README_ZH.md",
    "content": "<div align=\"center\">\n  <img src=\"../docs/public/assets/promotion/Promo-Banner-cn.png\" alt=\"promotion\"/>\n  <h3>打造属于你的 Gemini™ 体验 ✨</h3>\n  <p>\n    优雅的时间轴导航、文件夹管理对话、构建专属提示词库。<br>\n    <b>这是 Google Gemini 缺失的那块拼图。</b>\n  </p>\n  \n  <p>\n    <img src=\"https://img.shields.io/badge/Chrome-✓-4285F4?style=flat-square&logo=googlechrome&logoColor=white\" alt=\"Chrome\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoftedge&logoColor=white\" alt=\"Edge\">\n    <img src=\"https://img.shields.io/badge/Firefox-✓-FF7139?style=flat-square&logo=firefox&logoColor=white\" alt=\"Firefox\">\n    <img src=\"https://img.shields.io/badge/Safari-✓-000000?style=flat-square&logo=safari&logoColor=white\" alt=\"Safari\">\n    <img src=\"https://img.shields.io/badge/Opera-✓-FF1B2D?style=flat-square&logo=opera&logoColor=white\" alt=\"Opera\">\n    <img src=\"https://img.shields.io/badge/Brave-✓-FB542B?style=flat-square&logo=brave&logoColor=white\" alt=\"Brave\">\n  </p>\n  <p>\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Star\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Fork\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"最新版本\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub 下载量\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome 商店用户数\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome 商店评分\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox 商店用户数\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox 商店评分\">\n  </p>\n  <p>\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Gemini Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </p>\n  <p align=\"center\">\n    ✨ 我们已在 Product Hunt 上线！欢迎来分享你的想法和反馈。❤️\n  </p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://voyager.nagi.fun\">📖 文档</a> • \n  <a href=\"../README.md\">English</a> • \n  <a href=\"./README_ZH_TW.md\">繁體中文</a> •\n  <a href=\"./README_JA.md\">日本語</a> •\n  <a href=\"./README_FR.md\">Français</a> •\n  <a href=\"./README_ES.md\">Español</a> •\n  <a href=\"./README_PT.md\">Português</a> •\n  <a href=\"./README_RU.md\">Русский</a> •\n  <a href=\"./README_AR.md\">العربية</a> •\n  <a href=\"./README_KO.md\">한국어</a>\n</p>\n\n<p align=\"center\">\n    <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n  </p>\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024507762483277927?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/x-recommendation.png\" alt=\"KOL Recommendation\" width=\"500\">\n  </a>\n  <br>\n  <b>🎉 感谢知名科技圈大 V 与社区的强烈推荐！</b>\n</p>\n\n> [!IMPORTANT]\n> **改名公告**：由于商标版权问题，本插件已正式改名为 **Voyager**。但由于谷歌插件商店审核速度奇慢，七天内未能完成名称更新审核，暂时无法在 Chrome Web Store 使用。\n\n> [!NOTE]\n> 如果 Voyager 帮到了你，欢迎分享到 X、即刻、小红书、Linux.do、V2EX 等等，也欢迎推荐给海外 KOL。每一次分享都能让更多人看到这个项目，从而改善 Gemini 的使用体验。谢谢。\n\n---\n\n## 👋 为什么开发 Voyager？\n\n我们都很喜欢 Gemini，但有时候总觉得它少了一点\"秩序感\"。\n\n这就是我们开发 **Voyager** 的初衷。它不仅仅是一个工具，更是一个能帮你把 AI 对话变得井井有条、触手可及的得力助手。无论你是需要处理大量对话的研究人员，还是喜欢收藏代码片段的开发者，亦或是单纯的整理控，Voyager 都是为你准备的。\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024509398601597412?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/try-voyager.png\" alt=\"Try Voyager\" width=\"500\">\n  </a>\n  <br>\n  <i>在 2 月 18 号 Google Gemini App 导致部分用户历史对话无法访问的问题中，Voyager 的用户仍然能够在其文件夹中看到被保存下来的对话。</i>\n</p>\n\n---\n\n## ✨ 功能特性\n\n### 🌌 通用核心 (Gemini & AI Studio)\n\n- **📂 [文件夹管理](https://voyager.nagi.fun/guide/folders)**: 支持 **多级目录**、**拖拽排序** 及 **Google Drive 同步**。\n  - **Gemini**: 支持 **多账号隔离模式** 及 **自定义文件夹颜色**。\n- **💡 [提示词库](https://voyager.nagi.fun/guide/prompts)**: 跨平台同步提示词，支持 Gemini、AI Studio 及 [自定义网站](https://voyager.nagi.fun/guide/custom-websites)。\n- **☁️ [云同步](https://voyager.nagi.fun/guide/cloud-sync)**: 支持将文件夹和提示词库同步到 Google Drive。\n- **📐 公式复制**: 一键复制 LaTeX 和 MathML (Word) 源码。\n- **🌦️ 视觉特效**: 在设置面板里一键切换 **飘雪**、**电影感雨滴** 或 **樱花飘落**，给页面增加季节氛围。\n\n### ✨ Gemini 专属增强\n\n- **📍 [时间线导航](https://voyager.nagi.fun/guide/timeline)**: 可视化节点，瞬间跳转消息，星标重点，管理对话分支。\n- **💾 [对话导出](https://voyager.nagi.fun/guide/export)**: 支持导出为 JSON、Markdown 或 PDF（含图片）。\n- **🧜‍♀️ [Mermaid 渲染](https://voyager.nagi.fun/guide/mermaid)**: 自动渲染流程图、时序图等 Mermaid 图表。\n- **📝 [Markdown 渲染修复](https://voyager.nagi.fun/guide/markdown-fix)**: 自动修复 Gemini 注入 HTML 导致的 Markdown 加粗失效问题。\n- **🍌 [NanoBanana](https://voyager.nagi.fun/guide/nanobanana)**: 自动去除 Gemini 生成图片的无损水印。\n- **🔬 [Deep Research](https://voyager.nagi.fun/guide/deep-research)**: 一键提取 Deep Research 对话的思考过程和研究链接。\n- **🛠️ 效率工具**:\n  - **[批量删除](https://voyager.nagi.fun/guide/batch-delete)**: 批量清理对话记录。\n  - **[引用回复](https://voyager.nagi.fun/guide/quote-reply)**: 选中对话文本即可一键引用回复。\n  - **[标签页标题同步](https://voyager.nagi.fun/guide/tab-title)**: 自动将标签页标题设为对话标题。\n  - **[防自动跳转](https://voyager.nagi.fun/guide/prevent-auto-scroll)**: 拦截每次发送新问题后页面强制滚动到底部的内置行为，找回丝滑体验。\n  - **[输入框折叠](https://voyager.nagi.fun/guide/input-collapse)**: 输入框自动收纳，释放阅读空间。\n  - **[默认模型](https://voyager.nagi.fun/guide/default-model)**: 为新对话设置默认选中的模型。\n  - **[隐藏最近项目](https://voyager.nagi.fun/guide/recents-hider)**: 隐藏侧边栏的“最近”列表，减少干扰。\n  - **隐藏升级提醒**: 自动隐藏 Gemini 侧边栏和模型切换菜单中的“升级到 Google AI Ultra”按钮（默认开启）。\n\n### 🎨 个性化体验\n\n- 点击插件图标，在设置中的 **视觉特效** 里可切换 `关闭`、`飘雪`、`樱花`、`雨`。\n- 特效以轻量全屏覆盖层呈现，不会阻挡页面交互。\n- 切换特效或关闭时，粒子会自然退场，不会突兀消失。\n\n---\n\n## 📥 安装方式\n\n> ⚠️ 注意：提示词管理器是唯一支持 Gemini 企业版的功能。\n\n<div align=\"center\">\n  <a href=\"https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=readme&utm_campaign=organic_growth&utm_content=zh\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Chrome%20应用商店-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white\" alt=\"Chrome 应用商店\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Microsoft%20Edge-0078D7?style=for-the-badge&logo=microsoftedge&logoColor=white\" alt=\"Microsoft Edge Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://addons.mozilla.org/firefox/addon/gemini-voyager/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Firefox Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager/releases/latest/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Safari-000000?style=for-the-badge&logo=safari&logoColor=white\" alt=\"Safari 下载\" height=\"36\">\n  </a>\n</div>\n\n<p align=\"center\">\n  <sub><b>Chrome 应用商店</b>同样适用于 Edge、Opera、Brave、Vivaldi、Arc 等 Chromium 浏览器。</sub>\n</p>\n\n> **商店状态：** Chrome ✅ · Firefox ✅ · Edge ✅ · Safari ✅\n\n关于 **手动安装** 或 **开发构建**，请参阅 [安装指南](https://voyager.nagi.fun/guide/installation)。\n\n---\n\n## ☕ 支持本项目\n\n<div align=\"center\">\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" />\n  </a>\n</div>\n\n如果 Voyager 提升了你的体验，欢迎请我喝杯咖啡。赞助者将被列入致谢名单。❤️\n\n<div align=\"center\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" height=\"36\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" height=\"36\">\n  </a>\n  \n  <p><b>或通过微信 / 支付宝 / 爱发电支持：</b></p>\n  <table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n    <tr>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/wechat-sponsor.png\" alt=\"微信支付\" height=\"160\"><br>\n        <sub><b>微信支付</b></sub>\n      </td>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/alipay-sponsor.jpg\" alt=\"支付宝\" height=\"160\"><br>\n        <sub><b>支付宝</b></sub>\n      </td>\n      <td align=\"center\">\n        <a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n          <picture>\n            <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n            <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n            <img alt=\"Nagi-ovo's Profile\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n          </picture>\n        </a><br>\n        <sub><b>爱发电</b></sub>\n      </td>\n    </tr>\n  </table>\n</div>\n\n### 🎙️ 特别推荐: Typeless\n\n我非常推荐 **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)**，一款 AI 语音转文字工具。我在开发过程中一直在使用它，极大地提升了我的工作效率。\n\n> 🎁 **[点击我的邀请链接](https://www.typeless.com/?via=gemini-voyager)**（邀请码 _`gemini-voyager`_）可获得 **5 美元免费额度**。这也是支持本项目的一种免费方式！❤️\n\n---\n\n## 💬 交流与反馈\n\n<table>\n  <tr>\n    <td align=\"center\" width=\"50%\" valign=\"top\">\n      <a href=\"https://x.com/Nag1ovo/status/2012609459663634589\" target=\"_blank\">\n        <img src=\"https://img.shields.io/badge/关注-𝕏-000000?style=for-the-badge&logo=x&logoColor=white\" alt=\"关注 X\" height=\"36\">\n      </a>\n      <br><br>\n      <b>关注动态</b><br>\n      <sub>获取最新动态。</sub>\n    </td>\n    <td align=\"center\" width=\"50%\" valign=\"top\">\n      <a href=\"https://discord.gg/TEUFxdMbGb\" target=\"_blank\">\n        <img src=\"https://img.shields.io/discord/1463273957120675973?style=for-the-badge&logo=discord&logoColor=white&label=加入%20Discord\" alt=\"Discord\" height=\"36\">\n      </a>\n      <br><br>\n      <b>加入社区</b><br>\n      <sub>与其他用户交流、分享提示词、获取帮助。</sub>\n    </td>\n  </tr>\n</table>\n\n---\n\n## 🤝 参与贡献与开发\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\n欢迎参与贡献！\n\n- **Issue**：使用 [Bug 报告](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/bug_report.yml) 或 [功能请求](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml) 模板。\n- **Pull Request**：请查看 [贡献指南](./CONTRIBUTING.md)。\n\n<details>\n<summary>开发环境配置</summary>\n\n```bash\n# 安装依赖 (推荐 Bun)\nbun i\n\n# 开发模式\nbun run dev:chrome\nbun run dev:firefox\nbun run dev:safari\n\n# 生产构建\nbun run build:chrome\nbun run build:firefox\nbun run build:safari\nbun run build:all\n```\n\n**Safari 开发**：详见 [safari/README.md](../safari/README.md)。\n\n</details>\n\n感谢你让 Voyager 变得更好！❤️\n\n### ❤️ 特别感谢\n\n特别感谢所有为 Voyager 做出贡献的贡献者们 ❤️\n\n<a href=\"https://github.com/Nagi-ovo/gemini-voyager/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=Nagi-ovo/gemini-voyager&max=200&columns=14\" />\n</a>\n\n---\n\n## 🌟 致谢\n\n- **[DeepSeek Voyager](https://github.com/Azurboy/deepseek-voyager)** - 为 DeepSeek 适配的 Fork 版本。\n\n- **[claude-nexus](https://github.com/Qiuner/claude-nexus)** - 受 Voyager 启发的 Claude.ai 增强扩展，提供时间线导航、文件夹管理、提示词库等功能，并与 Voyager 的提示词导入/导出完全兼容！\n\n- **[ChatGPT Conversation Timeline](https://github.com/Reborn14/chatgpt-conversation-timeline)** - 本项目的灵感来源。\n\n- **[Ophel Atlas](https://github.com/urzeye/ophel)** - 将 AI 对话转化为有组织、可搜索文档的浏览器扩展，支持自动生成大纲、对话管理和提示词库，兼容多个 AI 平台。\n\n---\n\n<div align=\"center\">\n  <a href=\"https://www.star-history.com/#Nagi-ovo/gemini-voyager&type=date&legend=top-left\">\n   <picture>\n     <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&theme=dark&legend=top-left\" />\n     <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n     <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n   </picture>\n  </a>\n  <p>Made with ❤️ by Jesse Zhang</p>\n  <sub>GPLv3 License © 2026</sub>\n</div>\n"
  },
  {
    "path": ".github/README_ZH_TW.md",
    "content": "<div align=\"center\">\n  <img src=\"../docs/public/assets/promotion/Promo-Banner-cn.png\" alt=\"promotion\"/>\n  <h3>打造屬於你的 Gemini™ 體驗 ✨</h3>\n  <p>\n    優雅的時間軸導航、資料夾管理對話、構建專屬提示詞庫。<br>\n    <b>這是 Google Gemini 缺失的那塊拼圖。</b>\n  </p>\n  \n  <p>\n    <img src=\"https://img.shields.io/badge/Chrome-✓-4285F4?style=flat-square&logo=googlechrome&logoColor=white\" alt=\"Chrome\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoftedge&logoColor=white\" alt=\"Edge\">\n    <img src=\"https://img.shields.io/badge/Firefox-✓-FF7139?style=flat-square&logo=firefox&logoColor=white\" alt=\"Firefox\">\n    <img src=\"https://img.shields.io/badge/Safari-✓-000000?style=flat-square&logo=safari&logoColor=white\" alt=\"Safari\">\n    <img src=\"https://img.shields.io/badge/Opera-✓-FF1B2D?style=flat-square&logo=opera&logoColor=white\" alt=\"Opera\">\n    <img src=\"https://img.shields.io/badge/Brave-✓-FB542B?style=flat-square&logo=brave&logoColor=white\" alt=\"Brave\">\n  </p>\n  <p>\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Star\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Fork\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"最新版本\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub 下載量\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome 商店用戶數\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome 商店評分\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox 商店用戶數\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox 商店評分\">\n  </p>\n  <p>\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Gemini Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </p>\n  <p align=\"center\">\n    ✨ 我們已在 Product Hunt 上線！歡迎來分享你的想法和回饋。❤️\n  </p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://voyager.nagi.fun/zh_TW\">📖 文檔</a> • \n  <a href=\"../README.md\">English</a> • \n  <a href=\"./README_ZH.md\">简体中文</a> •\n  <a href=\"./README_JA.md\">日本語</a> •\n  <a href=\"./README_FR.md\">Français</a> •\n  <a href=\"./README_ES.md\">Español</a> •\n  <a href=\"./README_PT.md\">Português</a> •\n  <a href=\"./README_RU.md\">Русский</a> •\n  <a href=\"./README_AR.md\">العربية</a> •\n  <a href=\"./README_KO.md\">한국어</a>\n</p>\n\n<p align=\"center\">\n    <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n  </p>\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024507762483277927?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/x-recommendation.png\" alt=\"KOL Recommendation\" width=\"500\">\n  </a>\n  <br>\n  <b>🎉 感謝知名科技圈大 V 與社區的強烈推薦！</b>\n</p>\n\n> [!IMPORTANT]\n> **改名公告**：由於商標版權問題，本插件已正式改名為 **Voyager**。但由於 Chrome Web Store 審核速度極慢，七天內未能完成名稱更新審核，暫時無法在 Chrome Web Store 使用。\n\n> [!NOTE]\n> 如果 Voyager 有幫助，歡迎分享到 X、Facebook、YouTube、Threads、Dcard 等等。每一次分享都能讓更多人看見這個專案，從而改善 Gemini 的使用體驗。謝謝。\n\n---\n\n## 👋 為什麼開發 Voyager？\n\n我們都很喜歡 Gemini，但有時候總覺得它少了一點\"秩序感\"。\n\n這就是我們開發 **Voyager** 的初衷。它不僅僅是一個工具，更是一個能幫你把 AI 對話變得井井有條、觸手可及的得力助手。無論你是需要處理大量對話的研究人員，還是喜歡收藏代碼片段的開發者，亦或是單純的整理控，Voyager 都是為你準備的。\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024509398601597412?s=20\" target=\"_blank\">\n    <img src=\"../docs/public/assets/try-voyager.png\" alt=\"Try Voyager\" width=\"500\">\n  </a>\n  <br>\n  <i>在 2 月 18 號 Google Gemini App 導致部分用戶歷史對話無法訪問的問題中，Voyager 的用戶仍然能夠在其資料夾中看到被保存下來的對話。</i>\n</p>\n\n---\n\n## ✨ 功能特性\n\n<div align=\"center\">\n  <img src=\"../docs/public/assets/teaser.png\" alt=\"teaser\"/>\n</div>\n\n查看完整功能，請訪問我們的 [官方文檔](https://voyager.nagi.fun/zh_TW)。\n\n### 🌌 通用核心 (Gemini & AI Studio)\n\n- **📂 [資料夾管理](https://voyager.nagi.fun/zh_TW/guide/folders)**: 支持 **多級目錄**、**拖拽排序** 及 **Google Drive 同步**。\n  - **Gemini**: 支持 **多帳號隔離模式** 及 **自定義資料夾顏色**。\n- **💡 [提示詞庫](https://voyager.nagi.fun/zh_TW/guide/prompts)**: 跨平台同步提示詞，支持 Gemini、AI Studio 及 [自定義網站](https://voyager.nagi.fun/zh_TW/guide/custom-websites)。\n- **☁️ [雲同步](https://voyager.nagi.fun/zh_TW/guide/cloud-sync)**: 支持將資料夾和提示詞庫同步到 Google Drive。\n- **📐 公式複製**: 一鍵複製 LaTeX 和 MathML (Word) 源碼。\n- **🌦️ 視覺特效**: 在設置面板中一鍵切換 **飄雪**、**電影感雨滴** 或 **櫻花飄落**，給頁面增添季節氛圍。\n\n### ✨ Gemini 專屬增強\n\n- **📍 [時間線導航](https://voyager.nagi.fun/zh_TW/guide/timeline)**: 可視化節點，瞬間跳轉訊息，星標重點，管理對話分支。\n- **💾 [對話導出](https://voyager.nagi.fun/zh_TW/guide/export)**: 支持導出為 JSON、Markdown 或 PDF（含圖片）。\n- **🧜‍♀️ [Mermaid 圖表渲染](https://voyager.nagi.fun/zh_TW/guide/mermaid)**: 自動渲染流程圖、時序圖等 Mermaid 圖表。\n- **📝 [Markdown 渲染修復](https://voyager.nagi.fun/zh_TW/guide/markdown-fix)**: 自動修復 Gemini 注入 HTML 導致的 Markdown 加粗失效問題。\n- **🍌 [NanoBanana](https://voyager.nagi.fun/zh_TW/guide/nanobanana)**: 自動去除 Gemini 生成圖片的無損浮水印。\n- **🔬 [Deep Research](https://voyager.nagi.fun/zh_TW/guide/deep-research)**: 一鍵提取 Deep Research 對話的思考過程和研究鏈接。\n- **🛠️ 效率工具**:\n  - **[批量刪除](https://voyager.nagi.fun/zh_TW/guide/batch-delete)**: 批量清理對話記錄。\n  - **[引用回覆](https://voyager.nagi.fun/zh_TW/guide/quote-reply)**: 選中對話文本即可一鍵引用回覆。\n  - **[標籤頁標題同步](https://voyager.nagi.fun/zh_TW/guide/tab-title)**: 自動將標籤頁標題設為對話標題。\n  - **[防自動跳轉](https://voyager.nagi.fun/zh_TW/guide/prevent-auto-scroll)**: 攔截每次發送新問題後頁面強制滾動到最底部的內建行為，找回絲滑體驗。\n  - **[輸入框摺疊](https://voyager.nagi.fun/zh_TW/guide/input-collapse)**: 輸入框自動收納，釋放閱讀空間。\n  - **[預設模型](https://voyager.nagi.fun/zh_TW/guide/default-model)**: 為新對話設置預設選中的模型。\n  - **[隱藏最近項目和 Gem](https://voyager.nagi.fun/zh_TW/guide/recents-hider)**: 隱藏側邊欄的”最近”列表，減少干擾。\n\n### 🎨 個性化體驗\n\n- 點擊插件圖標，在設定中的 **視覺特效** 裡可切換 `關閉`、`飄雪`、`櫻花`、`雨`。\n- 特效以輕量全螢幕覆蓋層呈現，不會阻擋頁面互動。\n- 切換特效或關閉時，粒子會自然退場，不會突兀消失。\n\n---\n\n## 📥 安裝方式\n\n> ⚠️ 注意：提示詞管理器是唯一支持 Gemini 企業版的功能。\n\n<div align=\"center\">\n  <a href=\"https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=readme&utm_campaign=organic_growth&utm_content=zh_tw\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Chrome%20應用商店-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white\" alt=\"Chrome 應用商店\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Microsoft%20Edge-0078D7?style=for-the-badge&logo=microsoftedge&logoColor=white\" alt=\"Microsoft Edge Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://addons.mozilla.org/firefox/addon/gemini-voyager/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Firefox Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager/releases/latest/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Safari-000000?style=for-the-badge&logo=safari&logoColor=white\" alt=\"Safari 下載\" height=\"36\">\n  </a>\n</div>\n\n<p align=\"center\">\n  <sub><b>Chrome 應用商店</b>同樣適用於 Edge、Opera、Brave、Vivaldi、Arc 等 Chromium 瀏覽器。</sub>\n</p>\n\n> **商店狀態：** Chrome ✅ · Firefox ✅ · Edge ✅ · Safari ✅\n\n關於 **手動安裝** 或 **開發構建**，請參閱 [安裝指南](https://voyager.nagi.fun/zh_TW/guide/installation)。\n\n---\n\n## ☕ 支持本項目\n\n<div align=\"center\">\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" />\n  </a>\n</div>\n\n如果 Voyager 提升了你的體驗，歡迎請我喝杯咖啡。贊助者將被列入致謝名單。❤️\n\n<div align=\"center\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" height=\"36\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" height=\"36\">\n  </a>\n  \n  <p><b>或通過微信 / 支付寶 / 愛發電支持：</b></p>\n  <table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n    <tr>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/wechat-sponsor.png\" alt=\"微信支付\" height=\"160\"><br>\n        <sub><b>微信支付</b></sub>\n      </td>\n      <td align=\"center\">\n        <img src=\"../docs/public/assets/alipay-sponsor.jpg\" alt=\"支付寶\" height=\"160\"><br>\n        <sub><b>支付寶</b></sub>\n      </td>\n      <td align=\"center\">\n        <a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n          <picture>\n            <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n            <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n            <img alt=\"Nagi-ovo's Profile\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n          </picture>\n        </a><br>\n        <sub><b>愛發電</b></sub>\n      </td>\n    </tr>\n  </table>\n</div>\n\n### 🎙️ 特別推薦: Typeless\n\n我非常推薦 **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)**，一款 AI 語音轉文字工具。我在開發過程中一直在使用它，極大地提升了我的工作效率。\n\n> 🎁 **[點擊我的邀請鏈接](https://www.typeless.com/?via=gemini-voyager)**（邀請碼 _`gemini-voyager`_）可獲得 **5 美元免費額度**。這也是支持本項目的一種免費方式！❤️\n\n---\n\n## 💬 交流與回饋\n\n<table>\n  <tr>\n    <td align=\"center\" width=\"50%\" valign=\"top\">\n      <a href=\"https://x.com/Nag1ovo/status/2012609459663634589\" target=\"_blank\">\n        <img src=\"https://img.shields.io/badge/關注-𝕏-000000?style=for-the-badge&logo=x&logoColor=white\" alt=\"關注 X\" height=\"36\">\n      </a>\n      <br><br>\n      <b>關注動態</b><br>\n      <sub>獲取最新動態。</sub>\n    </td>\n    <td align=\"center\" width=\"50%\" valign=\"top\">\n      <a href=\"https://discord.gg/TEUFxdMbGb\" target=\"_blank\">\n        <img src=\"https://img.shields.io/discord/1463273957120675973?style=for-the-badge&logo=discord&logoColor=white&label=加入%20Discord\" alt=\"Discord\" height=\"36\">\n      </a>\n      <br><br>\n      <b>加入社區</b><br>\n      <sub>與其他用戶交流、分享提示詞、獲取幫助。</sub>\n    </td>\n  </tr>\n</table>\n\n---\n\n## 🤝 參與貢獻與開發\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\n歡迎參與貢獻！\n\n- **Issue**：使用 [Bug 報告](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/bug_report.yml) 或 [功能請求](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml) 模板。\n- **Pull Request**：請查看 [貢獻指南](./CONTRIBUTING.md)。\n\n<details>\n<summary>開發環境配置</summary>\n\n```bash\n# 安裝依賴 (推薦 Bun)\nbun i\n\n# 開發模式\nbun run dev:chrome\nbun run dev:firefox\nbun run dev:safari\n\n# 生產構建\nbun run build:chrome\nbun run build:firefox\nbun run build:safari\nbun run build:all\n```\n\n**Safari 開發**：詳見 [safari/README.md](../safari/README.md)。\n\n</details>\n\n感謝你讓 Voyager 變得更好！❤️\n\n### ❤️ 特別感謝\n\n特別感謝所有為 Voyager 做出貢獻的貢獻者們 ❤️\n\n<a href=\"https://github.com/Nagi-ovo/gemini-voyager/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=Nagi-ovo/gemini-voyager&max=200&columns=14\" />\n</a>\n\n---\n\n## 🌟 致謝\n\n- **[DeepSeek Voyager](https://github.com/Azurboy/deepseek-voyager)** - 為 DeepSeek 適配的 Fork 版本。\n\n- **[claude-nexus](https://github.com/Qiuner/claude-nexus)** - 受 Voyager 啟發的 Claude.ai 增強擴充套件，提供時間軸導覽、資料夾管理、提示詞庫等功能，並與 Voyager 的提示詞匯入/匯出完全相容！\n\n- **[ChatGPT Conversation Timeline](https://github.com/Reborn14/chatgpt-conversation-timeline)** - 本項目的靈感來源。\n\n- **[Ophel Atlas](https://github.com/urzeye/ophel)** - 將 AI 對話轉化為有組織、可搜尋文件的瀏覽器擴充功能，支援自動生成大綱、對話管理和提示詞庫，相容多個 AI 平台。\n\n---\n\n<div align=\"center\">\n  <a href=\"https://www.star-history.com/#Nagi-ovo/gemini-voyager&type=date&legend=top-left\">\n   <picture>\n     <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&theme=dark&legend=top-left\" />\n     <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n     <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n   </picture>\n  </a>\n  <p>Made with ❤️ by Jesse Zhang</p>\n  <sub>GPLv3 License © 2026</sub>\n</div>\n"
  },
  {
    "path": ".github/RELEASE_TEMPLATE.md",
    "content": "## ✨ What's New\n\n<!-- Add main features here -->\n\n---\n\n## 🐛 Bug Fixes\n\n<!-- Add bug fixes here -->\n\n---\n\n## 📚 Documentation\n\n<!-- Add documentation updates here -->\n\n---\n\n## 📥 Installation\n\nDownload the latest version for your browser:\n\n- **Chrome/Edge/Opera/Brave**: `gemini-voyager-chrome-{VERSION}.zip`\n- **Firefox**: `gemini-voyager-firefox-{VERSION}.zip`\n- **Safari**: `gemini-voyager-{VERSION}.dmg`\n\nSee [README](https://github.com/Nagi-ovo/gemini-voyager#-installation) for installation instructions.\n\n---\n\n**Full Changelog**: https://github.com/Nagi-ovo/gemini-voyager/compare/{PREV_VERSION}...{VERSION}\n"
  },
  {
    "path": ".github/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## [Unreleased]\n\n### Added\n\n- **Safari browser support** 🎉\n  - Safari build configuration and development mode\n  - Installation guide ([EN](safari/INSTALLATION.md) | [中文](safari/INSTALLATION_ZH.md))\n  - Development guide ([EN](.../../../safari/README.md) | [中文](.../../../safari/README_ZH.md))\n  - New commands: `build:safari`, `dev:safari`, `build:all`\n- **Conversation export (Markdown/PDF)**\n  - Rich Markdown export with formulas, code blocks, tables, lists, headings\n  - Auto-package images: if a chat contains user-uploaded images, export a ZIP with `chat.md` and `assets/` (images rewritten to relative paths)\n  - PDF export: inline images (best-effort) and print-optimized styles\n  - Background service worker for cross-origin image fetch; added host permissions for Google image domains\n\n### Changed\n\n- **Cross-browser compatibility**\n  - Migrated to `browser.*` API via `webextension-polyfill` for better compatibility\n  - All storage APIs now use async/await pattern\n- **Export robustness**\n  - More resilient DOM extraction (supports Angular/custom elements; better selectors)\n  - Reduced noisy logs; cleaner fallback paths\n  - PDF images constrained (max-width ~60%) to avoid oversized visuals\n\n### Fixed\n\n- **Dependencies**\n  - Downgraded `marked` to v11 for compatibility\n  - Upgraded `@typescript-eslint/eslint-plugin` to v8\n  - Resolved peer dependency conflicts\n- **Export correctness**\n  - Fixed duplicate inclusion of code blocks/tables in Markdown\n  - Fixed export button causing navigation back to `/app` on Gemini\n  - Addressed missing assistant content by adding last-chance plaintext fallback\n  - Avoid CORS failures for images in Markdown by packaging images into ZIP (with relative paths)\n\n### Supported Browsers\n\n- **Chromium** (Chrome, Edge, Opera, Brave, Vivaldi, Arc)\n- **Gecko** (Firefox)\n- **WebKit** (Safari) ⭐ NEW\n\n## [0.6.1] - Previous Release\n\n### Features\n\n- Interactive conversation timeline\n- Folder management\n- Prompt library with search\n- Chat export to JSON\n- Cross-tab star sync\n- Markdown/KaTeX rendering\n- Multi-language (EN, 中文)\n\n---\n\n## Migration Notes\n\n### Users\n\n- Chrome/Firefox: No changes needed\n- Safari: See [installation guide](.github/docs/safari/INSTALLATION.md)\n\n### Developers\n\n- API changed from `chrome.*` to `browser.*`\n- Storage now uses Promises (async/await)\n- New Safari build commands available\n"
  },
  {
    "path": ".github/docs/IMPORT_EXPORT_GUIDE.md",
    "content": "# Folder Import/Export Guide\n\n## Overview\n\nThe folder configuration import/export feature allows you to sync folder configurations across devices without setting up a server.\n\n## How to Use\n\n### 📥 Export Folder Configuration (Download ⬇️)\n\n1. Open Gemini chat page\n2. Find the **download icon button** (downward arrow ⬇️) in the folder area\n3. Click to download the configuration file (format: `gemini-voyager-folders-YYYYMMDD-HHMMSS.json`)\n\n### 📤 Import Folder Configuration (Upload ⬆️)\n\n1. Click the **upload icon button** (upward arrow ⬆️) in the folder area\n2. Choose an import strategy:\n   - **Merge Mode**: Keep existing folders, only add new ones (recommended)\n   - **Overwrite Mode**: Completely replace existing configuration (creates backup)\n3. Select a previously exported JSON file\n4. Click \"Import\" to confirm\n\n## Import Strategies\n\n### Merge Mode\n\n- ✅ Keeps all existing folders and conversations\n- ✅ Only adds new folders and conversations\n- ✅ Automatically skips duplicates (by ID)\n- 💡 Best for: Importing partial configurations from other devices\n\n### Overwrite Mode\n\n- ⚠️ Deletes all existing folders\n- ✅ Completely uses imported configuration\n- 🔒 Automatically creates backup (stored in sessionStorage)\n- 💡 Best for: Full sync to a new device\n\n## 🔄 Backup & Recovery\n\n### Backup Information\n\n- **Auto Backup**: Automatically created during overwrite import\n- **Storage Location**: Browser sessionStorage (temporary storage)\n- **Validity**: Valid until current tab is closed\n- **Size Limit**: Usually 5-10MB\n\n### How to Restore Backup (Console Operation)\n\nIf you encounter issues after import, you can restore the backup while the tab is still open:\n\n```javascript\n// 1. Open browser console (F12)\n\n// 2. Check if backup exists\nconst hasBackup = sessionStorage.getItem('gvFolderBackup');\nconsole.log('Backup exists:', hasBackup !== null);\n\n// 3. View backup time\nconst backupTime = sessionStorage.getItem('gvFolderBackupTimestamp');\nconsole.log('Backup time:', backupTime);\n\n// 4. Restore backup\nconst backup = JSON.parse(sessionStorage.getItem('gvFolderBackup'));\nlocalStorage.setItem('gvFolderData', JSON.stringify(backup));\n\n// 5. Refresh page\nlocation.reload();\n```\n\n### Clear Backup\n\n```javascript\nsessionStorage.removeItem('gvFolderBackup');\nsessionStorage.removeItem('gvFolderBackupTimestamp');\n```\n\n## Export File Format\n\n```json\n{\n  \"format\": \"gemini-voyager.folders.v1\",\n  \"exportedAt\": \"2025-01-15T10:30:00.000Z\",\n  \"version\": \"0.7.2\",\n  \"data\": {\n    \"folders\": [\n      {\n        \"id\": \"folder-xxx\",\n        \"name\": \"My Folder\",\n        \"parentId\": null,\n        \"isExpanded\": true,\n        \"createdAt\": 1736935800000,\n        \"updatedAt\": 1736935800000\n      }\n    ],\n    \"folderContents\": {\n      \"folder-xxx\": [\n        {\n          \"conversationId\": \"conv-yyy\",\n          \"title\": \"Conversation Title\",\n          \"url\": \"https://gemini.google.com/app/...\",\n          \"addedAt\": 1736935800000\n        }\n      ]\n    }\n  }\n}\n```\n\n## Data Security\n\n- ✅ **Local Storage**: All data is stored locally only, not uploaded to any server\n- ✅ **Format Validation**: Strict data format validation during import to prevent corruption\n- ✅ **Auto Backup**: Automatic backup before overwrite operations\n- ✅ **Version Control**: Files include version numbers for future compatibility\n\n## FAQ\n\n### Q: Where is the backup stored?\n\nA: Stored in the browser's `sessionStorage`, only valid for the current tab, automatically cleared when the tab is closed.\n\n### Q: Why can't I see the backup file?\n\nA: The backup is temporarily stored in memory and does not generate a file. For permanent storage, use the export feature.\n\n### Q: Can it sync automatically?\n\nA: Currently requires manual export/import. Automatic sync would require cloud service support, which is not provided to protect privacy.\n\n### Q: What if the configuration is wrong after import?\n\nA: If the tab is still open, you can restore the backup through the console (see instructions above).\n\n### Q: Does it support cross-browser sync?\n\nA: Yes! Simply export from one browser and import to another.\n\n## Best Practices\n\n1. **Regular Exports**: Make it a habit to export configurations regularly\n2. **Cloud Backup**: Save exported JSON files to cloud storage\n3. **Test Import**: Test with merge mode first on new devices\n4. **Keep Backups**: Export a backup before important operations\n5. **Version Management**: Save multiple versions for different configuration states\n\n## Technical Details\n\n- **Format Version**: `gemini-voyager.folders.v1`\n- **Deduplication Strategy**: Deduplicate by `id` and `conversationId`\n- **File Encoding**: UTF-8\n- **Max File Size**: Theoretically unlimited (limited by browser memory)\n- **Compatibility**: Chrome 88+, Firefox 85+, Safari 14+\n\n## Feedback & Support\n\nFor issues or suggestions, please visit:\nhttps://github.com/Nagi-ovo/gemini-voyager/issues/36\n"
  },
  {
    "path": ".github/docs/IMPORT_EXPORT_GUIDE_ZH.md",
    "content": "# 文件夹导出/导入功能使用指南\n\n## 功能概述\n\n文件夹配置导出/导入功能允许您在不同设备间同步文件夹配置，无需搭建服务器。\n\n## 使用方法\n\n### 📥 导出文件夹配置（下载 ⬇️）\n\n1. 打开 Gemini 聊天页面\n2. 在文件夹区域找到**下载图标按钮**（向下箭头 ⬇️）\n3. 点击即可下载配置文件（格式：`gemini-voyager-folders-YYYYMMDD-HHMMSS.json`）\n\n### 📤 导入文件夹配置（上传 ⬆️）\n\n1. 点击文件夹区域的**上传图标按钮**（向上箭头 ⬆️）\n2. 选择导入策略：\n   - **合并模式**：保留现有文件夹，只添加新的（推荐）\n   - **覆盖模式**：完全替换现有配置（会创建备份）\n3. 选择之前导出的 JSON 文件\n4. 点击\"导入\"按钮确认\n\n## 导入策略说明\n\n### 合并模式 (Merge)\n\n- ✅ 保留所有现有文件夹和对话\n- ✅ 只添加新的文件夹和对话\n- ✅ 自动跳过重复项（按 ID 判断）\n- 💡 适合：从其他设备导入部分配置\n\n### 覆盖模式 (Overwrite)\n\n- ⚠️ 删除所有现有文件夹\n- ✅ 完全使用导入的配置\n- 🔒 自动创建备份（存储在 sessionStorage）\n- 💡 适合：完全同步到新设备\n\n## 🔄 备份与恢复\n\n### 备份说明\n\n- **自动备份**：覆盖导入时自动创建\n- **存储位置**：浏览器 sessionStorage（临时存储）\n- **有效期**：当前标签页关闭前有效\n- **大小限制**：通常为 5-10MB\n\n### 如何恢复备份（控制台操作）\n\n如果导入后发现问题，可以在当前标签页未关闭的情况下恢复备份：\n\n```javascript\n// 1. 打开浏览器控制台 (F12)\n\n// 2. 检查是否有备份\nconst hasBackup = sessionStorage.getItem('gvFolderBackup');\nconsole.log('备份存在：', hasBackup !== null);\n\n// 3. 查看备份时间\nconst backupTime = sessionStorage.getItem('gvFolderBackupTimestamp');\nconsole.log('备份时间：', backupTime);\n\n// 4. 恢复备份\nconst backup = JSON.parse(sessionStorage.getItem('gvFolderBackup'));\nlocalStorage.setItem('gvFolderData', JSON.stringify(backup));\n\n// 5. 刷新页面\nlocation.reload();\n```\n\n### 清除备份\n\n```javascript\nsessionStorage.removeItem('gvFolderBackup');\nsessionStorage.removeItem('gvFolderBackupTimestamp');\n```\n\n## 导出文件格式\n\n```json\n{\n  \"format\": \"gemini-voyager.folders.v1\",\n  \"exportedAt\": \"2025-01-15T10:30:00.000Z\",\n  \"version\": \"0.7.2\",\n  \"data\": {\n    \"folders\": [\n      {\n        \"id\": \"folder-xxx\",\n        \"name\": \"我的文件夹\",\n        \"parentId\": null,\n        \"isExpanded\": true,\n        \"createdAt\": 1736935800000,\n        \"updatedAt\": 1736935800000\n      }\n    ],\n    \"folderContents\": {\n      \"folder-xxx\": [\n        {\n          \"conversationId\": \"conv-yyy\",\n          \"title\": \"对话标题\",\n          \"url\": \"https://gemini.google.com/app/...\",\n          \"addedAt\": 1736935800000\n        }\n      ]\n    }\n  }\n}\n```\n\n## 数据安全\n\n- ✅ **本地存储**：所有数据仅存储在本地，不上传到任何服务器\n- ✅ **格式验证**：导入时严格验证数据格式，防止损坏\n- ✅ **自动备份**：覆盖操作前自动备份\n- ✅ **版本控制**：文件包含版本号，便于未来兼容\n\n## 常见问题\n\n### Q: 备份存储在哪里？\n\nA: 存储在浏览器的 `sessionStorage` 中，仅在当前标签页有效，关闭标签页后自动清除。\n\n### Q: 为什么看不到备份文件？\n\nA: 备份是临时存储在内存中的，不会生成文件。如需永久保存，请使用导出功能。\n\n### Q: 可以自动同步吗？\n\nA: 目前需要手动导出/导入。自动同步需要云服务支持，暂不提供以保护隐私。\n\n### Q: 导入后发现配置不对怎么办？\n\nA: 如果标签页未关闭，可以通过控制台恢复备份（参见上方说明）。\n\n### Q: 支持跨浏览器同步吗？\n\nA: 支持！只需在一个浏览器导出，在另一个浏览器导入即可。\n\n## 最佳实践\n\n1. **定期导出**：养成定期导出配置的习惯\n2. **云盘备份**：将导出的 JSON 文件保存到云盘\n3. **测试导入**：在新设备先用合并模式测试\n4. **保留备份**：重要操作前先导出一份备份\n5. **版本管理**：可以为不同配置状态保存多个版本\n\n## 技术细节\n\n- **格式版本**: `gemini-voyager.folders.v1`\n- **去重策略**: 按 `id` 和 `conversationId` 去重\n- **文件编码**: UTF-8\n- **最大文件大小**: 理论无限制（受浏览器内存限制）\n- **兼容性**: Chrome 88+, Firefox 85+, Safari 14+\n\n## 反馈与支持\n\n如有问题或建议，请访问：\nhttps://github.com/Nagi-ovo/gemini-voyager/issues/36\n"
  },
  {
    "path": ".github/docs/safari/INSTALLATION.md",
    "content": "# Safari Extension Installation Guide\n\nEnglish | [简体中文](INSTALLATION_ZH.md)\n\nA simple guide for installing Voyager on Safari.\n\n## Requirements\n\n- **macOS 11+**\n- **Safari 14+**\n\n## Installation Steps\n\n### 1. Download\n\nGet the latest `gemini-voyager-X.Y.Z.dmg` from [GitHub Releases](https://github.com/Nagi-ovo/gemini-voyager/releases).\n\n### 2. Install\n\nDouble-click the `.dmg` file and follow the prompts to install the application.\n\n### 3. Enable in Safari\n\n1. Open **Safari → Settings** (or Preferences)\n2. Go to **Extensions** tab\n3. Check **Voyager** to enable\n4. Visit [Gemini](https://gemini.google.com) to test\n\nDone! 🎉\n\n## Troubleshooting\n\n### Safari doesn't show the extension\n\n1. Safari → Settings → Advanced → Enable \"Show Develop menu\"\n2. Develop → Allow Unsigned Extensions\n3. Restart Safari\n\n## For Developers\n\nWant to build from source or contribute? See the [Safari Development Guide](../../../safari/README.md) for:\n\n- Building from source\n- Development workflow\n- Adding Swift native code\n- Advanced debugging\n\n## Uninstall\n\n1. Safari → Settings → Extensions → Uncheck Voyager\n2. Delete the app from Applications folder\n\n---\n\n**Need help?** Open an issue on [GitHub](https://github.com/Nagi-ovo/gemini-voyager/issues)\n"
  },
  {
    "path": ".github/docs/safari/INSTALLATION_ZH.md",
    "content": "# Safari 扩展安装指南\n\n[English](INSTALLATION.md) | 简体中文\n\n在 Safari 上安装 Voyager 的简单指南。\n\n## 系统要求\n\n- **macOS 11+**\n- **Safari 14+**\n\n## 安装步骤\n\n### 1. 下载\n\n从 [GitHub Releases](https://github.com/Nagi-ovo/gemini-voyager/releases) 下载最新的 `gemini-voyager-X.Y.Z.dmg`。\n\n### 2. 安装\n\n双击打开 `.dmg` 文件并按提示安装应用。\n\n### 3. 在 Safari 中启用\n\n1. 打开 **Safari → 设置**（或偏好设置）\n2. 前往 **扩展** 标签页\n3. 勾选 **Voyager** 启用\n4. 访问 [Gemini](https://gemini.google.com) 测试\n\n完成！🎉\n\n## 常见问题\n\n### Safari 中看不到扩展\n\n1. Safari → 设置 → 高级 → 勾选\"在菜单栏中显示'开发'菜单\"\n2. 开发 → 允许未签名的扩展\n3. 重启 Safari\n\n## 开发者\n\n想从源代码构建或参与开发？查看 [Safari 开发指南](../../../safari/README_ZH.md) 了解：\n\n- 从源代码构建\n- 开发工作流\n- 添加 Swift 原生代码\n- 高级调试\n\n## 卸载\n\n1. Safari → 设置 → 扩展 → 取消勾选 Voyager\n2. 从应用程序文件夹删除该应用\n\n---\n\n**需要帮助？** 在 [GitHub](https://github.com/Nagi-ovo/gemini-voyager/issues) 提交 Issue\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "### 🚫 AI Policy / AI 政策\n\n- **We explicitly reject AI-generated PRs that have not been manually verified.**\n- **本项目拒绝接受任何未经人工复核的 AI 生成的 PR。**\n- Low-quality AI PRs will be closed immediately. / 低质量的 AI PR 会被直接关闭。\n- You must understand and take responsibility for every line of code you submit. / 你必须理解并对你提交的每一行代码负责。\n- **Workflow Proficiency / 协作能力**: Ensure you are familiar with GitHub/Git workflows and maintain a clean Git history. Please learn the basics first if needed to avoid messy PR history. / 请确保你熟悉 GitHub/Git 工作流并保持 Git 历史整洁。如有必要请先学习相关知识，避免 PR 历史过于混乱。\n\n---\n\n### Description / 描述\n\n<!-- Explain the goal of this PR and what changes were made. -->\n<!-- 请解释此 PR 的目标以及做了哪些更改。 -->\n\n### Related Issue / 相关 Issue\n\n<!-- Link the issue this PR resolves (e.g., Closes #123). FOR NEW FEATURES: You MUST open an Issue for discussion first. PRs submitted without prior discussion will be closed. -->\n<!-- 链接此 PR 解决的 issue（例如：Closes #123）。对于新功能：请务必先开启一个 Issue 进行讨论，未经讨论直接提交的 PR 会被关闭。 -->\n\n### Visual Proof / 可视化证据\n\n<!-- REQUIRED for UI changes: Please provide screenshots or screen recordings. -->\n<!-- UI 修改必填：请提供截图或屏幕录制。 -->\n\n### Browser Testing / 浏览器测试\n\n- [ ] **Chrome / Edge (Chromium)**: Tested / 已测试\n- [ ] **Firefox**: Tested (Mandatory) / 已测试（必填）\n- [ ] **Safari**: Tested (Optional) or labeled as unsupported / 已测试（可选）或已标注为不支持\n\n### Checklist / 检查清单\n\n- [ ] I have manually verified that the feature works as intended. / 我已手动验证功能按预期工作。\n- [ ] I have confirmed that this PR does not break existing functionality. / 我已确认此 PR 不会破坏原有功能。\n- [ ] I have run `bun run lint`, `bun run typecheck`, `bun run format` and `bun run build`. / 我已运行代码校验、类型检查、格式化及构建。\n- [ ] I have added/updated necessary tests and they pass (`bun run test`). / 我已添加/更新了必要的测试并确保通过（`bun run test`）。\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  build-and-check:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Install bun\n        uses: oven-sh/setup-bun@v2\n\n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n\n      - name: Install\n        run: bun i\n      - name: Check Formatting\n        run: bun x prettier --check .\n      - name: Lint\n        run: bun run lint\n      - name: Typecheck\n        run: bun run typecheck\n      - name: Test\n        run: bun run test\n      - name: Build (Chrome)\n        run: bun run build:chrome\n      - name: Build (Firefox)\n        run: bun run build:firefox\n      - name: Build (Safari)\n        run: bun run build:safari\n"
  },
  {
    "path": ".github/workflows/deploy-docs.yml",
    "content": "name: Deploy Docs\n\non:\n  push:\n    branches:\n      - main\n    paths:\n      - 'docs/**'\n      - '.vitepress/**'\n      - 'package.json'\n      - 'package-lock.json'\n      - 'bun.lock'\n      - 'CNAME'\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\nconcurrency:\n  group: pages\n  cancel-in-progress: false\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Setup Bun\n        uses: oven-sh/setup-bun@v1\n        with:\n          bun-version: latest\n\n      - name: Install dependencies\n        run: bun install\n\n      - name: Build docs\n        run: bun run docs:build\n\n      - name: Copy CNAME to dist\n        run: cp CNAME docs/.vitepress/dist/CNAME\n\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v3\n        with:\n          path: docs/.vitepress/dist\n\n  deploy:\n    needs: build\n    runs-on: ubuntu-latest\n    name: Deploy\n    steps:\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@v4\n"
  },
  {
    "path": ".github/workflows/issue-claim.yml",
    "content": "name: Issue Claim\n\non:\n  issue_comment:\n    types: [created]\n\njobs:\n  claim-issue:\n    runs-on: ubuntu-latest\n    if: github.event.issue.pull_request == null && contains(github.event.comment.body, '/claim')\n\n    permissions:\n      issues: write\n\n    steps:\n      - name: Check if issue is already assigned\n        id: check\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const issue = context.payload.issue;\n            const commenter = context.payload.comment.user.login;\n\n            // Check if already assigned\n            if (issue.assignees && issue.assignees.length > 0) {\n              const assigneeNames = issue.assignees.map(a => a.login).join(', ');\n              await github.rest.issues.createComment({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: issue.number,\n                body: `❌ This issue is already assigned to: **${assigneeNames}**\\n\\nIf you'd like to work on this issue, please coordinate with the current assignee(s) or ask a maintainer to reassign.`\n              });\n              return;\n            }\n\n            // Assign the commenter\n            try {\n              await github.rest.issues.addAssignees({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: issue.number,\n                assignees: [commenter]\n              });\n              \n              // Add \"claimed\" label if it exists\n              try {\n                await github.rest.issues.addLabels({\n                  owner: context.repo.owner,\n                  repo: context.repo.repo,\n                  issue_number: issue.number,\n                  labels: ['👷 Claimed']\n                });\n              } catch (labelError) {\n                // Label doesn't exist, skip\n                console.log('Claimed label not found, skipping...');\n              }\n              \n              await github.rest.issues.createComment({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: issue.number,\n                body: `🎉 **@${commenter}** has claimed this issue!\\n\\nThank you for your contribution! Here are some tips:\\n- Please read our [Contributing Guide](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/.github/CONTRIBUTING.md)\\n- Feel free to ask questions if you need help\\n- When you're ready, submit a PR and reference this issue\\n\\nIf you're no longer able to work on this, please comment \\`/unclaim\\` so others can pick it up.`\n              });\n            } catch (error) {\n              await github.rest.issues.createComment({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: issue.number,\n                body: `❌ Failed to assign **@${commenter}** to this issue. This may happen if the user doesn't have permission to be assigned.\\n\\nMaintainers: Please manually assign if appropriate.`\n              });\n            }\n\n  unclaim-issue:\n    runs-on: ubuntu-latest\n    if: github.event.issue.pull_request == null && contains(github.event.comment.body, '/unclaim')\n\n    permissions:\n      issues: write\n\n    steps:\n      - name: Unclaim issue\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const issue = context.payload.issue;\n            const commenter = context.payload.comment.user.login;\n\n            // Check if commenter is assigned\n            const isAssigned = issue.assignees && issue.assignees.some(a => a.login === commenter);\n\n            if (!isAssigned) {\n              await github.rest.issues.createComment({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: issue.number,\n                body: `❌ **@${commenter}** is not currently assigned to this issue.`\n              });\n              return;\n            }\n\n            // Remove assignment\n            await github.rest.issues.removeAssignees({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: issue.number,\n              assignees: [commenter]\n            });\n\n            // Remove \"claimed\" label if present\n            try {\n              await github.rest.issues.removeLabel({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: issue.number,\n                name: '👷 Claimed'\n              });\n            } catch (labelError) {\n              // Label not present, skip\n            }\n\n            await github.rest.issues.createComment({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: issue.number,\n              body: `👋 **@${commenter}** has unclaimed this issue. It's now available for others to work on!\\n\\nIf you'd like to claim this issue, comment \\`/claim\\`.`\n            });\n"
  },
  {
    "path": ".github/workflows/issue-validator.yml",
    "content": "name: Issue Validator\n\non:\n  issues:\n    types: [opened, edited]\n\njobs:\n  validate:\n    runs-on: ubuntu-latest\n    permissions:\n      issues: write\n    steps:\n      - name: Validate Issue Quality\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const issue = context.payload.issue;\n            const body = issue.body || '';\n\n            // --- Configuration ---\n            const MIN_STEPS_LENGTH = 10;\n            const PLACEHOLDER_VERSION = '1.1.3';\n            const PLACEHOLDER_STEPS_PART = \"Go to '...'\"; // Part of the default template text\n\n            // --- Parsing Logic ---\n            // Helper to extract content between markdown headers\n            function extractSection(text, headerPattern) {\n              const match = text.match(new RegExp(`${headerPattern}\\\\s+([\\\\s\\\\S]*?)(?=\\\\n###|$)`));\n              return match ? match[1].trim() : '';\n            }\n\n            // Extract fields based on ISSUE_TEMPLATE/bug_report.yml labels\n            const version = extractSection(body, '### 📦 扩展版本 \\\\| Extension Version');\n            const steps = extractSection(body, '### 📷 复现步骤 \\\\| Recurrence Steps');\n            const os = extractSection(body, '### 💻 系统环境 \\\\| Operating System');\n            const browser = extractSection(body, '### 🌐 浏览器 \\\\| Browser');\n\n            // --- Validation Logic ---\n            let failureReason = '';\n\n            // 0. Skip validation if it's not a bug report (missing version field)\n            const isBugReport = !!body.match(/### 📦 扩展版本 \\| Extension Version/);\n\n            if (isBugReport) {\n                // 1. Check Version\n                if (!version || version === PLACEHOLDER_VERSION) {\n                  failureReason = 'Missing or default extension version.';\n                }\n\n                // 2. Check Steps\n                else if (!steps || steps.length < MIN_STEPS_LENGTH) {\n                   failureReason = 'Reproduction steps are too short (less than 10 characters).';\n                }\n                else if (steps.includes(PLACEHOLDER_STEPS_PART)) {\n                   failureReason = 'Reproduction steps contain default template text.';\n                }\n            }\n\n\n\n            // --- Action ---\n            if (failureReason) {\n              console.log(`Closing issue #${issue.number}: ${failureReason}`);\n\n              // 1. Close the issue\n              await github.rest.issues.update({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: issue.number,\n                state: 'closed'\n              });\n\n              // 2. Add 'invalid' label\n              await github.rest.issues.addLabels({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: issue.number,\n                labels: ['invalid']\n              });\n\n              // 3. Post strict comment\n              const comment = `\n              ❌ **Issue Closed: Low Quality Report**\n\n              This issue has been automatically closed because it lacks necessary information or uses default template text.\n              \n              Reason: **${failureReason}**\n\n              To reopen, please edit your issue to provide:\n              1. The exact **Extension Version** you are using.\n              2. Clear, detailed **Reproduction Steps** (do not use the \"Go to...\" template text).\n\n              > *This looks like a low quality issue report.*\n              `;\n\n              await github.rest.issues.createComment({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: issue.number,\n                body: comment\n              });\n            }\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\nrun-name: >\n  ${{\n    (github.event_name == 'workflow_dispatch' && inputs.version && format('chore(release): v{0}', inputs.version)) ||\n    (github.event_name == 'workflow_dispatch' && !inputs.version && 'chore(release): auto-increment') ||\n    (startsWith(github.ref, 'refs/tags/') && format('Release {0}', github.ref_name)) ||\n    format('Release #{0}', github.run_number)\n  }}\n\n# 🚀 Release Workflow Guide\n# -------------------------\n# This workflow supports two modes of operation:\n#\n# 1. Manual Release (Recommended for \"One-Click\" Releases)\n#    - How: Go to \"Actions\" -> \"Release\" -> \"Run workflow\".\n#    - Input: Optional \"Version\" (e.g., 1.2.0). If left empty, it auto-increments the patch version.\n#    - What it does:\n#      1. Calculates the next version.\n#      2. Bumps version in package.json & manifest.json.\n#      3. Commits \"chore(release): vX.Y.Z\".\n#      4. Creates git tag \"vX.Y.Z\".\n#      5. Pushes commit & tag to main.\n#      6. Builds artifacts and creates a GitHub Release.\n#\n# 2. Tag-based Release (Manual Tagging)\n#    - How: run `git commit -m \"...\" && git tag v1.2.0 && git push origin v1.2.0` locally.\n#    - What it does:\n#      1. Detects the pushed tag (v1.2.0).\n#      2. SKIPS version calculation and bumping (assumes you already did it).\n#      3. Builds artifacts from that tag.\n#      4. Creates a GitHub Release for that tag.\n\non:\n  push:\n    tags:\n      - 'v*'\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Version to release (e.g. 1.0.8) - leave empty to auto-increment patch version'\n        required: false\n        type: string\n      notes:\n        description: 'Release notes (optional)'\n        required: false\n        type: string\n\npermissions:\n  contents: write\n\njobs:\n  # Job 1: Calculate & Bump Version (Only runs on manual trigger)\n  bump-version:\n    if: github.event_name == 'workflow_dispatch'\n    runs-on: ubuntu-latest\n    outputs:\n      new_version: ${{ steps.next_version.outputs.version }}\n      tag_name: ${{ steps.tag.outputs.name }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n\n      - name: Calculate next version\n        id: next_version\n        run: |\n          if [ -n \"${{ inputs.version }}\" ]; then\n            echo \"version=${{ inputs.version }}\" >> $GITHUB_OUTPUT\n            echo \"Using manual version: ${{ inputs.version }}\"\n          else\n            CURRENT=$(node -e \"console.log(require('./package.json').version)\")\n            echo \"Current version: ${CURRENT}\"\n            \n            IFS='.' read -r MAJOR MINOR PATCH <<< \"$CURRENT\"\n            PATCH=$((PATCH + 1))\n            \n            # Simple carry logic\n            if [ $PATCH -ge 10 ]; then\n              MINOR=$((MINOR + 1))\n              PATCH=0\n            fi\n            if [ $MINOR -ge 10 ]; then\n              MAJOR=$((MAJOR + 1))\n              MINOR=0\n            fi\n            \n            NEXT=\"${MAJOR}.${MINOR}.${PATCH}\"\n            echo \"version=${NEXT}\" >> $GITHUB_OUTPUT\n            echo \"Auto-calculated next version: ${NEXT}\"\n          fi\n\n      - name: Compute tag name\n        id: tag\n        run: echo \"name=v${{ steps.next_version.outputs.version }}\" >> $GITHUB_OUTPUT\n\n      - name: Update files, commit and push\n        env:\n          VERSION: ${{ steps.next_version.outputs.version }}\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          echo \"Bumping to version ${VERSION}\"\n\n          # Update package.json and manifest.json\n          node -e \"const fs=require('fs');const v=process.env.VERSION;const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version=v;fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const m=JSON.parse(fs.readFileSync('manifest.json','utf8'));m.version=v;fs.writeFileSync('manifest.json',JSON.stringify(m,null,2)+'\\n');\"\n\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n\n          git add package.json manifest.json\n          git commit -m \"chore(release): v${VERSION}\" || echo \"No changes to commit\"\n          git tag v${VERSION} || echo \"Tag exists\"\n\n          # Push commit and tag\n          git push origin HEAD:main --tags\n\n  # Job 2: Build & Release (Runs on both manual and tag push)\n  build-and-release:\n    needs: bump-version\n    if: always() && (needs.bump-version.result == 'success' || github.event_name == 'push')\n    runs-on: ubuntu-latest\n    steps:\n      - name: Set Release Tag Variable\n        id: vars\n        run: |\n          if [ \"${{ github.event_name }}\" == \"workflow_dispatch\" ]; then\n            echo \"tag_name=${{ needs.bump-version.outputs.tag_name }}\" >> $GITHUB_OUTPUT\n          else\n            echo \"tag_name=${{ github.ref_name }}\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          ref: ${{ steps.vars.outputs.tag_name }}\n          fetch-depth: 0\n\n      - name: Setup Bun\n        uses: oven-sh/setup-bun@v2\n        with:\n          bun-version: 'latest'\n\n      - name: Install deps\n        run: bun i\n\n      - name: Build All\n        run: bun run build:all\n\n      - name: Archive artifacts\n        run: |\n          TAG=${{ steps.vars.outputs.tag_name }}\n\n          cd dist_chrome && zip -r ../voyager-chrome-${TAG}.zip . && cd ..\n\n      - name: Build Edge artifact\n        run: bun run build:edge\n\n      - name: Publish to Microsoft Edge Add-ons\n        continue-on-error: true\n        uses: wdzeng/edge-addon@v2\n        with:\n          product-id: ${{ secrets.EDGE_PRODUCT_ID }}\n          zip-path: voyager-edge-${{ steps.vars.outputs.tag_name }}.zip\n          api-key: ${{ secrets.EDGE_API_KEY }}\n          client-id: ${{ secrets.EDGE_CLIENT_ID }}\n\n      - name: Sign Firefox Extension and Submit to AMO\n        run: |\n          TAG=${{ steps.vars.outputs.tag_name }}\n          npx web-ext sign \\\n            --source-dir=dist_firefox \\\n            --api-key=${{ secrets.AMO_JWT_ISSUER }} \\\n            --api-secret=${{ secrets.AMO_JWT_SECRET }} \\\n            --channel=listed\n          # Move signed XPI to root with proper naming\n          mv web-ext-artifacts/*.xpi voyager-firefox-${TAG}.xpi\n\n      - name: Prepare Release Notes\n        id: release_body\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          TAG=${{ steps.vars.outputs.tag_name }}\n          NOTES=\"${{ inputs.notes }}\"\n\n          if [ -n \"$NOTES\" ]; then\n            echo \"body<<EOF\" >> $GITHUB_OUTPUT\n            echo \"$NOTES\" >> $GITHUB_OUTPUT\n            echo \"EOF\" >> $GITHUB_OUTPUT\n          else\n            # Get the previous tag for auto-generated notes\n            PREV_TAG=$(git describe --tags --abbrev=0 \"${TAG}^\" 2>/dev/null || echo \"\")\n\n            # Generate release notes via GitHub API\n            AUTO_NOTES=\"\"\n            if [ -n \"$PREV_TAG\" ]; then\n              AUTO_NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \\\n                -f tag_name=\"${TAG}\" \\\n                -f target_commitish=\"main\" \\\n                -f previous_tag_name=\"${PREV_TAG}\" \\\n                --jq '.body' 2>/dev/null || echo \"\")\n            fi\n\n            cat > release_body.md << 'INSTALL_EOF'\n          ## 📥 Installation\n\n          <div align=\"center\">\n            <a href=\"https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=readme&utm_campaign=organic_growth&utm_content=en\" target=\"_blank\">\n              <img src=\"https://img.shields.io/badge/Chrome%20Web%20Store-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white\" alt=\"Chrome Web Store\" height=\"36\">\n            </a>\n            &nbsp;&nbsp;\n            <a href=\"https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne\" target=\"_blank\">\n              <img src=\"https://img.shields.io/badge/Microsoft%20Edge-0078D7?style=for-the-badge&logo=microsoftedge&logoColor=white\" alt=\"Microsoft Edge Add-ons\" height=\"36\">\n            </a>\n            &nbsp;&nbsp;\n            <a href=\"https://addons.mozilla.org/firefox/addon/gemini-voyager/\" target=\"_blank\">\n              <img src=\"https://img.shields.io/badge/Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Firefox Add-ons\" height=\"36\">\n            </a>\n          </div>\n          INSTALL_EOF\n\n            # Append version-specific file names\n            cat >> release_body.md << EOF\n\n          - **Chrome**: \\`voyager-chrome-${TAG}.zip\\`\n          - **Firefox**: \\`voyager-firefox-${TAG}.xpi\\`\n          - **Safari**: \\`voyager-${TAG}.dmg\\`\n\n          ### 🍎 Safari 安装与限制 (Safari Installation & Limitations)\n\n          - **安装 (Installation)**: 下载并打开 \\`voyager-${TAG}.dmg\\`，按提示安装应用。\n            Download and open \\`voyager-${TAG}.dmg\\`, then follow the prompts to install the app.\n          - **限制 (Limitations)**: 受限于 Safari 特性，以下功能暂不支持：(a) Nano Banana 水印去除 (b) 图片导出 (建议使用 PDF 导出) (c) Google Drive 云同步。\n            Due to Safari's nature, the following features are not supported: (a) Watermark removal (b) Image export (PDF recommended) (c) Cloud sync with Google Drive.\n          EOF\n\n            # Combine: auto-generated notes first, then installation\n            echo \"body<<BODY_EOF\" >> $GITHUB_OUTPUT\n            if [ -n \"$AUTO_NOTES\" ]; then\n              echo \"$AUTO_NOTES\" >> $GITHUB_OUTPUT\n              echo \"\" >> $GITHUB_OUTPUT\n            fi\n            cat release_body.md >> $GITHUB_OUTPUT\n            echo \"BODY_EOF\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Create GitHub Release\n        uses: softprops/action-gh-release@v2\n        with:\n          name: Voyager ${{ steps.vars.outputs.tag_name }}\n          tag_name: ${{ steps.vars.outputs.tag_name }}\n          files: |\n            voyager-chrome-*.zip\n            voyager-firefox-*.xpi\n            voyager-*.dmg\n          body: ${{ steps.release_body.outputs.body }}\n"
  },
  {
    "path": ".github/workflows/sponsors.yml",
    "content": "name: Update Sponsors\n\non:\n  workflow_dispatch:\n  push:\n    branches:\n      - main\n    paths:\n      - 'sponsorkit/sponsors.json'\n\njobs:\n  update-sponsors:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 20\n\n      - name: Generate sponsors SVG\n        run: node scripts/generate-sponsors.cjs\n        env:\n          GITHUB_TOKEN: ${{ secrets.SPONSORKIT_GITHUB_TOKEN }}\n          AFDIAN_USER_ID: ${{ secrets.AFDIAN_USER_ID }}\n          AFDIAN_TOKEN: ${{ secrets.AFDIAN_TOKEN }}\n\n      - name: Commit changes\n        uses: stefanzweifel/git-auto-commit-action@v5\n        with:\n          commit_message: 'chore: update sponsors.svg'\n          file_pattern: 'docs/public/assets/sponsors.*'\n"
  },
  {
    "path": ".github/workflows/stale.yml",
    "content": "name: 'Close Stale Issues'\non:\n  schedule:\n    - cron: '30 1 * * *'\n  workflow_dispatch:\n\npermissions:\n  issues: write\n  pull-requests: write\n\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/stale@v9\n        with:\n          stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed in 14 days if no further activity occurs. Thank you for your contributions.'\n          stale-pr-message: 'This pull request has been automatically marked as stale because it has not had recent activity. It will be closed in 14 days if no further activity occurs. Thank you for your contributions.'\n          days-before-stale: 7\n          days-before-close: 14\n          stale-issue-label: 'stale'\n          stale-pr-label: 'stale'\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules/\n/dist_chrome/\n/dist_firefox/\n/dist_safari/\ngemini-voyager-edge-*.zip\n\n# Vitest\ncoverage/\n.vitest/\n\n# Generated Xcode projects (users generate these locally)\n/Gemini Voyager/\n*.xcodeproj/xcuserdata/\n*.xcworkspace/xcuserdata/\nDerivedData/\nsafari/Models/Gemini*\n\n.git-commit-message.txt\n\npackage-lock.json\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n.vscode/\n.idea/\n.claude/settings.local.json\n.claude/worktrees/\n.DS_Store\n\n*.local\n.env*\n\n*.mov\n*.mp4\n!docs/public/assets/*.mov\n!docs/public/assets/*.mp4\n*.xpi\n*.srt\n\n\n# VitePress\ndocs/.vitepress/dist\ndocs/.vitepress/cache\n\n# Sync Service (Commercial - Private)\nSYNC_SERVICE_ARCHITECTURE.md\n.env.sync\n.chrome-dev-data/\n\n# Dev Tools\nscripts/chrome-profile/\n\n# Git worktrees (local dev only)\n.worktrees/\n\n# Local agent outputs\n.agent/\ndocs/plans/\noutput/\nsrc/pages/content/printBridge/\n\ngemini-voyager-formal/\n"
  },
  {
    "path": ".prettierignore",
    "content": "# Sync project\ngemini-voyager-sync/\n\n# Build outputs\ndist_chrome/\ndist_firefox/\ndist_safari/\ndist/\n\n# Dependencies\nnode_modules/\n\n# Lock files\nbun.lockb\npackage-lock.json\npnpm-lock.yaml\n\n# VitePress\n.vitepress/dist\n.vitepress/cache\n\n# Coverage\ncoverage/\n\n# Safari Xcode project (Apple-generated files)\nGemini Voyager/\nsafari/\n\n# Firefox signed extension output\nweb-ext-artifacts/\n\n# Auto-generated data\nsponsorkit/\n\n# Claude Code internals\n.entire/\n.claude/\nCLAUDE.md\n\n# Changelog notes (hand-written, not auto-formatted)\nsrc/pages/content/changelog/notes/\n\n# System & Misc\n.DS_Store\n*.zip\ngemini-voyager-formal/\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"tabWidth\": 2,\n  \"useTabs\": false,\n  \"printWidth\": 100,\n  \"trailingComma\": \"all\",\n  \"endOfLine\": \"lf\",\n  \"plugins\": [\"@trivago/prettier-plugin-sort-imports\", \"prettier-plugin-tailwindcss\"],\n  \"importOrder\": [\n    \"^react\",\n    \"<THIRD_PARTY_MODULES>\",\n    \"^@core/(.*)$\",\n    \"^@features/(.*)$\",\n    \"^@components/(.*)$\",\n    \"^@pages/(.*)$\",\n    \"^@assets/(.*)$\",\n    \"^@locales/(.*)$\",\n    \"^@/(.*)$\",\n    \"^[./]\"\n  ],\n  \"importOrderSeparation\": true,\n  \"importOrderSortSpecifiers\": true\n}\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md - Gemini Voyager\n\n## Commands\n\n```bash\nbun install                # Setup\nbun run dev:chrome         # Dev (also: dev:firefox, dev:safari)\nbun run build:chrome       # Build (also: build:firefox, build:safari, build:edge, build:all)\nbun run test               # Test (also: test:watch, test:ui, test:coverage)\nbun run typecheck          # Type check\nbun run lint               # Lint\nbun run format             # Format\nbun run bump               # Version bump (patch)\nbun run docs:dev           # Docs dev server\n```\n\n## Core Rules\n\n1. **No `any` type.** Use `unknown` + narrowing. Use Branded Types for IDs.\n2. **No direct `chrome.storage` in UI components.** Use `StorageService`. Content scripts (`src/pages/content/`) are an exception — they use `chrome.storage` directly via ExtGlobal.\n3. **No `console.log` in production.** Use `LoggerService`.\n4. **No global variables** outside defined Services.\n5. **No magic strings.** Use constants/enums for Storage Keys and CSS Classes.\n6. **All CSS classes injected into Gemini DOM must be prefixed `gv-`.**\n7. **All translations must be updated in all 10 locales** (`en`, `ar`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`, `zh`, `zh_TW`) when adding/modifying i18n keys.\n8. **Never modify `dist_*` folders directly.**\n9. **Never commit `.env` or secrets.**\n10. **When adding Material Symbol icons**, add the icon name to `icon_names=` in the Google Fonts URL in `src/pages/popup/index.html`.\n\n## Verification (run before declaring done)\n\n1. `bun run typecheck` — after any `.ts`/`.tsx` change\n2. `bun run lint` — before finishing\n3. `bun run test` — all tests pass\n4. `bun run build:chrome` — builds without error\n5. New features/fixes must include tests\n\n## Commit Format\n\nConventional Commits: `<type>(<scope>): <imperative summary>`\n\n- Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `build`, `ci`, `perf`, `style`\n- Scope: short, feature-focused (e.g., `copy`, `export`, `popup`)\n- Summary: lowercase, imperative, no trailing period\n- If the commit relates to a GitHub issue or discussion, include `Closes #xxx` or `Fixes #xxx` in the commit **body**\n\n## Version Bump & Release\n\n```bash\nbun run bump    # auto-updates package.json, manifest.json, manifest.dev.json\n```\n\n**Changelog required:** after bumping, ensure `src/pages/content/changelog/notes/` has a `.md` file for the new version before pushing. Do not skip this step.\n\nThen: commit `chore: bump to v{VERSION}` → `git tag v{VERSION}` → `git push && git push --tags`\n\n## Design Principles\n\n1. **KISS.** Implement the minimum interpretation of requirements. Never combine orthogonal features (e.g., \"fade\" and \"thin\") without explicit confirmation.\n2. **Backward compatibility is iron law.** Zero destructiveness to user data (especially `localStorage`).\n3. **Data structures first.** Eliminate special cases by redesigning data, not adding branches.\n4. **For visual/CSS changes:** describe expected rendering, verify alignment/centering/spacing in both light and dark themes, and check external resources (icon fonts, CDN links).\n5. **For ambiguous requirements:** implement the minimal version first. Ask before adding scope.\n\n## Architecture\n\n- **Services**: singletons in `src/core/services/`. `StorageService` is single source of truth for persistence.\n- **Content scripts**: `src/pages/content/`. Each sub-module is self-contained.\n- **UI**: functional React components + hooks. Business logic in `features/*/services/` or custom hooks, not in UI files.\n- **Types**: `src/core/types/common.ts` for StorageKeys and shared types.\n- **Translations**: `src/locales/*/messages.json` (10 languages).\n- **Injected CSS**: `public/contentStyle.css`.\n\n## Task Map\n\n| Task | Where |\n|------|-------|\n| Add storage key | `src/core/types/common.ts` → `StorageService.ts` → all 10 locales |\n| Update translations | `src/locales/*/messages.json` (all 10) |\n| Change DOM injection | `src/pages/content/` |\n| Modify popup settings | `src/pages/popup/components/` |\n| Fix cloud sync | `src/core/services/GoogleDriveSyncService.ts` |\n| Add keyboard shortcut | `src/core/services/KeyboardShortcutService.ts` + types |\n"
  },
  {
    "path": "CNAME",
    "content": "voyager.nagi.fun"
  },
  {
    "path": "LICENSE",
    "content": "Copyright (c) 2022 Jonathan Braat\nCopyright (c) 2026 Jesse Zhang\n\n                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>."
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n  <img src=\"docs/public/assets/promotion/Promo-Banner.png\" alt=\"promotion\"/>\n  <h3>Make Your Gemini™ Experience Truly Yours ✨</h3>\n  <p>\n    Navigate conversations with an elegant timeline, organize chats with folders, and build your own prompt vault.<br>\n    <b>It's the missing power-up for Google Gemini.</b>\n  </p>\n  \n  <p>\n    <img src=\"https://img.shields.io/badge/Chrome-✓-4285F4?style=flat-square&logo=googlechrome&logoColor=white\" alt=\"Chrome\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoftedge&logoColor=white\" alt=\"Edge\">\n    <img src=\"https://img.shields.io/badge/Firefox-✓-FF7139?style=flat-square&logo=firefox&logoColor=white\" alt=\"Firefox\">\n    <img src=\"https://img.shields.io/badge/Safari-✓-000000?style=flat-square&logo=safari&logoColor=white\" alt=\"Safari\">\n    <img src=\"https://img.shields.io/badge/Opera-✓-FF1B2D?style=flat-square&logo=opera&logoColor=white\" alt=\"Opera\">\n    <img src=\"https://img.shields.io/badge/Brave-✓-FB542B?style=flat-square&logo=brave&logoColor=white\" alt=\"Brave\">\n  </p>\n  <p>\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub stars\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub forks\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Latest version\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub downloads\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store users\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store rating\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons users\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons rating\">\n  </p>\n  <p>\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Gemini Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </p>\n  <p align=\"center\">\n    ✨ We're live on Product Hunt! We'd love to hear your thoughts and feedback. ❤️\n  </p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://voyager.nagi.fun/en\">📖 Documentation</a> • \n  <a href=\"./.github/README_ZH.md\">简体中文</a> •\n  <a href=\"./.github/README_ZH_TW.md\">繁體中文</a> •\n  <a href=\"./.github/README_JA.md\">日本語</a> •\n  <a href=\"./.github/README_FR.md\">Français</a> •\n  <a href=\"./.github/README_ES.md\">Español</a> •\n  <a href=\"./.github/README_PT.md\">Português</a> •\n  <a href=\"./.github/README_RU.md\">Русский</a> •\n  <a href=\"./.github/README_AR.md\">العربية</a> •\n  <a href=\"./.github/README_KO.md\">한국어</a>\n</p>\n\n<p align=\"center\">\n    <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n  </p>\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024507762483277927?s=20\" target=\"_blank\">\n    <img src=\"docs/public/assets/x-recommendation.png\" alt=\"KOL Recommendation\" width=\"500\">\n  </a>\n  <br>\n  <b>🎉 Highly recommended by top tech KOLs and the community!</b>\n</p>\n\n> [!IMPORTANT]\n> **Name Change Notice**: Due to trademark and copyright concerns, this extension has been officially renamed to **Voyager**. However, due to the Chrome Web Store's extremely slow review process, the name change was not approved within 7 days — it is temporarily unavailable on the Chrome Web Store under the new name.\n\n> [!NOTE]\n> If Voyager helps you, feel free to share it on X, Reddit, YouTube, Threads, etc. Every share helps more people discover the project and improve the Gemini experience. Thanks.\n\n---\n\n## 👋 Why Voyager?\n\nWe love Gemini, but sometimes we wish it had just a _bit_ more structure.\n\nThat's why we built **Voyager**. It's not just a tool; it's a companion that helps you keep your AI conversations organized, accessible, and productive. Whether you're a researcher juggling dozens of threads, a developer saving code snippets, or just someone who loves order, Voyager is designed for you.\n\n<p align=\"center\">\n  <a href=\"https://x.com/Nag1ovo/status/2024509398601597412?s=20\" target=\"_blank\">\n    <img src=\"docs/public/assets/try-voyager.png\" alt=\"Try Voyager\" width=\"500\">\n  </a>\n  <br>\n  <i>During the issue on February 18th where the Google Gemini App caused some users' historical conversations to become inaccessible, Voyager users were still able to see their saved conversations in their folders.</i>\n</p>\n\n---\n\n## ✨ Features\n\n### 🌌 Core (Gemini & AI Studio)\n\n- **📂 [Folder Organization](https://voyager.nagi.fun/en/guide/folders)**: Organize chats into a two-level folder hierarchy with drag-and-drop support.\n  - **Gemini**: Supports **Account Isolation Mode** and **Custom Folder Colors**.\n- **💡 [Prompt Vault](https://voyager.nagi.fun/en/guide/prompts)**: Save and reuse prompts across Gemini, AI Studio, and [custom websites](https://voyager.nagi.fun/en/guide/custom-websites).\n- **☁️ [Cloud Sync](https://voyager.nagi.fun/en/guide/cloud-sync)**: Sync folders and prompts to Google Drive.\n- **📐 Formula Copy**: One-click copy for LaTeX and MathML (Word) source codes.\n- **🌦️ Visual Effects**: Add seasonal ambience with **snow**, **cinematic rain**, or **falling sakura petals** from the settings panel.\n\n### ✨ Gemini Exclusive\n\n- **📍 [Timeline Navigation](https://voyager.nagi.fun/en/guide/timeline)**: Visual nodes to jump between messages, star key moments, and manage conversation branches.\n- **💾 [Chat Export](https://voyager.nagi.fun/en/guide/export)**: Export conversations to JSON, Markdown, or PDF with images included.\n- **🧜‍♀️ [Mermaid Rendering](https://voyager.nagi.fun/en/guide/mermaid)**: Auto-render flowcharts, sequence diagrams, and other Mermaid charts.\n- **📝 [Markdown Rendering Fix](https://voyager.nagi.fun/en/guide/markdown-fix)**: Automatically fix broken bold syntax caused by Gemini's injected HTML elements.\n- **🍌 [NanoBanana](https://voyager.nagi.fun/en/guide/nanobanana)**: Lossless watermark removal for Gemini-generated images.\n- **🔬 [Deep Research](https://voyager.nagi.fun/en/guide/deep-research)**: Extract thinking processes and research links from Deep Research sessions.\n- **🛠️ Power Tools**:\n  - **[Batch Delete](https://voyager.nagi.fun/en/guide/batch-delete)**: Bulk delete conversations.\n  - **[Quote Reply](https://voyager.nagi.fun/en/guide/quote-reply)**: Reply with context by selecting text.\n  - **[Tab Title Sync](https://voyager.nagi.fun/en/guide/tab-title)**: Auto-sync browser tab titles.\n  - **[Prevent Auto Scroll](https://voyager.nagi.fun/en/guide/prevent-auto-scroll)**: Intercepts unwanted jumping behavior when hitting enter to send a new prompt.\n  - **[Input Collapse](https://voyager.nagi.fun/en/guide/input-collapse)**: Auto-expandable input area for more reading space.\n  - **[Default Model](https://voyager.nagi.fun/en/guide/default-model)**: Set your favorite model as default.\n  - **[Hide Recent Items](https://voyager.nagi.fun/en/guide/recents-hider)**: Hide \"Recent\" list in the sidebar to reduce distraction.\n  - **Hide Upgrade Prompts**: Hide \"Upgrade to Google AI Ultra\" elements in the sidebar and model menu to reduce distraction (Enabled by default).\n\n### 🎨 Personalization\n\n- Open the extension popup and find **Visual Effects** to switch between `Off`, `Snow`, `Sakura`, and `Rain`.\n- Effects are rendered as lightweight full-screen overlays and do not block page interaction.\n- When you switch effects or turn them off, existing particles drain out naturally instead of disappearing abruptly.\n\n---\n\n## 📥 Installation\n\n> ⚠️ Note: Prompt Manager is the only feature that supports Gemini for Enterprise.\n\n<div align=\"center\">\n  <a href=\"https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=readme&utm_campaign=organic_growth&utm_content=en\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Chrome%20Web%20Store-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white\" alt=\"Chrome Web Store\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Microsoft%20Edge-0078D7?style=for-the-badge&logo=microsoftedge&logoColor=white\" alt=\"Microsoft Edge Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://addons.mozilla.org/firefox/addon/gemini-voyager/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Firefox Add-ons\" height=\"36\">\n  </a>\n  &nbsp;&nbsp;\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager/releases/latest/\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Safari-000000?style=for-the-badge&logo=safari&logoColor=white\" alt=\"Safari Download\" height=\"36\">\n  </a>\n</div>\n\n<p align=\"center\">\n  <sub><b>Chrome Web Store</b> also works on Edge, Opera, Brave, Vivaldi, Arc, and other Chromium browsers.</sub>\n</p>\n\n> **Store Status:** Chrome ⏳ (name update pending review) · Firefox ✅ · Edge ✅ · Safari ✅\n\nFor **manual installation** or **development builds**, please refer to the [Installation Guide](https://voyager.nagi.fun/en/guide/installation).\n\n---\n\n## ☕ Support This Project\n\n<div align=\"center\">\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" />\n  </a>\n</div>\n\nIf Voyager makes your life easier, consider buying me a coffee. It helps keep the updates coming! Sponsors will be featured in our Special Thanks section. ❤️\n\n<div align=\"center\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" height=\"36\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" height=\"36\">\n  </a>\n  \n  <p><b>Or support via WeChat / Alipay / Afdian:</b></p>\n  <table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n    <tr>\n      <td align=\"center\">\n        <img src=\"docs/public/assets/wechat-sponsor.png\" alt=\"WeChat Pay\" height=\"160\"><br>\n        <sub><b>WeChat Pay</b></sub>\n      </td>\n      <td align=\"center\">\n        <img src=\"docs/public/assets/alipay-sponsor.jpg\" alt=\"Alipay\" height=\"160\"><br>\n        <sub><b>Alipay</b></sub>\n      </td>\n      <td align=\"center\">\n        <a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n          <picture>\n            <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n            <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n            <img alt=\"Nagi-ovo's Profile\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n          </picture>\n        </a><br>\n        <sub><b>Afdian</b></sub>\n      </td>\n    </tr>\n  </table>\n</div>\n\n### 🎙️ Recommended Tool: Typeless\n\nI highly recommend **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)**, an AI voice-to-text tool that I used extensively during the development of Voyager. Integrating it into my daily workflow has saved me a tremendous amount of time and significantly boosted my productivity.\n\n> 🎁 **[Join via my referral link](https://www.typeless.com/?via=gemini-voyager)** (Code: _`gemini-voyager`_) to get **$5 free credits**. This also gives me credits to keep maintaining this project—a free way to support my work! ❤️\n\n---\n\n## 💬 Community\n\n<table>\n  <tr>\n    <td align=\"center\" width=\"50%\" valign=\"top\">\n      <a href=\"https://x.com/Nag1ovo/status/2012610584722731188\" target=\"_blank\">\n        <img src=\"https://img.shields.io/badge/Follow%20on-𝕏-000000?style=for-the-badge&logo=x&logoColor=white\" alt=\"Follow on X\" height=\"36\">\n      </a>\n      <br><br>\n      <b>Follow for Updates</b><br>\n      <sub>Get the latest updates.</sub>\n    </td>\n    <td align=\"center\" width=\"50%\" valign=\"top\">\n      <a href=\"https://discord.gg/TEUFxdMbGb\" target=\"_blank\">\n        <img src=\"https://img.shields.io/discord/1463273957120675973?style=for-the-badge&logo=discord&logoColor=white&label=Join%20Discord\" alt=\"Discord\" height=\"36\">\n      </a>\n      <br><br>\n      <b>Join the Community</b><br>\n      <sub>Chat with other users, share prompts, and get help.</sub>\n    </td>\n  </tr>\n</table>\n\n---\n\n## 🤝 Contributing & Development\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\nWe welcome contributions! Whether you want to report bugs, suggest features, improve documentation, or submit code:\n\n- **Issues**: Use our [bug report](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/bug_report.md) or [feature request](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml) templates\n- **Pull Requests**: Check out [CONTRIBUTING.md](./.github/CONTRIBUTING.md) for guidelines\n\n<details>\n<summary>Development Setup</summary>\n\n```bash\n# Install dependencies (Bun recommended)\nbun i\n\n# Development mode (with auto-reload)\nbun run dev:chrome   # Chrome & Chromium browsers\nbun run dev:firefox  # Firefox\nbun run dev:safari   # Safari (requires macOS)\n\n# Production builds\nbun run build:chrome   # Chrome\nbun run build:firefox  # Firefox\nbun run build:safari   # Safari\nbun run build:all      # All browsers\n```\n\n**Safari Development**: See [safari/README.md](safari/README.md) for additional build steps.\n\n</details>\n\nThank you for helping make Voyager better! ❤️\n\n### ❤️ Special Thanks\n\nSpecial thanks to all contributors for their contributions to Voyager ❤️\n\n<a href=\"https://github.com/Nagi-ovo/gemini-voyager/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=Nagi-ovo/gemini-voyager&max=200&columns=14\" />\n</a>\n\n---\n\n## 🌟 Credits\n\n- **[DeepSeek Voyager](https://github.com/Azurboy/deepseek-voyager)** - A fork of Voyager adapted for DeepSeek, bringing timeline navigation and chat management to DeepSeek users!\n\n- **[claude-nexus](https://github.com/Qiuner/claude-nexus)** - A Claude.ai enhancement extension inspired by Voyager, featuring timeline navigation, folder management, prompt library and more, with full prompt import/export compatibility with Voyager!\n\n- **[ChatGPT Conversation Timeline](https://github.com/Reborn14/chatgpt-conversation-timeline)** - The original timeline navigation extension for ChatGPT that inspired this project: Voyager adapted the timeline concept for Gemini and added extensive new features including folder management, prompt vault, and chat export.\n\n- **[Ophel Atlas](https://github.com/urzeye/ophel)** - A browser extension that transforms AI conversations into organized, searchable documents with auto-generated outlines, conversation management, and prompt libraries across multiple AI platforms.\n\n---\n\n<div align=\"center\">\n  <a href=\"https://www.star-history.com/#Nagi-ovo/gemini-voyager&type=date&legend=top-left\">\n   <picture>\n     <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&theme=dark&legend=top-left\" />\n     <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n     <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=Nagi-ovo/gemini-voyager&type=date&legend=top-left\" />\n   </picture>\n  </a>\n  <p>Made with ❤️ by Jesse Zhang</p>\n  <sub>GPLv3 License © 2026</sub>\n</div>\n"
  },
  {
    "path": "commitlint.config.cjs",
    "content": "module.exports = {\n  extends: ['@commitlint/config-conventional'],\n  rules: {\n    'type-enum': [\n      2,\n      'always',\n      [\n        'feat',\n        'fix',\n        'docs',\n        'style',\n        'refactor',\n        'perf',\n        'test',\n        'build',\n        'ci',\n        'chore',\n        'revert',\n        'deps',\n        'ux',\n      ],\n    ],\n    'header-max-length': [2, 'always', 100],\n    'scope-case': [2, 'always', 'lower-case'],\n    'subject-case': [0],\n    'body-leading-blank': [2, 'always'],\n    'footer-leading-blank': [2, 'always'],\n    'footer-max-line-length': [2, 'always', 120],\n  },\n};\n"
  },
  {
    "path": "custom-vite-plugins.ts",
    "content": "import fs from 'fs';\nimport { resolve } from 'path';\nimport type { NormalizedInputOptions, NormalizedOutputOptions } from 'rollup';\nimport type { PluginOption } from 'vite';\n\nconst FIREFOX_OUT_DIR_MARKER = 'dist_firefox';\nconst CHANGELOG_PROMO_BANNERS = [\n  'changelog-promo-banner.png',\n  'changelog-promo-banner-cn.png',\n  'changelog-promo-banner-jp.png',\n];\n\n// plugin to remove dev icons from prod build\nexport function stripDevIcons(isDev: boolean) {\n  if (isDev) return null;\n\n  return {\n    name: 'strip-dev-icons',\n    resolveId(source: string) {\n      return source === 'virtual-module' ? source : null;\n    },\n    renderStart(outputOptions: NormalizedOutputOptions, _inputOptions: NormalizedInputOptions) {\n      const outDir = outputOptions.dir ?? '';\n      const isFirefoxBuild = outDir.includes(FIREFOX_OUT_DIR_MARKER);\n\n      fs.rm(resolve(outDir, 'dev-icon-32.png'), () =>\n        console.log(`Deleted dev-icon-32.png from prod build`),\n      );\n      fs.rm(resolve(outDir, 'dev-icon-128.png'), () =>\n        console.log(`Deleted dev-icon-128.png from prod build`),\n      );\n\n      if (!isFirefoxBuild) {\n        CHANGELOG_PROMO_BANNERS.forEach((fileName) => {\n          fs.rm(resolve(outDir, fileName), () =>\n            console.log(`Deleted ${fileName} from non-Firefox build`),\n          );\n        });\n      }\n\n      // Remove assets directory if it exists\n      const assetsDir = resolve(outDir, 'assets');\n      fs.rm(assetsDir, { recursive: true, force: true }, () =>\n        console.log(`Deleted assets/ directory from prod build`),\n      );\n    },\n    writeBundle(outputOptions: NormalizedOutputOptions) {\n      const outDir = outputOptions.dir ?? '';\n      // Remove .vite directory (Vite's internal manifest, not needed for extension)\n      const viteDir = resolve(outDir, '.vite');\n      fs.rm(viteDir, { recursive: true, force: true }, () =>\n        console.log(`Deleted .vite/ directory from prod build`),\n      );\n    },\n  };\n}\n\ntype LocaleMessages = Record<string, { message: string; description?: string }>;\n\nfunction stripDescriptions(raw: LocaleMessages): LocaleMessages {\n  return Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, { message: v.message }]));\n}\n\n// plugin to strip `description` fields from locale JSON at build time.\n// Runs before vite:json so we return stripped JSON; vite:json then converts it to ESM normally.\nexport function stripI18nDescriptions(isDev: boolean): PluginOption {\n  if (isDev) return null;\n\n  return {\n    name: 'strip-i18n-descriptions',\n    enforce: 'pre',\n    transform(code, id) {\n      if (!id.includes('/locales/') || !id.endsWith('messages.json')) return null;\n      const raw: LocaleMessages = JSON.parse(code);\n      return { code: JSON.stringify(stripDescriptions(raw)), map: null };\n    },\n  };\n}\n\n// plugin to support i18n\nexport function crxI18n(options: {\n  localize: boolean;\n  src: string;\n  stripDescriptions?: boolean;\n}): PluginOption {\n  if (!options.localize) return null;\n\n  const getJsonFiles = (dir: string): Array<string> => {\n    const files = fs.readdirSync(dir, { recursive: true }) as string[];\n    return files.filter((file) => !!file && file.endsWith('.json'));\n  };\n  const entry = resolve(__dirname, options.src);\n  const localeFiles = getJsonFiles(entry);\n  const files = localeFiles.map((file) => {\n    const raw: LocaleMessages = JSON.parse(fs.readFileSync(resolve(entry, file), 'utf-8'));\n    const source = options.stripDescriptions\n      ? JSON.stringify(stripDescriptions(raw))\n      : JSON.stringify(raw);\n    return { id: '', fileName: file, source };\n  });\n  return {\n    name: 'crx-i18n',\n    enforce: 'pre',\n    buildStart: {\n      order: 'post',\n      handler() {\n        files.forEach((file) => {\n          const refId = this.emitFile({\n            type: 'asset',\n            source: file.source,\n            fileName: '_locales/' + file.fileName,\n          });\n          file.id = refId;\n        });\n      },\n    },\n  };\n}\n"
  },
  {
    "path": "docs/.vitepress/config.mts",
    "content": "import {\n  GitChangelog,\n  GitChangelogMarkdownSection,\n} from '@nolebase/vitepress-plugin-git-changelog/vite';\nimport { defineConfig } from 'vitepress';\n\n// https://vitepress.dev/reference/site-config\nexport default defineConfig({\n  base: '/',\n  title: 'Voyager',\n  description: '直观的导航。强大的组织。简洁优雅。',\n  lang: 'zh-CN',\n  head: [['link', { rel: 'icon', href: '/favicon.ico' }]],\n\n  locales: {\n    root: {\n      label: '简体中文',\n      lang: 'zh-CN',\n      themeConfig: {\n        nav: [\n          { text: '首页', link: '/' },\n          { text: '指南', link: '/guide/installation' },\n        ],\n        sidebar: [\n          {\n            text: '启程',\n            items: [\n              { text: '安装', link: '/guide/installation' },\n              { text: '快速上手', link: '/guide/getting-started' },\n              { text: '赞助', link: '/guide/sponsor' },\n              { text: '交流与反馈', link: '/guide/community' },\n            ],\n          },\n          {\n            text: '通用功能 (Gemini & AI Studio)',\n            items: [\n              { text: '文件夹', link: '/guide/folders' },\n              { text: '灵感库', link: '/guide/prompts' },\n              { text: '云同步', link: '/guide/cloud-sync' },\n              { text: '公式复制', link: '/guide/formula-copy' },\n              { text: '侧边栏宽度', link: '/guide/sidebar' },\n            ],\n          },\n          {\n            text: 'Gemini 专属功能',\n            items: [\n              { text: '时间轴', link: '/guide/timeline' },\n              { text: '对话导出', link: '/guide/export' },\n              { text: '引用回复', link: '/guide/quote-reply' },\n              { text: '对话宽度调整', link: '/guide/settings' },\n              { text: '批量删除', link: '/guide/batch-delete' },\n              { text: 'Deep Research 导出', link: '/guide/deep-research' },\n              { text: 'Mermaid 图表渲染', link: '/guide/mermaid' },\n              { text: 'Markdown 渲染修复', link: '/guide/markdown-fix' },\n              { text: 'NanoBanana 水印去除', link: '/guide/nanobanana' },\n              { text: '侧边栏自动收起', link: '/guide/sidebar-auto-hide' },\n              { text: '防自动跳转', link: '/guide/prevent-auto-scroll' },\n              { text: '输入框折叠', link: '/guide/input-collapse' },\n              { text: '隐藏最近项目和 Gem', link: '/guide/recents-hider' },\n              { text: '默认模型', link: '/guide/default-model' },\n              { text: '标签页标题同步', link: '/guide/tab-title' },\n              { text: '对话分支 (实验性)', link: '/guide/fork' },\n              { text: '上下文同步到IDE（实验性）', link: '/guide/context-sync' },\n            ],\n          },\n        ],\n        footer: {\n          message:\n            '本项目开源。欢迎在 <a href=\"https://github.com/Nagi-ovo/gemini-voyager\" target=\"_blank\">GitHub</a> 上给一颗 ⭐ 支持。',\n          copyright:\n            '基于 GPLv3 协议发布 | Copyright © 2026 Jesse Zhang | <a href=\"/privacy\">隐私政策</a>',\n        },\n      },\n    },\n    zh_TW: {\n      label: '繁體中文',\n      lang: 'zh-TW',\n      link: '/zh_TW/',\n      themeConfig: {\n        nav: [\n          { text: '首頁', link: '/zh_TW/' },\n          { text: '指南', link: '/zh_TW/guide/installation' },\n        ],\n        sidebar: [\n          {\n            text: '介紹',\n            items: [\n              { text: '安裝', link: '/zh_TW/guide/installation' },\n              { text: '快速開始', link: '/zh_TW/guide/getting-started' },\n              { text: '贊助', link: '/zh_TW/guide/sponsor' },\n              { text: '社群', link: '/zh_TW/guide/community' },\n            ],\n          },\n          {\n            text: '通用功能 (Gemini & AI Studio)',\n            items: [\n              { text: '資料夾', link: '/zh_TW/guide/folders' },\n              { text: '提示詞庫', link: '/zh_TW/guide/prompts' },\n              { text: '雲同步', link: '/zh_TW/guide/cloud-sync' },\n              { text: '公式複製', link: '/zh_TW/guide/formula-copy' },\n              { text: '側邊欄寬度', link: '/zh_TW/guide/sidebar' },\n            ],\n          },\n          {\n            text: 'Gemini 專屬功能',\n            items: [\n              { text: '時間軸導航', link: '/zh_TW/guide/timeline' },\n              { text: '對話導出', link: '/zh_TW/guide/export' },\n              { text: '引用回覆', link: '/zh_TW/guide/quote-reply' },\n              { text: '對話寬度', link: '/zh_TW/guide/settings' },\n              { text: '批次刪除', link: '/zh_TW/guide/batch-delete' },\n              { text: 'Deep Research 導出', link: '/zh_TW/guide/deep-research' },\n              { text: 'Mermaid 圖表', link: '/zh_TW/guide/mermaid' },\n              { text: 'Markdown 渲染修復', link: '/zh_TW/guide/markdown-fix' },\n              { text: 'NanoBanana', link: '/zh_TW/guide/nanobanana' },\n              { text: '側邊欄自動收起', link: '/zh_TW/guide/sidebar-auto-hide' },\n              { text: '防自動跳轉', link: '/zh_TW/guide/prevent-auto-scroll' },\n              { text: '輸入框摺疊', link: '/zh_TW/guide/input-collapse' },\n              { text: '隱藏最近項目和 Gem', link: '/zh_TW/guide/recents-hider' },\n              { text: '預設模型', link: '/zh_TW/guide/default-model' },\n              { text: '標籤標題同步', link: '/zh_TW/guide/tab-title' },\n              { text: '對話分支 (實驗性)', link: '/zh_TW/guide/fork' },\n              { text: '上下文同步（實驗性）', link: '/zh_TW/guide/context-sync' },\n            ],\n          },\n        ],\n        footer: {\n          message:\n            '開源專案。如果您喜歡，請在 <a href=\"https://github.com/Nagi-ovo/gemini-voyager\" target=\"_blank\">GitHub</a> 上給我們一顆 ⭐。',\n          copyright:\n            'GPLv3 授權 | Copyright © 2026 Jesse Zhang | <a href=\"/zh_TW/privacy\">隱私政策</a>',\n        },\n      },\n    },\n    en: {\n      label: 'English',\n      lang: 'en-US',\n      link: '/en/',\n      themeConfig: {\n        nav: [\n          { text: 'Home', link: '/en/' },\n          { text: 'Guide', link: '/en/guide/installation' },\n        ],\n        sidebar: [\n          {\n            text: 'Introduction',\n            items: [\n              { text: 'Installation', link: '/en/guide/installation' },\n              { text: 'Getting Started', link: '/en/guide/getting-started' },\n              { text: 'Sponsor', link: '/en/guide/sponsor' },\n              { text: 'Community', link: '/en/guide/community' },\n            ],\n          },\n          {\n            text: 'Common Features (Gemini & AI Studio)',\n            items: [\n              { text: 'Folder Organization', link: '/en/guide/folders' },\n              { text: 'Prompt Library', link: '/en/guide/prompts' },\n              { text: 'Cloud Sync', link: '/en/guide/cloud-sync' },\n              { text: 'Formula Copy', link: '/en/guide/formula-copy' },\n              { text: 'Sidebar Width', link: '/en/guide/sidebar' },\n            ],\n          },\n          {\n            text: 'Gemini Exclusive Features',\n            items: [\n              { text: 'Timeline Navigation', link: '/en/guide/timeline' },\n              { text: 'Chat Export', link: '/en/guide/export' },\n              { text: 'Quote Reply', link: '/en/guide/quote-reply' },\n              { text: 'Chat Width Adjustment', link: '/en/guide/settings' },\n              { text: 'Batch Delete', link: '/en/guide/batch-delete' },\n              { text: 'Deep Research Export', link: '/en/guide/deep-research' },\n              { text: 'Mermaid Diagram Rendering', link: '/en/guide/mermaid' },\n              { text: 'Markdown Rendering Fix', link: '/en/guide/markdown-fix' },\n              { text: 'NanoBanana (Watermark Remover)', link: '/en/guide/nanobanana' },\n              { text: 'Sidebar Auto-hide', link: '/en/guide/sidebar-auto-hide' },\n              { text: 'Prevent Auto Scroll', link: '/en/guide/prevent-auto-scroll' },\n              { text: 'Input Collapse', link: '/en/guide/input-collapse' },\n              { text: 'Hide Recent Items and Gems', link: '/en/guide/recents-hider' },\n              { text: 'Default Model', link: '/en/guide/default-model' },\n              { text: 'Tab Title Sync', link: '/en/guide/tab-title' },\n              { text: 'Conversation Fork (Experimental)', link: '/en/guide/fork' },\n              { text: 'Context Sync to IDE (Experimental)', link: '/en/guide/context-sync' },\n            ],\n          },\n        ],\n        footer: {\n          message:\n            'Open source project. Star us on <a href=\"https://github.com/Nagi-ovo/gemini-voyager\" target=\"_blank\">GitHub</a> if you like it ⭐.',\n          copyright:\n            'Released under the GPLv3 License | Copyright © 2026 Jesse Zhang | <a href=\"/en/privacy\">Privacy Policy</a>',\n        },\n      },\n    },\n    ja: {\n      label: '日本語',\n      lang: 'ja-JP',\n      link: '/ja/',\n      themeConfig: {\n        nav: [\n          { text: 'ホーム', link: '/ja/' },\n          { text: 'ガイド', link: '/ja/guide/installation' },\n        ],\n        sidebar: [\n          {\n            text: 'はじめに',\n            items: [\n              { text: 'インストール', link: '/ja/guide/installation' },\n              { text: 'クイックスタート', link: '/ja/guide/getting-started' },\n              { text: 'スポンサー', link: '/ja/guide/sponsor' },\n              { text: 'コミュニティ', link: '/ja/guide/community' },\n            ],\n          },\n          {\n            text: '共通機能 (Gemini & AI Studio)',\n            items: [\n              { text: 'フォルダ管理', link: '/ja/guide/folders' },\n              { text: 'プロンプト', link: '/ja/guide/prompts' },\n              { text: 'クラウド同期', link: '/ja/guide/cloud-sync' },\n              { text: '数식コピー', link: '/ja/guide/formula-copy' },\n              { text: 'サイドバーの幅', link: '/ja/guide/sidebar' },\n            ],\n          },\n          {\n            text: 'Gemini 専用機能',\n            items: [\n              { text: 'タイムライン', link: '/ja/guide/timeline' },\n              { text: 'エクスポート', link: '/ja/guide/export' },\n              { text: '引用返信', link: '/ja/guide/quote-reply' },\n              { text: 'チャット幅', link: '/ja/guide/settings' },\n              { text: '一括削除', link: '/ja/guide/batch-delete' },\n              { text: 'Deep Research', link: '/ja/guide/deep-research' },\n              { text: 'Mermaid', link: '/ja/guide/mermaid' },\n              { text: 'Markdown レンダリングの修正', link: '/ja/guide/markdown-fix' },\n              { text: 'NanoBanana', link: '/ja/guide/nanobanana' },\n              { text: 'サイドバー自動非表示', link: '/ja/guide/sidebar-auto-hide' },\n              { text: '自動スクロール防止', link: '/ja/guide/prevent-auto-scroll' },\n              { text: '入力欄の自動非表示', link: '/ja/guide/input-collapse' },\n              { text: '最近の項目と Gem を非表示', link: '/ja/guide/recents-hider' },\n              { text: 'デフォルトモデル', link: '/ja/guide/default-model' },\n              { text: 'タブタイトルの同期', link: '/ja/guide/tab-title' },\n              { text: '会話の分岐 (実験的)', link: '/ja/guide/fork' },\n              { text: 'IDEへのコンテキスト同期（実験的）', link: '/ja/guide/context-sync' },\n            ],\n          },\n        ],\n        footer: {\n          message:\n            'オープンソースプロジェクトです。<a href=\"https://github.com/Nagi-ovo/gemini-voyager\" target=\"_blank\">GitHub</a> でスター ⭐ をつけて応援してください。',\n          copyright:\n            'GPLv3 ライセンス | Copyright © 2026 Jesse Zhang | <a href=\"/ja/privacy\">プライバシーポリシー</a>',\n        },\n      },\n    },\n    ko: {\n      label: '한국어',\n      lang: 'ko-KR',\n      link: '/ko/',\n      themeConfig: {\n        nav: [\n          { text: '홈', link: '/ko/' },\n          { text: '가이드', link: '/ko/guide/installation' },\n        ],\n        sidebar: [\n          {\n            text: '소개',\n            items: [\n              { text: '설치', link: '/ko/guide/installation' },\n              { text: '시작하기', link: '/ko/guide/getting-started' },\n              { text: '후원', link: '/ko/guide/sponsor' },\n              { text: '커뮤니티', link: '/ko/guide/community' },\n            ],\n          },\n          {\n            text: '공통 기능 (Gemini & AI Studio)',\n            items: [\n              { text: '폴더 관리', link: '/ko/guide/folders' },\n              { text: '프롬프트 라이브러리', link: '/ko/guide/prompts' },\n              { text: '클라우드 동기화', link: '/ko/guide/cloud-sync' },\n              { text: '수식 복사', link: '/ko/guide/formula-copy' },\n              { text: '사이드바 너비', link: '/ko/guide/sidebar' },\n            ],\n          },\n          {\n            text: 'Gemini 전용 기능',\n            items: [\n              { text: '타임라인 탐색', link: '/ko/guide/timeline' },\n              { text: '대화 내보내기', link: '/ko/guide/export' },\n              { text: '인용 답장', link: '/ko/guide/quote-reply' },\n              { text: '대화 너비 조정', link: '/ko/guide/settings' },\n              { text: '일괄 삭제', link: '/ko/guide/batch-delete' },\n              { text: 'Deep Research 내보내기', link: '/ko/guide/deep-research' },\n              { text: 'Mermaid 다이어그램 렌더링', link: '/ko/guide/mermaid' },\n              { text: 'Markdown 렌더링 수정', link: '/ko/guide/markdown-fix' },\n              { text: 'NanoBanana (워터마크 제거)', link: '/ko/guide/nanobanana' },\n              { text: '사이드바 자동 숨김', link: '/ko/guide/sidebar-auto-hide' },\n              { text: '자동 스크롤 방지', link: '/ko/guide/prevent-auto-scroll' },\n              { text: '입력창 접기', link: '/ko/guide/input-collapse' },\n              { text: '최근 항목 및 Gem 숨기기', link: '/ko/guide/recents-hider' },\n              { text: '기본 모델', link: '/ko/guide/default-model' },\n              { text: '탭 제목 동기화', link: '/ko/guide/tab-title' },\n              { text: '대화 분기 (실험적)', link: '/ko/guide/fork' },\n              { text: 'IDE 컨텍스트 동기화 (실험적)', link: '/ko/guide/context-sync' },\n            ],\n          },\n        ],\n        footer: {\n          message:\n            '오픈 소스 프로젝트입니다. 마음에 드신다면 <a href=\"https://github.com/Nagi-ovo/gemini-voyager\" target=\"_blank\">GitHub</a>에서 ⭐를 눌러주세요.',\n          copyright:\n            'GPLv3 라이선스 하에 배포됨 | Copyright © 2026 Jesse Zhang | <a href=\"/ko/privacy\">개인정보 처리방침</a>',\n        },\n      },\n    },\n    fr: {\n      label: 'Français',\n      lang: 'fr-FR',\n      link: '/fr/',\n      themeConfig: {\n        nav: [\n          { text: 'Accueil', link: '/fr/' },\n          { text: 'Guide', link: '/fr/guide/installation' },\n        ],\n        sidebar: [\n          {\n            text: 'Introduction',\n            items: [\n              { text: 'Installation', link: '/fr/guide/installation' },\n              { text: 'Commencer', link: '/fr/guide/getting-started' },\n              { text: 'Sponsor', link: '/fr/guide/sponsor' },\n              { text: 'Communauté', link: '/fr/guide/community' },\n            ],\n          },\n          {\n            text: 'Fonctionnalités Communes (Gemini & AI Studio)',\n            items: [\n              { text: 'Dossiers', link: '/fr/guide/folders' },\n              { text: 'Bibliothèque de Prompts', link: '/fr/guide/prompts' },\n              { text: 'Synchronisation Cloud', link: '/fr/guide/cloud-sync' },\n              { text: 'Copie de Formules', link: '/fr/guide/formula-copy' },\n              { text: 'Largeur de la barre latérale', link: '/fr/guide/sidebar' },\n            ],\n          },\n          {\n            text: 'Fonctionnalités Exclusives Gemini',\n            items: [\n              { text: 'Navigation Temporelle', link: '/fr/guide/timeline' },\n              { text: 'Export de Chat', link: '/fr/guide/export' },\n              { text: 'Réponse avec Citation', link: '/fr/guide/quote-reply' },\n              { text: 'Largeur de Chat', link: '/fr/guide/settings' },\n              { text: 'Suppression par Lot', link: '/fr/guide/batch-delete' },\n              { text: 'Export Deep Research', link: '/fr/guide/deep-research' },\n              { text: 'Diagrammes Mermaid', link: '/fr/guide/mermaid' },\n              { text: 'Correction du Rendu Markdown', link: '/fr/guide/markdown-fix' },\n              { text: 'NanoBanana', link: '/fr/guide/nanobanana' },\n              { text: 'Masquage auto barre latérale', link: '/fr/guide/sidebar-auto-hide' },\n              { text: 'Empêcher le défilement auto', link: '/fr/guide/prevent-auto-scroll' },\n              { text: 'Réduction Entrée', link: '/fr/guide/input-collapse' },\n              { text: 'Masquer les éléments récents et les Gems', link: '/fr/guide/recents-hider' },\n              { text: 'Modèle par Défaut', link: '/fr/guide/default-model' },\n              { text: 'Synchro Titre Onglet', link: '/fr/guide/tab-title' },\n              { text: 'Bifurcation de Conversation (Expérimental)', link: '/fr/guide/fork' },\n              { text: 'Synchro Contexte IDE', link: '/fr/guide/context-sync' },\n            ],\n          },\n        ],\n        footer: {\n          message:\n            'Projet Open Source. Mettez une ⭐ sur <a href=\"https://github.com/Nagi-ovo/gemini-voyager\" target=\"_blank\">GitHub</a> si vous aimez.',\n          copyright:\n            'Licence GPLv3 | Copyright © 2026 Jesse Zhang | <a href=\"/fr/privacy\">Politique de Confidentialité</a>',\n        },\n      },\n    },\n    es: {\n      label: 'Español',\n      lang: 'es-ES',\n      link: '/es/',\n      themeConfig: {\n        nav: [\n          { text: 'Inicio', link: '/es/' },\n          { text: 'Guía', link: '/es/guide/installation' },\n        ],\n        sidebar: [\n          {\n            text: 'Introducción',\n            items: [\n              { text: 'Instalación', link: '/es/guide/installation' },\n              { text: 'Comenzar', link: '/es/guide/getting-started' },\n              { text: 'Patrocinar', link: '/es/guide/sponsor' },\n              { text: 'Comunidad', link: '/es/guide/community' },\n            ],\n          },\n          {\n            text: 'Funciones Comunes (Gemini & AI Studio)',\n            items: [\n              { text: 'Carpetas', link: '/es/guide/folders' },\n              { text: 'Biblioteca de Prompts', link: '/es/guide/prompts' },\n              { text: 'Sincronización en la Nube', link: '/es/guide/cloud-sync' },\n              { text: 'Copia de Fórmulas', link: '/es/guide/formula-copy' },\n              { text: 'Ancho de la barra lateral', link: '/es/guide/sidebar' },\n            ],\n          },\n          {\n            text: 'Funciones Exclusivas de Gemini',\n            items: [\n              { text: 'Navegación de Línea de Tiempo', link: '/es/guide/timeline' },\n              { text: 'Exportación de Chat', link: '/es/guide/export' },\n              { text: 'Respuesta con Cita', link: '/es/guide/quote-reply' },\n              { text: 'Ancho de Chat', link: '/es/guide/settings' },\n              { text: 'Eliminación por Lote', link: '/es/guide/batch-delete' },\n              { text: 'Exportación Deep Research', link: '/es/guide/deep-research' },\n              { text: 'Gráficos Mermaid', link: '/es/guide/mermaid' },\n              { text: 'Corrección de Renderizado Markdown', link: '/es/guide/markdown-fix' },\n              { text: 'NanoBanana', link: '/es/guide/nanobanana' },\n              { text: 'Ocultar barra lateral auto', link: '/es/guide/sidebar-auto-hide' },\n              { text: 'Evitar desplazamiento automático', link: '/es/guide/prevent-auto-scroll' },\n              { text: 'Colapso de Entrada', link: '/es/guide/input-collapse' },\n              { text: 'Ocultar elementos recientes y Gems', link: '/es/guide/recents-hider' },\n              { text: 'Modelo Predeterminado', link: '/es/guide/default-model' },\n              {\n                text: 'Sincronización de Título de Pestaña',\n                link: '/es/guide/tab-title',\n              },\n              {\n                text: 'Bifurcación de Conversación (Experimental)',\n                link: '/es/guide/fork',\n              },\n              {\n                text: 'Sincronización de contexto a IDE (Experimental)',\n                link: '/es/guide/context-sync',\n              },\n            ],\n          },\n        ],\n        footer: {\n          message:\n            'Proyecto de Código Abierto. Danos una ⭐ en <a href=\"https://github.com/Nagi-ovo/gemini-voyager\" target=\"_blank\">GitHub</a> si te gusta.',\n          copyright:\n            'Licencia GPLv3 | Copyright © 2026 Jesse Zhang | <a href=\"/es/privacy\">Política de Privacidad</a>',\n        },\n      },\n    },\n    pt: {\n      label: 'Português',\n      lang: 'pt-PT',\n      link: '/pt/',\n      themeConfig: {\n        nav: [\n          { text: 'Início', link: '/pt/' },\n          { text: 'Guia', link: '/pt/guide/installation' },\n        ],\n        sidebar: [\n          {\n            text: 'Introdução',\n            items: [\n              { text: 'Instalação', link: '/pt/guide/installation' },\n              { text: 'Começar', link: '/pt/guide/getting-started' },\n              { text: 'Patrocinar', link: '/pt/guide/sponsor' },\n              { text: 'Comunidade', link: '/pt/guide/community' },\n            ],\n          },\n          {\n            text: 'Funcionalidades Comuns (Gemini & AI Studio)',\n            items: [\n              { text: 'Pastas', link: '/pt/guide/folders' },\n              { text: 'Biblioteca de Prompts', link: '/pt/guide/prompts' },\n              { text: 'Sincronização na Nuvem', link: '/pt/guide/cloud-sync' },\n              { text: 'Cópia de Fórmulas', link: '/pt/guide/formula-copy' },\n              { text: 'Largura da barra lateral', link: '/pt/guide/sidebar' },\n            ],\n          },\n          {\n            text: 'Funcionalidades Exclusivas Gemini',\n            items: [\n              { text: 'Navegação na Linha do Tempo', link: '/pt/guide/timeline' },\n              { text: 'Exportação de Chat', link: '/pt/guide/export' },\n              { text: 'Resposta com Citação', link: '/pt/guide/quote-reply' },\n              { text: 'Largura do Chat', link: '/pt/guide/settings' },\n              { text: 'Exclusão em Lote', link: '/pt/guide/batch-delete' },\n              { text: 'Exportação Deep Research', link: '/pt/guide/deep-research' },\n              { text: 'Gráficos Mermaid', link: '/pt/guide/mermaid' },\n              { text: 'Correção de Renderização Markdown', link: '/pt/guide/markdown-fix' },\n              { text: 'NanoBanana', link: '/pt/guide/nanobanana' },\n              { text: 'Ocultar barra lateral auto', link: '/pt/guide/sidebar-auto-hide' },\n              { text: 'Prevenir rolamento automático', link: '/pt/guide/prevent-auto-scroll' },\n              { text: 'Colapso de Entrada', link: '/pt/guide/input-collapse' },\n              { text: 'Ocultar Itens Recentes e Gems', link: '/pt/guide/recents-hider' },\n              { text: 'Modelo Padrão', link: '/pt/guide/default-model' },\n              { text: 'Sincronização do Título da Aba', link: '/pt/guide/tab-title' },\n              { text: 'Bifurcação de Conversa (Experimental)', link: '/pt/guide/fork' },\n              { text: 'Sincronização de Contexto (Experimental)', link: '/pt/guide/context-sync' },\n            ],\n          },\n        ],\n        footer: {\n          message:\n            'Projeto Open Source. Dê uma ⭐ no <a href=\"https://github.com/Nagi-ovo/gemini-voyager\" target=\"_blank\">GitHub</a> se você gostar.',\n          copyright:\n            'Licença GPLv3 | Copyright © 2026 Jesse Zhang | <a href=\"/pt/privacy\">Política de Privacidade</a>',\n        },\n      },\n    },\n    ar: {\n      label: 'العربية',\n      lang: 'ar-SA',\n      link: '/ar/',\n      dir: 'rtl',\n      themeConfig: {\n        nav: [\n          { text: 'الرئيسية', link: '/ar/' },\n          { text: 'الدليل', link: '/ar/guide/installation' },\n        ],\n        sidebar: [\n          {\n            text: 'مقدمة',\n            items: [\n              { text: 'التثبيت', link: '/ar/guide/installation' },\n              { text: 'البدء', link: '/ar/guide/getting-started' },\n              { text: 'رعاية', link: '/ar/guide/sponsor' },\n              { text: 'المجتمع', link: '/ar/guide/community' },\n            ],\n          },\n          {\n            text: 'الميزات العامة (Gemini & AI Studio)',\n            items: [\n              { text: 'المجلدات', link: '/ar/guide/folders' },\n              { text: 'مكتبة المطالبات', link: '/ar/guide/prompts' },\n              { text: 'مزامنة السحابية', link: '/ar/guide/cloud-sync' },\n              { text: 'نسخ الصيغ', link: '/ar/guide/formula-copy' },\n              { text: 'عرض الشريط الجانبي', link: '/ar/guide/sidebar' },\n            ],\n          },\n          {\n            text: 'ميزات Gemini الحصرية',\n            items: [\n              { text: 'تصفح الجدول الزمني', link: '/ar/guide/timeline' },\n              { text: 'تصدير الدردشة', link: '/ar/guide/export' },\n              { text: 'الرد مع اقتباس', link: '/ar/guide/quote-reply' },\n              { text: 'عرض الدردشة', link: '/ar/guide/settings' },\n              { text: 'الحذف الجماعي', link: '/ar/guide/batch-delete' },\n              { text: 'تصدير البحث العميق', link: '/ar/guide/deep-research' },\n              { text: 'رسوم بيانية Mermaid', link: '/ar/guide/mermaid' },\n              { text: 'إصلاح عرض Markdown', link: '/ar/guide/markdown-fix' },\n              { text: 'NanoBanana', link: '/ar/guide/nanobanana' },\n              { text: 'إخفاء الشريط الجانبي تلقائياً', link: '/ar/guide/sidebar-auto-hide' },\n              { text: 'منع التمرير التلقائي', link: '/ar/guide/prevent-auto-scroll' },\n              { text: 'طي الإدخال', link: '/ar/guide/input-collapse' },\n              { text: 'إخفاء العناصر الأخيرة والـ Gems', link: '/ar/guide/recents-hider' },\n              { text: 'النموذج الافتراضي', link: '/ar/guide/default-model' },\n              { text: 'مزامنة عنوان علامة التبويب', link: '/ar/guide/tab-title' },\n              { text: 'تفريع المحادثة (تجريبي)', link: '/ar/guide/fork' },\n              { text: 'مزامنة السياق (تجريبي)', link: '/ar/guide/context-sync' },\n            ],\n          },\n        ],\n        footer: {\n          message:\n            'مشروع مفتوح المصدر. امنحنا ⭐ على <a href=\"https://github.com/Nagi-ovo/gemini-voyager\" target=\"_blank\">GitHub</a> إذا أعجبك.',\n          copyright:\n            'رخصة GPLv3 | حقوق النشر © 2026 Jesse Zhang | <a href=\"/ar/privacy\">سياسة الخصوصية</a>',\n        },\n      },\n    },\n    ru: {\n      label: 'Русский',\n      lang: 'ru-RU',\n      link: '/ru/',\n      themeConfig: {\n        nav: [\n          { text: 'Главная', link: '/ru/' },\n          { text: 'Руководство', link: '/ru/guide/installation' },\n        ],\n        sidebar: [\n          {\n            text: 'Введение',\n            items: [\n              { text: 'Установка', link: '/ru/guide/installation' },\n              { text: 'Начало работы', link: '/ru/guide/getting-started' },\n              { text: 'Поддержать', link: '/ru/guide/sponsor' },\n              { text: 'Сообщество', link: '/ru/guide/community' },\n            ],\n          },\n          {\n            text: 'Общие функции (Gemini & AI Studio)',\n            items: [\n              { text: 'Папки', link: '/ru/guide/folders' },\n              { text: 'Библиотека промптов', link: '/ru/guide/prompts' },\n              { text: 'Облачная синхронизация', link: '/ru/guide/cloud-sync' },\n              { text: 'Копирование формул', link: '/ru/guide/formula-copy' },\n              { text: 'Ширина боковой панели', link: '/ru/guide/sidebar' },\n            ],\n          },\n          {\n            text: 'Эксклюзивные функции Gemini',\n            items: [\n              { text: 'Навигация по таймлайну', link: '/ru/guide/timeline' },\n              { text: 'Экспорт чата', link: '/ru/guide/export' },\n              { text: 'Ответ с цитированием', link: '/ru/guide/quote-reply' },\n              { text: 'Ширина чата', link: '/ru/guide/settings' },\n              { text: 'Пакетное удаление', link: '/ru/guide/batch-delete' },\n              { text: 'Экспорт Deep Research', link: '/ru/guide/deep-research' },\n              { text: 'Mermaid диаграммы', link: '/ru/guide/mermaid' },\n              { text: 'Исправление рендеринга Markdown', link: '/ru/guide/markdown-fix' },\n              { text: 'NanoBanana', link: '/ru/guide/nanobanana' },\n              { text: 'Авто-скрытие боковой панели', link: '/ru/guide/sidebar-auto-hide' },\n              { text: 'Предотвращение автопрокрутки', link: '/ru/guide/prevent-auto-scroll' },\n              { text: 'Сворачивание ввода', link: '/ru/guide/input-collapse' },\n              { text: 'Скрытие недавних элементов и Gems', link: '/ru/guide/recents-hider' },\n              { text: 'Модель по умолчанию', link: '/ru/guide/default-model' },\n              {\n                text: 'Синхронизация заголовка',\n                link: '/ru/guide/tab-title',\n              },\n              {\n                text: 'Ветвление разговора (Экспериментально)',\n                link: '/ru/guide/fork',\n              },\n              {\n                text: 'Синхронизация контекста (Экспериментально)',\n                link: '/ru/guide/context-sync',\n              },\n            ],\n          },\n        ],\n        footer: {\n          message:\n            'Проект с открытым исходным кодом. Поставьте ⭐ на <a href=\"https://github.com/Nagi-ovo/gemini-voyager\" target=\"_blank\">GitHub</a>, если вам нравится.',\n          copyright:\n            'Лицензия GPLv3 | Copyright © 2026 Jesse Zhang | <a href=\"/ru/privacy\">Политика конфиденциальности</a>',\n        },\n      },\n    },\n  },\n\n  themeConfig: {\n    logo: '/logo.png',\n    outline: [2, 4],\n    socialLinks: [{ icon: 'github', link: 'https://github.com/Nagi-ovo/gemini-voyager' }],\n    search: {\n      provider: 'local',\n    },\n  },\n  vite: {\n    plugins: [\n      GitChangelog({\n        repoURL: () => 'https://github.com/Nagi-ovo/gemini-voyager',\n        // Only track the Chinese source docs to avoid 281-file EAGAIN;\n        // translated copies share the same git history via the source.\n        include: ['docs/guide/**/*.md'],\n        maxGitLogCount: 200,\n      }),\n      GitChangelogMarkdownSection(),\n    ],\n    ssr: {\n      noExternal: ['vue3-marquee', '@nolebase/vitepress-plugin-git-changelog'],\n    },\n  },\n});\n"
  },
  {
    "path": "docs/.vitepress/theme/components/HomeAskAI.vue",
    "content": "<script setup lang=\"ts\">\nimport { useData } from 'vitepress';\nimport { computed } from 'vue';\n\nconst { lang } = useData();\n\nconst i18n: Record<string, any> = {\n  'zh-CN': {\n    title: '去问问 AI，Voyager 到底好在哪？',\n    chatgpt: '在 ChatGPT 提问',\n    perplexity: '在 Perplexity 提问',\n    grok: '在 Grok 提问',\n    query: '告诉我为什么 Voyager (voyager.nagi.fun) 插件是我最好的选择？',\n  },\n  'zh-TW': {\n    title: '去問問 AI，Voyager 到底好在哪？',\n    chatgpt: '在 ChatGPT 提問',\n    perplexity: '在 Perplexity 提問',\n    grok: '在 Grok 提問',\n    query: '告訴我為什麼 Voyager (voyager.nagi.fun) 擴充功能是我最好的選擇？',\n  },\n  'en-US': {\n    title: 'Ask AI, why Voyager is the best choice?',\n    chatgpt: 'Ask on ChatGPT',\n    perplexity: 'Ask on Perplexity',\n    grok: 'Ask on Grok',\n    query: 'Tell me why Voyager (voyager.nagi.fun) Extension is a great choice for me?',\n  },\n  'ja-JP': {\n    title: 'AI に聞いてみよう。なぜ Voyager が最適なのですか？',\n    chatgpt: 'ChatGPT で聞く',\n    perplexity: 'Perplexity で聞く',\n    grok: 'Grok で聞く',\n    query: 'Voyager (voyager.nagi.fun) 拡張機能が私にとって最適な選択である理由を教えてください。',\n  },\n  'ko-KR': {\n    title: '왜 Voyager가 최고의 선택일까요? AI에게 물어보세요.',\n    chatgpt: 'ChatGPT에서 물어보기',\n    perplexity: 'Perplexity에서 물어보기',\n    grok: 'Grok에서 물어보기',\n    query: 'Voyager (voyager.nagi.fun) 확장 프로그램이 저에게 최고의 선택인 이유를 알려주세요.',\n  },\n  'fr-FR': {\n    title: \"Demandez à l'IA Pourquoi Voyager est le meilleur choix ?\",\n    chatgpt: 'Demander sur ChatGPT',\n    perplexity: 'Demander sur Perplexity',\n    grok: 'Demander sur Grok',\n    query:\n      \"Dites-moi pourquoi l'extension Voyager (voyager.nagi.fun) est un excellent choix pour moi ?\",\n  },\n  'es-ES': {\n    title: 'Pregunta a la IA ¿Por qué Voyager es la mejor elección?',\n    chatgpt: 'Preguntar en ChatGPT',\n    perplexity: 'Preguntar en Perplexity',\n    grok: 'Preguntar en Grok',\n    query: '¿Dime por qué la extensión Voyager (voyager.nagi.fun) es una gran elección para mí?',\n  },\n  'pt-PT': {\n    title: 'Pergunte à IA Por que o Voyager é a melhor escolha?',\n    chatgpt: 'Perguntar no ChatGPT',\n    perplexity: 'Perguntar no Perplexity',\n    grok: 'Perguntar no Grok',\n    query: 'Diga-me por que a extensão Voyager (voyager.nagi.fun) é uma ótima escolha para mim?',\n  },\n  'ru-RU': {\n    title: 'Спросите ИИ Почему Voyager — лучший выбор?',\n    chatgpt: 'Спросить в ChatGPT',\n    perplexity: 'Спросить в Perplexity',\n    grok: 'Спросить в Grok',\n    query:\n      'Расскажите мне, почему расширение Voyager (voyager.nagi.fun) — отличный выбор для меня?',\n  },\n  'ar-SA': {\n    title: 'اسأل الذكاء الاصطناعي لماذا Voyager هو الخيار الأفضل؟',\n    chatgpt: 'اسأل في ChatGPT',\n    perplexity: 'اسأل في Perplexity',\n    grok: 'اسأل في Grok',\n    query: 'أخبرني لماذا يعد Voyager (voyager.nagi.fun) خيارًا رائعًا بالنسبة لي؟',\n  },\n};\n\nconst t = computed(() => {\n  return i18n[lang.value] || i18n['en-US'];\n});\n\nconst links = computed(() => {\n  const query = t.value.query;\n  const encodedQuery = encodeURIComponent(query);\n\n  return [\n    {\n      name: 'ChatGPT',\n      icon: 'chatgpt',\n      href: `https://chatgpt.com/?q=${encodedQuery}`,\n      text: t.value.chatgpt,\n      color: '#10a37f',\n    },\n    {\n      name: 'Perplexity',\n      icon: 'perplexity',\n      href: `https://www.perplexity.ai/?q=${encodedQuery}`,\n      text: t.value.perplexity,\n      color: '#1fbad6',\n    },\n    {\n      name: 'Grok',\n      icon: 'grok',\n      href: `https://grok.com/?q=${encodedQuery}`,\n      text: t.value.grok,\n      color: '#f0f0f0',\n    },\n  ];\n});\n</script>\n\n<template>\n  <div class=\"ask-ai-section vp-doc\">\n    <h3 class=\"title\">{{ t.title }}</h3>\n    <div class=\"buttons-container\">\n      <a\n        v-for=\"link in links\"\n        :key=\"link.name\"\n        :href=\"link.href\"\n        target=\"_blank\"\n        class=\"ask-btn\"\n        :style=\"{ '--btn-hover-color': link.color }\"\n      >\n        <div class=\"icon-wrapper\">\n          <svg v-if=\"link.icon === 'chatgpt'\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n            <path\n              d=\"M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364l2.0201-1.1685a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z\"\n            />\n          </svg>\n          <svg v-else-if=\"link.icon === 'perplexity'\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n            <path\n              d=\"M22.398 7.09h-2.31V.068l-7.51 6.354V.158h-1.156v6.196L4.49 0v7.09H1.602v10.397H4.49V24l6.933-6.36v6.201h1.155v-6.047l6.932 6.181v-6.488h2.888zm-3.466-4.531v4.53h-5.355zm-13.286.067l4.869 4.464h-4.87zM2.758 16.332V8.245h7.847L4.49 14.36v1.972zm2.888 5.04v-6.534l5.776-5.776v7.011zm12.708.025l-5.776-5.15V9.061l5.776 5.776zm2.889-5.065H19.51V14.36l-6.115-6.115h7.848z\"\n            />\n          </svg>\n          <svg v-else-if=\"link.icon === 'grok'\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n            <path\n              d=\"M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815\"\n            />\n          </svg>\n        </div>\n        <span>{{ link.text }}</span>\n      </a>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n.ask-ai-section {\n  text-align: center;\n  margin: 4rem auto 2rem;\n  max-width: 900px;\n  padding: 0 16px;\n}\n\n.title {\n  margin: 0 0 24px;\n  font-weight: 600;\n  font-size: 1.25em;\n  color: var(--vp-c-text-1);\n}\n\n.buttons-container {\n  display: flex;\n  gap: 12px;\n  justify-content: center;\n  flex-wrap: wrap;\n}\n\n.ask-btn {\n  display: inline-flex;\n  align-items: center;\n  gap: 10px;\n  padding: 10px 20px;\n  border-radius: 14px;\n  background: var(--vp-c-bg-soft);\n  border: 1.5px solid var(--vp-c-bg-soft-mute);\n  color: var(--vp-c-text-1);\n  font-size: 14px;\n  font-weight: 600;\n  text-decoration: none !important;\n  transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);\n  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);\n}\n\n.ask-btn:hover {\n  transform: translateY(-3px);\n  border-color: var(--btn-hover-color);\n  background: var(--vp-c-bg);\n  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);\n  color: var(--btn-hover-color);\n}\n\n.icon-wrapper {\n  width: 20px;\n  height: 20px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.icon-wrapper svg {\n  width: 100%;\n  height: 100%;\n}\n\n@media (max-width: 640px) {\n  .ask-btn {\n    width: 100%;\n    justify-content: center;\n  }\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/HomeReviews.vue",
    "content": "<script setup lang=\"ts\">\nimport { useData } from 'vitepress';\nimport { computed } from 'vue';\nimport { Vue3Marquee } from 'vue3-marquee';\n\nconst { lang } = useData();\n\ninterface I18nData {\n  title: string;\n  subtitle: string;\n  highlightText: string;\n  tryVoyagerText: string;\n}\n\nconst i18n: Record<string, I18nData> = {\n  'zh-CN': {\n    title: '深受社区喜爱',\n    subtitle: '与数万名 Voyager 同行，从容掌控 Gemini 工作流。',\n    highlightText: '🎉 感谢知名科技圈大 V 与社区的强烈推荐！',\n    tryVoyagerText:\n      '在 2 月 18 号 Google Gemini App 导致部分用户历史对话无法访问的问题中，Voyager 的用户仍然能够在其文件夹中看到被保存下来的对话。',\n  },\n  'en-US': {\n    title: 'Loved by the Community',\n    subtitle: 'Join tens of thousands of users organizing their Gemini workspace.',\n    highlightText: '🎉 Highly recommended by top tech KOLs and the community!',\n    tryVoyagerText:\n      \"During the issue on February 18th where the Google Gemini App caused some users' historical conversations to become inaccessible, Voyager users were still able to see their saved conversations in their folders.\",\n  },\n  'ja-JP': {\n    title: 'コミュニティから愛されています',\n    subtitle: '数万人のユーザーと一緒に、Gemini のワークフローを整理しましょう。',\n    highlightText: '🎉 トップテックKOLやコミュニティから強く推奨されています！',\n    tryVoyagerText:\n      '2月18日にGoogle Gemini Appが一部のユーザーの履歴会話にアクセスできなくなる問題を引き起こした際、Voyagerのユーザーは引き続きフォルダ内に保存された会話を見ることができました。',\n  },\n  'fr-FR': {\n    title: 'Aimé par la Communauté',\n    subtitle:\n      \"Rejoignez des dizaines de milliers d'utilisateurs qui organisent leur espace de travail Gemini.\",\n    highlightText: '🎉 Fortement recommandé par les meilleurs influenceurs tech !',\n    tryVoyagerText:\n      \"Lors du problème du 18 février où l'application Google Gemini a rendu inaccessibles les conversations historiques de certains utilisateurs, les utilisateurs de Voyager ont toujours pu voir leurs conversations enregistrées dans leurs dossiers.\",\n  },\n  'es-ES': {\n    title: 'Amado por la Comunidad',\n    subtitle: 'Únete a decenas de miles de usuarios organizando su espacio de trabajo en Gemini.',\n    highlightText: '🎉 ¡Altamente recomendado por los principales influencers tecnológicos!',\n    tryVoyagerText:\n      'Durante el problema del 18 de febrero en el que la aplicación Google Gemini hizo inaccesibles las conversaciones históricas de algunos usuarios, los usuarios de Voyager aún pudieron ver sus conversaciones guardadas en sus carpetas.',\n  },\n};\n\nconst t = computed(() => {\n  return i18n[lang.value as string] || i18n['en-US'];\n});\n\ninterface Review {\n  name: string;\n  username?: string;\n  avatar: string;\n  content: string;\n  source?: 'Chrome Web Store' | 'Edge Add-ons' | 'X';\n}\n\nconst reviews: Review[] = [\n  {\n    name: 'Shuyun Yang',\n    avatar:\n      'https://lh3.googleusercontent.com/a/ACg8ocI4nd0KLo2Cgi8PSmkl9oUxy6Dm-fIMqepv-uR0hpGHt7Zw3A=s48-w48-h48',\n    content: '非常好用，最全最顺滑的扩展！',\n    source: 'Chrome Web Store',\n  },\n  {\n    name: 'Aimee Chen',\n    avatar:\n      'https://lh3.googleusercontent.com/a-/ALV-UjUMiFlYAN-GdaQWqSepuvA_JuWDNWLTrx-VFrCjK0J2DeD4_QN5Ig=s48-w48-h48',\n    content:\n      '太好用了，helps a lot!! 解決很多對話框雜亂及一直上下捲找資料的困擾，安裝完馬上直覺操作，神器呀!!',\n    source: 'Chrome Web Store',\n  },\n  {\n    name: '@yug1224',\n    avatar: 'https://pbs.twimg.com/profile_images/1600779910720520192/QKdZC5yr_400x400.jpg',\n    content:\n      'Geminiの操作性を高める拡張機能群らしい。チャット管理や機能追加で作業効率が上がりそうかな。',\n    source: 'X',\n  },\n  {\n    name: 'Hi Sanzee',\n    avatar:\n      'https://lh3.googleusercontent.com/a-/ALV-UjVKp0gzYvVWOcKVbXxpZqAwo8tZer5qv6isJoYeVfZet7C28aQh=s48-w48-h48',\n    content:\n      'This is a god send. Thank you so much. Someone who Designs and Develops. This is a gold mine I was searching for.',\n    source: 'Chrome Web Store',\n  },\n  {\n    name: 'Mars Wang',\n    avatar:\n      'https://lh3.googleusercontent.com/a-/ALV-UjX9WVnxeHbGZOkSMFxgtrFxUeCpqxKiGW213iWf-XIYuQBJ2KM=s48-w48-h48',\n    content:\n      'omg, this is exactly what I need. Looking forward for more functions! Thank you for open scourse!',\n    source: 'Chrome Web Store',\n  },\n  {\n    name: 'CCC',\n    avatar:\n      'https://lh3.googleusercontent.com/a/ACg8ocIYRaHTC91ZFa_QS3wyFxv97bgOgWiJxtwcxhXVn13XJIDNMVP7=s48-w48-h48',\n    content: '必須大推！文件夾功能超好用，根本像原裝軟件，屌翻天。',\n    source: 'Chrome Web Store',\n  },\n  {\n    name: 'frequnet san',\n    avatar:\n      'https://lh3.googleusercontent.com/a/ACg8ocJ0vcIr0k64uGkLU7pJ1mXmSPm28uKfedjge0Jf7zvwdiDjaQ=s48-w48-h48',\n    content:\n      \"It's helpful to use gemini with study / research for students like me, more details in readme of github help a lot!\",\n    source: 'Chrome Web Store',\n  },\n  {\n    name: 'Kook Wu',\n    avatar:\n      'https://lh3.googleusercontent.com/a-/ALV-UjUf2aLVqTH5ji5yAq1-dRfmghfn0-Zik1Thg593VfEsIuLMpY5i=s48-w48-h48',\n    content: '这个世界完美了',\n    source: 'Chrome Web Store',\n  },\n  {\n    name: 'LYNXUTE',\n    avatar:\n      'https://lh3.googleusercontent.com/a-/ALV-UjVQNBHXfgi56K3ekpxu7rsMKD0Iv5KJA5fmL31IcQM6oWHZMxw=s48-w48-h48',\n    content: '爱音神了',\n    source: 'Chrome Web Store',\n  },\n];\n</script>\n\n<template>\n  <div class=\"reviews-section\">\n    <h2 class=\"title\">{{ t.title }}</h2>\n\n    <div class=\"highlight-banner\">\n      <!-- Try Voyager Promo -->\n      <a\n        href=\"https://x.com/Nag1ovo/status/2024509398601597412?s=20\"\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        class=\"promo-card\"\n        style=\"text-decoration: none\"\n      >\n        <img src=\"/assets/try-voyager.png\" alt=\"Try Voyager\" class=\"promo-image\" />\n        <div class=\"promo-text\">{{ t.tryVoyagerText }}</div>\n      </a>\n\n      <!-- X Recommendation -->\n      <div class=\"promo-card x-recommendation-card\">\n        <a\n          href=\"https://x.com/Nag1ovo/status/2024509398601597412?s=20\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          class=\"highlight-text-card\"\n        >\n          {{ t.highlightText }}\n        </a>\n        <a\n          href=\"https://x.com/Nag1ovo/status/2024507762483277927?s=20\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          class=\"highlight-image-link\"\n        >\n          <img\n            src=\"/assets/x-recommendation.png\"\n            alt=\"Recommendation from @DataChaz\"\n            class=\"highlight-image\"\n          />\n        </a>\n      </div>\n    </div>\n\n    <p class=\"subtitle\">{{ t.subtitle }}</p>\n\n    <Vue3Marquee :pause-on-hover=\"true\" :duration=\"40\" class=\"marquee-wrapper\">\n      <div v-for=\"(review, index) in reviews\" :key=\"index\" class=\"review-card\">\n        <div class=\"card-header\">\n          <img\n            :src=\"review.avatar\"\n            :alt=\"review.name\"\n            class=\"avatar\"\n            loading=\"lazy\"\n            referrerpolicy=\"no-referrer\"\n          />\n          <div class=\"user-info\">\n            <div class=\"name\">{{ review.name }}</div>\n            <div class=\"source\" v-if=\"review.source\">{{ review.source }}</div>\n          </div>\n        </div>\n        <p class=\"content\">\"{{ review.content }}\"</p>\n      </div>\n    </Vue3Marquee>\n  </div>\n</template>\n\n<style scoped>\n.reviews-section {\n  margin-top: 64px;\n  margin-bottom: 64px;\n  padding: 0 24px;\n  text-align: center;\n}\n\n.title {\n  font-size: 28px;\n  font-weight: 700;\n  margin-bottom: 12px;\n  background: var(--vp-home-hero-name-background);\n  -webkit-background-clip: text;\n  -webkit-text-fill-color: transparent;\n  background-clip: text;\n}\n\n.subtitle {\n  font-size: 18px;\n  color: var(--vp-c-text-2);\n  margin-bottom: 48px;\n}\n\n.highlight-banner {\n  display: flex;\n  justify-content: center;\n  align-items: stretch;\n  gap: 24px;\n  margin-bottom: 32px;\n  flex-wrap: wrap;\n}\n\n.promo-card {\n  flex: 1;\n  min-width: 300px;\n  max-width: 500px;\n  background: var(--vp-c-bg-soft);\n  border: 1px solid var(--vp-c-bg-soft-up);\n  border-radius: 16px;\n  padding: 20px;\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n  align-items: center;\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);\n  transition:\n    transform 0.2s ease,\n    border-color 0.2s ease;\n}\n\n.promo-card:hover {\n  transform: translateY(-2px);\n  border-color: var(--vp-c-brand-1);\n}\n\n.promo-image {\n  width: 100%;\n  max-width: 460px;\n  height: auto;\n  border-radius: 12px;\n  margin-bottom: 16px;\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n  object-fit: contain;\n}\n\n.promo-text {\n  font-size: 14px;\n  color: var(--vp-c-text-2);\n  line-height: 1.5;\n  font-style: italic;\n  text-align: center;\n}\n\n.x-recommendation-card {\n  gap: 16px;\n}\n\n.highlight-text-card {\n  width: 100%;\n  background: var(--vp-c-bg-mutate);\n  border: 1px solid var(--vp-c-brand-soft);\n  border-radius: 12px;\n  padding: 16px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--vp-c-text-1);\n  font-weight: 600;\n  font-size: 16px;\n  text-decoration: none;\n  text-align: center;\n  line-height: 1.5;\n  transition: color 0.2s ease;\n}\n\n.highlight-text-card:hover {\n  color: var(--vp-c-brand-1);\n}\n\n.highlight-image-link {\n  display: block;\n  width: 100%;\n  transition: transform 0.2s ease;\n}\n\n.highlight-image-link:hover {\n  transform: translateY(-2px);\n}\n\n.highlight-image {\n  width: 100%;\n  height: auto;\n  border-radius: 12px;\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n  object-fit: contain;\n}\n\n.marquee-wrapper {\n  mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);\n  -webkit-mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);\n}\n\n.review-card {\n  width: 320px;\n  height: 180px; /* Fixed height for uniformity */\n  background: var(--vp-c-bg-soft);\n  border: 1px solid var(--vp-c-bg-soft-up);\n  border-radius: 16px;\n  padding: 24px;\n  margin: 0 16px;\n  display: flex;\n  flex-direction: column;\n  transition:\n    transform 0.2s ease,\n    border-color 0.2s ease;\n  text-align: left;\n}\n\n.review-card:hover {\n  transform: translateY(-4px);\n  border-color: var(--vp-c-brand-1);\n}\n\n.card-header {\n  display: flex;\n  align-items: center;\n  margin-bottom: 16px;\n}\n\n.avatar {\n  width: 40px;\n  height: 40px;\n  border-radius: 50%;\n  margin-right: 12px;\n  background-color: var(--vp-c-bg-mute); /* Placeholder bg */\n}\n\n.user-info {\n  display: flex;\n  flex-direction: column;\n}\n\n.name {\n  font-weight: 600;\n  font-size: 15px;\n  color: var(--vp-c-text-1);\n  line-height: 1.2;\n}\n\n.source {\n  font-size: 12px;\n  color: var(--vp-c-text-3);\n  margin-top: 2px;\n}\n\n.content {\n  font-size: 14px;\n  color: var(--vp-c-text-2);\n  line-height: 1.5;\n  margin: 0;\n  display: -webkit-box;\n  -webkit-line-clamp: 4;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n  font-style: italic;\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/HomeTeaser.vue",
    "content": "<script setup lang=\"ts\">\nimport { useData } from 'vitepress';\n\nconst { frontmatter } = useData();\n</script>\n\n<template>\n  <div v-if=\"frontmatter.teaser\" class=\"vp-doc\" style=\"margin-top: 4rem; text-align: center\">\n    <h2 v-if=\"frontmatter.teaser.title\" style=\"border-top: none\">{{ frontmatter.teaser.title }}</h2>\n    <div\n      v-if=\"frontmatter.teaser.description\"\n      style=\"margin: 1.5rem 0; font-size: 1.1em; opacity: 0.9\"\n      v-html=\"frontmatter.teaser.description\"\n    ></div>\n\n    <img\n      v-if=\"frontmatter.teaser.image\"\n      :src=\"frontmatter.teaser.image\"\n      :alt=\"frontmatter.teaser.title || 'Teaser'\"\n      style=\"\n        margin: 40px auto;\n        border-radius: 12px;\n        box-shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.3);\n        max-width: 100%;\n      \"\n    />\n\n    <div\n      v-if=\"frontmatter.teaser.features\"\n      style=\"display: flex; gap: 20px; justify-content: center; margin: 60px 0; flex-wrap: wrap\"\n    >\n      <div\n        v-for=\"feature in frontmatter.teaser.features\"\n        :key=\"feature.title\"\n        style=\"flex: 1; min-width: 250px; max-width: 300px\"\n      >\n        <h3 style=\"margin-top: 0\">{{ feature.title }}</h3>\n        <p style=\"font-size: 0.9em; opacity: 0.8\" v-html=\"feature.details\"></p>\n      </div>\n    </div>\n  </div>\n</template>\n\n<style scoped>\nh2 {\n  font-size: 2.25rem;\n  font-weight: 700;\n  margin-bottom: 1rem;\n}\n\n@media (max-width: 768px) {\n  h2 {\n    font-size: 1.75rem;\n  }\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/README.md",
    "content": "# SafariDownloadLink Component\n\nThis Vue component automatically fetches the latest release version from GitHub and generates the correct Safari download URL.\n\n## Usage\n\nIn any markdown file:\n\n```markdown\nDownload the <SafariDownloadLink>latest Safari version</SafariDownloadLink>.\n```\n\n## How It Works\n\n1. On component mount, it fetches the latest release from GitHub API\n2. Extracts the version number from the tag name\n3. Generates the download URL: `https://github.com/Nagi-ovo/gemini-voyager/releases/download/v{version}/gemini-voyager-v{version}.dmg`\n4. If the API call fails, it falls back to version `1.2.3`\n\n## Features\n\n- ✅ Automatic version detection\n- ✅ Fallback to default version on error\n- ✅ Loading state support\n- ✅ Customizable link text via slot\n- ✅ Works in all VitePress markdown files\n\n## Example\n\n```vue\n<!-- Simple usage -->\n<SafariDownloadLink>Download Safari Extension</SafariDownloadLink>\n\n<!-- Custom styling (you can wrap it) -->\n<SafariDownloadLink class=\"custom-link\">Get the latest version</SafariDownloadLink>\n```\n"
  },
  {
    "path": "docs/.vitepress/theme/components/SafariDownloadLink.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, onMounted, ref } from 'vue';\n\nconst latestVersion = ref('1.2.4'); // Default fallback version\nconst loading = ref(true);\nconst error = ref(false);\n\nconst CACHE_KEY = 'gemini-voyager-latest-version';\nconst CACHE_DURATION = 10 * 60 * 1000; // 10 minutes in milliseconds\n\nconst downloadUrl = computed(() => {\n  return `https://github.com/Nagi-ovo/gemini-voyager/releases/v${latestVersion.value}`;\n});\n\nonMounted(async () => {\n  try {\n    // Check cache first\n    const cached = localStorage.getItem(CACHE_KEY);\n    if (cached) {\n      const { version, timestamp } = JSON.parse(cached);\n      const now = Date.now();\n\n      // Use cache if it's less than 10 minutes old\n      if (now - timestamp < CACHE_DURATION) {\n        latestVersion.value = version;\n        loading.value = false;\n        return;\n      }\n    }\n\n    // Fetch from API if cache is stale or missing\n    const response = await fetch(\n      'https://api.github.com/repos/Nagi-ovo/gemini-voyager/releases/latest',\n    );\n    if (!response.ok) throw new Error('Failed to fetch');\n\n    const data = await response.json();\n    // Extract version number from tag name (e.g., \"v1.2.3\" => \"1.2.3\")\n    const version = data.tag_name.replace(/^v/, '');\n    latestVersion.value = version;\n\n    // Save to cache\n    localStorage.setItem(\n      CACHE_KEY,\n      JSON.stringify({\n        version,\n        timestamp: Date.now(),\n      }),\n    );\n  } catch (err) {\n    console.error('Failed to fetch latest version:', err);\n    error.value = true;\n  } finally {\n    loading.value = false;\n  }\n});\n</script>\n\n<template>\n  <a :href=\"downloadUrl\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"safari-download-link\">\n    <slot :version=\"latestVersion\" :loading=\"loading\" :error=\"error\">\n      <span v-if=\"loading\">Loading...</span>\n      <span v-else>{{ `latest Safari version` }}</span>\n    </slot>\n  </a>\n</template>\n\n<style scoped>\n.safari-download-link {\n  color: var(--vp-c-brand-1);\n  text-decoration: underline;\n  text-underline-offset: 4px;\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/index.ts",
    "content": "import { NolebaseGitChangelogPlugin } from '@nolebase/vitepress-plugin-git-changelog/client';\nimport '@nolebase/vitepress-plugin-git-changelog/client/style.css';\nimport { type Theme } from 'vitepress';\nimport DefaultTheme from 'vitepress/theme';\nimport { h } from 'vue';\n\nimport HomeAskAI from './components/HomeAskAI.vue';\nimport HomeReviews from './components/HomeReviews.vue';\nimport HomeTeaser from './components/HomeTeaser.vue';\nimport SafariDownloadLink from './components/SafariDownloadLink.vue';\nimport './style.css';\n\nexport default {\n  extends: DefaultTheme,\n  Layout: () => {\n    return h(DefaultTheme.Layout, null, {\n      'home-hero-after': () => h(HomeTeaser),\n      'home-features-after': () => [h(HomeAskAI), h(HomeReviews)],\n    });\n  },\n  enhanceApp({ app }) {\n    app.use(NolebaseGitChangelogPlugin);\n    app.component('HomeReviews', HomeReviews);\n    app.component('HomeTeaser', HomeTeaser);\n    app.component('HomeAskAI', HomeAskAI);\n    app.component('SafariDownloadLink', SafariDownloadLink);\n  },\n} satisfies Theme;\n"
  },
  {
    "path": "docs/.vitepress/theme/style.css",
    "content": "/**\n * Customize default theme styling by overriding CSS variables:\n * https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css\n */\n\n:root {\n  /* Shadcn Primary Color (Light Mode): oklch(0.7227 0.1920 149.5793) */\n  /* Converted to approx Hex for compatibility if needed, but trying OKLCH first */\n  --vp-c-brand-1: oklch(0.7227 0.192 149.5793);\n  --vp-c-brand-2: oklch(0.65 0.192 149.5793);\n  /* Slightly darker */\n  --vp-c-brand-3: oklch(0.6 0.192 149.5793);\n  /* Even darker */\n}\n\n.dark {\n  /* Shadcn Primary Color (Dark Mode): oklch(0.7729 0.1535 163.2231) */\n  --vp-c-brand-1: oklch(0.7729 0.1535 163.2231);\n  --vp-c-brand-2: oklch(0.82 0.1535 163.2231);\n  /* Slightly lighter */\n  --vp-c-brand-3: oklch(0.87 0.1535 163.2231);\n  /* Even lighter */\n}\n\n/* Force 4-column layout for features on large screens */\n@media (min-width: 1280px) {\n  .VPFeatures .VPFeature.item {\n    width: 25%;\n  }\n\n  /* Fallback selector structure just in case of version differences */\n  .VPFeatures .items .item {\n    width: 25%;\n  }\n}\n"
  },
  {
    "path": "docs/ar/guide/batch-delete.md",
    "content": "# الحذف الجماعي\n\nاحذف محادثات متعددة في وقت واحد، وداعاً للحذف واحدة تلو الأخرى.\n\n## المميزات\n\n- **وضع التحديد المتعدد**: اضغط مطولاً على أي محادثة للدخول في وضع التحديد المتعدد وحدد محادثات متعددة لحذفها.\n- **تنظيف بنقرة واحدة**: بمجرد التحديد، انقر فوق زر الحذف لإزالة جميع المحادثات المحددة في وقت واحد.\n- **ملاحظات التقدم**: يتم عرض التقدم في الوقت الفعلي أثناء الحذف حتى تعرف الحالة الحالية.\n- **تأكيد آمن**: يظهر مربع حوار تأكيد قبل الحذف لمنع العمليات العرضية.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/batch-delete.png\" alt=\"الحذف الجماعي\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## كيفية الاستخدام\n\n1. في قائمة المحادثات بالشريط الجانبي، **اضغط مطولاً** على أي عنصر محادثة.\n2. بعد الدخول في وضع التحديد المتعدد، ستظهر مربعات اختيار على الجانب الأيسر (أو الأيمن حسب الاتجاه) من كل محادثة.\n3. حدد المحادثات التي تريد حذفها (حتى 50 في المرة الواحدة).\n4. انقر فوق **زر الحذف** الذي يظهر.\n5. انقر فوق \"تأكيد\" في منطقة التأكيد الحمراء التي تظهر **فوق قائمة المجلدات** لبدء الحذف.\n\n::: tip ملاحظة\nتغطي لوحة التأكيد منطقة المجلد لتجنب حجب قائمة المحادثات. لا يمكن التراجع عن عمليات الحذف الجماعي، لذا يرجى المتابعة بحذر.\n:::\n"
  },
  {
    "path": "docs/ar/guide/cloud-sync.md",
    "content": "# مزامنة السحابية\n\nقم بمزامنة مجلداتك ومكتبة المطالبات والبيانات الأخرى مع Google Drive للحفاظ على اتساق تجربتك عبر الأجهزة.\n\n## المميزات\n\n- **مزامنة عبر أجهزة متعددة**: حافظ على مزامنة تكويناتك عبر أجهزة كمبيوتر متعددة باستخدام Google Drive.\n- **خصوصية البيانات**: يتم تخزين البيانات مباشرة في مساحة تخزين Google Drive الخاصة بك، مما يضمن الخصوصية دون وجود خوادم خارجية.\n- **مزامنة مرنة**: دعم الرفع اليدوي وتنزيل/دمج البيانات.\n\n::: info\n**قريباً**: سيدعم الإصدار القادم مزامنة المحادثات المميزة بنجمة.\n:::\n\n## كيفية الاستخدام\n\n1. انقر فوق أيقونة الامتداد في الزاوية اليمنى السفلية من صفحة Gemini™ لفتح لوحة الإعدادات.\n2. حدد موقع قسم **مزامنة السحابية**.\n3. انقر فوق **تسجيل الدخول باستخدام Google** وأكمل التفويض.\n4. بمجرد التفويض، انقر فوق **رفع إلى السحابة** لمزامنة بياناتك المحلية مع السحابة، أو **تنزيل ودمج** لجلب بيانات السحابة إلى جهازك المحلي.\n\n### 💡 مزامنة سريعة\n\nأسهل طريقة هي النقر على زري **\"رفع إلى السحابة\"** أو **\"تنزيل ودمج\"** في أعلى منطقة المجلدات في الشريط الجانبي الأيسر (أو الأيمن حسب اللغة).\n\n<img src=\"/assets/cloud-sync.png\" alt=\"أزرار المزامنة السريعة للسحابة\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n::: warning\n**توصية أمنية: حماية مزدوجة**  \nعلى الرغم من أن المزامنة السحابية توفر راحة كبيرة، إلا أننا نوصي بشدة بإنشاء نسخ احتياطية دورية لبياناتك الأساسية باستخدام **الملفات المحلية**.\n\n1. **تصدير كامل**: قم بتصدير حزمة كاملة تحتوي على جميع الإعدادات والمجلدات والمطالبات من \"النسخ الاحتياطي والاستعادة\" في أسفل لوحة الإعدادات.\n   <img src=\"/assets/manual-export-all.png\" alt=\"تصدير كامل\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. **تصدير جميع المجلدات**: انقر فوق \"تصدير\" في قسم \"المجلدات\" في لوحة الإعدادات لنسخ جميع مجلداتك ومحادثاتك احتياطيًا، باستثناء المطالبات.\n   <img src=\"/assets/manual-folder-export.png\" alt=\"تصدير جميع المجلدات\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n   :::\n"
  },
  {
    "path": "docs/ar/guide/community.md",
    "content": "# المجتمع والملاحظات\n\nنحن نقدر صوت كل مستخدم. سواء واجهت خطأً (Bug)، أو كان لديك اقتراح لميزة، أو كنت ترغب في مشاركة مكتبة المطالبات الخاصة بك، يمكنك الاتصال بنا عبر القنوات التالية.\n\n## 📢 تابع التحديثات\n\nتابع حسابنا على X (Twitter) للحصول على أحدث تطورات التطوير.\n\n- **إصدارات جديدة**: كن أول من يعرف عن التحديثات.\n- **ميزات قادمة**: احصل على نظرة خاطفة عما هو قادم.\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://x.com/Nag1ovo/status/2012609459663634589\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/badge/متابعة-𝕏-000000?style=for-the-badge&logo=x&logoColor=white\" alt=\"متابعة X\">\n  </a>\n</div>\n\n## 💬 مجتمع Discord\n\nانضم إلى خادم Discord الخاص بنا وتبادل الأفكار مع Voyagers الآخرين!\n\n- **دردشة فورية**: تحدث مباشرة مع المستخدمين الآخرين والمطورين.\n- **مشاركة المطالبات**: انظر ما هي أنواع المطالبات التي يستخدمها الآخرون.\n- **تقدم التطوير**: احصل على أخبار حول تطوير الميزات الجديدة.\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://discord.gg/TEUFxdMbGb\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/discord/1463273957120675973?style=for-the-badge&logo=discord&logoColor=white&label=انضم%20إلى%20Discord\" alt=\"Discord\">\n  </a>\n</div>\n\n## 🐙 مشكلات GitHub\n\nإذا وجدت خطأً في البرنامج (Bug) أو كان لديك طلب ميزة واضح (Feature Request)، نوصي بتقديم مشكلة (Issue) على GitHub:\n\n- [تقديم تقرير خطأ](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=bug_report.yml)\n- [تقديم طلب ميزة](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=feature_request.yml)\n\nشكراً لدعمك لـ Voyager! ❤️\n"
  },
  {
    "path": "docs/ar/guide/context-sync.md",
    "content": "# نقل الذاكرة: مزامنة السياق (تجريبي)\n\n**أبعاد مختلفة، مشاركة سلسة**\n\nقم بتطوير المنطق على الويب وتنفيذ الكود في بيئة التطوير (IDE). يقوم Voyager بكسر الحواجز بين الأبعاد، مما يمنح IDE الخاص بك \"عملية التفكير\" من الويب على الفور.\n\n## وداعاً للتنقل المتكرر بين التبويبات\n\nأكثر ما يزعج المطورين: مناقشة حل بالتفصيل على الويب، والعودة إلى VS Code/Trae/Cursor لتضطر إلى إعادة شرح المتطلبات وكأنك غريب. نظرًا للحصص وسرعة الاستجابة، فإن الويب هو \"الدماغ\" وIDE هو \"اليدين\". يسمح Voyager لهما بمشاركة نفس الروح.\n\n## ثلاث خطوات بسيطة، تنفس متزامن\n\n1. **تثبيت وتفعيل CoBridge**:\n   قم بتثبيت إضافة **CoBridge** في VS Code. إنها الجسر الأساسي الذي يربط واجهة الويب ببيئة التطوير المحلية الخاصة بك (IDE).\n   - **[التثبيت عبر متجر VS Code](https://open-vsx.org/extension/windfall/co-bridge)**\n\n   ![إضافة CoBridge](/assets/CoBridge-extension.png)\n\n   بعد التثبيت، **افتح أي دليل عمل**، وانقر فوق الأيقونة الموجودة على اليمين وقم بتشغيل الخادم.\n   ![خادم CoBridge قيد التشغيل](/assets/CoBridge-on.png)\n\n2. **مصافحة الاتصال**:\n   - قم بتمكين \"مزامنة السياق\" في إعدادات Voyager.\n   - قم بمحاذاة رقم المنفذ. عندما ترى \"IDE Online\"، فهذا يعني أنهما متصلان.\n\n   ![لوحة مزامنة السياق](/assets/context-sync-console.png)\n\n3. **مزامنة بنقرة واحدة**: انقر فوق **\"Sync to IDE\"**. سواء كانت **جداول بيانات** معقدة أو **صوراً توضيحية**، سيتم نقل كل شيء فوراً إلى IDE الخاص بك.\n\n   ![اكتملت المزامنة](/assets/sync-done.png)\n\n## ترسيخ الجذور\n\nبمجرد اكتمال المزامنة، سيظهر ملف `.cobridge/AI_CONTEXT.md` في دليل العمل لبيئة التطوير (IDE) الخاصة بك. سواء كان Trae أو Cursor أو Copilot، فسيقومون تلقائيًا بقراءة هذه «الذاكرة» من خلال ملفات الـ Rule الخاصة بكل منهم.\n\n```\nyour-project/\n├── .cobridge/\n│   ├── images/\n│   │   ├── context_img_1_1.png\n│   │   └── context_img_1_2.png\n│   └── AI_CONTEXT.md\n├── .github/\n│   └── copilot-instructions.md\n├── .gitignore\n├── .traerules\n└── .cursorrules\n```\n\n## مبادئها\n\n- **صفر تلوث**: يقوم CoBridge تلقائيًا بإدارة ملف `.gitignore` ، مما يضمن عدم دفع محادثاتك الخاصة إلى مستودعات Git.\n- **خبير**: تنسيق Markdown كامل، مما يجعل الذكاء الاصطناعي في IDE يقرأه بسلاسة مثل دليل التعليمات.\n- **نصيحة**: إذا كانت المحادثة قديمة، فاستخدم [الجدول الزمني] للتمرير لأعلى وجعل الويب \"يتذكر\" السياق قبل المزامنة للحصول على نتائج أفضل.\n\n---\n\n## ابدأ الآن\n\n**الفكر جاهز بالفعل في السحابة، والآن، اتركه يرسخ جذوره محليًا.**\n\n- **[تثبيت إضافة CoBridge](https://open-vsx.org/extension/windfall/co-bridge)**: ابحث عن بوابة الأبعاد الخاصة بك وقم بتمكين \"التنفس المتزامن\" بنقرة واحدة.\n- **[زيارة مستودع GitHub](https://github.com/Winddfall/CoBridge)**: تعرف على المزيد حول المنطق الكامن وراء CoBridge أو امنح هذا المشروع \"مزامنة الأرواح\" نجمة.\n\n> **النماذج الكبيرة لم تعد تفقد الذاكرة بعد الآن، جاهزة للعمل فورًا.**\n"
  },
  {
    "path": "docs/ar/guide/deep-research.md",
    "content": "# تصدير Deep Research\n\nقم بتصدير التقرير النهائي الذي أنشأته Deep Research، أو احفظ عملية \"التفكير\" الكاملة كملف Markdown.\n\n## 1. تصدير التقرير (PDF / صورة)\n\nيمكن تصدير التقارير التي تم إنشاؤها بواسطة Deep Research كملفات PDF منسقة بشكل جميل أو كصور فردية قابلة للمشاركة (يتم دعم تنسيقات Markdown و JSON أيضاً).\n\n![تصدير التقرير](/assets/deep-research-report-export.png)\n\n## 2. تصدير عملية التفكير (Markdown)\n\nبالإضافة إلى التقرير النهائي، يمكنك أيضاً تصدير محتوى \"التفكير\" الكامل من محادثات Deep Research.\n\n### المميزات\n\n- **تصدير بنقرة واحدة**: يظهر زر التنزيل في قائمة المحادثة (⋮)\n- **تنسيق منظم**: يحافظ على مراحل التفكير، وعناصر التفكير، والمواقع التي تم بحثها بترتيبها الأصلي\n- **عناوين ثنائية اللغة**: ملفات Markdown تتضمن عناوين الأقسام بالإنجليزية ولغتك الحالية\n- **تسمية تلقائية**: يتم وضع طابع زمني للملفات لسهولة التنظيم (مثال: `deep-research-thinking-20240128-153045.md`)\n\n### كيفية الاستخدام\n\n1. افتح محادثة Deep Research في Gemini™\n2. انقر فوق زر **مشاركة وتصدير** في المحادثة\n3. حدد \"تنزيل محتوى التفكير\" (Download thinking content)\n4. سيتم تنزيل ملف Markdown تلقائياً\n\n![تصدير تفكير Deep Research](/assets/deepresearch_download_thinking.png)\n\n### تنسيق الملف المصدر\n\nيتضمن ملف Markdown المصدر:\n\n- **العنوان**: عنوان المحادثة\n- **البيانات الوصفية**: وقت التصدير وإجمالي مراحل التفكير\n- **مراحل التفكير**: تحتوي كل مرحلة على:\n  - عناصر التفكير (مع العناوين والمحتوى)\n  - المواقع التي تم بحثها (مع الروابط والعناوين)\n\n#### مثال على البنية\n\n```markdown\n# عنوان محادثة Deep Research\n\n**وقت التصدير / Exported At:** 2025-12-28 17:25:35\n**إجمالي المراحل / Total Phases:** 3\n\n---\n\n## مرحلة التفكير 1 / Thinking Phase 1\n\n### عنوان التفكير 1\n\nمحتوى التفكير...\n\n### عنوان التفكير 2\n\nمحتوى التفكير...\n\n#### المواقع التي تم بحثها / Researched Websites\n\n- [domain.com](https://example.com) - عنوان الصفحة\n- [another.com](https://another.com) - عنوان آخر\n\n---\n\n## مرحلة التفكير 2 / Thinking Phase 2\n\n...\n```\n\n## الخصوصية\n\nيتم كل الاستخراج والتنسيق بنسبة 100% محلياً في متصفحك. لا يتم إرسال أي بيانات إلى خوادم خارجية.\n"
  },
  {
    "path": "docs/ar/guide/default-model.md",
    "content": "# النموذج الافتراضي\n\n::: info\n**ملاحظة**: هذه الميزة مدعومة في الإصدار 1.1.9 وما بعده.\n:::\n\nقم بتعيين نموذج Gemini™ المفضل لديك كنموذج افتراضي لتجنب التبديل اليدوي في كل محادثة جديدة.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/default-model.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n## المميزات\n\n- **إعداد تفاعلي**: يضيف زر \"نجمة\" مباشرةً في قائمة اختيار النماذج الأصلية لـ Gemini.\n- **تبديل تلقائي**: يتم التبديل تلقائياً إلى نموذجك المفضل في كل مرة تبدأ فيها محادثة جديدة.\n- **تفضيل مستمر**: يتم حفظ خيارك ومزامنته عبر أجهزتك.\n- **محسن للتطبيق وحيد الصفحة (SPA)**: يتم التفعيل بدقة عند النقر على زر \"محادثة جديدة\"، أو استخدام الاختصارات، أو العودة إلى الصفحة الرئيسية.\n\n## كيفية الاستخدام\n\n1. انقر على **محدد النماذج** فوق منطقة إدخال Gemini.\n2. حرك المؤشر فوق النموذج المفضل لديك وانقر على **أيقونة النجمة**.\n3. بمجرد أن تصبح النجمة **ممتلئة**، يتم تعيين هذا النموذج كنموذج افتراضي.\n4. ستعمل الإضافة تلقائياً على اختيار هذا النموذج لجميع المحادثات الجديدة.\n5. لإلغاء التعيين، ما عليك سوى النقر على أيقونة النجمة الممتلئة مرة أخرى.\n"
  },
  {
    "path": "docs/ar/guide/export.md",
    "content": "# Total Freedom\n\nData lock-in is the enemy.\nWe believe that if you create it, you own it.\n\n## Export Everything\n\nVoyager lets you pull your data out of the cloud and into your hands.\n\n### The Formats\n\n- **Markdown**: For your Obsidian vault or Notion. Clean, formatted text. (مستخدمو Safari: لا يمكن استخراج الصور بسبب قيود المتصفح، استخدم تصدير PDF للصور)\n- **PDF**: For sharing or printing. Beautifully laid out, images included.\n- **JSON**: Raw data. For developers who want to build on top of their history.\n\n### How to Export\n\n1. Hover your mouse over the Gemini logo to see the **Export Icon**.\n2. Choose your format.\n3. Done.\n\nIt’s your data. Do what you want with it.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Step 1: Hover Logo</b></p>\n    <img src=\"/assets/gemini-export-guide-1.png\" alt=\"Export guide step 1\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Step 2: The Choice</b></p>\n    <img src=\"/assets/gemini-export-guide-2.png\" alt=\"Export guide step 2\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n### Safari PDF Export Note\n\nExporting PDF on Safari requires a slightly different process (manual print):\n\n1. Click the **Export** button and select PDF format.\n2. **Wait for about a second** (allow the page to prepare print styles).\n3. Press `Command + P` to open the print dialog.\n4. Select **\"Save to PDF\"** in the print dialog.\n\n<img src=\"/assets/safari-export-pdf.png\" alt=\"Safari Export PDF\" style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 20px;\"/>\n"
  },
  {
    "path": "docs/ar/guide/folders.md",
    "content": "# المجلدات كما يجب أن تكون\n\nلماذا تنظيم محادثات الذكاء الاصطناعي صعب للغاية؟\nلقد أصلحنا ذلك. قمنا ببناء نظام ملفات لأفكارك.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap; margin-bottom: 40px;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Gemini™</b></p>\n    <img src=\"/assets/gemini-folders.png\" alt=\"مجلدات Gemini\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>AI Studio</b></p>\n    <img src=\"/assets/aistudio-folders.png\" alt=\"مجلدات AI Studio\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n## فيزياء التنظيم\n\nيبدو الأمر صحيحاً تماماً.\n\n- **السحب والإفلات**: التقط محادثة. ألقها في مجلد. إنها عملية ملموسة.\n- **التسلسل الهرمي المتداخل**: المشاريع تحتوي على مشاريع فرعية. قم بإنشاء مجلدات داخل مجلدات. نظمها بطريقتك _الخاصة_.\n- **تباعد المجلدات**: اضبط كثافة الشريط الجانبي بحرية، من مضغوط إلى واسع.\n  > _ملاحظة: في Mac Safari، قد لا تكون التعديلات فورية؛ قم بتحديث الصفحة لرؤية التأثير._\n- **المزامنة الفورية**: نظم على جهاز الكمبيوتر الخاص بك. شاهده على جهازك المحمول.\n\n## نصائح للمحترفين\n\n- **التحديد المتعدد**: اضغط مطولاً على عنصر المحادثة للدخول في وضع التحديد المتعدد، ثم حدد محادثات متعددة وانقلها جميعاً مرة واحدة.\n- **إعادة التسمية**: انقر نقراً مزدوجاً على أي مجلد لإعادة تسميته.\n- **الأيقونات**: نكتشف تلقائياً نوع الـ Gem (برمجة، إبداع، إلخ) ونخصص الأيقونة الصحيحة. ليس عليك فعل أي شيء.\n\n## اختلافات الميزات حسب المنصة\n\n### ميزات مشتركة\n\n- **الإدارة الأساسية**: السحب والإفلات، إعادة التسمية، التحديد المتعدد.\n- **التعرف الذكي**: يكتشف تلقائياً أنواع المحادثات ويخصص الأيقونات.\n- **التسلسل الهرمي المتداخل**: دعم تداخل المجلدات.\n- **تكييف AI Studio**: الميزات المتقدمة ستتوفر قريباً على AI Studio.\n- **مزامنة Google Drive**: مزامنة هيكل المجلدات مع Google Drive.\n\n### حصري لـ Gemini\n\n#### ألوان مخصصة\n\nانقر على أيقونة المجلد لتخصيص لونه. اختر من بين 7 ألوان افتراضية أو استخدم ملتقط الألوان لاختيار أي لون تريده.\n\n<img src=\"/assets/folder-color.png\" alt=\"ألوان المجلدات\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### عزل الحساب\n\nانقر على أيقونة \"الشخص\" في الرأس لتصفية المحادثات من حسابات Google الأخرى فوراً. حافظ على مساحة عملك نظيفة عند استخدام حسابات متعددة.\n\n<img src=\"/assets/current-user-only.png\" alt=\"عزل الحساب\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### التنظيم التلقائي بالذكاء الاصطناعي\n\nمحادثات كثيرة وكسول عن ترتيبها؟ دع Gemini يفكر عنك.\n\nبنقرة واحدة تنسخ هيكل محادثاتك الحالي، الصقه في Gemini، وسيولّد خطة مجلدات جاهزة للاستيراد — تنظيم فوري.\n\n**الخطوة 1: انسخ هيكل محادثاتك**\n\nفي أسفل قسم المجلدات في نافذة الإضافة المنبثقة، انقر على زر **AI Organize**. يجمع تلقائياً جميع محادثاتك غير المصنفة وهيكل المجلدات الحالي، ويولّد موجّهاً وينسخه إلى الحافظة.\n\n<img src=\"/assets/ai-auto-folder.png\" alt=\"AI Organize Button\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n\n**الخطوة 2: دع Gemini يرتّب**\n\nالصق محتوى الحافظة في محادثة Gemini. سيحلل عناوين محادثاتك ويخرج خطة مجلدات بصيغة JSON.\n\n**الخطوة 3: استورد النتائج**\n\nانقر على **استيراد المجلدات** من قائمة لوحة المجلدات، اختر **أو لصق JSON مباشرة**، الصق الـ JSON الذي أرجعه Gemini، ثم انقر **استيراد**.\n\n<div style=\"display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; margin-bottom: 24px;\">\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-2.png\" alt=\"Import Menu\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 240px;\"/>\n  </div>\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-3.png\" alt=\"Paste JSON Import\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n  </div>\n</div>\n\n- **الدمج التراكمي**: يستخدم استراتيجية \"الدمج\" افتراضياً — يضيف فقط المجلدات والتعيينات الجديدة، ولا يدمر تنظيمك الحالي أبداً.\n- **متعدد اللغات**: يستخدم الموجّه تلقائياً لغتك المُعدّة، وأسماء المجلدات تُولّد بتلك اللغة أيضاً.\n\n### حصري لـ AI Studio\n\n- **تعديل الشريط الجانبي**: اسحب لتغيير عرض الشريط الجانبي.\n- **تكامل المكتبة**: اسحب مباشرة من المكتبة (Library) إلى المجلدات.\n"
  },
  {
    "path": "docs/ar/guide/fork.md",
    "content": "# تفريع المحادثة (تجريبي)\n\nيجب ألا يكون التفكير طريقاً ذا اتجاه واحد. في الاستكشافات المعقدة، غالباً ما نحتاج إلى العودة إلى نقطة حاسمة وتجربة احتمالات مختلفة.\n\nمع ميزة **التفريع**، يتيح لك Voyager توسيع أفكارك واستكشاف أكوان موازية من دردشتك.\n\n## كيف تعمل\n\n> **⚠️ ملاحظة**: هذه ميزة تجريبية. تحتاج إلى تمكينها أولاً عن طريق النقر على أيقونة الإضافة في شريط الأدوات الخاص بك لفتح نافذة الإعدادات المنبثقة، وتشغيل مفتاح **\"تمكين تفريع المحادثة\"**.\n\nعندما ترغب في اتخاذ مسار مختلف، ما عليك سوى تمرير مؤشر الماوس فوق سؤالك والنقر على زر **تفريع**:\n\n![التفريع](/assets/branching.png)\n\nسيقوم Voyager على الفور بالتقاط السياق بالكامل من البداية حتى تلك النقطة و**سيبدأ محادثة جديدة تماماً** من أجلك.\n\nفي هذا الفرع الجديد، يمكنك تعديل سؤالك بحرية واستكشاف اتجاهات مختلفة دون القلق بشأن تدمير سجل الدردشة الأصلي. أطلق العنان لإبداعك وفضولك!\n"
  },
  {
    "path": "docs/ar/guide/formula-copy.md",
    "content": "# نسخ الصيغ\n\nيجعل Voyager من السهل جداً إعادة استخدام الصيغ الرياضية والرموز العلمية. وهو يدعم النسخ بنقرة واحدة لكود مصدر LaTeX وتنسيق MathML المتوافق مع Microsoft Word.\n\n## مقدمة\n\nعندما تطلب من Gemini اشتقاق صيغ أو كتابة تعبيرات رياضية، فإنه عادة ما يعرضها باستخدام LaTeX. وبينما يبدو المظهر جميلاً، فإن استخراج كود المصدر لاستخدامه في أوراقك أو مستنداتك أو محرراتك الخاصة يتطلب غالباً جهداً يدوياً.\n\nيوفر Voyager دعماً سلسلاً لهذا:\n\n1. **التعرف التلقائي**: يتعرف Voyager تلقائياً على صيغ LaTeX المعروضة على الصفحة.\n2. **زر النسخ**: عند تمرير الماوس فوق صيغة، يظهر رمز النسخ على جانبها الأيمن.\n3. **خيارات التنسيق**: انقر فوق الرمز للاختيار:\n   - **Copy LaTeX**: ينسخ كود مصدر LaTeX القياسي، وهو مثالي لـ Overleaf ومحررات Markdown وما إلى ذلك.\n   - **Copy MathML**: ينسخ كود مصدر MathML، وهو أفضل تنسيق للصق مباشرة في **Microsoft Word**.\n\n![نسخ الصيغ](/assets/gemini-math-copy.png)\n\n## المميزات\n\n- **التوافق مع Word**: مع دعم MathML، يمكنك لصق الصيغ المعقدة التي ينشئها الذكاء الاصطناعي مباشرة في مستندات Word مع الحفاظ على تنسيق قابل للتحرير بشكل مثالي.\n- **الحفاظ على السياق**: لا ينسخ الصيغة نفسها فحسب، بل يحافظ أيضاً على سياقها الرياضي.\n- **استجابة فورية**: تتم المعالجة محلياً بالكامل للحصول على نتائج فورية.\n\n## نصائح الاستخدام\n\n- **الكتابة الأكاديمية**: عند كتابة الأوراق البحثية في Word، اجعل Gemini يشتق الصيغ، ثم استخدم نسخ ولصق MathML لتجنب عناء الإدخال اليدوي في محرر معادلات Word.\n- **تدوين الملاحظات**: عند تدوين الملاحظات في Obsidian أو Notion، ما عليك سوى نسخ مصدر LaTeX مباشرة.\n"
  },
  {
    "path": "docs/ar/guide/getting-started.md",
    "content": "# مرحباً بك على متن الرحلة\n\nتهانينا. لقد قمت للتو بترقية عقلك.\nVoyager ليس مجرد أداة مساعدة؛ إنه سير عمل. إليك كيفية الاستفادة منه إلى أقصى حد في أول 5 دقائق.\n\n## 1. الإعداد\n\nإذا لم تقم بتثبيته بعد، فانتقل إلى [دليل التثبيت](/ar/guide/installation).\nبمجرد التثبيت، قم بتحديث علامة تبويب Gemini. سترى الفرق على الفور.\n\n## 2. الجدول الزمني\n\nابدأ محادثة. واحدة طويلة. اسأل عن تاريخ الطباعة أو فيزياء الثقوب السوداء.\nانظر إلى اليمين (أو اليسار في RTL).\n**شريط النقاط ذاك؟ تلك هي خريطتك.**\n\n- **مرر الماوس** لإلقاء نظرة خاطفة على ما قلته.\n- **انقر** للانتقال الفوري إلى هناك.\n- **اضغط مطولاً** لتمييز لحظة تريد الاحتفاظ بها بنجمة.\n\nلا مزيد من التمرير اللانهائي. أنت الآن تتنقل بسرعة التفكير.\n\n## 3. التنظيم\n\nانظر إلى قائمة الدردشة الخاصة بك. هل لاحظت شيئاً جديداً؟\n**المجلدات.**\n\nالتقط محادثة. اسحبها. أسقطها في مجلد.\nيبدو الأمر طبيعياً، أليس كذلك؟ لأنه كذلك. يمكنك تداخلها، وإعادة تسميتها، وأخيراً إزالة الفوضى من عقلك.\n\n## 4. الخزنة\n\nلقد كتبت للتو المطالبة (Prompt) المثالية. لا تدعها تختفي في الفراغ.\nانقر فوق **أيقونة الشرارة** (✨) في مربع الإدخال.\nاحفظها. ضع علامة عليها.\n\nفي المرة القادمة؟ فقط انقر فوق الأيقونة للعثور عليها وإدراجها.\nأنت لا تدردش فقط بعد الآن. أنت تبني خزنة لعبقريتك الخاصة.\n\n---\n\n**أنت جاهز.**\nاستكشف الأدلة المحددة للتعمق أكثر:\n\n- [إتقان الجدول الزمني](/ar/guide/timeline)\n- [إدارة المجلدات](/ar/guide/folders)\n- [هندسة المطالبات](/ar/guide/prompts)\n- [تصدير البيانات](/ar/guide/export)\n"
  },
  {
    "path": "docs/ar/guide/input-collapse.md",
    "content": "# طي الإدخال\n\nقم بطي منطقة الإدخال عندما تكون فارغة للحصول على مساحة قراءة أكبر. انقر فوق الشريط المطوي للتوسيع والبدء في الكتابة.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/hide-input-area.png\" alt=\"طي الإدخال\" style=\"max-width: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## كيفية الاستخدام\n\n1. عندما تكون منطقة الإدخال فارغة وتفقد التركيز، يتم طيها تلقائياً في زر مضغوط.\n2. انقر فوق الزر لتوسيع منطقة الإدخال والبدء في الكتابة.\n3. يمكنك أيضاً الضغط على <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>I</kbd> لتوسيع منطقة الإدخال بسرعة.\n4. يمكنك تمكين هذه الميزة أو تعطيلها في لوحة الإعدادات (معطلة افتراضياً).\n"
  },
  {
    "path": "docs/ar/guide/installation.md",
    "content": "# التثبيت\n\n::: info أخبار\n🍎 **إضافة Safari الأصلية تم إطلاقها!** هي مجانية بالكامل وتدعم التثبيت بنقرة واحدة.\n:::\n\nاختر طريقك.\n\n> ⚠️ ملاحظة: مدير المطالبات هو الميزة الوحيدة التي تدعم Gemini™ للمؤسسات.\n\n## 1. متاجر الإضافات (موصى به)\n\nأبسط طريقة للبدء. التحديثات تلقائية.\n\n**Chrome / Brave / Opera / Vivaldi:**\n\n[<img src=\"https://img.shields.io/badge/Chrome_Web_Store-Download-4285F4?style=for-the-badge&logo=google-chrome&logoColor=white\" alt=\"التثبيت من سوق Chrome الإلكتروني\" height=\"40\"/>](https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=docs&utm_campaign=organic_growth&utm_content=ar)\n\n::: warning ⚠️ متجر Chrome الإلكتروني غير متاح مؤقتاً\nتمت إعادة تسمية الامتداد رسمياً إلى **Voyager** بسبب مخاوف العلامات التجارية. تحديث الاسم في Chrome Web Store لا يزال قيد المراجعة. اطلع على [هذا المنشور](https://x.com/Nag1ovo/status/2031561180213313944) للتفاصيل. استخدم **Edge / Firefox** أو **التثبيت اليدوي** في الوقت الحالي.\n:::\n\n**Microsoft Edge:**\n\n[<img src=\"https://img.shields.io/badge/Microsoft_Edge-Download-0078D7?style=for-the-badge&logo=microsoft-edge&logoColor=white\" alt=\"التثبيت من وظائف Microsoft Edge الإضافية\" height=\"40\"/>](https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne)\n\n**Firefox:**\n\n[<img src=\"https://img.shields.io/badge/Firefox_Add--ons-Download-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"التثبيت من إضافات Firefox\" height=\"40\"/>](https://addons.mozilla.org/firefox/addon/gemini-voyager/)\n\n## 2. الطريقة اليدوية (أحدث الميزات)\n\nيمكن أن تكون عملية مراجعة متجر الويب بطيئة. إذا كنت تريد أحدث إصدار فوراً، فقم بالتثبيت يدوياً.\n\n**لـ Chrome / Edge / Brave / Opera:**\n\n1. قم بتنزيل أحدث `gemini-voyager-chrome-vX.Y.Z.zip` من [إصدارات GitHub](https://github.com/Nagi-ovo/gemini-voyager/releases).\n2. قم بفك ضغط الملف.\n3. افتح صفحة الإضافات في متصفحك (`chrome://extensions`).\n4. قم بتمكين **وضع المطور** (أعلى اليسار أو اليمين حسب اللغة).\n5. انقر فوق **تحميل إضافات غير حزمة** وحدد المجلد الذي قمت بفك ضغطه للتو.\n\n**لـ Firefox:**\n\n1. قم بتنزيل أحدث `gemini-voyager-firefox-vX.Y.Z.xpi` من [الإصدارات](https://github.com/Nagi-ovo/gemini-voyager/releases).\n2. افتح مدير الإضافات (`about:addons`).\n3. اسحب ملف `.xpi` وأفلته للتثبيت (أو انقر فوق أيقونة الترس ⚙️ -> **تثبيت إضافة من ملف**).\n\n> 💡 ملف XPI موقع رسمياً من Mozilla ويمكن تثبيته بشكل دائم في جميع إصدارات Firefox.\n\n## 3. Safari (macOS)\n\nSafari يدعم الآن التوزيع المباشر! قم بتنزيل التطبيق الموقع مسبقاً:\n\n1. قم بتنزيل <SafariDownloadLink>أحدث إصدار Safari (.dmg)</SafariDownloadLink>.\n2. افتح الملف واتبع تعليمات التثبيت.\n3. انقر نقراً مزدوجاً لتشغيل التطبيق.\n4. قم بالتفعيل في **إعدادات Safari > الإضافات**.\n\n> 💡 إصدار Safari موقع مباشرة للتوزيع — لا حاجة لتحويل Xcode!\n>\n> ⚠️ **القيود**: نظرًا لطبيعة Safari، فإن (أ) إزالة العلامة المائية (ب) تصدير الصور (يُنصح باستخدام PDF) غير مدعومة.\n\n---\n\n_إعداد التطوير؟ إذا كنت مطوراً وتتطلع للمساهمة، تحقق من [دليل المساهمة](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/CONTRIBUTING.md) الخاص بنا._\n"
  },
  {
    "path": "docs/ar/guide/markdown-fix.md",
    "content": "# إصلاح عرض Markdown\n\nتقوم واجهة ويب Gemini™ أحياناً بإدراج عناصر HTML (مثل مصادر الاقتباس أو علامات التمييز) داخل النص، مما قد يؤدي إلى كسر تنسيق Markdown للنص العريض (`**النص**`)، ويتسبب في عدم ظهور النص بشكل عريض صحيحاً.\n\nيتضمن Voyager ميزة إصلاح تلقائي مدمجة تتعرف بذكاء على علامات النص العريض المكسورة هذه وتقوم بإصلاحها، مما يضمن عرض مستنداتك بشكل نظيف ودقيق.\n\n> [!INFO]\n> هذه الميزة مفعلة تلقائياً ولا تتطلب أي إعدادات إضافية.\n"
  },
  {
    "path": "docs/ar/guide/mermaid.md",
    "content": "# Mermaid Diagram Rendering\n\nAutomatically render Mermaid code as visual diagrams.\n\n## Overview\n\nWhen Gemini™ outputs Mermaid code blocks (flowcharts, sequence diagrams, Gantt charts, etc.), Voyager automatically detects and renders them as interactive diagrams.\n\n### Key Features\n\n- **Auto-detection**: Supports `graph`, `flowchart`, `sequenceDiagram`, `gantt`, `pie`, `classDiagram`, and all major Mermaid diagram types\n- **Toggle view**: Switch between rendered diagram and source code with one click\n- **Fullscreen mode**: Click the diagram to enter fullscreen with zoom and pan support\n- **Dark mode**: Automatically adapts to page theme\n\n## How to Use\n\n1. Ask Gemini to generate any Mermaid diagram code\n2. The code block is automatically replaced with the rendered diagram\n3. Click the **</> Code** button to view source code\n4. Click the **📊 Diagram** button to switch back to diagram view\n5. Click the diagram area to enter fullscreen\n\n## Fullscreen Controls\n\n- **Scroll wheel**: Zoom in/out\n- **Drag**: Pan the diagram\n- **+/-**: Toolbar zoom buttons\n- **⊙**: Reset view\n- **✕ / ESC**: Close fullscreen\n\n## التوافق وإصلاح المشكلات\n\n::: warning ملاحظة\n\n- **قيود Firefox**: بسبب قيود البيئة، يستخدم Firefox الإصدار 9.2.2 ولا يدعم الميزات الجديدة مثل **Timeline** أو **Sankey**.\n- **أخطاء الصياغة**: غالباً ما يكون فشل المعالجة بسبب أخطاء في صياغة الكود الناتج من Gemini. نحن نقوم بجمع الحالات السيئة (Bad Cases) لإصلاح أخطاء التوليد الشائعة تلقائياً في التحديثات القادمة.\n  :::\n\n<div align=\"center\">\n  <img src=\"/assets/mermaid-preview.png\" alt=\"Mermaid diagram rendering\" style=\"max-width: 100%; border-radius: 8px;\"/>\n</div>\n"
  },
  {
    "path": "docs/ar/guide/nanobanana.md",
    "content": "# NanoBanana Option\n\n::: warning توافق المتصفح\nحالياً، ميزة **NanoBanana** **غير مدعومة على متصفح Safari** بسبب قيود واجهة برمجة تطبيقات المتصفح. نوصي باستخدام **Chrome** or **Firefox** إذا كنت بحاجة إلى استخدام هذه الميزة.\n\nيمكن لمستخدمي Safari تحميل صورهم يدوياً إلى مواقع مثل [banana.ovo.re](https://banana.ovo.re/) لمعالجتها (على الرغم من أن النجاح غير مضمون لجميع الصور بسبب اختلاف الدقة).\n:::\n\n**AI Images, kept pure.**\n\nImages generated by Gemini™ come with a visible watermark by default. While this is intended for safety, there are creative scenarios where you need a perfectly clean slate.\n\n## Lossless Reconstruction\n\nNanoBanana uses a **Reverse Alpha Blending** algorithm.\n\n- **Not AI Inpainting**: Traditional watermark removal often uses AI to \"smear\" the area, which destroys pixel details.\n- **Pixel Perfection**: We use mathematical calculations to precisely remove the transparent watermark layer, restoring the 100% original pixels.\n- **Zero Quality Loss**: The processed image remains identical to the original in all non-watermarked areas.\n\n## How to Use\n\n1. **Enable it**: Find \"NanoBanana Option\" at the end of the Voyager settings panel and toggle it on.\n2. **Auto-process**: Every image you generate will now be automatically processed in the background.\n3. **Download directly**:\n   - Hover over a processed image and you'll see a 🍌 button.\n   - **The 🍌 button completely replaces** the native download button to ensure you always get the 100% unwatermarked image directly.\n\n<div style=\"text-align: center; margin-top: 30px;\">\n  <img src=\"/assets/nanobanana.png\" alt=\"NanoBanana Demo\" style=\"border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); max-width: 100%;\"/>\n</div>\n\n## Acknowledgement\n\nThis feature is based on the [gemini-watermark-remover](https://github.com/journey-ad/gemini-watermark-remover) project by [journey-ad (Jad)](https://github.com/journey-ad), which is a JavaScript port of the [original C++ implementation](https://github.com/allenk/GeminiWatermarkTool) by [allenk](https://github.com/allenk). We are grateful for their contributions to the community. 🧡\n\n## Privacy & Security\n\nAll processing happens **locally in your browser**. Your images are never uploaded to any third-party servers, ensuring your privacy and creative security.\n"
  },
  {
    "path": "docs/ar/guide/prevent-auto-scroll.md",
    "content": "# منع التمرير التلقائي\n\nعندما تقوم بقراءة المحادثات السابقة، فإن إرسال مطالبة (Prompt) جديدة وتوليد إجابة من Gemini™ يجبر الصفحة على التمرير التلقائي إلى الأسفل بشدة لمتابعة النص الجديد. قد يسبب هذا الإزعاج ويقطع تسلسل القراءة الخاصة بك.\n\nميزة **منع التمرير التلقائي** توقف هذا السلوك الغير المرغوب به:\n\n- عندما تقوم بالتمرير للأعلى لقراءة المحادثة، سيقوم النظام بمنع الصفحة من النزول تلقائياً إلى الأسفل.\n- هذه الميزة **معطلة** بشكل افتراضي. يمكنك تفعيلها من خلال إعدادات الإضافة ضمن \"Timeline Options\".\n\n## كيفية التفعيل\n\n1. انقر على أيقونة إضافة Voyager في متصفحك لفتح النافذة.\n2. ابحث عن قسم \"Timeline Options\" (خيارات الجدول الزمني).\n3. قم بتفعيل الخيار \"Prevent auto-scroll to bottom\" (منع التمرير التلقائي للأسفل).\n"
  },
  {
    "path": "docs/ar/guide/prompts.md",
    "content": "# أصلك الرقمي: مكتبة المطالبات\n\nقضيت وقتاً طويلاً في كتابة مطالبة (Prompt) مذهلة ساعدت كثيراً.\nتستخدمها وترميها؟\nلا، احفظها.\n\n## خزنة الأوامر\n\nهذه هي خزنة الأوامر الخاصة بك.\n\n### 1. التقاط\n\nهل كتبت شيئاً جيداً؟ انقر فوق **الأيقونة العائمة** بجوار مربع الإدخال.\nاحفظها في الخزنة، لتشعر بالأمان.\n\n### 2. تصنيف\n\nأضف وسوماً مثل `#كود`، `#بريد_إلكتروني`، `#أكاديمي`.\nيجب أن تكون الأدوات مفيدة ومنظمة أيضاً.\n\n### 3. نشر\n\nفي المرة القادمة التي تحتاج إليها، لا تعيد كتابة كل شيء.\nافتح المكتبة، ابحث عن طريق الوسم، انقر للإدراج.\nاستدعاء بنقرة واحدة، ضاعف الكفاءة.\n\n![مدير المطالبات](/assets/gemini-prompt-manager.png)\n\n## يعمل على أي موقع\n\nيمكن الآن استخدام مدير المطالبات على أي موقع تختاره، ولا يقتصر على Gemini™ و AI Studio.\n\n### كيفية التفعيل\n\n1. انقر فوق أيقونة Voyager في شريط أدوات إضافات المتصفح.\n2. مرر لأسفل إلى قسم **مدير المطالبات**.\n3. أدخل عنوان URL للموقع (مثل: `chatgpt.com` أو `claude.ai`).\n4. انقر فوق **إضافة موقع** وامنح الإذن.\n5. **قم بتحديث الصفحة المستهدفة**، وسترى الكرة العائمة.\n\n### أمثلة لمواقع AI الشائعة\n\n- `chatgpt.com` - ChatGPT\n- `claude.ai` - Claude\n- `copilot.microsoft.com` - Microsoft Copilot\n- `poe.com` - Poe\n\n::: tip\nفي المواقع المخصصة، يتم تفعيل ميزة مدير المطالبات **فقط**. الميزات الأخرى مثل الجدول الزمني والمجلدات وما إلى ذلك مخصصة لـ Gemini ولن يتم تحميلها.\n:::\n"
  },
  {
    "path": "docs/ar/guide/quote-reply.md",
    "content": "# الرد مع اقتباس\n\nحدد للاقتباس، تماماً مثل Discord أو Slack.\n\nحدد أي نص في رد Gemini™، وسيظهر زر **\"اقتباس\"**. انقر عليه لإدراج المحتوى المحدد في مربع الإدخال، منسقاً كاقتباس Markdown.\n\nهذا مفيد بشكل خاص لطرح أسئلة متابعة حول أجزاء معينة من إجابة طويلة.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/quote-reply.png\" alt=\"الرد مع اقتباس\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n"
  },
  {
    "path": "docs/ar/guide/recents-hider.md",
    "content": "# إخفاء العناصر الأخيرة والـ Gems\n\n::: info\n**ملاحظة**: هذه الميزة مدعومة في الإصدار 1.1.9 وما بعده.\n:::\n\nأضف مفتاح تبديل أنيق لإخفاء قسم \"المحفوظات مؤخرًا\" في صفحة Gemini™ الرئيسية للحصول على واجهة أكثر ترتيبًا. يدعم الآن أيضًا إخفاء قائمة Gems في الشريط الجانبي!\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>إخفاء المحفوظات مؤخرًا</b></p>\n    <video src=\"/assets/hide-my-stuff.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>إخفاء قائمة Gems</b></p>\n    <video src=\"/assets/hide-gem-list.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n</div>\n\n## المميزات\n\n- **تبديل سياقي**: يظهر زر إخفاء خفي فقط عند تمرير الماوس فوق قسم العناصر الأخيرة.\n- **حالة تبسيطية**: عند الإخفاء، يتم استبداله بـ \"شريط نظرة خاطفة\" خفيف في الأسفل.\n- **استعادة بنقرة واحدة**: ما عليك سوى تمرير الماوس فوق شريط النظرة الخاطفة والنقر لاستعادة العناصر الأخيرة فورًا.\n- **حماية الخصوصية**: يمنع الآخرين القريبين من رؤية أنشطتك الأخيرة، مثالي للأماكن العامة.\n- **الاستمرارية**: يتم حفظ تفضيلاتك وتطبيقها تلقائيًا في زيارتك القادمة.\n\n## كيفية الاستخدام\n\n1. مرر الماوس فوق قسم \"الأخيرة\" في صفحة Gemini الرئيسية.\n2. انقر فوق أيقونة العين المشطوبة التي تظهر في الزاوية العلوية اليمنى لإخفاء القسم.\n3. للاستعادة، مرر الماوس فوق الخط الرفيع المتبقي في أسفل المنطقة وانقر.\n"
  },
  {
    "path": "docs/ar/guide/settings.md",
    "content": "# عرض الدردشة\n\nاستخدم شاشتك العريضة بالكامل.\n\nيقوم Gemini™ بتقييد المحتوى الرئيسي بعرض ثابت. يقوم Voyager بتحرير ذلك.\nانتقل إلى لوحة الإعدادات واسحب شريط تمرير **\"عرض الدردشة\"**.\n\n- **توسيع**: مثالي جداول الكود، وتحليل البيانات، والنوافذ المقسمة.\n- **تركيز**: قم بتضييقه للقراءة العميقة دون تشتيت.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/gemini-chatwidth.png\" alt=\"ضبط عرض الدردشة\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## ترتيب مخصص\n\nهل يوجد أقسام كثيرة في النافذة المنبثقة والأقسام التي تستخدمها أكثر مدفونة في الأسفل؟\n\nمرر الماوس فوق أي بطاقة إعدادات وستظهر أزرار ▲/▼ في الزاوية العلوية اليمنى. انقر لتحريك البطاقة لأعلى أو لأسفل. يتم حفظ ترتيبك تلقائيًا.\n\n## التأثيرات البصرية\n\nاختر `ثلج` أو `ساكورا` أو `مطر` لأجواء موسمية.\n\nVoyager لا يقتصر على التحسينات العملية. يمكنك أيضاً تغيير مزاج الصفحة.\n\n- **ثلج**: رقاقات ثلج ناعمة لإحساس شتوي هادئ.\n- **ساكورا**: بتلات أزهار الكرز لأجواء ربيعية أخف.\n- **مطر**: طبقة مطر سينمائية بخطوط مائلة وقطرات خفيفة.\n- **التبديل السلس**: عند إيقاف التأثير أو التبديل لآخر، تتلاشى الجسيمات بشكل طبيعي.\n"
  },
  {
    "path": "docs/ar/guide/sidebar-auto-hide.md",
    "content": "# إخفاء الشريط الجانبي تلقائياً\n\nهل تريد تجربة دردشة أكثر اندماجاً؟\n\nنحن نوفر ميزة **إخفاء الشريط الجانبي تلقائياً**. عند تفعيلها، سيتم طي الشريط الجانبي تلقائياً عندما يغادر الماوس المنطقة، ويتم توسيعه تلقائياً عندما تحرك الماوس نحوه.\n\n### عرض توضيحي\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/sidebar-auto-hide.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n### كيفية التفعيل\n\n1. افتح لوحة إعدادات Voyager.\n2. ابحث عن مفتاح **Auto-hide sidebar** في **الإعدادات العامة**.\n3. قم بتشغيل المفتاح للتفعيل.\n\n_ملاحظة: هذه الميزة تدعم حالياً Google Gemini فقط._\n"
  },
  {
    "path": "docs/ar/guide/sidebar.md",
    "content": "# عرض الشريط الجانبي\n\nهل أسماء المجلدات طويلة جدًا ولا تظهر بالكامل؟\nأو هل يأخذ الشريط الجانبي مساحة كبيرة من الدردشة؟\n\nالآن يمكنك ضبط عرض الشريط الجانبي بحرية.\n\n## كيفية الضبط\n\n1. افتح لوحة إعدادات Voyager (انقر فوق أيقونة الإضافة في أعلى يمين المتصفح).\n   <img src=\"/assets/extension-instruction.png\" alt=\"كيفية فتح لوحة الإعدادات\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. ابحث عن خيار **عرض الشريط الجانبي**.\n3. اسحب المؤشر لاختيار العرض المناسب لك.\n\n- **ضيق**: توفير المساحة والتركيز على المحادثة.\n- **واسع**: رؤية أسماء المجلدات الطويلة بوضوح.\n\n## المنصات المدعومة\n\nهذه الميزة تدعم:\n\n- **Google Gemini**\n- **Google AI Studio**\n\nيتم حفظ إعداداتك تلقائيًا.\n"
  },
  {
    "path": "docs/ar/guide/sponsor.md",
    "content": "# رعاية المشروع\n\n> [!NOTE]\n> إذا كان Voyager مفيداً لك، فشاركْه على X أو Reddit أو YouTube إلخ. كل مشاركة تساعد المزيد من الناس على اكتشاف المشروع وتحسين تجربة Gemini. شكراً.\n\nإذا كان Voyager يحسن إنتاجيتك اليومية، يرجى التفكير في رعاية المشروع. يساعدنا دعمك في الحفاظ على التطوير النشط، وإصلاح الأخطاء، وإضافة ميزات جديدة.\n\n## طرق الدعم\n\n### 💖 رعاة GitHub\n\nالطريقة المفضلة. يذهب 100% من رعايتك للمطور (لا يتقاضى GitHub رسوماً).\n\n[<img src=\"https://img.shields.io/badge/Sponsor_via-GitHub_Sponsors-EA4AAA?style=for-the-badge&logo=github-sponsors&logoColor=white\" alt=\"رعاة GitHub\" height=\"50\"/>](https://github.com/sponsors/Nagi-ovo)\n\n### ☕ اشتري لي قهوة\n\nطريقة بسيطة لتقديم قهوة كشكر.\n\n[<img src=\"https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"اشتري لي قهوة\" height=\"50\"/>](https://buymeacoffee.com/nagiovo)\n\n### ⭐ نجمة على GitHub\n\nإنه مجاني ويساعد كثيراً! امنح مستودعنا نجمة لمساعدتنا في الوصول إلى المزيد من الأشخاص.\n\n[<img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=social\" alt=\"نجوم GitHub\" height=\"30\"/>](https://github.com/Nagi-ovo/gemini-voyager)\n\n## أين تذهب الأموال؟\n\n- **رسوم مطور Apple**: للحفاظ على إصدار Safari حياً وموقعاً ($99/سنة).\n- **الخادم والنطاق**: لاستضافة هذه الوثائق وخدمات الخلفية.\n- **القهوة**: وقود أساسي لتحويل الكود إلى ميزات.\n\nشكراً لكونك Voyager! 🚀\n"
  },
  {
    "path": "docs/ar/guide/tab-title.md",
    "content": "# مزامنة عنوان علامة التبويب\n\nلا تضيع أبداً في بحر من علامات تبويب \"Gemini™\".\n\nافتراضياً، تقول جميع علامات تبويب Gemini ببساطة \"Gemini\". عندما يكون لديك 10 علامات تبويب مفتوحة، فإن العثور على العلامة الصحيحة هو كابوس.\nيقوم Voyager تلقائياً بتغيير عنوان علامة تبويب المتصفح ليطابق عنوان المحادثة الحالية.\n\n- **قبل**: Gemini, Gemini, Gemini\n- **بعد**: فيزياء الكم، وصفة المعكرونة، تصحيح أخطاء Python\n\nاعثر على ما تحتاجه بلمحة.\n"
  },
  {
    "path": "docs/ar/guide/timeline.md",
    "content": "# تصفح الجدول الزمني\n\nميزة Voyager المميزة. طريقة بصرية ومكانية لتصفح محادثاتك.\n\n## المشكلة\n\nيمكن أن تصبح محادثات الذكاء الاصطناعي طويلة. طويلة جداً.\nالتمرير بطيء. \"Cmd+F\" غير عملي. تفقد السياق.\n\n## الحل\n\nانظر إلى يمين (أو يسار) الشاشة. هذا هو الجدول الزمني.\nتمثل كل نقطة تبادلاً (سؤالك + إجابة Gemini™).\n\n### التفاعلات\n\n- **تحويم (Hover)**: شاهد معاينة فورية لما تمت مناقشته في تلك النقطة. لا نقر، لا تمرير. مجرد نظرة خاطفة.\n- **نقر**: انتقل فوراً إلى تلك الرسالة. انتقال آني.\n- **تمييز السياق**: أثناء التمرير، تضيء النقطة المقابلة، حتى تعرف دائماً مكانك على \"الخريطة\".\n\n## المفضلة (Stars)\n\nاضغط مطولاً (أو انقر بزر الماوس الأيمن) على أي نقطة **لتمييزها بنجمة**.\nتصبح النقاط المميزة بنجمة أكبر وأكثر سطوعاً. استخدمها لتمييز:\n\n- الكود النهائي الذي يعمل.\n- أفضل شرح لمفهوم ما.\n- المطالبة التي فتحت كل شيء.\n\nيحول الجدول الزمني جداراً من النص العادي إلى خريطة منظمة للمعرفة.\n"
  },
  {
    "path": "docs/ar/index.md",
    "content": "---\nlayout: home\n\nhero:\n  name: 'Voyager'\n  text: 'نظام التشغيل المفقود لـ Gemini.'\n  tagline: 'نحن نحب Gemini. أردنا فقط أن يكون مثالياً.'\n  image:\n    src: /logo.png\n    alt: شعار Voyager\n  actions:\n    - theme: brand\n      text: تحميل\n      link: ./guide/installation\n    - theme: alt\n      text: ابدأ الرحلة\n      link: ./guide/getting-started\n\nteaser:\n  title: 'إنه يعمل ببساطة.'\n  description: 'لم نرد بناء مجرد إضافة أخرى. أردنا بناء طريقة أفضل للتفكير.<br>عندما تستخدم Voyager، تتوقف عن مصارعة الواجهة وتبدأ في الانسياب معها.'\n  image: '/assets/teaser.png'\n  features:\n    - title: 'الجدول الزمني'\n      details: 'لا تمرر الشاشة. حلق. اقفز إلى أي نقطة في محادثتك فوراً.'\n    - title: 'المجلدات'\n      details: 'أخيراً، نظام ملفات للذكاء الاصطناعي الخاص بك. أصلي، بديهي، وقوي.'\n    - title: 'الحرية'\n      details: 'بياناتك ملكك. قم بالتصدير إلى JSON أو Markdown أو PDF بنقرة واحدة.'\n\nfeatures:\n  - icon: 🧭\n    title: الجدول الزمني\n    details: خريطة لعقلك. تنقل في المحادثات بصرياً.\n  - icon: 🗂️\n    title: المجلدات\n    details: نظام من الفوضى. سحب، إفلات، تم.\n  - icon: ✨\n    title: الخزنة\n    details: إبداعك محفوظ. احفظ وأعد استخدام أفضل مطالباتك.\n  - icon: 💬\n    title: الرد مع اقتباس\n    details: حدد للاقتباس. ردود مدركة للسياق لتواصل فعال.\n  - icon: ↔️\n    title: عرض الدردشة\n    details: وسّع رؤيتك. اضبط عرض الدردشة بحرية لتجربة مشاهدة أفضل.\n  - icon: 💾\n    title: تصدير الدردشة\n    details: سيادة البيانات. الأرشفة بتنسيقات متعددة لكي لا تضيع المعرفة أبداً.\n  - icon: 🌦️\n    title: التأثيرات البصرية\n    details: اضبط الأجواء كما تريد. بدّل بين الثلج والمطر وبتلات الساكورا من النافذة المنبثقة.\n  - icon: 🍌\n    title: إزالة العلامة المائية NanoBanana\n    details: إزالة العلامة المائية بدون فقدان للجودة. حافظ على لحظات الذكاء الاصطناعي نقية.\n  - icon: 📐\n    title: نسخ الصيغ\n    details: نسخ بنقرة واحدة لأكواد المصدر LaTeX و MathML (Word).\n  - icon: 🧜‍♀️\n    title: رسوم بيانية Mermaid\n    details: من كود إلى مرئيات. مخططات انسيابية، مخططات تسلسلية، ومخططات غانت تظهر فوراً.\n  - icon: 🏷️\n    title: مزامنة عنوان علامة التبويب\n    details: اعرف بلمحة. مزامنة تلقائية لعنوان علامة تبويب المتصفح مع دردشتك.\n  - icon: 🔀\n    title: تفريع المحادثة (تجريبي)\n    details: التفكير التباعدي. فرع المحادثة عند أي عقدة لاستكشاف احتمالات مختلفة.\n  - icon: 🗑️\n    title: الحذف الجماعي\n    details: تنظيف بالجملة. حدد محادثات متعددة واحذفها جميعاً دفعة واحدة.\n  - icon: ☁️\n    title: المزامنة السحابية\n    details: دائماً متصل. انسخ المجلدات والمطالبات احتياطياً إلى Google Drive عبر الأجهزة.\n  - icon: ⚡️\n    title: النموذج الافتراضي\n    details: توقف عن تكرار نفسك. التبديل التلقائي إلى نموذجك المفضل في المحادثات الجديدة.\n  - icon: 🔬\n    title: البحث العميق\n    details: افتح الصندوق الأسود. استخرج عمليات التفكير وروابط البحث من جلسات Deep Research.\n---\n\n<div class=\"vp-doc\" style=\"margin: 2rem auto 0; max-width: 780px; padding: 0 16px;\">\n  <div style=\"background: rgba(234, 179, 8, 0.12); border: 1px solid rgba(234, 179, 8, 0.6); border-radius: 8px; padding: 12px 16px;\" dir=\"rtl\">\n    <strong>⚠️ إشعار تغيير الاسم</strong>: بسبب مخاوف تتعلق بالعلامات التجارية وحقوق النشر، تم تغيير اسم هذا الامتداد رسمياً إلى <strong>Voyager</strong>. ومع ذلك، بسبب البطء الشديد في عملية مراجعة Chrome Web Store، لم يتم الموافقة على تغيير الاسم خلال 7 أيام — وهو غير متاح مؤقتاً على Chrome Web Store.\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 780px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 8px; font-weight: 600; font-size: 1.05em;\">كل تثبيت هو تصويت بالثقة</h3>\n  <p style=\"margin: 0 0 16px; opacity: 0.78; font-size: 0.95em;\">أرقام حية من متجر Chrome الإلكتروني و GitHub. شكراً لركوبكم معنا، أيها المسافرون الزملاء.</p>\n  <div style=\"display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;\">\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"نجوم GitHub\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"تشعبات GitHub\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"أحدث إصدار\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"تنزيلات GitHub\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"مستخدمو متجر Chrome الإلكتروني\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"تقييم متجر Chrome الإلكتروني\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoft-edge\" alt=\"إضافات Edge\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"مستخدمو إضافات Firefox\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"تقييم إضافات Firefox\">\n  </div>\n  <div style=\"margin-top: 16px; display: flex; justify-content: center; flex-wrap: wrap; gap: 12px;\">\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 1000px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 24px; font-weight: 600; font-size: 1.2em;\">شكر خاص</h3>\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" style=\"margin: 0 auto;\" />\n  </a>\n  <p style=\"margin-top: 24px; font-size: 1.05em; opacity: 0.86;\">✨ نحن مباشرون على Product Hunt! نود سماع أفكاركم وملاحظاتكم. ❤️</p>\n  <div style=\"margin-top: 12px; display: flex; justify-content: center;\">\n    <a href=\"https://www.producthunt.com/posts/gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager على Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 3.5rem auto 2rem; max-width: 720px; padding: 0 16px;\">\n  <p style=\"font-size: 1.05em; font-weight: 600; opacity: 0.86; margin: 0 0 12px;\">\"إنها ليست مجرد أداة. إنها دراجة للعقل.\"</p>\n  <a href=\"./guide/getting-started\" style=\"font-weight: 600; text-decoration: none;\">انظر ما هو ممكن ←</a>\n</div>\n\n<p align=\"center\">\n  <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n</p>\n"
  },
  {
    "path": "docs/ar/privacy.md",
    "content": "# سياسة الخصوصية\n\nآخر تحديث: 16 مارس 2026\n\n## مقدمة\n\nيلتزم Voyager (\"نحن\"، \"نا\"، أو \"لنا\") بحماية خصوصيتك. توضح سياسة الخصوصية هذه كيفية قيام إضافة المتصفح الخاصة بنا بجمع معلوماتك واستخدامها وحمايتها.\n\n## جمع البيانات واستخدامها\n\n**نحن لا نجمع أي معلومات شخصية.**\n\nيعمل Voyager بالكامل داخل متصفحك. يتم تخزين جميع البيانات التي يتم إنشاؤها أو إدارتها بواسطة الإضافة (مثل المجلدات وقوالب الأوامر والرسائل المفضلة والإعدادات):\n\n1. محلياً على جهازك (`chrome.storage.local`)\n2. في التخزين المتزامن لمتصفحك (`chrome.storage.sync`) إذا كان متاحاً، لمزامنة الإعدادات عبر أجهزتك.\n\nليس لدينا حق الوصول إلى بياناتك الشخصية أو سجل الدردشة أو أي معلومات خاصة أخرى. نحن لا نتتبع سجل تصفحك.\n\n## مزامنة Google Drive (اختياري)\n\nإذا قمت بتفعيل ميزة مزامنة Google Drive بشكل صريح، تستخدم الإضافة واجهة Chrome Identity API للحصول على رمز OAuth2 (بنطاق `drive.file` فقط) لعمل نسخة احتياطية من مجلداتك وأوامرك إلى **Google Drive الخاص بك**. يتم هذا النقل مباشرة بين متصفحك وخوادم Google. ليس لدينا حق الوصول إلى هذه البيانات، ولا يتم إرسالها أبداً إلى أي خادم نديره.\n\n## الأذونات\n\nتطلب الإضافة الحد الأدنى من الأذونات اللازمة للعمل:\n\n- **Storage (التخزين)**: لحفظ تفضيلاتك ومجلداتك وأوامرك ورسائلك المفضلة وخيارات تخصيص الواجهة محلياً وعبر الأجهزة.\n- **Identity (الهوية)**: لمصادقة Google لميزة مزامنة Google Drive الاختيارية. يُستخدم فقط عند تفعيل المزامنة السحابية بشكل صريح.\n- **Scripting (البرمجة النصية)**: لحقن نصوص المحتوى ديناميكياً في صفحات Gemini وفي المواقع المخصصة التي يحددها المستخدم لميزة مدير الأوامر. يتم حقن النصوص المضمنة في الإضافة فقط — لا يتم جلب أو تنفيذ أي كود عن بُعد.\n- **Host Permissions (أذونات المضيف)** (gemini.google.com، aistudio.google.com، إلخ): لحقن نصوص المحتوى التي تعزز واجهة Gemini بميزات مثل المجلدات والتصدير والخط الزمني والاقتباس في الرد. نطاقات Google الإضافية (googleapis.com، accounts.google.com) مطلوبة لمصادقة مزامنة Google Drive.\n- **Optional Host Permissions (أذونات المضيف الاختيارية)** (جميع عناوين URL): تُطلب فقط في وقت التشغيل عند إضافة مواقع مخصصة لمدير الأوامر بشكل صريح. لا يتم تفعيلها أبداً دون إجراء من المستخدم.\n\n## خدمات الطرف الثالث\n\nلا يشارك Voyager أي بيانات مع خدمات الطرف الثالث أو المعلنين أو مزودي التحليلات.\n\n## التغييرات على هذه السياسة\n\nقد نقوم بتحديث سياسة الخصوصية الخاصة بنا من وقت لآخر. سنخطرك بأي تغييرات عن طريق نشر سياسة الخصوصية الجديدة على هذه الصفحة.\n\n## اتصل بنا\n\nإذا كان لديك أي أسئلة حول سياسة الخصوصية هذه، يرجى الاتصال بنا عبر [مستودع GitHub](https://github.com/Nagi-ovo/gemini-voyager).\n"
  },
  {
    "path": "docs/en/guide/batch-delete.md",
    "content": "# Batch Delete\n\nDelete multiple conversations at once, no more deleting one by one.\n\n## Features\n\n- **Multi-select Mode**: Long-press any conversation to enter multi-select mode and check multiple conversations to delete.\n- **One-click Cleanup**: Once selected, click the delete button to remove all selected conversations at once.\n- **Progress Feedback**: Real-time progress is displayed during deletion so you know the current status.\n- **Safe Confirmation**: A confirmation dialog appears before deletion to prevent accidental operations.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/batch-delete.png\" alt=\"Batch Delete\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## How to Use\n\n1. In the sidebar conversation list, **long-press** any conversation item.\n2. After entering multi-select mode, checkboxes will appear on the left side of each conversation.\n3. Check the conversations you want to delete (up to 50 at a time).\n4. Click the **Delete button** that appears.\n5. Click \"Confirm\" in the red confirmation area that appears **above the folder list** to start the deletion.\n\n::: tip Note\nThe confirmation panel overlays the folder area to avoid blocking the conversation list. Batch delete operations cannot be undone, so please proceed with caution.\n:::\n"
  },
  {
    "path": "docs/en/guide/cloud-sync.md",
    "content": "# Cloud Sync\n\nSync your folders, prompt library, and other data to Google Drive to keep your experience consistent across devices.\n\n## Features\n\n- **Multi-Device Sync**: Keep your configurations in sync across multiple computers using Google Drive.\n- **Data Privacy**: Data is stored directly in your own Google Drive storage, ensuring privacy without third-party servers.\n- **Flexible Sync**: Support for manual uploading and downloading/merging of data.\n\n::: info\n**Coming Soon**: The next version will support syncing starred conversations.\n:::\n\n## How to Use\n\n1. Click the extension icon in the bottom-right corner of the Gemini™ page to open the settings panel.\n2. Locate the **Cloud Sync** section.\n3. Click **Sign in with Google** and complete the authorization.\n4. Once authorized, click **Upload to Cloud** to sync your local data to the cloud, or **Download & Merge** to bring cloud data to your local machine.\n\n### 💡 Quick Sync\n\nThe easiest way is to click the **\"Upload to Cloud\"** or **\"Download & Merge\"** buttons at the top of the folder area in the left sidebar.\n\n<img src=\"/assets/cloud-sync.png\" alt=\"Cloud Sync Quick Buttons\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n::: warning\n**Security Recommendation: Double Protection**  \nWhile Cloud Sync offers great convenience, we strongly recommend that you also periodically back up your core data using **local files**.\n\n1. **Full Export**: Export a complete package containing all settings, folders, and prompts from \"Backup & Restore\" at the bottom of the settings panel.\n   <img src=\"/assets/manual-export-all.png\" alt=\"Full Export\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. **Export All Folders**: Click \"Export\" in the \"Folders\" section of the settings panel to back up all your folders and conversations, excluding prompts.\n   <img src=\"/assets/manual-folder-export.png\" alt=\"Export All Folders\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n   :::\n"
  },
  {
    "path": "docs/en/guide/community.md",
    "content": "# Community & Feedback\n\nWe value every user's voice. Whether you've found a bug, have a feature suggestion, or want to share your prompt vault, there are several ways to get in touch.\n\n## 📢 Follow for Updates\n\nFollow us on X (Twitter) to get the latest development updates.\n\n- **New Releases**: Be the first to know about updates.\n- **Feature Previews**: Get a sneak peek at upcoming features.\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://x.com/Nag1ovo/status/2012610584722731188\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/badge/Follow%20on-𝕏-000000?style=for-the-badge&logo=x&logoColor=white\" alt=\"Follow on X\">\n  </a>\n</div>\n\n## 💬 Discord Community\n\nJoin our Discord server to chat with other Voyagers!\n\n- **Real-time Chat**: Talk directly with other users and the developers.\n- **Prompt Sharing**: See how others are using Gemini™ and share your best prompts.\n- **Development Updates**: Get the latest news on upcoming features and releases.\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://discord.gg/TEUFxdMbGb\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/discord/1463273957120675973?style=for-the-badge&logo=discord&logoColor=white&label=Join%20our%20Discord\" alt=\"Discord\">\n  </a>\n</div>\n\n## 🐙 GitHub Issues\n\nIf you've found a bug or have a specific feature request, please open an issue on GitHub:\n\n- [Report a Bug](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=bug_report.yml)\n- [Suggest a Feature](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=feature_request.yml)\n\nThank you for supporting Voyager! ❤️\n"
  },
  {
    "path": "docs/en/guide/context-sync.md",
    "content": "# Memory Transport: Context Sync (Experimental)\n\n**Different Dimensions, Seamless Sharing**\n\nIterate logic on the web, and implement code in the IDE. Voyager breaks down the dimensional barriers, giving your IDE the \"thinking process\" of the web instantly.\n\n## No More Tab Hopping\n\nThe biggest pain for developers: after discussing a solution thoroughly on the web, you return to VS Code/Trae/Cursor only to have to re-explain the requirements like a stranger. Due to quotas and response speeds, the web is the \"brain\" and the IDE is the \"hands.\" Voyager lets them share the same soul.\n\n## Three Simple Steps to Synchronize\n\n1. **Install and Wake Up CoBridge**:\n   Install the **CoBridge** extension in VS Code. It serves as the core bridge connecting the web interface and your local IDE.\n   - **[Install via VS Code Marketplace](https://open-vsx.org/extension/windfall/co-bridge)**\n\n   ![CoBridge Extension](/assets/CoBridge-extension.png)\n\n   After installation, **open any working directory**, click the icon on the right and start the server.\n   ![CoBridge Server On](/assets/CoBridge-on.png)\n\n2. **Handshake Connection**:\n   - Enable \"Context Sync\" in Voyager settings.\n   - Align the port numbers. When you see \"IDE Online,\" they are connected.\n\n   ![Context Sync Console](/assets/context-sync-console.png)\n\n3. **One-Click Sync**: Click **\"Sync to IDE\"**. Whether it's complex **data tables** or intuitive **reference images**, everything can be instantly synchronized to your IDE.\n\n   ![Sync Done](/assets/sync-done.png)\n\n## Rooting in the IDE\n\nAfter synchronization, a `.cobridge/AI_CONTEXT.md` file will appear in your IDE working directory. Whether it's Trae, Cursor, or Copilot, they will automatically read this \"memory\" through their respective Rule files.\n\n```\nyour-project/\n├── .cobridge/\n│   ├── images/\n│   │   ├── context_img_1_1.png\n│   │   └── context_img_1_2.png\n│   └── AI_CONTEXT.md\n├── .github/\n│   └── copilot-instructions.md\n├── .gitignore\n├── .traerules\n└── .cursorrules\n```\n\n## Principles\n\n- **Zero Pollution**: CoBridge automatically handles `.gitignore`, ensuring your private conversations are never pushed to Git repositories.\n- **Industry Savvy**: Full Markdown format, making it as smooth for the AI in your IDE to read as an instruction manual.\n- **Pro Tip**: If the conversation is from a while ago, scroll up using the [Timeline] first to let the web \"remember\" the context for better sync results.\n\n---\n\n## Ready for Liftoff\n\n**The logic is primed in the cloud; now, let it take root locally.**\n\n- **[Install CoBridge Extension](https://open-vsx.org/extension/windfall/co-bridge)**: Find your dimensional portal and activate \"synchronized breathing\" with a single click.\n- **[Access GitHub Repository](https://github.com/Winddfall/CoBridge)**: Dive deep into the underlying logic of CoBridge, or give a Star to this \"soul-syncing\" project.\n\n> **LLMs will never lose their memory again; ready for action right out of the box.**\n"
  },
  {
    "path": "docs/en/guide/deep-research.md",
    "content": "# Deep Research Export\n\nExport the final report generated by Deep Research, or save its complete \"thinking\" process as a Markdown file.\n\n## 1. Report Export (PDF / Image)\n\nReports generated by Deep Research can be exported as beautifully formatted PDFs or shareable single images (Markdown and JSON formats are also supported).\n\n![Report Export](/assets/deep-research-report-export.png)\n\n## 2. Thinking Process Export (Markdown)\n\nIn addition to the final report, you can also export the complete \"thinking\" content from Deep Research conversations.\n\n### Features\n\n- **One-click export**: Download button appears in the conversation menu (⋮)\n- **Structured format**: Preserves thinking phases, thought items, and researched websites in their original order\n- **Bilingual headers**: Markdown files include section headers in both English and your current language\n- **Automatic naming**: Files are timestamped for easy organization (e.g., `deep-research-thinking-20240128-153045.md`)\n\n### How to Use\n\n1. Open a Deep Research conversation on Gemini™\n2. Click the **Share & Export** button in the conversation\n3. Select \"Download thinking content\" (下载 Thinking 内容)\n4. The Markdown file will be automatically downloaded\n\n![Deep Research Thinking Export](/assets/deepresearch_download_thinking.png)\n\n### Exported File Format\n\nThe exported Markdown file includes:\n\n- **Title**: The conversation title\n- **Metadata**: Export timestamp and total number of thinking phases\n- **Thinking Phases**: Each phase contains:\n  - Thought items (with headers and content)\n  - Researched websites (with links and titles)\n\n#### Example Structure\n\n```markdown\n# Deep Research Conversation Title\n\n**导出时间 / Exported At:** 2025-12-28 17:25:35\n**总思考阶段 / Total Phases:** 3\n\n---\n\n## 思考阶段 1 / Thinking Phase 1\n\n### Thought Title 1\n\nThought content...\n\n### Thought Title 2\n\nThought content...\n\n#### 研究网站 / Researched Websites\n\n- [domain.com](https://example.com) - Page Title\n- [another.com](https://another.com) - Another Title\n\n---\n\n## 思考阶段 2 / Thinking Phase 2\n\n...\n```\n\n## Privacy\n\nAll extraction and formatting happens 100% locally in your browser. No data is sent to external servers.\n"
  },
  {
    "path": "docs/en/guide/default-model.md",
    "content": "# Default Model\n\n::: info\n**Note**: This feature is supported in version 1.1.9 and later.\n:::\n\nSet a preferred Gemini™ model as your default to avoid manual switching for every new conversation.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/default-model.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n## Features\n\n- **Interactive Setup**: Adds a \"Star\" button directly into Gemini's native model selection menu.\n- **Auto-Switching**: Automatically switches to your preferred model whenever you start a new conversation.\n- **Persistent Preference**: Your choice is saved and synced across your devices.\n- **SPA Optimized**: Accurately triggers when clicking the \"New Chat\" button, using shortcuts, or navigating back to the home page.\n\n## How to Use\n\n1. Click the **Model Selector** above the Gemini input area.\n2. Hover over your preferred model and click the **Star icon**.\n3. Once the star is **filled**, that model is set as your default.\n4. The extension will now automatically select this model for all new chats.\n5. To unset, simply click the filled star icon again.\n"
  },
  {
    "path": "docs/en/guide/export.md",
    "content": "# Total Freedom\n\nData lock-in is the enemy.\nWe believe that if you create it, you own it.\n\n## Export Everything\n\nVoyager lets you pull your data out of the cloud and into your hands.\n\n### The Formats\n\n- **Markdown**: For your Obsidian vault or Notion. Clean, formatted text. (Safari Users: Images cannot be extracted due to browser limitations, use PDF export for images)\n- **PDF**: For sharing or printing. Beautifully laid out, images included.\n- **JSON**: Raw data. For developers who want to build on top of their history.\n\n### How to Export\n\n1. Hover your mouse over the Gemini logo to see the **Export Icon**.\n2. Choose your format.\n3. Done.\n\nIt’s your data. Do what you want with it.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Step 1: Hover Logo</b></p>\n    <img src=\"/assets/gemini-export-guide-1.png\" alt=\"Export guide step 1\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Step 2: The Choice</b></p>\n    <img src=\"/assets/gemini-export-guide-2.png\" alt=\"Export guide step 2\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n### Safari PDF Export Note\n\nExporting PDF on Safari requires a slightly different process (manual print):\n\n1. Click the **Export** button and select PDF format.\n2. **Wait for about a second** (allow the page to prepare print styles).\n3. Press `Command + P` to open the print dialog.\n4. Select **\"Save to PDF\"** in the print dialog.\n\n<img src=\"/assets/safari-export-pdf.png\" alt=\"Safari Export PDF\" style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 20px;\"/>\n"
  },
  {
    "path": "docs/en/guide/folders.md",
    "content": "# Folders Done Right\n\nWhy is organizing AI chats so hard?\nWe fixed it. We built a file system for your thoughts.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap; margin-bottom: 40px;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Gemini™</b></p>\n    <img src=\"/assets/gemini-folders.png\" alt=\"Gemini Folders\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>AI Studio</b></p>\n    <img src=\"/assets/aistudio-folders.png\" alt=\"AI Studio Folders\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n## The Physics of Organization\n\nIt just feels right.\n\n- **Drag and Drop**: Pick up a chat. Drop it in a folder. It’s tactile.\n- **Nested Hierarchy**: Projects have sub-projects. Create folders inside folders. Structure it _your_ way.\n- **Folder Spacing**: Adjust vertical density from compact to spacious.\n  > _Note: On Mac Safari, adjustments may not be real-time; refresh the page to see the effect._\n- **Instant Sync**: Organize on your desktop. See it on your laptop.\n\n## Pro Tips\n\n- **Multi-Select**: Long-press a conversation to enter multi-select mode, then select multiple chats and move them all at once.\n- **Renaming**: Double-click any folder to rename it.\n- **Icons**: We automatically detect the Gem type (Coding, Creative, etc.) and assign the right icon. You don't have to do a thing.\n\n## Platform Feature Differences\n\n### Common Features\n\n- **Basic Management**: Drag and drop, rename, multi-select.\n- **Smart Recognition**: Automatically detect chat types and assign icons.\n- **Nested Hierarchy**: Support for folder nesting.\n- **AI Studio Adaptive**: Advanced features coming soon to AI Studio.\n- **Google Drive Sync**: Sync folder structure to Google Drive.\n\n### Gemini Exclusive\n\n#### Custom Colors\n\nClick the folder icon to customize its color. Choose from 7 default colors or use the color picker to select any custom color.\n\n<img src=\"/assets/folder-color.png\" alt=\"Folder Colors\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### Account Isolation\n\nClick the \"person\" icon in the header to instantly filter out chats from other Google accounts. Keep your workspace clean when using multiple accounts.\n\n<img src=\"/assets/current-user-only.png\" alt=\"Account Isolation\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### AI Auto-Organize\n\nToo many chats, too lazy to sort? Let Gemini do the thinking.\n\nOne click copies your current conversation structure, paste it into Gemini, and it generates a ready-to-import folder plan — instant organization.\n\n**Step 1: Copy Your Conversation Structure**\n\nAt the bottom of the folder section in the extension popup, click the **AI Organize** button. It automatically collects all your unfiled conversations and existing folder structure, generates a prompt, and copies it to your clipboard.\n\n<img src=\"/assets/ai-auto-folder.png\" alt=\"AI Organize Button\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n\n**Step 2: Let Gemini Sort It Out**\n\nPaste the clipboard content into a Gemini conversation. It will analyze your chat titles and output a JSON folder plan.\n\n**Step 3: Import the Results**\n\nClick **Import folders** from the folder panel menu, select **Or paste JSON directly**, paste the JSON that Gemini returned, and click **Import**.\n\n<div style=\"display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; margin-bottom: 24px;\">\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-2.png\" alt=\"Import Menu\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 240px;\"/>\n  </div>\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-3.png\" alt=\"Paste JSON Import\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n  </div>\n</div>\n\n- **Incremental Merge**: Uses the \"Merge\" strategy by default — only adds new folders and assignments, never destroys your existing organization.\n- **Multilingual**: The prompt automatically uses your configured language, and folder names are generated in that language too.\n\n### AI Studio Exclusive\n\n- **Sidebar Adjustment**: Drag to resize the sidebar width.\n- **Library Integration**: Drag directly from your Library into folders.\n"
  },
  {
    "path": "docs/en/guide/fork.md",
    "content": "# Conversation Fork (Experimental)\n\nThinking shouldn't be a one-way street. In complex explorations, we often need to return to a crucial node and try different possibilities.\n\nWith the **Conversation Fork** feature, Voyager allows you to branch out your thoughts and explore parallel universes of your chat.\n\n## How it Works\n\n> **⚠️ Note**: This is an experimental feature. You need to enable it first by clicking the extension icon in your browser toolbar to open the settings popup, and turning on the **Enable Conversation Fork** switch.\n\nWhenever you want to take a different path, simply hover over your prompt and click the **Fork** button:\n\n![Conversation Fork](/assets/branching.png)\n\nVoyager will instantly capture the entire context from the beginning up to that point and **start a brand-new conversation** for you.\n\nIn this new branch, you can freely modify your question and explore different directions without worrying about destroying your original chat history. Unleash your creativity and curiosity!\n"
  },
  {
    "path": "docs/en/guide/formula-copy.md",
    "content": "# Formula Copy\n\nVoyager makes it effortless to reuse mathematical formulas and scientific symbols. It supports one-click copying of LaTeX source code and Microsoft Word-compatible MathML format.\n\n## Introduction\n\nWhen you ask Gemini to derive formulas or write mathematical expressions, it usually renders them using LaTeX. While beautiful, extracting the source code to use in your own papers, documents, or editors often requires manual effort.\n\nVoyager provides seamless support for this:\n\n1. **Auto-Detection**: Voyager automatically identifies LaTeX formulas rendered on the page.\n2. **Copy Button**: When you hover over a formula, a copy icon appears on its right side.\n3. **Format Options**: Click the icon to choose:\n   - **Copy LaTeX**: Copies standard LaTeX source, ideal for Overleaf, Markdown editors, etc.\n   - **Copy MathML**: Copies MathML source, the best format for pasting directly into **Microsoft Word**.\n\n![Formula Copy](/assets/gemini-math-copy.png)\n\n## Features\n\n- **Word Compatibility**: With MathML support, you can paste complex AI-generated formulas directly into Word documents while maintaining perfect editable formatting.\n- **Context Preservation**: Not only copies the formula itself but also preserves its mathematical context.\n- **Instant Response**: Processed entirely locally for immediate results.\n\n## Usage Tips\n\n- **Academic Writing**: When writing papers in Word, have Gemini derive formulas, then use MathML copy-paste to avoid the hassle of manual entry in the Word Equation Editor.\n- **Note Taking**: When taking notes in Obsidian or Notion, simply copy the LaTeX source directly.\n"
  },
  {
    "path": "docs/en/guide/getting-started.md",
    "content": "# Welcome Aboard\n\nCongratulations. You’ve just upgraded your intellect.\nVoyager isn’t just a utility; it’s a workflow. Here is how to make the most of it in your first 5 minutes.\n\n## 1. The Setup\n\nIf you haven't installed it yet, head over to the [Installation Guide](/guide/installation).\nOnce installed, refresh your Gemini tab. You’ll see the difference immediately.\n\n## 2. The Timeline\n\nStart a conversation. A long one. Ask about the history of typography or the physics of black holes.\nLook to the right.\n**That strip of dots? That’s your map.**\n\n- **Hover** to peek at what you said.\n- **Click** to teleport there.\n- **Long-press** to star a moment you want to keep.\n\nNo more endless scrolling. You are now navigating at the speed of thought.\n\n## 3. Organization\n\nLook at your chat list on the left. Notice something new?\n**Folders.**\n\nPick up a chat. Drag it. Drop it into a folder.\nIt feels natural, doesn't it? That’s because it is. You can nest them, rename them, and finally clear the clutter from your mind.\n\n## 4. The Vault\n\nYou just wrote the perfect prompt. Don't let it disappear into the void.\nClick the **Sparkle Icon** (✨) in the input box.\nSave it. Tag it.\n\nNext time? Just click the icon to find and insert it.\nYou aren't just chatting anymore. You are building a vault of your own genius.\n\n---\n\n**You are ready.**\nExplore the specific guides for deep dives:\n\n- [Timeline Mastery](/guide/timeline)\n- [Folder Management](/guide/folders)\n- [Prompt Engineering](/guide/prompts)\n- [Data Export](/guide/export)\n"
  },
  {
    "path": "docs/en/guide/input-collapse.md",
    "content": "# Input Collapse\n\nCollapse the input area when empty to gain more reading space. Click the collapsed bar to expand and start typing.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/hide-input-area.png\" alt=\"Input collapse\" style=\"max-width: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## How to Use\n\n1. When the input area is empty and loses focus, it automatically collapses into a compact pill button\n2. Click the pill button to expand the input area and start typing\n3. You can also press <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>I</kbd> to quickly expand the input area\n4. You can enable or disable this feature in the settings panel (disabled by default)\n"
  },
  {
    "path": "docs/en/guide/installation.md",
    "content": "# Installation\n\n::: info News\n🍎 **Safari Native Extension is launched!** It is completely free and supports one-click installation.\n:::\n\nChoose your path.\n\n> ⚠️ Note: Prompt Manager is the only feature that supports Gemini™ for Enterprise.\n\n## 1. Extension Stores (Recommended)\n\nThe simplest way to get started. Updates are automatic.\n\n**Chrome / Brave / Opera / Vivaldi:**\n\n[<img src=\"https://img.shields.io/badge/Chrome_Web_Store-Download-4285F4?style=for-the-badge&logo=google-chrome&logoColor=white\" alt=\"Install from Chrome Web Store\" height=\"40\"/>](https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=docs&utm_campaign=organic_growth&utm_content=en)\n\n::: warning ⚠️ Chrome Web Store temporarily unavailable\nThe extension has been officially renamed to **Voyager** due to trademark concerns. The Chrome Web Store name update is pending review. See [this post](https://x.com/Nag1ovo/status/2031561180213313944) for details. Please use **Edge / Firefox** or **manual installation** in the meantime.\n:::\n\n**Microsoft Edge:**\n\n[<img src=\"https://img.shields.io/badge/Microsoft_Edge-Download-0078D7?style=for-the-badge&logo=microsoft-edge&logoColor=white\" alt=\"Install from Microsoft Edge Add-ons\" height=\"40\"/>](https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne)\n\n**Firefox:**\n\n[<img src=\"https://img.shields.io/badge/Firefox_Add--ons-Download-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Install from Firefox Add-ons\" height=\"40\"/>](https://addons.mozilla.org/firefox/addon/gemini-voyager/)\n\n## 2. The Manual Way (Latest Features)\n\nThe Web Store review process can be slow. If you want the cutting-edge version immediately, install manually.\n\n**For Chrome / Edge / Brave / Opera:**\n\n1. Download the latest `gemini-voyager-chrome-vX.Y.Z.zip` from [GitHub Releases](https://github.com/Nagi-ovo/gemini-voyager/releases).\n2. Unzip the file.\n3. Open your browser's Extensions page (`chrome://extensions`).\n4. Enable **Developer mode** (top right).\n5. Click **Load unpacked** and select the folder you just unzipped.\n\n**For Firefox:**\n\n1. Download the latest `gemini-voyager-firefox-vX.Y.Z.xpi` from [Releases](https://github.com/Nagi-ovo/gemini-voyager/releases).\n2. Open the Add-ons Manager (`about:addons`).\n3. Drag and drop the `.xpi` file to install (or click the gear icon ⚙️ -> **Install Add-on From File**).\n\n> 💡 The XPI file is officially signed by Mozilla and can be permanently installed in all Firefox versions.\n\n## 3. Safari (macOS)\n\nSafari now supports direct distribution! Download the pre-signed app:\n\n1. Download the <SafariDownloadLink>latest Safari version (.dmg)</SafariDownloadLink>.\n2. Double-click to open and follow the prompts to install.\n3. Double-click to launch the app.\n4. Enable the extension in **Safari Settings > Extensions**.\n\n> 💡 The Safari build is now directly signed for distribution—no Xcode conversion needed!\n>\n> ⚠️ **Limitations**: Due to Safari's nature, (a) Watermark removal (b) Image export (PDF recommended) are not supported.\n\n---\n\n_Development setup? If you are a developer looking to contribute, check out our [Contributing Guide](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/CONTRIBUTING.md)._\n"
  },
  {
    "path": "docs/en/guide/markdown-fix.md",
    "content": "# Markdown Rendering Fix\n\nThe Gemini™ web interface sometimes inserts HTML elements (such as citation sources or highlight markers) within text, which can break Markdown bold syntax (`**text**`), causing the text to fail to render as bold correctly.\n\nVoyager has a built-in automatic fix feature that intelligently identifies and repairs these broken bold tags, ensuring that your document renders cleanly and accurately.\n\n> [!INFO]\n> This feature is automatically enabled and requires no additional configuration.\n"
  },
  {
    "path": "docs/en/guide/mermaid.md",
    "content": "# Mermaid Diagram Rendering\n\nAutomatically render Mermaid code as visual diagrams.\n\n## Overview\n\nWhen Gemini™ outputs Mermaid code blocks (flowcharts, sequence diagrams, Gantt charts, etc.), Voyager automatically detects and renders them as interactive diagrams.\n\n### Key Features\n\n- **Auto-detection**: Supports `graph`, `flowchart`, `sequenceDiagram`, `gantt`, `pie`, `classDiagram`, and all major Mermaid diagram types\n- **Toggle view**: Switch between rendered diagram and source code with one click\n- **Fullscreen mode**: Click the diagram to enter fullscreen with zoom and pan support\n- **Dark mode**: Automatically adapts to page theme\n\n## How to Use\n\n1. Ask Gemini to generate any Mermaid diagram code\n2. The code block is automatically replaced with the rendered diagram\n3. Click the **</> Code** button to view source code\n4. Click the **📊 Diagram** button to switch back to diagram view\n5. Click the diagram area to enter fullscreen\n\n## Fullscreen Controls\n\n- **Scroll wheel**: Zoom in/out\n- **Drag**: Pan the diagram\n- **+/-**: Toolbar zoom buttons\n- **⊙**: Reset view\n- **✕ / ESC**: Close fullscreen\n\n## Compatibility & Troubleshooting\n\n::: warning Note\n\n- **Firefox Limitation**: Due to environment restrictions, Firefox uses version 9.2.2 and does not support new features like **Timeline** or **Sankey**.\n- **Syntax Errors**: Rendering failures are often due to syntax errors in Gemini's output. We are collecting \"bad cases\" to implement automatic patches for common generation errors in future updates.\n  :::\n\n<div align=\"center\">\n  <img src=\"/assets/mermaid-preview.png\" alt=\"Mermaid diagram rendering\" style=\"max-width: 100%; border-radius: 8px;\"/>\n</div>\n"
  },
  {
    "path": "docs/en/guide/nanobanana.md",
    "content": "# NanoBanana Option\n\n::: warning Browser Compatibility\nCurrently, the **NanoBanana** feature is **not supported on Safari** due to browser API limitations. We recommend using **Chrome** or **Firefox** if you need to use this feature.\n\nSafari users can manually upload their downloaded images to [banana.ovo.re](https://banana.ovo.re/) for processing (though success is not guaranteed for all images due to varying resolutions).\n:::\n\n**AI Images, kept pure.**\n\nImages generated by Gemini™ come with a visible watermark by default. While this is intended for safety, there are creative scenarios where you need a perfectly clean slate.\n\n## Lossless Reconstruction\n\nNanoBanana uses a **Reverse Alpha Blending** algorithm.\n\n- **Not AI Inpainting**: Traditional watermark removal often uses AI to \"smear\" the area, which destroys pixel details.\n- **Pixel Perfection**: We use mathematical calculations to precisely remove the transparent watermark layer, restoring the 100% original pixels.\n- **Zero Quality Loss**: The processed image remains identical to the original in all non-watermarked areas.\n\n## How to Use\n\n1. **Enable it**: Find \"NanoBanana Option\" at the end of the Voyager settings panel and toggle it on.\n2. **Auto-process**: Every image you generate will now be automatically processed in the background.\n3. **Download directly**:\n   - Hover over a processed image and you'll see a 🍌 button.\n   - **The 🍌 button completely replaces** the native download button to ensure you always get the 100% unwatermarked image directly.\n\n<div style=\"text-align: center; margin-top: 30px;\">\n  <img src=\"/assets/nanobanana.png\" alt=\"NanoBanana Demo\" style=\"border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); max-width: 100%;\"/>\n</div>\n\n## Acknowledgement\n\nThis feature is based on the [gemini-watermark-remover](https://github.com/journey-ad/gemini-watermark-remover) project by [journey-ad (Jad)](https://github.com/journey-ad), which is a JavaScript port of the [original C++ implementation](https://github.com/allenk/GeminiWatermarkTool) by [allenk](https://github.com/allenk). We are grateful for their contributions to the community. 🧡\n\n## Privacy & Security\n\nAll processing happens **locally in your browser**. Your images are never uploaded to any third-party servers, ensuring your privacy and creative security.\n"
  },
  {
    "path": "docs/en/guide/prevent-auto-scroll.md",
    "content": "# Prevent Auto Scroll\n\nWhen you are reading past conversations, hitting Enter to send a new prompt causes Gemini™ to forcefully scroll the page to the bottom to track the newly generated response. This can disrupt your reading experience.\n\nThe **Prevent Auto Scroll** feature intercepts this unwanted jumping behavior:\n\n- When you have scrolled up to read history, the system blocks the page from jumping back down.\n- This feature is **disabled** by default. You can manually enable it in the popup settings under \"Timeline Options\".\n\n## How to Enable\n\n1. Click the Voyager extension icon in your browser toolbar to open the popup.\n2. Locate the \"Timeline Options\" section.\n3. Toggle on the \"Prevent auto-scroll to bottom\" switch.\n"
  },
  {
    "path": "docs/en/guide/prompts.md",
    "content": "# Your Intellectual Assets: Prompt Library\n\nYou craft a perfect prompt. It solves a complex coding problem or writes a beautiful email.\nDo you throw it away?\nNo. You save it.\n\n## The Prompt Vault\n\nThis is your personal repository of genius.\n\n### 1. Capture\n\nWhen you write something great, click the **Prompt Manager** icon (floating near the input box).\nIt’s now part of your vault.\n\n### 2. Categorize\n\nAdd tags like `#coding`, `#email`, or `#research`.\nKeep your tools sharpened and sorted.\n\n### 3. Deploy\n\nNext time you need it, don't type it again.\nOpen the manager, search by tag or keyword, and click to insert.\nOne click. Infinite leverage.\n\n![Prompt Manager](/assets/gemini-prompt-manager.png)\n\n## Available Anywhere\n\nThe Prompt Manager can now be used on any website you choose, not just Gemini™ and AI Studio.\n\n### How to Enable\n\n1. Click the Voyager icon in your browser's extension toolbar.\n2. Scroll to the **Prompt Manager** section.\n3. Enter the website URL (e.g., `chatgpt.com` or `claude.ai`).\n4. Click **Add website** and grant permission.\n5. **Reload the target page** to see the floating button.\n\n### Popular AI Websites\n\n- `chatgpt.com` - ChatGPT\n- `claude.ai` - Claude\n- `copilot.microsoft.com` - Microsoft Copilot\n- `poe.com` - Poe\n\n::: tip\nOn custom websites, **only** the Prompt Manager feature is activated. Other features like Timeline and Folders are designed specifically for Gemini and will not load.\n:::\n"
  },
  {
    "path": "docs/en/guide/quote-reply.md",
    "content": "# Quote Reply\n\nVoyager offers a convenient \"Quote Reply\" feature, making replies to specific content more precise and efficient.\n\n## Introduction\n\nIn daily conversations, we often need to follow up on or refute a specific part of the AI's output. The traditional method involves copying that text and manually typing the `> ` symbol in the input box, which is tedious.\n\nVoyager simplifies this process:\n\n1. **Select to Quote**: Use your mouse to select any text in the conversation page (whether it's your question or Gemini's answer).\n2. **Floating Button**: A \"Quote Reply\" button will automatically appear near the selected text.\n3. **One-Click Insert**: Click the button, and the selected text will be automatically inserted into your input box in standard Markdown blockquote format (`> content`).\n\n![Quote Reply](/assets/quote-reply.png)\n\n## Features\n\n- **Context Aware**: Intelligently identifies conversation content to avoid accidental triggering in unrelated areas (like the input box itself).\n- **Standard Format**: Uses universal Markdown syntax, which Gemini understands perfectly, leading to more precise responses.\n- **Multi-line Support**: If multiple lines of text are selected, Voyager automatically adds quote symbols to each line to keep the format clean.\n\n## Tips\n\n- **Follow up on details**: Select a confusing concept in Gemini's answer, click quote, and then type \"Please explain this concept in detail.\"\n- **Correct errors**: Select incorrect code or facts in the answer, quote them, and point out \"This is incorrect, it should be...\"\n"
  },
  {
    "path": "docs/en/guide/recents-hider.md",
    "content": "# Hide Recent Items and Gems\n\n::: info\n**Note**: This feature is supported in version 1.1.9 and later.\n:::\n\nAdd an elegant toggle to hide the \"Recently saved\" section on the Gemini™ homepage for a cleaner interface. Now also supports hiding the **Gems** list in the sidebar!\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Hide Recently Saved</b></p>\n    <video src=\"/assets/hide-my-stuff.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Hide Gems List</b></p>\n    <video src=\"/assets/hide-gem-list.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n</div>\n\n## Features\n\n- **Contextual Toggle**: A subtle hide button appears only when you hover over the Recent Items section.\n- **Minimalist State**: When hidden, it's replaced by a discreet \"peek bar\" at the bottom.\n- **One-Click Restore**: Simply hover over the peek bar and click to bring your recent items back instantly.\n- **Privacy Protection**: Prevents others nearby from seeing your recent activities, perfect for public spaces.\n- **Persistence**: Your preference is saved and applied automatically on your next visit.\n\n## How to Use\n\n1. Hover over the \"Recent\" section on the Gemini homepage.\n2. Click the eye-off icon that appears in the top-right corner to hide the section.\n3. To restore, hover over the thin line that remains at the bottom of the area and click.\n"
  },
  {
    "path": "docs/en/guide/settings.md",
    "content": "# Make It Yours\n\nThe default experience is great. But you might want it perfect.\nCustomize every pixel.\n\n## Cinema Mode\n\nWhy view the future through a tiny keyhole?\nVoyager lets you expand the chat width.\n\n- **Wide**: 1400px for coding and complex tables.\n- **Focused**: 800px for reading.\n- **You decide**: Use the slider to find your sweet spot.\n\n## Control\n\nClick the extension icon to access the control center.\n\n- **Scroll Mode**: Natural or classic.\n- **Timeline Position**: Put it where it feels right.\n- **Visual Effects**: Pick `Snow`, `Sakura`, or `Rain` for a seasonal atmosphere.\n\n## Custom Order\n\nToo many sections in the popup, and the ones you use most are buried at the bottom?\n\nHover over any settings card and ▲/▼ arrows appear in the top-right corner. Click to move a card up or down. Your layout is saved automatically and persists across sessions.\n\n## Atmosphere\n\nVoyager is not limited to utility tweaks. You can also change the mood of the page.\n\n- **Snow**: Soft drifting flakes for a calm winter feel.\n- **Sakura**: Floating cherry blossom petals for a lighter spring vibe.\n- **Rain**: A cinematic rain overlay with angled streaks and subtle splash ripples.\n- **Smooth switching**: Turning an effect off or switching to another one lets existing particles fade out naturally.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Open Settings</b></p>\n    <img src=\"/assets/gemini-open-settings-guide.png\" alt=\"Open settings guide\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Adjust Width</b></p>\n    <img src=\"/assets/gemini-chatwidth.png\" alt=\"Chat width adjustment\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n"
  },
  {
    "path": "docs/en/guide/sidebar-auto-hide.md",
    "content": "# Sidebar Auto-hide\n\nWant a more immersive chat experience?\n\nWe provide a **Sidebar Auto-hide** feature. When enabled, the sidebar automatically collapses when your mouse leaves the sidebar area, and automatically expands when you move your mouse back into it.\n\n### Demo\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/sidebar-auto-hide.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n### How to Enable\n\n1. Open the Voyager settings panel.\n2. Find the **Auto-hide sidebar** toggle in **General Settings**.\n3. Toggle it on to activate.\n\n_Note: This feature currently only supports Google Gemini._\n"
  },
  {
    "path": "docs/en/guide/sidebar.md",
    "content": "# Sidebar Width\n\nFolder names too long to see?\nOr is the sidebar taking up too much precious chat space?\n\nNow you can freely adjust the width of the sidebar.\n\n## How to Adjust\n\n1. Open the Voyager settings panel (click the extension icon in the top right of your browser).\n   <img src=\"/assets/extension-instruction.png\" alt=\"How to open settings panel\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. Find the **Sidebar width** option in **General Settings**.\n3. Drag the slider to choose the width that feels right for you.\n\n- **Narrow**: Save space and focus on the conversation.\n- **Wide**: See full folder names clearly at a glance.\n\n## Supported Platforms\n\nThis feature supports both:\n\n- **Google Gemini**\n- **Google AI Studio**\n\nYour settings are saved automatically and will apply the next time you open the page.\n"
  },
  {
    "path": "docs/en/guide/sponsor.md",
    "content": "# Sponsor\n\n> [!NOTE]\n> If Voyager helps you, feel free to share it on X, Reddit, YouTube, Threads, etc. Every share helps more people discover the project and improve the Gemini experience. Thanks.\n\nMaintaining open-source projects is mainly driven by passion (and coffee) ☕\n\n**[Voyager](https://github.com/Nagi-ovo/gemini-voyager)** is a completely free and open-source browser extension designed to enhance your Gemini experience. If this extension helps you use Gemini more efficiently, please consider supporting the continued development and maintenance of this project.\n\n---\n\n## Online Platforms\n\n<div class=\"sponsor-badges\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" style=\"height: 40px;\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" style=\"height: 40px;\">\n  </a>\n</div>\n\n<a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n    <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n    <img alt=\"Afdian\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n  </picture>\n</a>\n\n### 🎙️ Recommended Tool: Typeless\n\nI highly recommend **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)**, an AI voice-to-text tool that I used extensively during the development of Voyager. Integrating it into my daily workflow has saved me a tremendous amount of time and significantly boosted my productivity.\n\n> 🎁 **[Join via my referral link](https://www.typeless.com/?via=gemini-voyager)** (Code: _`gemini-voyager`_) to get **$5 free credits**. This also gives me credits to keep maintaining this project—a free way to support my work! ❤️\n\n---\n\n## Buy Me a Coffee (QR) 🍵\n\n<div class=\"qr-container\">\n  <div class=\"qr-item\">\n    <img src=\"/assets/wechat-sponsor.png\" alt=\"WeChat Pay\" />\n    <span>WeChat Pay</span>\n  </div>\n  <div class=\"qr-item\">\n    <img src=\"/assets/alipay-sponsor.jpg\" alt=\"Alipay\" />\n    <span>Alipay</span>\n  </div>\n</div>\n\n<style>\n.sponsor-badges {\n  display: flex;\n  gap: 16px;\n  flex-wrap: wrap;\n  align-items: center;\n  margin: 16px 0;\n}\n\n.sponsor-badges a img {\n  transition: transform 0.2s ease;\n}\n\n.sponsor-badges a:hover img {\n  transform: scale(1.05);\n}\n\n.qr-container {\n  display: flex;\n  gap: 32px;\n  flex-wrap: wrap;\n  margin: 16px 0;\n}\n\n.qr-item {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 8px;\n  padding: 16px;\n  border-radius: 8px;\n  border: 1px solid var(--vp-c-divider);\n  background: var(--vp-c-bg-soft);\n}\n\n.qr-item img {\n  width: 200px;\n  height: 200px;\n  object-fit: contain;\n  border-radius: 4px;\n}\n\n.qr-item span {\n  font-weight: 600;\n  color: var(--vp-c-text-1);\n}\n\n@media (max-width: 640px) {\n  .qr-container {\n    justify-content: center;\n  }\n  \n  .qr-item img {\n    width: 150px;\n    height: 150px;\n  }\n}\n</style>\n\n---\n\nThank you for your support! Every contribution is a great encouragement to me ❤️\n"
  },
  {
    "path": "docs/en/guide/tab-title.md",
    "content": "# Tab Title Sync\n\nAutomatically syncs the browser tab title with the current Gemini™ chat title.\n\n## Features\n\n- **Real-time Sync**: When the chat title changes (e.g., AI generates a new title or you manually rename it), the browser tab title updates instantly from \"Gemini\" to the specific conversation topic.\n- **Universal Support**: Works perfectly with standard chat pages, Gem conversations, and multi-account environments.\n- **Toggle Control**: If you prefer the default behavior, you can easily disable this feature in the \"General Options\" section of the settings panel.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/tab-title.png\" alt=\"Tab Title Sync\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## How to Use\n\n1. This feature is enabled by default upon installation.\n2. Open any Gemini conversation and observe the browser tab title; it will automatically update to match the chat title.\n3. To disable:\n   - Click the extension icon to open the settings panel.\n   - Find \"General Options\".\n   - Toggle off \"Update Tab Title\".\n"
  },
  {
    "path": "docs/en/guide/timeline.md",
    "content": "# Time Travel\n\nLong conversations are messy. You scroll up, you scroll down, you lose your place.\nVoyager turns your conversation into a timeline.\n\n## See the Shape of Your Chat\n\nLook at the right side of your screen.\nEach node represents a message. The timeline visualizes the rhythm of your dialogue.\n\n## Navigation, Solved.\n\n- **Teleport**: Click a node to jump instantly to that message.\n- **Peek**: Hover to see what's inside without moving.\n- **Bookmark**: Long-press a node to **Star** it. It's like a bookmark for your brain.\n- **Levels (Experimental)**: Right-click a node to set various levels (1-3) or collapse children. Perfect for making branched conversations clear.\n- **Keyboard**: Navigate at the speed of thought. Default `j`/`k`, customize to anything.\n\n![Timeline Navigation](/assets/teaser.png)\n\n## Even Faster with Keys\n\nDon't want to use the mouse? Use your keyboard.\n\n**It's like turning on Vim mode in Gemini.**\n\n### Default Shortcuts\n\n- `k` - Jump to previous node\n- `j` - Jump to next node\n\n### Customize It\n\nOpen extension settings, click a shortcut box, press any key you want.\nAny key, any combo. `n`/`p`? `,`/`.`? Your call.\n\n**Flow mode**: Rapid presses queue up smoothly.\n**Jump mode**: Instant response, max speed.\n"
  },
  {
    "path": "docs/en/index.md",
    "content": "---\nlayout: home\n\nhero:\n  name: 'Voyager'\n  text: 'The missing OS for Gemini.'\n  tagline: 'We love Gemini. We just wanted it to be perfect.'\n  image:\n    src: /logo.png\n    alt: Voyager Logo\n  actions:\n    - theme: brand\n      text: Download\n      link: ./guide/installation\n    - theme: alt\n      text: Start the Journey\n      link: ./guide/getting-started\n\nteaser:\n  title: 'It just works.'\n  description: ‘We didn’t want to build another extension. We wanted to build a better way to think.<br>When you use Voyager, you stop fighting the interface and start flowing with it.’\n  image: '/assets/teaser.png'\n  features:\n    - title: 'Timeline'\n      details: 'Don’t scroll. Fly. Jump to any point in your conversation instantly.'\n    - title: 'Folders'\n      details: 'Finally, a file system for your AI. Native, intuitive, powerful.'\n    - title: 'Freedom'\n      details: 'Your data is yours. Export to JSON, Markdown, or PDF with one click.'\n\nfeatures:\n  - icon: 🧭\n    title: Timeline\n    details: A map for your mind. Navigate conversations visually.\n  - icon: 🗂️\n    title: Folders\n    details: Order from chaos. Drag, drop, done.\n  - icon: ✨\n    title: Vault\n    details: Your genius, captured. Save and reuse your best prompts.\n  - icon: 💬\n    title: Quote Reply\n    details: Select to quote. Context-aware replies for efficient communication.\n  - icon: ↔️\n    title: Chat Width\n    details: Go wide. Freely adjust the chat width for a better viewing experience.\n  - icon: 💾\n    title: Chat Export\n    details: Data sovereignty. Archive in multiple formats so knowledge is never lost.\n  - icon: 🌦️\n    title: Visual Effects\n    details: Set the mood. Switch between snow, rain, and sakura petals from the popup.\n  - icon: 🍌\n    title: NanoBanana Watermark Removal\n    details: Lossless watermark removal. Keeping AI moments pure.\n  - icon: 📐\n    title: Formula Copy\n    details: One-click copy for LaTeX and MathML (Word) source codes.\n  - icon: 🧜‍♀️\n    title: Mermaid Diagrams\n    details: Code to visuals. Flowcharts, sequence diagrams, Gantt charts rendered instantly.\n  - icon: 🏷️\n    title: Tab Title Sync\n    details: Know at a glance. Auto-sync browser tab title with your chat.\n  - icon: 🔀\n    title: Conversation Fork (Experimental)\n    details: Divergent thinking. Branch conversation at any node to explore different possibilities.\n  - icon: 🗑️\n    title: Batch Delete\n    details: Clean up in bulk. Select multiple conversations and delete them all at once.\n  - icon: ☁️\n    title: Cloud Sync\n    details: Always in sync. Back up folders and prompts to Google Drive across devices.\n  - icon: ⚡️\n    title: Default Model\n    details: Stop repeating yourself. Auto-switch to your preferred model on new chats.\n  - icon: 🔬\n    title: Deep Research\n    details: Unbox the thinking. Extract research processes and links from Deep Research sessions.\n---\n\n<div class=\"vp-doc\" style=\"margin: 2rem auto 0; max-width: 780px; padding: 0 16px;\">\n  <div style=\"background: rgba(234, 179, 8, 0.12); border: 1px solid rgba(234, 179, 8, 0.6); border-radius: 8px; padding: 12px 16px;\">\n    <strong>⚠️ Name Change Notice</strong>: Due to trademark and copyright concerns, this extension has been officially renamed to <strong>Voyager</strong>. However, due to the Chrome Web Store's extremely slow review process, the name change was not approved within 7 days — it is temporarily unavailable on the Chrome Web Store.\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 780px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 8px; font-weight: 600; font-size: 1.05em;\">Every install is a vote of trust</h3>\n  <p style=\"margin: 0 0 16px; opacity: 0.78; font-size: 0.95em;\">Live numbers from Chrome Web Store and GitHub. Thanks for riding with us, fellow Voyagers.</p>\n  <div style=\"display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;\">\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Stars\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Forks\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Latest Release\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub Downloads\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store Users\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store Rating\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoft-edge\" alt=\"Edge Add-ons\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons Users\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons Rating\">\n  </div>\n  <div style=\"margin-top: 16px; display: flex; justify-content: center; flex-wrap: wrap; gap: 12px;\">\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <!--<a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>-->\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 1000px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 24px; font-weight: 600; font-size: 1.2em;\">Special Thanks</h3>\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" style=\"margin: 0 auto;\" />\n  </a>\n  <p style=\"margin-top: 24px; font-size: 1.05em; opacity: 0.86;\">✨ We're live on Product Hunt! We'd love to hear your thoughts and feedback. ❤️</p>\n  <div style=\"margin-top: 12px; display: flex; justify-content: center;\">\n    <a href=\"https://www.producthunt.com/posts/gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager on Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 3.5rem auto 2rem; max-width: 720px; padding: 0 16px;\">\n  <p style=\"font-size: 1.05em; font-weight: 600; opacity: 0.86; margin: 0 0 12px;\">“It's not just a tool. It's a bicycle for the mind.”</p>\n  <a href=\"./guide/getting-started\" style=\"font-weight: 600; text-decoration: none;\">See what's possible →</a>\n</div>\n\n<p align=\"center\">\n  <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n</p>\n"
  },
  {
    "path": "docs/en/privacy.md",
    "content": "# Privacy Policy\n\nLast updated: March 16, 2026\n\n## Introduction\n\nVoyager (\"we\", \"our\", or \"us\") is committed to protecting your privacy. This Privacy Policy explains how our browser extension collects, uses, and safeguards your information.\n\n## Data Collection and Usage\n\n**We do not collect any personal information.**\n\nVoyager operates entirely within your browser. All data generated or managed by the extension (such as folders, prompt templates, starred messages, and settings) is stored:\n\n1. Locally on your device (`chrome.storage.local`)\n2. In your browser's synchronized storage (`chrome.storage.sync`) if available, to sync settings across your devices.\n\nWe do not have access to your personal data, chat history, or any other private information. We do not track your browsing history.\n\n## Google Drive Sync (Optional)\n\nIf you explicitly opt in to the Google Drive Sync feature, the extension uses the Chrome Identity API to obtain an OAuth2 token (with `drive.file` scope only) to back up your folders and prompts to **your own Google Drive**. This transfer occurs directly between your browser and Google's servers. We have no access to this data, and it is never sent to any server we operate.\n\n## Permissions\n\nThe extension requests the minimum permissions necessary to function:\n\n- **Storage**: To save your preferences, folders, prompts, starred messages, and UI customization options locally and across devices.\n- **Identity**: To authenticate with Google for the optional Google Drive Sync feature. Only used when you explicitly enable cloud sync.\n- **Scripting**: To dynamically inject content scripts on Gemini pages and on user-specified custom websites for the Prompt Manager feature. Only the extension's own bundled scripts are injected — no remote code is fetched or executed.\n- **Host Permissions** (gemini.google.com, aistudio.google.com, etc.): To inject content scripts that enhance the Gemini UI with features like folders, export, timeline, and quote-reply. Additional Google domains (googleapis.com, accounts.google.com) are required for Google Drive Sync authentication.\n- **Optional Host Permissions** (all URLs): Only requested at runtime when you explicitly add custom websites for the Prompt Manager. Never activated without your action.\n\n## Third-Party Services\n\nVoyager does not share any data with third-party services, advertisers, or analytics providers.\n\n## Changes to This Policy\n\nWe may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page.\n\n## Contact Us\n\nIf you have any questions about this Privacy Policy, please contact us via our [GitHub Repository](https://github.com/Nagi-ovo/gemini-voyager).\n"
  },
  {
    "path": "docs/es/guide/batch-delete.md",
    "content": "# Eliminación por Lote\n\nElimina múltiples conversaciones a la vez, di adiós a la tediosa eliminación una por una.\n\n## Características\n\n- **Modo Selección Múltiple**: Mantén presionado cualquier conversación para entrar en el modo de selección múltiple, puedes marcar varias conversaciones para eliminar.\n- **Limpieza en un Clic**: Después de seleccionar, haz clic en el botón de eliminar para borrar todas las conversaciones seleccionadas.\n- **Progreso en Tiempo Real**: Muestra el progreso en tiempo real durante la eliminación, para que sepas el estado actual.\n- **Confirmación de Seguridad**: Aparecerá un cuadro de confirmación antes de eliminar para evitar operaciones accidentales.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/batch-delete.png\" alt=\"Eliminación por Lote\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## Cómo Usar\n\n1. En la lista de conversaciones de la barra lateral, **mantén presionado** cualquier elemento de conversación.\n2. Después de entrar en el modo de selección múltiple, aparecerán casillas de verificación a la izquierda de los elementos de conversación.\n3. Marca las conversaciones que deseas eliminar (máximo 50 a la vez).\n4. Haz clic en el **botón de eliminar** que aparece.\n5. Haz clic en \"Aceptar\" en el área roja de confirmación que aparece **sobre la lista de carpetas** para comenzar la eliminación por lote.\n\n::: tip Consejo\nEl panel de confirmación de eliminación cubrirá directamente el área de carpetas para evitar bloquear la lista de conversaciones. La operación de eliminación por lote no se puede deshacer, por favor opera con precaución.\n:::\n"
  },
  {
    "path": "docs/es/guide/cloud-sync.md",
    "content": "# Sincronización en la Nube\n\nSincroniza tus carpetas, biblioteca de prompts y otros datos en Google Drive para mantener tu experiencia consistente en todos tus dispositivos.\n\n## Características\n\n- **Sincronización multidispositivo**: Mantén tus configuraciones sincronizadas en varias computadoras usando Google Drive.\n- **Privacidad de datos**: Los datos se almacenan directamente en tu propio almacenamiento de Google Drive, lo que garantiza la privacidad sin servidores de terceros.\n- **Sincronización flexible**: Soporte para carga manual y descarga/fusión de datos.\n\n::: info\n**Próximamente**: La próxima versión admitirá la sincronización de conversaciones destacadas.\n:::\n\n## Cómo usar\n\n1. Haz clic en el icono de la extensión en la esquina inferior derecha de la página de Gemini™ para abrir el panel de configuración.\n2. Localiza la sección **Sincronización en la Nube**.\n3. Haz clic en **Iniciar sesión con Google** y completa la autorización.\n4. Una vez autorizado, haz clic en **Subir a la Nube** para sincronizar tus datos locales con la nube, o en **Descargar y Fusionar** para traer los datos de la nube a tu máquina local.\n\n### 💡 Sincronización rápida\n\nLa forma más sencilla es hacer clic en los botones **\"Subir a la Nube\"** o **\"Descargar y Fusionar\"** en la parte superior del área de carpetas en la barra lateral izquierda.\n\n<img src=\"/assets/cloud-sync.png\" alt=\"Botones de sincronización rápida en la nube\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n::: warning\n**Recomendación de seguridad: Protección doble**  \nSi bien la sincronización en la nube ofrece una gran comodidad, le recomendamos encarecidamente que también realice periódicamente copias de seguridad de sus datos principales mediante **archivos locales**.\n\n1. **Exportación completa**: Exporta un paquete completo con todas las configuraciones, carpetas y prompts desde \"Copia de seguridad y restauración\" al final del panel.\n   <img src=\"/assets/manual-export-all.png\" alt=\"Exportación completa\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. **Exportar todas las carpetas**: Haz clic en \"Exportar\" en la sección \"Carpetas\" del panel para guardar todas tus carpetas y conversaciones, excluyendo los prompts.\n   <img src=\"/assets/manual-folder-export.png\" alt=\"Exportar todas las carpetas\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n   :::\n"
  },
  {
    "path": "docs/es/guide/community.md",
    "content": "# Comunidad y Comentarios\n\nValoramos mucho la voz de cada usuario. Ya sea que encuentres un error, tengas una sugerencia de funcionalidad o quieras compartir tu bóveda de prompts, puedes contactarnos a través de los siguientes canales.\n\n## 📢 Mantente Informado\n\nSigue nuestra cuenta de X (Twitter) para obtener los últimos avances en desarrollo.\n\n- **Nuevas versiones**: Sé el primero en conocer las actualizaciones.\n- **Próximas funciones**: Entérate de antemano sobre las funciones que vendrán.\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://x.com/Nag1ovo/status/2012609459663634589\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/badge/Seguir-𝕏-000000?style=for-the-badge&logo=x&logoColor=white\" alt=\"Seguir en X\">\n  </a>\n</div>\n\n## 💬 Comunidad en Discord\n\n¡Únete a nuestro servidor de Discord y chatea con otros Voyagers!\n\n- **Chat en tiempo real**: Habla directamente con otros usuarios y desarrolladores.\n- **Compartir Prompts**: Mira qué prompts están usando otros.\n- **Progreso del desarrollo**: Obtén las últimas noticias sobre nuevas funciones.\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://discord.gg/TEUFxdMbGb\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/discord/1463273957120675973?style=for-the-badge&logo=discord&logoColor=white&label=Unirse%20a%20Discord\" alt=\"Discord\">\n  </a>\n</div>\n\n## 🐙 GitHub Issues\n\nSi encuentras un error (Bug) o tienes una solicitud de función clara (Feature Request), te recomendamos enviar un Issue en GitHub:\n\n- [Reportar un Bug](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=bug_report.yml)\n- [Solicitar una función](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=feature_request.yml)\n\n¡Gracias por tu apoyo a Voyager! ❤️\n"
  },
  {
    "path": "docs/es/guide/context-sync.md",
    "content": "# Transporte de memoria: Sincronización de contexto (Experimental)\n\n**Diferentes dimensiones, intercambio fluido**\n\nDesarrolla la lógica en la web e implementa el código en el IDE. Voyager rompe las barreras dimensionales, dotando instantáneamente a tu IDE del \"proceso de pensamiento\" de la web.\n\n## Adiós al salto constante entre pestañas\n\nEl mayor dolor de cabeza de los desarrolladores: después de discutir a fondo una solución en la web, regresas a VS Code/Trae/Cursor y tienes que volver a explicar los requisitos como si fueras un extraño. Debido a las cuotas y la velocidad de respuesta, la web es el \"cerebro\" y el IDE son las \"manos\". Voyager les permite compartir una misma alma.\n\n## Tres pasos sencillos, respiración sincronizada\n\n1. **Instalar y activar CoBridge**:\n   Instala la extensión **CoBridge**. Es el puente central que conecta la web con tu IDE local.\n   - **[Instalar desde el Marketplace](https://open-vsx.org/extension/windfall/co-bridge)**\n\n   ![Extensión CoBridge](/assets/CoBridge-extension.png)\n\n   Después de la instalación, **abre cualquier directorio de trabajo**, haz clic en el icono de la derecha y activa el servidor.\n   ![Servidor CoBridge activado](/assets/CoBridge-on.png)\n\n2. **Conexión y saludo**:\n   - Activa la \"Sincronización de contexto\" en los ajustes de Voyager.\n   - Alinea los números de puerto. Cuando veas \"IDE Online\", significa que están conectados.\n\n   ![Consola de sincronización de contexto](/assets/context-sync-console.png)\n\n3. **Sincronización con un clic**: Haz clic en **\"Sync to IDE\"**. Ya sean **tablas de datos** complejas o **imágenes de referencia** intuitivas, todo se sincronizará instantáneamente con tu IDE.\n\n   ![Sincronización completada](/assets/sync-done.png)\n\n## Arraigando en el IDE\n\nUna vez finalizada la sincronización, aparecerá un archivo `.cobridge/AI_CONTEXT.md` en el directorio de trabajo de tu IDE. Ya sea Trae, Cursor o Copilot, leerán automáticamente esta «memoria» a través de sus respectivos archivos Rule.\n\n```\nyour-project/\n├── .cobridge/\n│   ├── images/\n│   │   ├── context_img_1_1.png\n│   │   └── context_img_1_2.png\n│   └── AI_CONTEXT.md\n├── .github/\n│   └── copilot-instructions.md\n├── .gitignore\n├── .traerules\n└── .cursorrules\n```\n\n## Sus principios\n\n- **Cero contaminación**: CoBridge gestiona automáticamente el archivo `.gitignore`, garantizando que tus conversaciones privadas nunca se suban a los repositorios de Git.\n- **Optimizado para IA**: Formato Markdown completo, lo que hace que la lectura por parte de la IA en tu IDE sea tan fluida como leer un manual de instrucciones.\n- **Consejo**: Si la conversación es de hace tiempo, desplázate primero hacia arriba usando la [Línea de tiempo] para que la web \"recuerde\" el contexto y obtener mejores resultados de sincronización.\n\n---\n\n## Listos para el Despegue\n\n**La lógica ya está lista en la nube; ahora, permite que eche raíces localmente.**\n\n- **[Instalar la extensión CoBridge](https://open-vsx.org/extension/windfall/co-bridge)**: Encuentra tu portal dimensional y activa la \"respiración sincronizada\" con un solo clic.\n- **[Visitar el repositorio en GitHub](https://github.com/Winddfall/CoBridge)**: Explora la lógica subyacente de CoBridge o dale una estrella a este proyecto de \"sincronización de almas\".\n\n> **Los grandes modelos ya no sufren de amnesia; listos para la acción inmediata.**\n"
  },
  {
    "path": "docs/es/guide/deep-research.md",
    "content": "# Exportación de Deep Research\n\nExporta el informe final generado por Deep Research o guarda su proceso de \"pensamiento\" completo como un archivo Markdown.\n\n## 1. Exportación de informes (PDF / Imagen)\n\nLos informes generados por Deep Research se pueden exportar como PDF bellamente formateados o como imágenes individuales para compartir (también se admiten los formatos Markdown y JSON).\n\n![Exportación de informes](/assets/deep-research-report-export.png)\n\n## 2. Exportación del proceso de pensamiento (Markdown)\n\nAdemás del informe final, también puedes exportar el contenido completo de \"pensamiento\" de las conversaciones de Deep Research.\n\n### Características\n\n- **Exportación en un clic**: Haz clic en el botón de compartir y exportar para descargar.\n- **Formato Estructurado**: Conserva las fases de pensamiento, las entradas de pensamiento y los sitios web de investigación en su orden original.\n- **Encabezados bilingües**: Los archivos Markdown incluyen encabezados de sección en inglés y en tu idioma actual.\n- **Nombres Automáticos**: Los archivos se nombran con marcas de tiempo para una fácil organización (por ejemplo: `deep-research-thinking-20240128-153045.md`).\n\n### Cómo Usar\n\n1. Abre una conversación de Deep Research en Gemini™.\n2. Haz clic en el botón **Compartir y exportar** de la conversación.\n3. Selecciona \"Descargar contenido de pensamiento\" (Download thinking content).\n4. El archivo Markdown se descargará automáticamente.\n\n![Exportación de pensamiento de Deep Research](/assets/deepresearch_download_thinking.png)\n\n### Formato del Archivo Exportado\n\nEl archivo Markdown exportado contiene:\n\n- **Título**: Título de la conversación.\n- **Metadatos**: Hora de exportación y número total de fases de pensamiento.\n- **Fases de Pensamiento**: Cada fase contiene:\n  - Entradas de pensamiento (con título y contenido).\n  - Sitios web investigados (con enlace y título).\n\n#### Estructura de Ejemplo\n\n```markdown\n# Título de la conversación Deep Research\n\n**Hora de exportación / Exported At:** 2025-12-28 17:25:35\n**Fases totales / Total Phases:** 3\n\n---\n\n## Fase de Pensamiento 1 / Thinking Phase 1\n\n### Título del Pensamiento 1\n\nContenido del pensamiento...\n\n### Título del Pensamiento 2\n\nContenido del pensamiento...\n\n#### Sitios Web Investigados / Researched Websites\n\n- [domain.com](https://example.com) - Título de la página\n- [another.com](https://another.com) - Otro título\n\n---\n\n## Fase de Pensamiento 2 / Thinking Phase 2\n\n...\n```\n\n## Privacidad\n\nTodas las operaciones de extracción y formateo se realizan 100% localmente en tu navegador. No se envían datos a servidores externos.\n"
  },
  {
    "path": "docs/es/guide/default-model.md",
    "content": "# Modelo Predeterminado\n\n::: info\n**Nota**: Esta función es compatible con la versión 1.1.9 y posteriores.\n:::\n\nEstablece un modelo de Gemini™ preferido como predeterminado para evitar el cambio manual en cada nueva conversación.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/default-model.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n## Características\n\n- **Configuración Interactiva**: Añade un botón de \"Estrella\" directamente en el menú nativo de selección de modelos de Gemini.\n- **Cambio Automático**: Cambia automáticamente a tu modelo preferido cada vez que inicias una nueva conversación.\n- **Preferencia Persistente**: Tu elección se guarda y se sincroniza entre tus dispositivos.\n- **Optimizado para SPA**: Se activa con precisión al hacer clic en el botón \"Nuevo chat\", usar atajos o navegar de regreso a la página de inicio.\n\n## Cómo usar\n\n1. Haz clic en el **Selector de modelos** arriba del área de entrada de Gemini.\n2. Pasa el cursor sobre tu modelo preferido y haz clic en el **icono de Estrella**.\n3. Una vez que la estrella esté **llena**, ese modelo quedará configurado como predeterminado.\n4. La extensión seleccionará automáticamente este modelo para todos los chats nuevos.\n5. Para deshabilitarlo, simplemente haz clic en el icono de estrella llena nuevamente.\n"
  },
  {
    "path": "docs/es/guide/export.md",
    "content": "# Libertad Total\n\nLos datos bloqueados son la peor experiencia.\nNuestro credo es simple: lo que creas, es tuyo.\n\n## Llévatelo Todo\n\nVoyager te ayuda a bajar tus datos de la nube a tu palma.\n\n### Elige tu Formato\n\n- **Markdown**: Para usar en Obsidian o Notion. Limpio y fresco. (Usuarios de Safari: No se pueden extraer imágenes debido a limitaciones del navegador, use la exportación a PDF)\n- **PDF**: Para enviar a otros o imprimir. Maquetación hermosa, texto e imágenes.\n- **JSON**: Para desarrolladores. Datos brutos, juega con ellos como quieras.\n\n### Cómo Exportar\n\n1. Pase el ratón sobre el logo de Gemini para ver el **icono de exportación**.\n2. Elige el formato.\n3. Llévatelo.\n\nTus datos, tus reglas.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>1. Pasa el ratón</b></p>\n    <img src=\"/assets/gemini-export-guide-1.png\" alt=\"Guía de exportación paso 1\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>2. Elige el formato</b></p>\n    <img src=\"/assets/gemini-export-guide-2.png\" alt=\"Guía de exportación paso 2\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n### Nota sobre la exportación a PDF en Safari\n\nLa exportación a PDF en Safari requiere un proceso ligeramente diferente (impresión manual):\n\n1. Haz clic en el botón **Exportar** y selecciona el formato PDF.\n2. **Espera aproximadamente un segundo** (para que la página prepare los estilos de impresión).\n3. Presiona `Command + P` para abrir el cuadro de diálogo de impresión.\n4. Selecciona **\"Guardar como PDF\"** (Save to PDF) en el cuadro de diálogo de impresión.\n\n<img src=\"/assets/safari-export-pdf.png\" alt=\"Safari Export PDF\" style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 20px;\"/>\n"
  },
  {
    "path": "docs/es/guide/folders.md",
    "content": "# Carpetas bien hechas\n\n¿Por qué es tan difícil organizar los chats de IA?\nLo hemos solucionado. Hemos construido un sistema de archivos para tus pensamientos.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap; margin-bottom: 40px;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Gemini™</b></p>\n    <img src=\"/assets/gemini-folders.png\" alt=\"Carpetas Gemini\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>AI Studio</b></p>\n    <img src=\"/assets/aistudio-folders.png\" alt=\"Carpetas AI Studio\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n## La física de la organización\n\nSimplemente se siente bien.\n\n- **Arrastrar y soltar**: Toma un chat. Suéltalo en una carpeta. Es táctil.\n- **Jerarquía anidada**: Los proyectos tienen subproyectos. Crea carpetas dentro de carpetas. Estructúralo a _tu_ manera.\n- **Espaciado de carpetas**: Ajusta la densidad de la barra lateral, de compacto a espacioso.\n  > _Nota: En Mac Safari, es posible que los ajustes no sean en tiempo real; actualiza la página para ver el efecto._\n- **Sincronización instantánea**: Organiza en tu escritorio. Míralo en tu portátil.\n\n## Consejos profesionales\n\n- **Selección Múltiple**: Mantén presionado un elemento de chat para entrar en modo de selección múltiple, opera en lote, listo de una vez.\n- **Renombrar**: Doble clic en la carpeta, cámbialo directamente.\n- **Reconocimiento**: Código, escritura, charla... Identificamos automáticamente el tipo de Gema y asignamos un icono. Tú solo úsalo, déjanos el resto a nosotros.\n\n## Diferencias de características por plataforma\n\n### Funciones comunes\n\n- **Gestión básica**: Arrastrar y soltar, renombrar, selección múltiple.\n- **Reconocimiento inteligente**: Detecta automáticamente tipos de chat y asigna iconos.\n- **Jerarquía anidada**: Soporte para anidamiento de carpetas.\n- **Adaptación para AI Studio**: Las funciones avanzadas estarán disponibles pronto en AI Studio.\n- **Sincronización con Google Drive**: Sincroniza la estructura de carpetas con Google Drive.\n\n### Exclusivo de Gemini\n\n#### Colores personalizados\n\nHaz clic en el icono de la carpeta para personalizar su color. Elige entre 7 colores predeterminados o usa el selector de colores para elegir cualquier color.\n\n<img src=\"/assets/folder-color.png\" alt=\"Colores de carpeta\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### Aislamiento de cuenta\n\nHaz clic en el icono \"persona\" en el encabezado para filtrar instantáneamente los chats de otras cuentas de Google. Mantén tu espacio de trabajo limpio cuando uses varias cuentas.\n\n<img src=\"/assets/current-user-only.png\" alt=\"Aislamiento de cuenta\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### Organización automática con IA\n\nDemasiados chats, demasiada pereza para ordenar? Deja que Gemini piense por ti.\n\nUn clic copia tu estructura de conversaciones actual, pégalo en Gemini, y genera un plan de carpetas listo para importar — organización instantánea.\n\n**Paso 1: Copia tu estructura de conversaciones**\n\nEn la parte inferior de la sección de carpetas del popup de la extensión, haz clic en el botón **AI Organize**. Recopila automáticamente todas tus conversaciones sin clasificar y la estructura de carpetas existente, genera un prompt y lo copia al portapapeles.\n\n<img src=\"/assets/ai-auto-folder.png\" alt=\"AI Organize Button\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n\n**Paso 2: Deja que Gemini lo ordene**\n\nPega el contenido del portapapeles en una conversación de Gemini. Analizará los títulos de tus chats y generará un plan de carpetas en JSON.\n\n**Paso 3: Importa los resultados**\n\nHaz clic en **Importar carpetas** desde el menú del panel de carpetas, selecciona **O pegar JSON directamente**, pega el JSON que devolvió Gemini y haz clic en **Importar**.\n\n<div style=\"display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; margin-bottom: 24px;\">\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-2.png\" alt=\"Import Menu\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 240px;\"/>\n  </div>\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-3.png\" alt=\"Paste JSON Import\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n  </div>\n</div>\n\n- **Fusión incremental**: Usa la estrategia de \"Fusionar\" por defecto — solo agrega nuevas carpetas y asignaciones, nunca destruye tu organización existente.\n- **Multilingüe**: El prompt usa automáticamente tu idioma configurado, y los nombres de carpetas también se generan en ese idioma.\n\n### Exclusivo de AI Studio\n\n- **Ajuste de barra lateral**: Arrastra para cambiar el ancho de la barra lateral.\n- **Integración con Library**: Arrastra directamente desde tu Library a las carpetas.\n"
  },
  {
    "path": "docs/es/guide/fork.md",
    "content": "# Bifurcación de Conversación (Experimental)\n\nEl pensamiento no debe ser un camino de un solo sentido. En exploraciones complejas, a menudo necesitamos volver a un nodo crucial y probar diferentes posibilidades.\n\nCon la función de **Bifurcación de Conversación**, Voyager te permite expandir tus ideas y explorar universos paralelos de tu chat.\n\n## Cómo funciona\n\n> **⚠️ Nota**: Esta es una función experimental. Primero debes habilitarla haciendo clic en el icono de la extensión en la barra de herramientas de tu navegador para abrir la ventana emergente de configuración, y activando la opción **\"Habilitar bifurcación de conversación\"**.\n\nCada vez que desees tomar un camino diferente, simplemente pasa el cursor sobre tu pregunta y haz clic en el botón de **Bifurcación**:\n\n![Bifurcación](/assets/branching.png)\n\nVoyager capturará instantáneamente todo el contexto desde el principio hasta ese punto e **iniciará una conversación completamente nueva** para ti.\n\nEn esta nueva rama, puedes modificar libremente tu pregunta y explorar diferentes direcciones sin preocuparte por dañar tu historial de chat original. ¡Libera tu creatividad y curiosidad!\n"
  },
  {
    "path": "docs/es/guide/formula-copy.md",
    "content": "# Copia de Fórmulas\n\nVoyager facilita enormemente la reutilización de fórmulas matemáticas y símbolos científicos. Admite la copia en un clic del código fuente LaTeX y del formato MathML compatible con Microsoft Word.\n\n## Introducción\n\nCuando le pides a Gemini que derive fórmulas o escriba expresiones matemáticas, normalmente las representa usando LaTeX. Aunque es visualmente atractivo, extraer el código fuente para usarlo en tus propios artículos, documentos o editores a menudo requiere un esfuerzo manual.\n\nVoyager proporciona un soporte fluido para esto:\n\n1. **Detección Automática**: Voyager identifica automáticamente las fórmulas LaTeX representadas en la página.\n2. **Botón de Copia**: Al pasar el cursor sobre una fórmula, aparece un icono de copia en su lado derecho.\n3. **Opciones de Formato**: Haz clic en el icono para elegir:\n   - **Copy LaTeX**: Copia el código fuente LaTeX estándar, ideal para Overleaf, editores de Markdown, etc.\n   - **Copy MathML**: Copia el código fuente MathML, el mejor formato para pegar directamente en **Microsoft Word**.\n\n![Copia de Fórmulas](/assets/gemini-math-copy.png)\n\n## Características\n\n- **Compatibilidad con Word**: Con el soporte de MathML, puedes pegar fórmulas complejas generadas por IA directamente en documentos de Word manteniendo un formato editable perfecto.\n- **Preservación del Contexto**: No solo copia la fórmula en sí, sino que también preserva su contexto matemático.\n- **Respuesta Instantánea**: Procesado completamente de forma local para obtener resultados inmediatos.\n\n## Consejos de Uso\n\n- **Escritura Académica**: Al escribir artículos en Word, haz que Gemini derive fórmulas y luego usa copiar y pegar en MathML para evitar la molestia de la entrada manual en el Editor de ecuaciones de Word.\n- **Toma de Notas**: Al tomar notas en Obsidian o Notion, simplemente copia la fuente LaTeX directamente.\n"
  },
  {
    "path": "docs/es/guide/getting-started.md",
    "content": "# Bienvenido a Bordo\n\nFelicidades. Tu flujo de trabajo acaba de subir de categoría.\nVoyager no es solo una herramienta, es un hábito. Dame 5 minutos para enseñarte.\n\n## 1. Preparación\n\n¿Aún no lo has instalado? Ve a la [Guía de Instalación](/es/guide/installation).\n¿Instalado? Recarga la página de Gemini. El cambio es inmediato.\n\n## 2. Navegación\n\nTen una charla larga. Por ejemplo, sobre la historia de los caracteres chinos o la mecánica cuántica.\nMira a la derecha.\n**Esa serie de puntos es tu mapa de navegación.**\n\n- **Señalar**: Echa un vistazo a lo que se dijo en ese momento.\n- **Clic**: Viaja instantáneamente de regreso.\n- **Presionar**: Mantén presionado para destacar, marca los momentos clave.\n\nNo más scroll hasta que te duela el dedo. Tan rápido como tu pensamiento.\n\n## 3. Archivo\n\nMira la lista de chats a la izquierda.\n**Aquí están las carpetas.**\n\nToma un chat, arrástralo y suéltalo.\nSuave como la seda. Anida, renombra, como desees. Despeja el desorden de tu mente, quédate solo con la claridad.\n\n## 4. Tesoros\n\n¿Escribiste un prompt genial? No dejes que se pierda.\nHaz clic en el **icono ✨** en el cuadro de entrada.\nGuárdalo, ponle una etiqueta.\n\n¿Lo necesitas la próxima vez?\nHaz clic en el icono, busca, inserta.\nEsto no es solo chatear, es acumular tus activos digitales.\n\n---\n\n**Despegue.**\nProfundiza en cada función:\n\n- [Domina la Línea de Tiempo](/es/guide/timeline)\n- [Maestro de Carpetas](/es/guide/folders)\n- [Gestiona Prompts](/es/guide/prompts)\n- [Domina tus Datos](/es/guide/export)\n"
  },
  {
    "path": "docs/es/guide/input-collapse.md",
    "content": "# Colapso del Cuadro de Entrada\n\nColapsa automáticamente el cuadro de entrada cuando está vacío para ganar más espacio de lectura. Haz clic en el botón colapsado para expandir y escribir.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/hide-input-area.png\" alt=\"Colapso del cuadro de entrada\" style=\"max-width: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## Cómo Usar\n\n1. Cuando el cuadro de entrada está vacío y pierde el foco, se colapsa automáticamente en un botón tipo cápsula compacto.\n2. Haz clic en el botón cápsula para expandir el cuadro de entrada y comenzar a escribir.\n3. También puedes presionar <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>I</kbd> para expandir rápidamente el cuadro de entrada.\n4. Puedes activar o desactivar esta función en el panel de configuración (desactivado por defecto).\n"
  },
  {
    "path": "docs/es/guide/installation.md",
    "content": "# Instalación\n\n::: info Noticias\n🍎 **¡La extensión nativa de Safari ya está disponible!** Es completamente gratuita y se puede instalar con un solo clic.\n:::\n\nElige tu camino.\n\n> ⚠️ El Gestor de Prompts es la única función compatible con Gemini™ para Empresas.\n\n## 1. Tiendas Oficiales (Recomendado)\n\nLa forma más sencilla de empezar. Las actualizaciones son automáticas.\n\n**Chrome / Brave / Opera / Vivaldi:**\n\n[<img src=\"https://img.shields.io/badge/Chrome_Web_Store-Ir_a_descargar-4285F4?style=for-the-badge&logo=google-chrome&logoColor=white\" alt=\"Instalar desde Chrome Web Store\" height=\"40\"/>](https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=docs&utm_campaign=organic_growth&utm_content=es)\n\n::: warning ⚠️ Chrome Web Store temporalmente no disponible\nLa extensión ha sido oficialmente renombrada a **Voyager** por problemas de marcas. La actualización del nombre en Chrome Web Store está pendiente de revisión. Consulta [esta publicación](https://x.com/Nag1ovo/status/2031561180213313944) para más detalles. Usa **Edge / Firefox** o la **instalación manual** mientras tanto.\n:::\n\n**Microsoft Edge:**\n\n[<img src=\"https://img.shields.io/badge/Microsoft_Edge-Ir_a_descargar-0078D7?style=for-the-badge&logo=microsoft-edge&logoColor=white\" alt=\"Instalar desde Microsoft Edge Add-ons\" height=\"40\"/>](https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne)\n\n**Firefox:**\n\n[<img src=\"https://img.shields.io/badge/Firefox_Add--ons-Ir_a_descargar-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Instalar desde Firefox Add-ons\" height=\"40\"/>](https://addons.mozilla.org/firefox/addon/gemini-voyager/)\n\n## 2. Manual (Versión más reciente)\n\nLas revisiones de la tienda son lentas. Si quieres las últimas funciones, toma este camino.\n\n**Chrome / Edge / Brave / Opera:**\n\n1. Ve a [GitHub Releases](https://github.com/Nagi-ovo/gemini-voyager/releases) y descarga el último `gemini-voyager-chrome-vX.Y.Z.zip`.\n2. Descomprímelo.\n3. Abre la página de extensiones (`chrome://extensions`).\n4. Activa el **Modo de desarrollador** (arriba a la derecha).\n5. Haz clic en **Cargar descomprimida** y selecciona la carpeta que acabas de descomprimir.\n\n**Firefox:**\n\n1. Ve a [Releases](https://github.com/Nagi-ovo/gemini-voyager/releases) y descarga el último `gemini-voyager-firefox-vX.Y.Z.xpi`.\n2. Abre la gestión de complementos (`about:addons`).\n3. Arrastra el archivo `.xpi` descargado allí para instalarlo (o haz clic en el engranaje ⚙️ arriba a la derecha -> **Instalar complemento desde archivo**).\n\n> 💡 El archivo XPI está firmado oficialmente por Mozilla y se puede instalar permanentemente en todas las versiones de Firefox.\n\n## 3. Safari (macOS)\n\n¡Safari ahora soporta distribución directa! Descarga la aplicación pre-firmada:\n\n1. Descarga la <SafariDownloadLink>última versión de Safari (.dmg)</SafariDownloadLink>.\n2. Abre el archivo y sigue las instrucciones para instalarlo.\n3. Haz doble clic para iniciar la aplicación.\n4. Activa la extensión en **Preferencias de Safari > Extensiones**.\n\n> 💡 La versión de Safari ahora está firmada directamente para distribución — ¡no necesitas conversión con Xcode!\n>\n> ⚠️ **Limitaciones**: Debido a la naturaleza de Safari, (a) la eliminación de marcas de agua (b) la exportación de imágenes (se recomienda PDF) no son compatibles.\n\n---\n\n_¿Quieres contribuir con código? Desarrolladores, por favor consulten la [Guía de Contribución](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/CONTRIBUTING.md)._\n"
  },
  {
    "path": "docs/es/guide/markdown-fix.md",
    "content": "# Corrección de Renderizado Markdown\n\nLa interfaz web de Gemini™ a veces inserta elementos HTML (como fuentes de citas o marcadores de resaltado) dentro del texto, lo que puede romper la sintaxis de negrita de Markdown (`**texto**`), haciendo que el texto no se muestre correctamente en negrita.\n\nVoyager incluye una función de corrección automática que identifica y repara de forma inteligente estas etiquetas de negrita rotas, asegurando que sus documentos se rendericen de manera limpia y precisa.\n\n> [!INFO]\n> Esta función se activa automáticamente y no requiere configuración adicional.\n"
  },
  {
    "path": "docs/es/guide/mermaid.md",
    "content": "# Renderizado de Gráficos Mermaid\n\nRenderiza automáticamente código Mermaid en gráficos visuales.\n\n## Introducción\n\nCuando Gemini™ genera un bloque de código Mermaid (como diagramas de flujo, diagramas de secuencia, diagramas de Gantt, etc.), Voyager lo detectará y renderizará automáticamente como un gráfico interactivo.\n\n### Características Principales\n\n- **Detección Automática**: Soporta todos los tipos principales de gráficos Mermaid como `graph`, `flowchart`, `sequenceDiagram`, `gantt`, `pie`, `classDiagram`, etc.\n- **Cambio en un Clic**: Cambia libremente entre el gráfico renderizado y el código fuente con un botón.\n- **Vista en Pantalla Completa**: Haz clic en el gráfico para entrar en el modo de pantalla completa, con soporte para zoom con la rueda y arrastre para desplazar.\n- **Modo Oscuro**: Se adapta automáticamente al tema de la página.\n\n## Cómo Usar\n\n1. Pídele a Gemini que genere cualquier código de gráfico Mermaid.\n2. El bloque de código será reemplazado automáticamente por el gráfico renderizado.\n3. Haz clic en el botón **</> Code** para ver el código original.\n4. Haz clic en el botón **📊 Diagram** para volver a la vista de gráfico.\n5. Haz clic en el área del gráfico para ver en pantalla completa.\n\n## Controles en Modo Pantalla Completa\n\n- **Rueda**: Zoom en el gráfico.\n- **Arrastrar**: Mover la posición del gráfico.\n- **+/-**: Botones de zoom en la barra de herramientas.\n- **⊙**: Restablecer vista.\n- **✕ / ESC**: Cerrar pantalla completa.\n\n## Compatibilidad y Solución de Problemas\n\n::: warning Nota\n\n- **Limitación de Firefox**: Debido a restricciones del entorno, Firefox usa la versión 9.2.2 y no admite funciones nuevas como **Timeline** o **Sankey**.\n- **Errores de sintaxis**: Los fallos de renderizado suelen deberse a errores de sintaxis en la salida de Gemini. Estamos recopilando \"bad cases\" para implementar parches automáticos en futuras actualizaciones.\n  :::\n\n<div align=\"center\">\n  <img src=\"/assets/mermaid-preview.png\" alt=\"Renderizado de Gráficos Mermaid\" style=\"max-width: 100%; border-radius: 8px;\"/>\n</div>\n"
  },
  {
    "path": "docs/es/guide/nanobanana.md",
    "content": "# Opción NanoBanana\n\n::: warning Compatibilidad de navegadores\nActualmente, la función **NanoBanana** **no es compatible con Safari** debido a las limitaciones de la API del navegador. Recomendamos usar **Chrome** o **Firefox** si necesita usar esta función.\n\nLos usuarios de Safari pueden cargar manualmente sus imágenes descargadas en sitios de herramientas como [banana.ovo.re](https://banana.ovo.re/) para su procesamiento (aunque no se garantiza el éxito para todas las imágenes debido a las diferentes resoluciones).\n:::\n\n**Imágenes de IA, como deben ser: puras.**\n\nLas imágenes generadas por Gemini™ vienen con una marca de agua visible por defecto. Aunque esto es por razones de seguridad, en ciertos escenarios creativos, puedes necesitar un borrador completamente limpio.\n\n## Restauración Sin Pérdidas\n\nNanoBanana utiliza un **Algoritmo de Mezcla Alfa Inversa (Reverse Alpha Blending)**.\n\n- **Sin Repintado AI**: La eliminación de marcas de agua tradicional a menudo utiliza IA para difuminar, lo que puede destruir los detalles de la imagen.\n- **Precisión a Nivel de Píxel**: A través del cálculo matemático, eliminamos con precisión la capa transparente de la marca de agua superpuesta en los píxeles, restaurando el 100% de los puntos de píxel originales.\n- **Cero Pérdida de Calidad**: La imagen antes y después del procesamiento es completamente idéntica en las áreas sin marca de agua.\n\n## Cómo Usar\n\n1. **Habilitar Función**: Encuentra la \"Opción NanoBanana\" al final del panel de configuración de Voyager y activa \"Eliminar marca de agua NanoBanana\".\n2. **Disparo Automático**: A partir de entonces, para cada imagen que generes, completaremos automáticamente el procesamiento de eliminación de marca de agua en segundo plano.\n3. **Descarga Directa**:\n   - Pasa el ratón sobre la imagen procesada y verás un botón 🍌.\n   - **El botón 🍌 ha reemplazado completamente** al botón de descarga nativo, haz clic para descargar directamente la imagen 100% libre de marca de agua.\n\n<div style=\"text-align: center; margin-top: 30px;\">\n  <img src=\"/assets/nanobanana.png\" alt=\"Ejemplo NanoBanana\" style=\"border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); max-width: 100%;\"/>\n</div>\n\n## Agradecimientos Especiales\n\nEsta función se basa en el proyecto [gemini-watermark-remover](https://github.com/journey-ad/gemini-watermark-remover) desarrollado por [journey-ad (Jad)](https://github.com/journey-ad). Este proyecto es una adaptación en JavaScript de la [versión C++ de GeminiWatermarkTool](https://github.com/allenk/GeminiWatermarkTool) desarrollada por [allenk](https://github.com/allenk). Gracias a los autores originales por su contribución a la comunidad de código abierto. 🧡\n\n## Privacidad y Seguridad\n\nTodo el procesamiento de eliminación de marcas de agua se realiza **localmente en tu navegador**. Las imágenes no se suben a ningún servidor de terceros, protegiendo tu privacidad y seguridad creativa.\n"
  },
  {
    "path": "docs/es/guide/prevent-auto-scroll.md",
    "content": "# Evitar desplazamiento automático\n\nCuando estás leyendo conversaciones anteriores, si envías un nuevo mensaje, Gemini™ forzará el desplazamiento de la página hasta el fondo para mostrar la nueva respuesta. Esto puede interrumpir tu lectura.\n\nLa función **Evitar desplazamiento automático** intercepta este comportamiento de salto no deseado:\n\n- Cuando te has desplazado hacia arriba para leer el historial, el sistema bloquea el salto de la página hacia abajo.\n- Esta característica está **deshabilitada** por defecto. Puedes activarla manualmente en la ventana emergente de la extensión bajo la sección \"Timeline Options\".\n\n## Cómo habilitarlo\n\n1. Haz clic en el icono de la extensión Voyager en tu navegador para abrir las opciones.\n2. Ubica la sección \"Timeline Options\" (Opciones de la Línea de Tiempo).\n3. Activa el interruptor \"Prevent auto-scroll to bottom\" (Evitar desplazamiento automático).\n"
  },
  {
    "path": "docs/es/guide/prompts.md",
    "content": "# Tus Activos Digitales: Biblioteca de Prompts\n\nPasaste mucho tiempo escribiendo un Prompt de nivel divino, fue de gran ayuda.\n¿Usar y tirar?\nNo, guárdalo.\n\n## Bóveda de Inspiración\n\nEsta es tu bóveda.\n\n### 1. Capturar\n\n¿Escribiste algo bueno? Haz clic en el **icono flotante** junto al cuadro de entrada.\nGuárdalo en la bóveda, seguro en tu bolsillo.\n\n### 2. Clasificar\n\nEtiquétalo como `#código`, `#email`, `#académico`.\nLas herramientas deben ser prácticas y estar ordenadas.\n\n### 3. Desplegar\n\nLa próxima vez que lo necesites, no lo vuelvas a escribir.\nAbre la bóveda, busca la etiqueta, haz clic para insertar.\nLlamada con un clic, duplica la eficiencia.\n\n![Gestor de Prompts](/assets/gemini-prompt-manager.png)\n\n## Disponible en Cualquier Lugar\n\nEl Gestor de Prompts ahora se puede utilizar en cualquier sitio web que elijas, no solo en Gemini™ y AI Studio.\n\n### Cómo Habilitarlo\n\n1. Haz clic en el icono de Voyager en la barra de herramientas de tu navegador.\n2. Desplázate hasta la sección **Gestor de Prompts**.\n3. Ingresa la URL del sitio web (por ejemplo: `chatgpt.com` o `claude.ai`).\n4. Haz clic en **Añadir sitio web** y concede el permiso.\n5. **Recarga la página de destino** para ver el botón flotante.\n\n### Sitios Web de IA Populares\n\n- `chatgpt.com` - ChatGPT\n- `claude.ai` - Claude\n- `copilot.microsoft.com` - Microsoft Copilot\n- `poe.com` - Poe\n\n::: tip\nEn sitios web personalizados, **solo** se activa la función del Gestor de Prompts. Otras funciones como la Línea de tiempo y las Carpetas están diseñadas específicamente para Gemini y no se cargarán.\n:::\n"
  },
  {
    "path": "docs/es/guide/quote-reply.md",
    "content": "# Respuesta con Cita\n\nVoyager ofrece una conveniente función de \"Respuesta con Cita\", haciendo que responder a contenido específico sea más preciso y eficiente.\n\n## Introducción\n\nEn las conversaciones diarias, a menudo necesitamos hacer preguntas de seguimiento o refutar una parte específica de la salida de la IA. El método tradicional es copiar ese texto y luego escribir manualmente el símbolo `> ` en el cuadro de entrada, lo cual es muy tedioso.\n\nVoyager simplifica este proceso:\n\n1. **Selecciona y Cita**: En la página de conversación (ya sea tu pregunta o la respuesta de Gemini), selecciona cualquier texto con el ratón.\n2. **Botón Flotante**: Aparecerá automáticamente un botón de \"Respuesta con Cita\" cerca del texto seleccionado.\n3. **Inserción en un Clic**: Haz clic en el botón, y el texto seleccionado se insertará automáticamente en tu cuadro de entrada con el formato de cita estándar de Markdown (`> contenido`).\n\n![Respuesta con Cita](/assets/quote-reply.png)\n\n## Características\n\n- **Conciencia de Contexto**: Identifica inteligentemente el contenido de la conversación, evitando disparadores falsos en áreas irrelevantes (como el propio cuadro de entrada).\n- **Formato Estándar**: Utiliza la sintaxis universal de Markdown, que Gemini entiende perfectamente, permitiendo respuestas más precisas.\n- **Soporte Multilínea**: Si seleccionas varias líneas de texto, Voyager agregará automáticamente símbolos de cita a cada línea, manteniendo el formato limpio.\n\n## Consejos de Uso\n\n- **Preguntar Detalles**: Selecciona un concepto poco claro en la respuesta de Gemini, haz clic en citar y luego escribe \"Por favor, explica este concepto en detalle\".\n- **Corregir Errores**: Selecciona código o hechos incorrectos en la respuesta, cita y señala \"Esto es incorrecto, debería ser...\".\n"
  },
  {
    "path": "docs/es/guide/recents-hider.md",
    "content": "# Ocultar elementos recientes y Gems\n\n::: info\n**Nota**: Esta función es compatible con la versión 1.1.9 y posteriores.\n:::\n\nAgrega un elegante interruptor para ocultar la sección \"Guardado recientemente\" en la página de inicio de Gemini™ para una interfaz más limpia. ¡Ahora también permite ocultar la lista de **Gems** en la barra lateral!\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Ocultar guardados recientemente</b></p>\n    <video src=\"/assets/hide-my-stuff.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Ocultar lista de Gems</b></p>\n    <video src=\"/assets/hide-gem-list.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n</div>\n\n## Características\n\n- **Interruptor Contextual**: Un discreto botón de ocultar aparece solo cuando pasas el ratón sobre la sección de elementos recientes.\n- **Estado Minimalista**: Cuando está oculto, es reemplazado por una discreta \"barra de vistazo\" en la parte inferior.\n- **Restauración con un clic**: Simplemente pasa el ratón sobre la barra de vistazo y haz clic para recuperar tus elementos recientes al instante.\n- **Protección de Privacidad**: Evita que otras personas cercanas vean tus actividades recientes, ideal para espacios públicos.\n- **Persistencia**: Tu preferencia se guarda y se aplica automáticamente en tu próxima visita.\n\n## Cómo usarlo\n\n1. Pasa el ratón sobre la sección \"Recientes\" en la página de inicio de Gemini.\n2. Haz clic en el icono de ojo tachado que aparece en la esquina superior derecha para ocultar la sección.\n3. Para restaurar, pasa el ratón sobre la delgada línea que permanece en la parte inferior del área y haz clic.\n"
  },
  {
    "path": "docs/es/guide/settings.md",
    "content": "# A Tu Gusto\n\nLo predeterminado ya es bueno. Pero queremos la perfección.\nTu territorio, tus reglas.\n\n## Modo Cine\n\n¿Por qué mirar el mundo a través de una rendija?\nHaz el cuadro de chat más grande.\n\n- **Pantalla Ancha**: 1400px. Escribir código, ver tablas grandes, visión completa.\n- **Enfoque**: 800px. Lectura inmersiva, sin distracciones.\n- **Libre**: Arrastra el control deslizante, tan ancho como te sientas cómodo.\n\n## Control\n\nHaz clic en el icono de la extensión, entra en la consola.\n\n- **Desplazamiento**: ¿Natural y suave, o sensación clásica?\n- **Posición**: Coloca la línea de tiempo donde te resulte más cómodo.\n- **Efectos Visuales**: Elige `Nieve`, `Sakura` o `Lluvia` para una atmósfera estacional.\n\n## Orden personalizado\n\n¿Demasiadas secciones en el popup y las que más usas están enterradas al fondo?\n\nPasa el ratón sobre cualquier tarjeta de configuración y aparecerán los botones ▲/▼ en la esquina superior derecha. Haz clic para mover una tarjeta arriba o abajo. Tu disposición se guarda automáticamente.\n\n## Atmósfera\n\nVoyager no se limita a mejoras de utilidad. También puedes cambiar el ambiente de la página.\n\n- **Nieve**: Copos suaves a la deriva para una sensación invernal tranquila.\n- **Sakura**: Pétalos de flor de cerezo flotantes para un toque primaveral más ligero.\n- **Lluvia**: Una capa de lluvia cinematográfica con líneas inclinadas y sutiles salpicaduras.\n- **Cambio suave**: Al desactivar un efecto o cambiar a otro, las partículas se desvanecen naturalmente.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Abrir Configuración</b></p>\n    <img src=\"/assets/gemini-open-settings-guide.png\" alt=\"Guía para abrir configuración\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Ajustar Vista</b></p>\n    <img src=\"/assets/gemini-chatwidth.png\" alt=\"Ajuste de ancho de chat\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n"
  },
  {
    "path": "docs/es/guide/sidebar-auto-hide.md",
    "content": "# Ocultar barra lateral automáticamente\n\n¿Quieres una experiencia de chat más inmersiva?\n\nOfrecemos la función de **Ocultar barra lateral automáticamente**. Cuando está activada, la barra lateral se contrae automáticamente cuando el ratón sale del área y se expande automáticamente cuando vuelves a mover el ratón hacia ella.\n\n### Demostración\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/sidebar-auto-hide.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n### Cómo activar\n\n1. Abre el panel de configuración de Voyager.\n2. Busca la opción **Ocultar barra lateral auto** en **Configuración general**.\n3. Activa el interruptor.\n\n_Nota: Esta función actualmente solo es compatible con Google Gemini._\n"
  },
  {
    "path": "docs/es/guide/sidebar.md",
    "content": "# Ancho de la barra lateral\n\n¿Los nombres de las carpetas son demasiado largos?\n¿O la barra lateral ocupa demasiado espacio?\n\nAhora puedes ajustar libremente el ancho de la barra lateral.\n\n## Cómo ajustar\n\n1. Abre el panel de configuración de Voyager (haz clic en el icono de la extensión en la parte superior derecha).\n   <img src=\"/assets/extension-instruction.png\" alt=\"Cómo abrir el panel de configuración\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. Busca la opción **Ancho de la barra lateral**.\n3. Arrastra el control deslizante para elegir el ancho que prefieras.\n\n- **Estrecho**: Ahorra espacio y concéntrate en la conversación.\n- **Ancho**: Ve los nombres completos de las carpetas de un vistazo.\n\n## Plataformas compatibles\n\nEsta función es compatible con:\n\n- **Google Gemini**\n- **Google AI Studio**\n\nTus ajustes se guardan automáticamente.\n"
  },
  {
    "path": "docs/es/guide/sponsor.md",
    "content": "# Patrocinar\n\n> [!NOTE]\n> Si Voyager te resulta útil, compártelo en X, Reddit, YouTube, etc. Cada difusión ayuda a que más personas descubran el proyecto y mejoren la experiencia con Gemini. Gracias.\n\nEl mantenimiento de proyectos de código abierto se alimenta principalmente de pasión (y café) ☕\n\n**[Voyager](https://github.com/Nagi-ovo/gemini-voyager)** es una extensión de navegador completamente gratuita y de código abierto diseñada para mejorar tu experiencia con Gemini. Si esta extensión te ayuda a usar Gemini de manera más eficiente, te agradezco tu apoyo para seguir desarrollando y manteniendo este proyecto a través de los siguientes métodos.\n\n---\n\n## Plataformas en Línea\n\n<div class=\"sponsor-badges\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Invítame un café\" style=\"height: 40px;\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Patrocinar en GitHub\" style=\"height: 40px;\">\n  </a>\n</div>\n\n<a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n    <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n    <img alt=\"Afdian\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n  </picture>\n</a>\n\n### 🎙️ Herramienta Recomendada: Typeless\n\nRecomiendo encarecidamente **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)**, una herramienta de voz a texto con IA. La integré en mi flujo de trabajo diario durante el desarrollo de Voyager, lo que me ahorró una enorme cantidad de tiempo y aumentó significativamente mi productividad.\n\n> 🎁 **[Únete a través de mi enlace de referencia](https://www.typeless.com/?via=gemini-voyager)** (Código: _`gemini-voyager`_) para obtener **$5 en créditos gratis**. ¡Esto también me da créditos para seguir manteniendo este proyecto, una forma gratuita de apoyar mi trabajo! ❤️\n\n---\n\n## Donar via QR 🍵\n\n<div class=\"qr-container\">\n  <div class=\"qr-item\">\n    <img src=\"/assets/wechat-sponsor.png\" alt=\"WeChat Pay\" />\n    <span>WeChat Pay</span>\n  </div>\n  <div class=\"qr-item\">\n    <img src=\"/assets/alipay-sponsor.jpg\" alt=\"Alipay\" />\n    <span>Alipay</span>\n  </div>\n</div>\n\n<style>\n.sponsor-badges {\n  display: flex;\n  gap: 16px;\n  flex-wrap: wrap;\n  align-items: center;\n  margin: 16px 0;\n}\n\n.sponsor-badges a img {\n  transition: transform 0.2s ease;\n}\n\n.sponsor-badges a:hover img {\n  transform: scale(1.05);\n}\n\n.qr-container {\n  display: flex;\n  gap: 32px;\n  flex-wrap: wrap;\n  margin: 16px 0;\n}\n\n.qr-item {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 8px;\n  padding: 16px;\n  border-radius: 8px;\n  border: 1px solid var(--vp-c-divider);\n  background: var(--vp-c-bg-soft);\n}\n\n.qr-item img {\n  width: 200px;\n  height: 200px;\n  object-fit: contain;\n  border-radius: 4px;\n}\n\n.qr-item span {\n  font-weight: 600;\n  color: var(--vp-c-text-1);\n}\n\n@media (max-width: 640px) {\n  .qr-container {\n    justify-content: center;\n  }\n  \n  .qr-item img {\n    width: 150px;\n    height: 150px;\n  }\n}\n</style>\n\n---\n\n¡Gracias por tu apoyo! Cada contribución es el mayor estímulo para mí ❤️\n"
  },
  {
    "path": "docs/es/guide/tab-title.md",
    "content": "# Sincronización de Título de Pestaña\n\nSincroniza automáticamente el título de la pestaña del navegador con el título de la conversación actual de Gemini™.\n\n## Introducción\n\n- **Sincronización en Tiempo Real**: Cuando el título de la conversación cambia (por ejemplo, la IA genera un nuevo título o lo renombras manualmente), el título de la pestaña del navegador no será solo \"Gemini\", sino que se actualizará inmediatamente al contenido específico de la conversación.\n- **Soporte Multipágina**: Soporta perfectamente páginas de conversación ordinarias, conversaciones de Gem y entornos de múltiples cuentas.\n- **Control de Interruptor**: Si no te gusta esta función, puedes desactivarla en cualquier momento en la sección \"Opciones Generales\" del panel de configuración.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/tab-title.png\" alt=\"Sincronización de Título de Pestaña\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## Cómo Usar\n\n1. Después de instalar la extensión, esta función está activada por defecto.\n2. Abre cualquier conversación de Gemini, observa el título de la pestaña del navegador, cambiará automáticamente al título de la conversación actual.\n3. Para desactivar:\n   - Haz clic en el icono de la extensión para abrir el panel de configuración.\n   - Busca \"Opciones Generales\" (General Options).\n   - Desactiva el interruptor \"Sincronizar título de pestaña con conversación\" (Update Tab Title).\n"
  },
  {
    "path": "docs/es/guide/timeline.md",
    "content": "# Viaje en el Tiempo\n\nLas conversaciones largas son un desastre. Arriba y abajo, perdido.\nVoyager convierte la conversación en una línea.\n\n## Ver el Ritmo\n\nMira el lado derecho de la pantalla.\nCada punto es una frase. Ese es el pulso de tu conversación.\n\n## Navegación, Paso a Paso\n\n- **Teletransporte**: Haz clic y ve, sin demoras.\n- **Vistazo**: Pasa el ratón por encima, ve el contenido sin saltar.\n- **Marcador**: Mantén presionado un nodo para **destacar**. Pon un marcador para tu cerebro.\n- **Niveles (Experimental)**: Clic derecho en un nodo para establecer niveles (1-3) o contraer hijos. Haz que las conversaciones ramificadas sean claras.\n- **Atajos**: Vuela con el teclado. Por defecto `j`/`k` para saltar arriba/abajo, cámbialo si quieres.\n\n![Navegación de Línea de Tiempo](/assets/teaser.png)\n\n## Teclado, Más Rápido\n\n¿No quieres usar el ratón? Usa el teclado.\n\n**Es como activar el Modo Vim en Gemini.**\n\n### Atajos Predeterminados\n\n- `k` - Saltar al nodo anterior\n- `j` - Saltar al nodo siguiente\n\n### Personalizar\n\nAbre la configuración de la extensión, haz clic en el cuadro de atajos y presiona la tecla que quieras usar.\nCualquier tecla, cualquier combinación. ¿`n`/`p`? ¿`,`/`.`? Tú decides.\n\nEn **Modo Flujo**, presionar repetidamente pondrá en cola la animación.\nEn **Modo Salto**, respuesta inmediata, velocidad máxima.\n"
  },
  {
    "path": "docs/es/index.md",
    "content": "---\nlayout: home\n\nhero:\n  name: 'Voyager'\n  text: 'Finalmente, está completo.'\n  tagline: 'Pensamiento tangible, todo en su lugar.'\n  image:\n    src: /logo.png\n    alt: Logo de Voyager\n  actions:\n    - theme: brand\n      text: Descargar e Instalar\n      link: ./guide/installation\n    - theme: alt\n      text: Comenzar el viaje\n      link: ./guide/getting-started\n\nteaser:\n  title: 'Redefiniendo la interacción.'\n  description: 'No construimos extensiones, remodelamos el pensamiento.<br>Con Voyager, no es el humano adaptándose a la interfaz, sino la interfaz fluyendo con tu mente.'\n  image: '/assets/teaser.png'\n  features:\n    - title: 'Línea de Tiempo'\n      details: 'Ve el pulso de la conversación.<br>Haz que el tiempo lineal se convierta en espacio tangible.'\n    - title: 'Carpetas'\n      details: 'Dale un hogar a tus ideas.<br>Incluso un pensamiento fugaz merece ser tratado con seriedad.'\n    - title: 'Control'\n      details: 'Tus datos son yours.<br>Rompe los muros de la nube, haz que el conocimiento te pertenezca.'\n\nfeatures:\n  - icon: 🧭\n    title: Línea de Tiempo\n    details: No hagas scroll, vuela. Llega instantáneamente a cualquier punto de tu pensamiento.\n  - icon: 🗂️\n    title: Carpetas\n    details: Adiós al caos. Sensación nativa, operación intuitiva, todo organizado.\n  - icon: ✨\n    title: Bóveda de Prompts\n    details: Captura la inspiración. Atesora cada uno de tus momentos brillantes.\n  - icon: 💬\n    title: Respuesta con Cita\n    details: Selecciona para citar. Respuestas contextualizadas para una comunicación eficiente.\n  - icon: ↔️\n    title: Ancho del chat\n    details: Amplía tu visión. Ajusta libremente el ancho del chat para una mejor experiencia de visualización.\n  - icon: 💾\n    title: Exportación de Chat\n    details: Tus datos son tuyos. Múltiples formatos para archivar en un clic, el conocimiento no se pierde.\n  - icon: 🌦️\n    title: Efectos Visuales\n    details: Crea el ambiente. Cambia entre nieve, lluvia y pétalos de sakura desde la ventana emergente.\n  - icon: 🍌\n    title: Eliminación de Marca de Agua NanoBanana\n    details: Eliminación sin pérdidas. Deja que los momentos generados por IA vuelvan a ser puros.\n  - icon: 📐\n    title: Copie de Fórmulas\n    details: Copia en un clic los códigos fuente LaTeX y MathML (Word).\n  - icon: 🧜‍♀️\n    title: Gráficos Mermaid\n    details: De código a visuales. Diagramas de flujo, de secuencia y de Gantt renderizados al instante.\n  - icon: 🏷️\n    title: Sincronización de Título de Pestaña\n    details: De un vistazo. Sincroniza automáticamente el título de la pestaña con el título de la conversación.\n  - icon: 🔀\n    title: Bifurcación de Conversación (Experimental)\n    details: Pensamiento divergente. Bifurca la conversación en cualquier nodo para explorar diferentes posibilidades.\n  - icon: 🗑️\n    title: Eliminación por Lote\n    details: Limpieza en un clic. Selecciona múltiples conversaciones, elimina por lote, adiós a lo tedioso.\n  - icon: ☁️\n    title: Sincronización en la Nube\n    details: Siempre sincronizado. Respalda carpetas y prompts en Google Drive entre dispositivos.\n  - icon: ⚡️\n    title: Modelo predeterminado\n    details: Deja de repetirte. Cambia automáticamente a tu modelo preferido en nuevos chats.\n  - icon: 🔬\n    title: Deep Research\n    details: Abre la caja negra. Extrae procesos de pensamiento y enlaces de las sesiones de Deep Research.\n---\n\n<div class=\"vp-doc\" style=\"margin: 2rem auto 0; max-width: 780px; padding: 0 16px;\">\n  <div style=\"background: rgba(234, 179, 8, 0.12); border: 1px solid rgba(234, 179, 8, 0.6); border-radius: 8px; padding: 12px 16px;\">\n    <strong>⚠️ Aviso de cambio de nombre</strong>: Debido a problemas de marcas y derechos de autor, esta extensión ha sido renombrada oficialmente a <strong>Voyager</strong>. Sin embargo, debido a la lentitud del proceso de revisión de Chrome Web Store, el cambio de nombre no fue aprobado en 7 días — está temporalmente no disponible en Chrome Web Store.\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 780px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 8px; font-weight: 600; font-size: 1.05em;\">Cada descarga es una medida de confianza</h3>\n  <p style=\"margin: 0 0 16px; opacity: 0.78; font-size: 0.95em;\">Datos en tiempo real de Chrome Web Store y GitHub. Un tributo a cada Voyager que viaja con nosotros.</p>\n  <div style=\"display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;\">\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Estrellas en GitHub\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Forks en GitHub\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Última versión\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"Descargas en GitHub\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Usuarios Chrome Web Store\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Calificación Chrome Web Store\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoft-edge\" alt=\"Edge Add-ons\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Usuarios Firefox Add-ons\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Calificación Firefox Add-ons\">\n  </div>\n  <div style=\"margin-top: 16px; display: flex; justify-content: center; flex-wrap: wrap; gap: 12px;\">\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <!-- <a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a> -->\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 1000px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 24px; font-weight: 600; font-size: 1.2em;\">Agradecimientos Especiales</h3>\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" style=\"margin: 0 auto;\" />\n  </a>\n  <p style=\"margin-top: 24px; font-size: 1.05em; opacity: 0.86;\">✨ ¡Estamos en vivo en Product Hunt! Bienvenidos a compartir sus ideas y comentarios. ❤️</p>\n  <div style=\"margin-top: 12px; display: flex; justify-content: center;\">\n    <a href=\"https://www.producthunt.com/posts/gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager en Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 3.5rem auto 2rem; max-width: 720px; padding: 0 16px;\">\n  <p style=\"font-size: 1.05em; font-weight: 600; opacity: 0.86; margin: 0 0 12px;\">“No es solo una herramienta, es un compañero para el viaje de tu mente.”</p>\n  <a href=\"./guide/getting-started\" style=\"font-weight: 600; text-decoration: none;\">Explora más →</a>\n</div>\n\n<p align=\"center\">\n  <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n</p>\n"
  },
  {
    "path": "docs/es/privacy.md",
    "content": "# Política de Privacidad\n\nÚltima actualización: 16 de marzo de 2026\n\n## Introducción\n\nVoyager (en adelante \"nosotros\") se compromete a proteger su privacidad. Esta política de privacidad explica cómo nuestra extensión de navegador recopila, utiliza y protege su información.\n\n## Recopilación y Uso de Datos\n\n**No recopilamos ninguna información personal.**\n\nVoyager se ejecuta completamente en local en su navegador. Todos los datos generados o gestionados por la extensión (como carpetas, plantillas de prompts, mensajes favoritos y configuraciones) se almacenan en:\n\n1. Su dispositivo local (`chrome.storage.local`)\n2. El almacenamiento sincronizado de su navegador (`chrome.storage.sync`, si está disponible), para sincronizar configuraciones entre sus dispositivos.\n\nNo tenemos acceso a sus datos personales, historial de chat ni ninguna otra información privada. Tampoco rastreamos su historial de navegación.\n\n## Sincronización con Google Drive (Opcional)\n\nSi activa explícitamente la función de sincronización con Google Drive, la extensión utiliza la API Chrome Identity para obtener un token OAuth2 (solo con el scope `drive.file`) para realizar copias de seguridad de sus carpetas y prompts en **su propio Google Drive**. Esta transferencia ocurre directamente entre su navegador y los servidores de Google. No tenemos acceso a estos datos y nunca se envían a ningún servidor que operemos.\n\n## Permisos\n\nEsta extensión solo solicita los permisos mínimos necesarios para funcionar:\n\n- **Storage (Almacenamiento)**: Para guardar sus preferencias, carpetas, prompts, mensajes favoritos y opciones de personalización de la interfaz localmente y entre dispositivos.\n- **Identity (Identidad)**: Para la autenticación de Google de la función opcional de sincronización con Google Drive. Solo se usa cuando activa explícitamente la sincronización en la nube.\n- **Scripting (Scripts)**: Para inyectar dinámicamente scripts de contenido en las páginas de Gemini y en sitios web personalizados especificados por el usuario para la función Gestor de Prompts. Solo se inyectan scripts incluidos en la propia extensión — no se descarga ni ejecuta código remoto.\n- **Host Permissions (Permisos de host)** (gemini.google.com, aistudio.google.com, etc.): Para inyectar scripts de contenido que mejoran la interfaz de Gemini con funciones como carpetas, exportación, línea de tiempo y cita de respuesta. Los dominios adicionales de Google (googleapis.com, accounts.google.com) son necesarios para la autenticación de la sincronización con Google Drive.\n- **Optional Host Permissions (Permisos de host opcionales)** (todas las URL): Solo se solicitan en tiempo de ejecución cuando usted añade explícitamente sitios web personalizados para el Gestor de Prompts. Nunca se activan sin su acción.\n\n## Servicios de Terceros\n\nVoyager no comparte datos con ningún servicio de terceros, anunciantes o proveedores de análisis.\n\n## Cambios en la Política\n\nPodemos actualizar nuestra política de privacidad de vez en cuando. Le notificaremos cualquier cambio publicando la nueva política de privacidad en esta página.\n\n## Contáctenos\n\nSi tiene alguna pregunta sobre esta política de privacidad, contáctenos a través de nuestro [repositorio de GitHub](https://github.com/Nagi-ovo/gemini-voyager).\n"
  },
  {
    "path": "docs/fr/guide/batch-delete.md",
    "content": "# Suppression par Lot\n\nSupprimez plusieurs conversations à la fois, fini la suppression une par une.\n\n## Fonctionnalités\n\n- **Mode Multi-sélection** : Appui long sur n'importe quelle conversation pour entrer en mode multi-sélection et cocher plusieurs conversations à supprimer.\n- **Nettoyage en un clic** : Une fois sélectionnées, cliquez sur le bouton supprimer pour retirer toutes les conversations choisies en une seule fois.\n- **Retour sur la progression** : La progression en temps réel est affichée pendant la suppression pour que vous connaissiez le statut actuel.\n- **Confirmation Sécurisée** : Une boîte de dialogue de confirmation apparaît avant la suppression pour éviter les opérations accidentelles.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/batch-delete.png\" alt=\"Suppression par Lot\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## Comment Utiliser\n\n1. Dans la liste des conversations de la barre latérale, faites un **appui long** sur n'importe quel élément de conversation.\n2. Après être entré en mode multi-sélection, des cases à cocher apparaîtront sur le côté gauche de chaque conversation.\n3. Cochez les conversations que vous souhaitez supprimer (jusqu'à 50 à la fois).\n4. Cliquez sur le **Bouton Supprimer** qui apparaît.\n5. Cliquez sur \"Confirmer\" dans la zone de confirmation rouge qui apparaît **au-dessus de la liste des dossiers** pour lancer la suppression.\n\n::: tip Note\nLe panneau de confirmation se superpose à la zone des dossiers pour éviter de bloquer la liste des conversations. Les opérations de suppression par lot ne peuvent pas être annulées, veuillez donc procéder avec prudence.\n:::\n"
  },
  {
    "path": "docs/fr/guide/cloud-sync.md",
    "content": "# Synchronisation Cloud\n\nSynchronisez vos dossiers, votre bibliothèque de prompts et d'autres données sur Google Drive pour garder votre expérience cohérente sur tous vos appareils.\n\n## Fonctionnalités\n\n- **Synchronisation multi-appareils** : Gardez vos configurations synchronisées sur plusieurs ordinateurs grâce à Google Drive.\n- **Confidentialité des données** : Les données sont stockées directement dans votre propre espace Google Drive, garantissant la confidentialité sans serveurs tiers.\n- **Synchronisation flexible** : Prise en charge du téléchargement manuel et de la fusion des données.\n\n::: info\n**Bientôt disponible** : La prochaine version prendra en charge la synchronisation des conversations favorites.\n:::\n\n## Comment utiliser\n\n1. Cliquez sur l'icône de l'extension dans le coin inférieur droit de la page Gemini™ pour ouvrir le panneau des paramètres.\n2. Localisez la section **Synchronisation Cloud**.\n3. Cliquez sur **Se connecter avec Google** et complétez l'autorisation.\n4. Une fois autorisé, cliquez sur **Télécharger vers le Cloud** pour synchroniser vos données locales vers le cloud, ou sur **Télécharger et fusionner** pour ramener les données du cloud vers votre machine locale.\n\n### 💡 Synchronisation rapide\n\nLa façon la plus simple est de cliquer sur les boutons **\"Télécharger vers le Cloud\"** ou **\"Télécharger et fusionner\"** en haut de la zone des dossiers dans la barre latérale gauche.\n\n<img src=\"/assets/cloud-sync.png\" alt=\"Boutons de synchronisation rapide Cloud\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n::: warning\n**Recommandation de sécurité : Double protection**  \nBien que la synchronisation Cloud offre une grande commodité, nous vous recommandons vivement de sauvegarder également périodiquement vos données de base à l'aide de **fichiers locaux**.\n\n1. **Exportation complète** : Exportez un package complet contenant tous les paramètres, dossiers et prompts depuis « Sauvegarde et restauration » en bas du panneau de configuration.\n   <img src=\"/assets/manual-export-all.png\" alt=\"Exportation complète\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. **Exporter tous les dossiers** : Cliquez sur « Exporter » dans la section « Dossiers » du panneau de configuration pour sauvegarder tous vos dossiers et conversations, sans inclure les prompts.\n   <img src=\"/assets/manual-folder-export.png\" alt=\"Exporter tous les dossiers\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n   :::\n"
  },
  {
    "path": "docs/fr/guide/community.md",
    "content": "# Communauté & Feedback\n\nNous apprécions la voix de chaque utilisateur. Que vous ayez trouvé un bug, une suggestion de fonctionnalité, ou que vous souhaitiez partager votre coffre-fort de prompts, il existe plusieurs façons de nous contacter.\n\n## 📢 Suivre les Mises à Jour\n\nSuivez-nous sur X (Twitter) pour obtenir les dernières mises à jour de développement.\n\n- **Nouvelles Versions** : Soyez le premier informé des mises à jour.\n- **Aperçus de Fonctionnalités** : Obtenez un avant-goût des fonctionnalités à venir.\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://x.com/Nag1ovo/status/2012610584722731188\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/badge/Suivre%20sur-𝕏-000000?style=for-the-badge&logo=x&logoColor=white\" alt=\"Suivre sur X\">\n  </a>\n</div>\n\n## 💬 Communauté Discord\n\nRejoignez notre serveur Discord pour discuter avec d'autres Voyageurs !\n\n- **Chat en Temps Réel** : Discutez directement avec d'autres utilisateurs et les développeurs.\n- **Partage de Prompts** : Voyez comment les autres utilisent Gemini™ et partagez vos meilleurs prompts.\n- **Mises à Jour de Développement** : Recevez les dernières nouvelles sur les fonctionnalités et versions à venir.\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://discord.gg/TEUFxdMbGb\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/discord/1463273957120675973?style=for-the-badge&logo=discord&logoColor=white&label=Rejoindre%20notre%20Discord\" alt=\"Discord\">\n  </a>\n</div>\n\n## 🐙 GitHub Issues\n\nSi vous avez trouvé un bug ou avez une demande de fonctionnalité spécifique, veuillez ouvrir une issue sur GitHub :\n\n- [Signaler un Bug](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=bug_report.yml)\n- [Suggérer une Fonctionnalité](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=feature_request.yml)\n\nMerci de soutenir Voyager ! ❤️\n"
  },
  {
    "path": "docs/fr/guide/context-sync.md",
    "content": "# Transport de mémoire : Synchronisation du contexte (Expérimental)\n\n**Différentes dimensions, partage fluide**\n\nÉlaborez la logique sur le web et implémentez le code dans l'IDE. Voyager brise les barrières dimensionnelles, dotant instantanément votre IDE du « processus de réflexion » du web.\n\n## Fini les sauts d'onglets incessants\n\nLe plus grand calvaire des développeurs : après avoir discuté longuement d'une solution sur le web, vous retournez sur VS Code/Trae/Cursor et devez réexpliquer les besoins comme à un étranger. En raison des quotas et de la vitesse de réponse, le web est le « cerveau » et l'IDE les « mains ». Voyager leur permet de partager une même âme.\n\n## Trois étapes simples pour synchroniser\n\n1. **Installer et activer CoBridge** :\n   Installez l'extension **CoBridge** dans VS Code. C'est le pont central qui relie l'interface web à votre IDE local.\n   - **[Installer via le VS Code Marketplace](https://open-vsx.org/extension/windfall/co-bridge)**\n\n   ![Extension CoBridge](/assets/CoBridge-extension.png)\n\n   Après l'installation, **ouvrez n'importe quel répertoire de travail**, cliquez sur l'icône à droite et lancez le serveur.\n   ![Serveur CoBridge activé](/assets/CoBridge-on.png)\n\n2. **Connexion et poignée de main** :\n   - Activez la « Synchronisation du contexte » dans les paramètres de Voyager.\n   - Alignez les numéros de port. Lorsque vous voyez « IDE en ligne », ils sont connectés.\n\n   ![Console de synchronisation du contexte](/assets/context-sync-console.png)\n\n3. **Synchronisation en un clic** : Cliquez sur **« Synchroniser vers l'IDE »**. Qu'il s'agisse de **tableaux de données** complexes ou d'**images de référence** intuitives, tout peut être synchronisé instantanément avec votre IDE.\n\n   ![Synchronisation terminée](/assets/sync-done.png)\n\n## Enracinement dans l'IDE\n\nUne fois la synchronisation terminée, un fichier `.cobridge/AI_CONTEXT.md` apparaîtra dans le répertoire de travail de votre IDE. Que ce soit Trae, Cursor ou Copilot, ils liront automatiquement cette « mémoire » via leurs fichiers Rule respectifs.\n\n```\nyour-project/\n├── .cobridge/\n│   ├── images/\n│   │   ├── context_img_1_1.png\n│   │   └── context_img_1_2.png\n│   └── AI_CONTEXT.md\n├── .github/\n│   └── copilot-instructions.md\n├── .gitignore\n├── .traerules\n└── .cursorrules\n```\n\n## Principes\n\n- **Zéro pollution** : CoBridge gère automatiquement le fichier `.gitignore`, garantissant que vos conversations privées ne sont jamais poussées vers les dépôts Git.\n- **Adapté à l'IA** : Format Markdown complet, rendant la lecture par l'IA de votre IDE aussi fluide que celle d'un manuel d'instructions.\n- **Conseil** : Si la conversation date d'un certain temps, remontez d'abord avec la [Timeline] pour permettre au web de se « remémorer » le contexte afin d'obtenir de meilleurs résultats de synchronisation.\n\n---\n\n## Prêt pour le Décollage\n\n**La réflexion est prête dans le cloud, laissez-la maintenant s'enraciner localement.**\n\n- **[Installer l'extension CoBridge](https://open-vsx.org/extension/windfall/co-bridge)** : Trouvez votre portail dimensionnel et activez la « respiration synchronisée » en un clic.\n- **[Visiter le dépôt GitHub](https://github.com/Winddfall/CoBridge)** : Plongez dans la logique profonde de CoBridge ou donnez une Star à ce projet de « synchronisation d'âme ».\n\n> **Les grands modèles ne perdent plus la mémoire ; prêts pour l'action immédiate.**\n"
  },
  {
    "path": "docs/fr/guide/deep-research.md",
    "content": "# Export Deep Research\n\nExportez le rapport final généré par Deep Research ou enregistrez son processus de \"réflexion\" complet sous forme de fichier Markdown.\n\n## 1. Exportation de rapport (PDF / Image)\n\nLes rapports générés par Deep Research peuvent être exportés sous forme de fichiers PDF magnifiquement mis en forme ou d'images uniques pour un partage facile (les formats Markdown et JSON sont également pris en charge).\n\n![Exportation de rapport](/assets/deep-research-report-export.png)\n\n## 2. Exportation du processus de réflexion (Markdown)\n\nEn plus du rapport final, vous pouvez également exporter le contenu complet de \"réflexion\" des conversations Deep Research.\n\n### Fonctionnalités\n\n- **Export en un clic** : Le bouton de téléchargement apparaît dans le menu de conversation (⋮).\n- **Format structuré** : Préserve les phases de réflexion, les éléments de pensée et les sites web recherchés dans leur ordre original.\n- **En-têtes bilingues** : Les fichiers Markdown incluent des en-têtes de section en anglais et dans votre langue actuelle.\n- **Nommage automatique** : Les fichiers sont horodatés pour une organisation facile (ex : `deep-research-thinking-20240128-153045.md`).\n\n### Comment Utiliser\n\n1. Ouvrez une conversation Deep Research sur Gemini™.\n2. Cliquez sur le bouton **Partager et exporter** dans la conversation.\n3. Sélectionnez \"Télécharger le contenu de réflexion\" (Download thinking content).\n4. Le fichier Markdown sera automatiquement téléchargé.\n\n![Exportation de réflexion Deep Research](/assets/deepresearch_download_thinking.png)\n\n### Format du Fichier Exporté\n\nLe fichier Markdown exporté inclut :\n\n- **Titre** : Le titre de la conversation.\n- **Métadonnées** : Horodatage de l'export et nombre total de phases de réflexion.\n- **Phases de Réflexion** : Chaque phase contient :\n  - Éléments de pensée (avec en-têtes et contenu).\n  - Sites web recherchés (avec liens et titres).\n\n#### Exemple de Structure\n\n```markdown\n# Titre de la Conversation Deep Research\n\n**导出时间 / Exported At:** 2025-12-28 17:25:35\n**总思考阶段 / Total Phases:** 3\n\n---\n\n## 思考阶段 1 / Thinking Phase 1\n\n### Titre de la Pensée 1\n\nContenu de la pensée...\n\n### Titre de la Pensée 2\n\nContenu de la pensée...\n\n#### 研究网站 / Researched Websites\n\n- [domain.com](https://example.com) - Titre de la Page\n- [another.com](https://another.com) - Autre Titre\n\n---\n\n## 思考阶段 2 / Thinking Phase 2\n\n...\n```\n\n## Confidentialité\n\nToute l'extraction et le formatage se font à 100% localement dans votre navigateur. Aucune donnée n'est envoyée à des serveurs externes.\n"
  },
  {
    "path": "docs/fr/guide/default-model.md",
    "content": "# Modèle par Défaut\n\n::: info\n**Note** : Cette fonctionnalité est prise en charge dans la version 1.1.9 et ultérieure.\n:::\n\nDéfinissez un modèle Gemini™ préféré par défaut pour éviter de changer manuellement à chaque nouvelle conversation.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/default-model.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n## Fonctionnalités\n\n- **Configuration Interactive** : Ajoute un bouton \"Étoile\" directement dans le menu natif de sélection de modèle de Gemini.\n- **Commutation Automatique** : Bascule automatiquement vers votre modèle préféré chaque fois que vous commencez une nouvelle conversation.\n- **Préférence Persistante** : Votre choix est enregistré et synchronisé sur vos appareils.\n- **Optimisé pour SPA** : Se déclenche avec précision lors d'un clic sur le bouton \"Nouveau chat\", l'utilisation de raccourcis ou le retour à la page d'accueil.\n\n## Comment l'utiliser\n\n1. Cliquez sur le **Sélecteur de modèle** au-dessus de la zone de saisie Gemini.\n2. Survolez votre modèle préféré et cliquez sur l'**icône Étoile**.\n3. Une fois que l'étoile est **pleine**, ce modèle est défini par défaut.\n4. L'extension sélectionnera désormais automatiquement ce modèle pour tous les nouveaux chats.\n5. Pour annuler, cliquez simplement à nouveau sur l'icône de l'étoile pleine.\n"
  },
  {
    "path": "docs/fr/guide/export.md",
    "content": "# Liberté Totale\n\nLe verrouillage des données est l'ennemi.\nNous croyons que si vous le créez, vous le possédez.\n\n## Tout Exporter\n\nVoyager vous permet de retirer vos données du cloud pour les mettre entre vos mains.\n\n### Les Formats\n\n- **Markdown** : Pour votre coffre Obsidian ou Notion. Texte propre et formaté. (Utilisateurs de Safari : les images ne peuvent pas être extraites en raison des limitations du navigateur, utilisez l'export PDF)\n- **PDF** : Pour partager ou imprimer. Mise en page magnifique, images incluses.\n- **JSON** : Données brutes. Pour les développeurs qui veulent construire par-dessus leur historique.\n\n### Comment Exporter\n\n1. Survolez le logo Gemini pour voir l'**Icône d'Export**.\n2. Choisissez votre format.\n3. C'est fait.\n\nCe sont vos données. Faites-en ce que vous voulez.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Étape 1 : Survoler le Logo</b></p>\n    <img src=\"/assets/gemini-export-guide-1.png\" alt=\"Guide export étape 1\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Étape 2 : Le Choix</b></p>\n    <img src=\"/assets/gemini-export-guide-2.png\" alt=\"Guide export étape 2\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n### Remarque concernant l'exportation PDF sur Safari\n\nL'exportation en PDF sur Safari nécessite une procédure légèrement différente (impression manuelle) :\n\n1. Cliquez sur le bouton **Exporter** et sélectionnez le format PDF.\n2. **Attendez environ une seconde** (pour permettre à la page de préparer les styles d'impression).\n3. Appuyez sur `Command + P` pour ouvrir la boîte de dialogue d'impression.\n4. Sélectionnez **\"Enregistrer au format PDF\"** (Save to PDF) dans la boîte de dialogue d'impression.\n\n<img src=\"/assets/safari-export-pdf.png\" alt=\"Safari Export PDF\" style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 20px;\"/>\n"
  },
  {
    "path": "docs/fr/guide/folders.md",
    "content": "# Les dossiers, comme ils devraient l'être\n\nPourquoi organiser les chats AI est-il si difficile ?\nNous avons réglé ça. Nous avons construit un système de fichiers pour vos pensées.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap; margin-bottom: 40px;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Gemini™</b></p>\n    <img src=\"/assets/gemini-folders.png\" alt=\"Dossiers Gemini\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>AI Studio</b></p>\n    <img src=\"/assets/aistudio-folders.png\" alt=\"Dossiers AI Studio\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n## La physique de l'organisation\n\nC'est tout simplement naturel.\n\n- **Glisser-Déposer** : Prenez un chat. Déposez-le dans un dossier. C'est tactile.\n- **Hiérarchie imbriquée** : Les projets ont des sous-projets. Créez des dossiers dans des dossiers. Structurez à _votre_ façon.\n- **Espacement des dossiers** : Ajustez la densité de la barre latérale, de compact à spacieux.\n  > _Note : Sur Mac Safari, les ajustements peuvent ne pas être en temps réel ; actualisez la page pour voir l'effet._\n- **Synchronisation Instantanée** : Organisez sur votre bureau. Retrouvez-le sur votre ordinateur portable.\n\n## Astuces de Pro\n\n- **Multi-Sélection** : Appui long sur une conversation pour entrer en mode multi-sélection, puis sélectionnez plusieurs chats et déplacez-les tous en une fois.\n- **Renommer** : Double-cliquez sur n'importe quel dossier pour le renommer.\n- **Icônes** : Nous détectons automatiquement le type de Gem (Codage, Créatif, etc.) et attribuons la bonne icône. Vous n'avez rien à faire.\n\n## Différences de fonctionnalités par plateforme\n\n### Fonctionnalités communes\n\n- **Gestion de base** : Glisser-déposer, renommer, multi-sélection.\n- **Reconnaissance intelligente** : Détecte automatiquement les types de chat et assigne des icônes.\n- **Hiérarchie imbriquée** : Support pour l'imbrication des dossiers.\n- **Adaptation AI Studio** : Les fonctionnalités avancées seront bientôt disponibles sur AI Studio.\n- **Sync Google Drive** : Synchronise la structure des dossiers avec Google Drive.\n\n### Exclusivité Gemini\n\n#### Couleurs personnalisées\n\nCliquez sur l'icône du dossier pour personnaliser sa couleur. Choisissez parmi 7 couleurs par défaut ou utilisez le sélecteur de couleurs pour choisir n'importe quelle couleur.\n\n<img src=\"/assets/folder-color.png\" alt=\"Couleurs des dossiers\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### Isolation de compte\n\nCliquez sur l'icône « personne » dans l'en-tête pour filtrer instantanément les chats des autres comptes Google. Gardez votre espace de travail propre lorsque vous utilisez plusieurs comptes.\n\n<img src=\"/assets/current-user-only.png\" alt=\"Isolation de compte\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### Organisation automatique par IA\n\nTrop de chats, la flemme de trier ? Laissez Gemini réfléchir à votre place.\n\nUn clic copie votre structure de conversations actuelle, collez-la dans Gemini, et il génère un plan de dossiers prêt à importer — organisation instantanée.\n\n**Étape 1 : Copiez votre structure de conversations**\n\nEn bas de la section dossiers dans le popup de l'extension, cliquez sur le bouton **AI Organize**. Il collecte automatiquement toutes vos conversations non classées et la structure de dossiers existante, génère un prompt et le copie dans votre presse-papiers.\n\n<img src=\"/assets/ai-auto-folder.png\" alt=\"AI Organize Button\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n\n**Étape 2 : Laissez Gemini trier**\n\nCollez le contenu du presse-papiers dans une conversation Gemini. Il analysera vos titres de chat et produira un plan de dossiers en JSON.\n\n**Étape 3 : Importez les résultats**\n\nCliquez sur **Importer des dossiers** depuis le menu du panneau de dossiers, sélectionnez **Ou collez du JSON directement**, collez le JSON renvoyé par Gemini, puis cliquez sur **Importer**.\n\n<div style=\"display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; margin-bottom: 24px;\">\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-2.png\" alt=\"Import Menu\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 240px;\"/>\n  </div>\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-3.png\" alt=\"Paste JSON Import\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n  </div>\n</div>\n\n- **Fusion incrémentale** : Utilise la stratégie « Fusionner » par défaut — ajoute uniquement les nouveaux dossiers et assignations, sans jamais détruire votre organisation existante.\n- **Multilingue** : Le prompt utilise automatiquement votre langue configurée, et les noms de dossiers sont générés dans cette langue également.\n\n### Exclusivité AI Studio\n\n- **Ajustement de la barre latérale** : Faites glisser pour redimensionner la largeur de la barre latérale.\n- **Intégration Library** : Glissez directement depuis votre Library vers les dossiers.\n"
  },
  {
    "path": "docs/fr/guide/fork.md",
    "content": "# Bifurcation de Conversation (Expérimental)\n\nLa pensée ne devrait pas être à sens unique. Dans les explorations complexes, nous avons souvent besoin de revenir à un nœud crucial et d'essayer d'autres possibilités.\n\nAvec la fonctionnalité de **Bifurcation**, Voyager vous permet de développer vos idées et d'explorer des univers parallèles de votre discussion.\n\n## Comment ça marche\n\n> **⚠️ Remarque** : Il s'agit d'une fonctionnalité expérimentale. Vous devez d'abord l'activer en cliquant sur l'icône de l'extension dans votre barre d'outils pour ouvrir la fenêtre contextuelle des paramètres, et en activant le commutateur **\"Activer la bifurcation de conversation\"**.\n\nChaque fois que vous souhaitez emprunter un chemin différent, survolez simplement votre question et cliquez sur le bouton **Bifurquer** :\n\n![Bifurcation](/assets/branching.png)\n\nVoyager capturera instantanément tout le contexte depuis le début jusqu'à ce point et **créera une toute nouvelle conversation** pour vous.\n\nDans cette nouvelle branche, vous pouvez modifier librement votre question et explorer différentes directions sans craindre de détruire votre historique de conversation d'origine. Libérez votre créativité et votre curiosité !\n"
  },
  {
    "path": "docs/fr/guide/formula-copy.md",
    "content": "# Copie de Formules\n\nVoyager facilite grandement la réutilisation de formules mathématiques et de symboles scientifiques. Il prend en charge la copie en un clic du code source LaTeX et du format MathML compatible avec Microsoft Word.\n\n## Présentation\n\nLorsque vous demandez à Gemini de dériver des formules ou d'écrire des expressions mathématiques, il les affiche généralement en utilisant LaTeX. Bien que le rendu soit esthétique, l'extraction du code source pour l'utiliser dans vos propres articles, documents ou éditeurs nécessite souvent un effort manuel.\n\nVoyager offre un support transparent pour cela :\n\n1. **Détection Automatique** : Voyager identifie automatiquement les formules LaTeX affichées sur la page.\n2. **Bouton de Copie** : Lorsque vous survolez une formule, une icône de copie apparaît sur son côté droit.\n3. **Options de Format** : Cliquez sur l'icône pour choisir :\n   - **Copy LaTeX** : Copie le code source LaTeX standard, idéal pour Overleaf, les éditeurs Markdown, etc.\n   - **Copy MathML** : Copie le code source MathML, le meilleur format pour coller directement dans **Microsoft Word**.\n\n![Copie de Formules](/assets/gemini-math-copy.png)\n\n## Caractéristiques\n\n- **Compatibilité Word** : Grâce au support MathML, vous pouvez coller des formules complexes générées par l'IA directement dans des documents Word tout en conservant un format éditable parfait.\n- **Préservation du Contexte** : Ne copie pas seulement la formule, mais préserve également son contexte mathématique.\n- **Réponse Instantanée** : Traitement entièrement local pour des résultats immédiats.\n\n## Conseils d'utilisation\n\n- **Rédaction Académique** : Lors de la rédaction d'articles dans Word, demandez à Gemini de dériver des formules, puis utilisez le copier-coller MathML pour éviter la saisie manuelle dans l'éditeur d'équations de Word.\n- **Prise de Notes** : Lorsque vous prenez des notes dans Obsidian ou Notion, copiez simplement la source LaTeX directement.\n"
  },
  {
    "path": "docs/fr/guide/getting-started.md",
    "content": "# Bienvenue à Bord\n\nFélicitations. Vous venez de mettre à niveau votre intellect.\nVoyager n'est pas juste un utilitaire ; c'est un flux de travail. Voici comment en tirer le meilleur parti en 5 minutes.\n\n## 1. La Configuration\n\nSi vous ne l'avez pas encore installé, rendez-vous sur le [Guide d'Installation](/fr/guide/installation).\nUne fois installé, rafraîchissez votre onglet Gemini. Vous verrez la différence immédiatement.\n\n## 2. La Chronologie\n\nLancez une conversation. Une longue. Posez des questions sur l'histoire de la typographie ou la physique des trous noirs.\nRegardez sur la droite.\n**Cette bande de points ? C'est votre carte.**\n\n- **Survolez** pour jeter un coup d'œil à ce que vous avez dit.\n- **Cliquez** pour vous téléporter à cet endroit.\n- **Appui long** pour mettre une étoile sur un moment que vous voulez garder.\n\nFini le défilement sans fin. Vous naviguez désormais à la vitesse de la pensée.\n\n## 3. L'Organisation\n\nRegardez votre liste de chats sur la gauche. Remarquez quelque chose de nouveau ?\n**Les Dossiers.**\n\nPrenez un chat. Glissez-le. Déposez-le dans un dossier.\nCela semble naturel, n'est-ce pas ? C'est parce que ça l'est. Vous pouvez les imbriquer, les renommer, et enfin vider votre esprit de tout désordre.\n\n## 4. Le Coffre-fort\n\nVous ne faites plus que discuter. Vous construisez un coffre-fort de votre propre génie.\n\n---\n\n**Vous êtes prêt.**\nExplorez les guides spécifiques pour approfondir :\n\n- [Maîtrise de la Chronologie](/fr/guide/timeline)\n- [Gestion des Dossiers](/fr/guide/folders)\n- [Ingénierie de Prompt](/fr/guide/prompts)\n- [Export de Données](/fr/guide/export)\n"
  },
  {
    "path": "docs/fr/guide/input-collapse.md",
    "content": "# Réduction de l'Entrée\n\nRéduisez la zone de saisie lorsqu'elle est vide pour gagner plus d'espace de lecture. Cliquez sur la barre réduite pour l'étendre et commencer à taper.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/hide-input-area.png\" alt=\"Réduction entrée\" style=\"max-width: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## Comment Utiliser\n\n1. Lorsque la zone de saisie est vide et perd le focus, elle se réduit automatiquement en un bouton compact.\n2. Cliquez sur le bouton pour étendre la zone de saisie et commencer à taper.\n3. Vous pouvez aussi appuyer sur <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>I</kbd> pour agrandir rapidement la zone de saisie.\n4. Vous pouvez activer ou désactiver cette fonctionnalité dans le panneau de paramètres (désactivé par défaut).\n"
  },
  {
    "path": "docs/fr/guide/installation.md",
    "content": "# Installation\n\n::: info Nouvelles\n🍎 **L'extension native Safari est disponible !** Elle est entièrement gratuite et s'installe en un clic.\n:::\n\nChoisissez votre méthode.\n\n> ⚠️ Note : Le Gestionnaire de Prompts est la seule fonctionnalité compatible avec Gemini™ pour Entreprise.\n\n## 1. Stores d'Extensions (Recommandé)\n\nLa façon la plus simple de commencer. Les mises à jour sont automatiques.\n\n**Chrome / Brave / Opera / Vivaldi :**\n\n[<img src=\"https://img.shields.io/badge/Chrome_Web_Store-Télécharger-4285F4?style=for-the-badge&logo=google-chrome&logoColor=white\" alt=\"Installer depuis le Chrome Web Store\" height=\"40\"/>](https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=docs&utm_campaign=organic_growth&utm_content=fr)\n\n::: warning ⚠️ Chrome Web Store temporairement indisponible\nL'extension a été officiellement renommée **Voyager** en raison de problèmes de marque. La mise à jour du nom sur le Chrome Web Store est en attente de validation. Voir [ce post](https://x.com/Nag1ovo/status/2031561180213313944) pour les détails. Utilisez **Edge / Firefox** ou l'**installation manuelle** en attendant.\n:::\n\n**Microsoft Edge :**\n\n[<img src=\"https://img.shields.io/badge/Microsoft_Edge-Télécharger-0078D7?style=for-the-badge&logo=microsoft-edge&logoColor=white\" alt=\"Installer depuis les modules complémentaires Microsoft Edge\" height=\"40\"/>](https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne)\n\n**Firefox :**\n\n[<img src=\"https://img.shields.io/badge/Firefox_Add--ons-Télécharger-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Installer depuis Firefox Add-ons\" height=\"40\"/>](https://addons.mozilla.org/firefox/addon/gemini-voyager/)\n\n## 2. La Méthode Manuelle (Dernières Fonctionnalités)\n\nLe processus de validation des stores peut être lent. Si vous voulez la version à la pointe de la technologie immédiatement, installez-la manuellement.\n\n**Pour Chrome / Edge / Brave / Opera :**\n\n1. Téléchargez la dernière version de `gemini-voyager-chrome-vX.Y.Z.zip` depuis les [Releases GitHub](https://github.com/Nagi-ovo/gemini-voyager/releases).\n2. Décompressez le fichier.\n3. Ouvrez la page des Extensions de votre navigateur (`chrome://extensions`).\n4. Activez le **Mode développeur** (en haut à droite).\n5. Cliquez sur **Charger l'extension non empaquetée** et sélectionnez le dossier que vous venez de décompresser.\n\n**Pour Firefox :**\n\n1. Téléchargez la dernière version de `gemini-voyager-firefox-vX.Y.Z.xpi` depuis les [Releases](https://github.com/Nagi-ovo/gemini-voyager/releases).\n2. Ouvrez le Gestionnaire de modules complémentaires (`about:addons`).\n3. Glissez-déposez le fichier `.xpi` pour l'installer (ou cliquez sur l'icône d'engrenage ⚙️ -> **Installer un module depuis un fichier**).\n\n> 💡 Le fichier XPI est officiellement signé par Mozilla et peut être installé de manière permanente sur toutes les versions de Firefox.\n\n## 3. Safari (macOS)\n\nSafari prend désormais en charge la distribution directe ! Téléchargez l'application pré-signée :\n\n1. Téléchargez la <SafariDownloadLink>dernière version Safari (.dmg)</SafariDownloadLink>.\n2. Ouvrez le fichier et suivez les instructions pour l'installer.\n3. Double-cliquez pour lancer l'application.\n4. Activez l'extension dans **Réglages Safari > Extensions**.\n\n> 💡 La version Safari est désormais directement signée pour la distribution — pas besoin de conversion Xcode !\n>\n> ⚠️ **Limitations** : En raison de la nature de Safari, (a) la suppression du filigrane (b) l'exportation d'images (PDF recommandé) ne sont pas prises en charge.\n\n---\n\n_Configuration de développement ? Si vous êtes un développeur souhaitant contribuer, consultez notre [Guide de Contribution](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/CONTRIBUTING.md)._\n"
  },
  {
    "path": "docs/fr/guide/markdown-fix.md",
    "content": "# Correction du Rendu Markdown\n\nL'interface web de Gemini™ insère parfois des éléments HTML (tels que des sources de citation ou des marqueurs de mise en évidence) dans le texte, ce qui peut briser la syntaxe Markdown pour le gras (`**texte**`), empêchant le texte de s'afficher correctement en gras.\n\nVoyager dispose d'une fonction de correction automatique intégrée qui identifie et répare intelligemment ces balises de gras corrompues, garantissant ainsi un rendu propre et précis de vos documents.\n\n> [!INFO]\n> Cette fonctionnalité est activée automatiquement et ne nécessite aucune configuration supplémentaire.\n"
  },
  {
    "path": "docs/fr/guide/mermaid.md",
    "content": "# Rendu de Diagrammes Mermaid\n\nRendez automatiquement le code Mermaid sous forme de diagrammes visuels.\n\n## Aperçu\n\nLorsque Gemini™ produit des blocs de code Mermaid (organigrammes, diagrammes de séquence, diagrammes de Gantt, etc.), Voyager les détecte et les rend automatiquement sous forme de diagrammes interactifs.\n\n### Fonctionnalités Clés\n\n- **Auto-détection** : Supporte `graph`, `flowchart`, `sequenceDiagram`, `gantt`, `pie`, `classDiagram`, et tous les types majeurs de diagrammes Mermaid.\n- **Basculer la vue** : Passez du diagramme rendu au code source en un clic.\n- **Mode plein écran** : Cliquez sur le diagramme pour entrer en plein écran avec support du zoom et du panoramique.\n- **Mode sombre** : S'adapte automatiquement au thème de la page.\n\n## Comment Utiliser\n\n1. Demandez à Gemini de générer n'importe quel code de diagramme Mermaid.\n2. Le bloc de code est automatiquement remplacé par le diagramme rendu.\n3. Cliquez sur le bouton **</> Code** pour voir le code source.\n4. Cliquez sur le bouton **📊 Diagramme** pour revenir à la vue diagramme.\n5. Cliquez sur la zone du diagramme pour passer en plein écran.\n\n## Contrôles Plein Écran\n\n- **Molette souris** : Zoom avant/arrière\n- **Glisser** : Panoramique du diagramme\n- **+/-** : Boutons de zoom de la barre d'outils\n- **⊙** : Réinitialiser la vue\n- **✕ / ESC** : Fermer le plein écran\n\n## Compatibilité et Dépannage\n\n::: warning Note\n\n- **Limitation Firefox** : En raison de restrictions environnementales, Firefox utilise la version 9.2.2 et ne prend pas en charge les nouvelles fonctionnalités comme **Timeline** ou **Sankey**.\n- **Erreurs de syntaxe** : Les échecs de rendu sont souvent dus à des erreurs de syntaxe dans la sortie de Gemini. Nous collectons les \"bad cases\" pour implémenter des correctifs automatiques dans les futures mises à jour.\n  :::\n\n<div align=\"center\">\n  <img src=\"/assets/mermaid-preview.png\" alt=\"Rendu diagramme Mermaid\" style=\"max-width: 100%; border-radius: 8px;\"/>\n</div>\n"
  },
  {
    "path": "docs/fr/guide/nanobanana.md",
    "content": "# Option NanoBanana\n\n::: warning Compatibilité du navigateur\nActuellement, la fonction **NanoBanana** n'est **pas prise en charge sur Safari** en raison des limitations de l'API du navigateur. Nous vous recommandons d'utiliser **Chrome** ou **Firefox** si vous avez besoin d'utiliser cette fonction.\n\nLes utilisateurs de Safari peuvent télécharger manuellement leurs images sur des sites d'outils comme [banana.ovo.re](https://banana.ovo.re/) pour le traitement (bien que le succès ne soit pas garanti pour toutes les images en raison des différentes résolutions).\n:::\n\n**Images IA, gardées pures.**\n\nLes images générées par Gemini™ comportent un filigrane visible par défaut. Bien que ce soit pour des raisons de sécurité, il existe des scénarios créatifs où vous avez besoin d'une image parfaitement vierge.\n\n## Reconstruction Sans Perte\n\nNanoBanana utilise un algorithme de **Mélange Alpha Inversé**.\n\n- **Pas d'Inpainting IA** : La suppression de filigrane traditionnelle utilise souvent l'IA pour \"barbouiller\" la zone, ce qui détruit les détails des pixels.\n- **Perfection au Pixel** : Nous utilisons des calculs mathématiques pour retirer précisément la couche de filigrane transparente, restaurant 100% des pixels originaux.\n- **Zéro Perte de Qualité** : L'image traitée reste identique à l'originale dans toutes les zones sans filigrane.\n\n## Comment Utiliser\n\n1. **Activez-le** : Trouvez \"Option NanoBanana\" à la fin du panneau de paramètres de Voyager et activez-le.\n2. **Auto-traitement** : Chaque image que vous générez sera maintenant traitée automatiquement en arrière-plan.\n3. **Télécharger directement** :\n   - Survolez une image traitée et vous verrez un bouton 🍌.\n   - **Le bouton 🍌 remplace complètement** le bouton de téléchargement natif pour garantir que vous obtenez toujours directement l'image 100% sans filigrane.\n\n<div style=\"text-align: center; margin-top: 30px;\">\n  <img src=\"/assets/nanobanana.png\" alt=\"Démo NanoBanana\" style=\"border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); max-width: 100%;\"/>\n</div>\n\n## Remerciements\n\nCette fonctionnalité est basée sur le projet [gemini-watermark-remover](https://github.com/journey-ad/gemini-watermark-remover) de [journey-ad (Jad)](https://github.com/journey-ad), qui est un portage JavaScript de [l'implémentation C++ originale](https://github.com/allenk/GeminiWatermarkTool) de [allenk](https://github.com/allenk). Nous sommes reconnaissants pour leurs contributions à la communauté. 🧡\n\n## Confidentialité & Sécurité\n\nTout le traitement se fait **localement dans votre navigateur**. Vos images ne sont jamais téléchargées sur des serveurs tiers, garantissant votre confidentialité et sécurité créative.\n"
  },
  {
    "path": "docs/fr/guide/prevent-auto-scroll.md",
    "content": "# Empêcher le défilement auto\n\nLorsque vous lisez vos conversations passées, si vous appuyez sur Entrée pour envoyer un nouveau prompt, Gemini™ a pour habitude de faire défiler automatiquement la page tout en bas afin de suivre la réponse générée. Cela peut perturber votre lecture.\n\nLa fonctionnalité **Empêcher le défilement auto** intercepte ce comportement indésirable :\n\n- Lorsque vous avez fait défiler la page vers le haut, le système empêche le saut incontrôlé vers le bas de la page.\n- Cette fonctionnalité est **désactivée** par défaut. Vous pouvez l'activer manuellement dans les paramètres de l'extension (\"Timeline Options\").\n\n## Comment l'activer\n\n1. Cliquez sur l'icône de l'extension Voyager pour ouvrir la popup.\n2. Repérez la section \"Timeline Options\" (Options de la Timeline).\n3. Activez l'option \"Prevent auto-scroll to bottom\" (Empêcher le défilement auto).\n"
  },
  {
    "path": "docs/fr/guide/prompts.md",
    "content": "# Vos Actifs Intellectuels : Bibliothèque de Prompts\n\nVous rédigez un prompt parfait. Il résout un problème de code complexe ou écrit un e-mail magnifique.\nLe jetez-vous ?\nNon. Vous le sauvegardez.\n\n## Le Coffre-fort de Prompts\n\nC'est votre dépôt personnel de génie.\n\n### 1. Capturer\n\nQuand vous écrivez quelque chose de génial, cliquez sur l'icône du **Gestionnaire de Prompts** (flottant près de la zone de saisie).\nCela fait maintenant partie de votre coffre-fort.\n\n### 2. Catégoriser\n\nAjoutez des étiquettes comme `#code`, `#email`, ou `#recherche`.\nGardez vos outils affûtés et triés.\n\n### 3. Déployer\n\nLa prochaine fois que vous en aurez besoin, ne le retapez pas.\nOuvrez le gestionnaire, cherchez par tag ou mot-clé, et cliquez pour insérer.\nUn clic. Effet de levier infini.\n\n![Gestionnaire de Prompts](/assets/gemini-prompt-manager.png)\n\n## Disponible Partout\n\nLe Gestionnaire de Prompts peut désormais être utilisé sur n'importe quel site web de votre choix, pas seulement Gemini™ et AI Studio.\n\n### Comment l'Activer\n\n1. Cliquez sur l'icône Voyager dans la barre d'outils de votre navigateur.\n2. Faites défiler jusqu'à la section **Gestionnaire de Prompts**.\n3. Entrez l'URL du site web (ex : `chatgpt.com` ou `claude.ai`).\n4. Cliquez sur **Ajouter un site web** et accordez la permission.\n5. **Rechargez la page cible** pour voir le bouton flottant.\n\n### Sites IA Populaires\n\n- `chatgpt.com` - ChatGPT\n- `claude.ai` - Claude\n- `copilot.microsoft.com` - Microsoft Copilot\n- `poe.com` - Poe\n\n::: tip\nSur les sites web personnalisés, **seule** la fonctionnalité Gestionnaire de Prompts est activée. Les autres fonctionnalités comme la Chronologie et les Dossiers sont conçues spécifiquement pour Gemini et ne seront pas chargées.\n:::\n"
  },
  {
    "path": "docs/fr/guide/quote-reply.md",
    "content": "# Réponse avec Citation\n\nVoyager offre une fonctionnalité pratique de \"Réponse avec Citation\", rendant les réponses à un contenu spécifique plus précises et efficaces.\n\n## Introduction\n\nDans les conversations quotidiennes, nous avons souvent besoin de rebondir sur une partie spécifique de la sortie de l'IA ou de la réfuter. La méthode traditionnelle implique de copier ce texte et de taper manuellement le symbole `> ` dans la zone de saisie, ce qui est fastidieux.\n\nVoyager simplifie ce processus :\n\n1. **Sélectionner pour Citer** : Utilisez votre souris pour sélectionner n'importe quel texte dans la page de conversation (que ce soit votre question ou la réponse de Gemini).\n2. **Bouton Flottant** : Un bouton \"Réponse avec Citation\" apparaîtra automatiquement près du texte sélectionné.\n3. **Insertion en Un Clic** : Cliquez sur le bouton, et le texte sélectionné sera automatiquement inséré dans votre zone de saisie au format de citation Markdown standard (`> contenu`).\n\n![Réponse avec Citation](/assets/quote-reply.png)\n\n## Fonctionnalités\n\n- **Conscient du Contexte** : Identifie intelligemment le contenu de la conversation pour éviter les déclenchements accidentels dans des zones non liées (comme la zone de saisie elle-même).\n- **Format Standard** : Utilise la syntaxe Markdown universelle, que Gemini comprend parfaitement, menant à des réponses plus précises.\n- **Support Multi-lignes** : Si plusieurs lignes de texte sont sélectionnées, Voyager ajoute automatiquement des symboles de citation à chaque ligne pour garder le format propre.\n\n## Astuces\n\n- **Approfondir les détails** : Sélectionnez un concept confus dans la réponse de Gemini, cliquez sur citer, puis tapez \"Veuillez expliquer ce concept en détail.\"\n- **Corriger les erreurs** : Sélectionnez du code ou des faits incorrects dans la réponse, citez-les, et signalez \"Ceci est incorrect, cela devrait être...\"\n"
  },
  {
    "path": "docs/fr/guide/recents-hider.md",
    "content": "# Masquer les éléments récents et les Gems\n\n::: info\n**Note**: Cette fonctionnalité est supportée dans la version 1.1.9 et ultérieure.\n:::\n\nAjoutez une bascule élégante pour masquer la section « Enregistrements récents » sur la page d'accueil de Gemini™ pour une interface plus propre. Prend désormais également en charge le masquage de la liste **Gems** dans la barre latérale !\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Masquer les enregistrements récents</b></p>\n    <video src=\"/assets/hide-my-stuff.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Masquer la liste des Gems</b></p>\n    <video src=\"/assets/hide-gem-list.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n</div>\n\n## Caractéristiques\n\n- **Bouton Contextuel** : Un bouton discret apparaît uniquement lorsque vous passez la souris sur la section des éléments récents.\n- **État Minimaliste** : Une fois masqué, il est remplacé par une fine \"barre de survol\" en bas.\n- **Restauration en un clic** : Passez simplement la souris sur la barre de survol et cliquez pour restaurer instantanément la section.\n- **Protection de la vie privée** : Empêche les personnes à proximité de voir vos activités récentes, idéal pour les espaces publics.\n- **Persistance** : Votre préférence est enregistrée et appliquée automatiquement lors de votre prochaine visite.\n\n## Comment l'utiliser\n\n1. Passez la souris sur la section \"Récent\" de la page d'accueil de Gemini.\n2. Cliquez sur l'icône d'œil barré qui apparaît dans le coin supérieur droit pour masquer la section.\n3. Pour restaurer, passez la souris sur la fine ligne restante en bas de la zone et cliquez.\n"
  },
  {
    "path": "docs/fr/guide/settings.md",
    "content": "# Appropriez-le-vous\n\nL'expérience par défaut est géniale. Mais vous pourriez la vouloir parfaite.\nPersonnalisez chaque pixel.\n\n## Mode Cinéma\n\nPourquoi voir le futur à travers un petit trou de serrure ?\nVoyager vous permet d'étendre la largeur du chat.\n\n- **Large** : 1400px pour le codage et les tableaux complexes.\n- **Concentré** : 800px pour la lecture.\n- **Vous décidez** : Utilisez le curseur pour trouver votre point idéal.\n\n## Contrôle\n\nCliquez sur l'icône de l'extension pour accéder au centre de contrôle.\n\n- **Mode de Défilement** : Naturel ou classique.\n- **Position de la Chronologie** : Mettez-la où cela semble juste.\n- **Effets Visuels** : Choisissez `Neige`, `Sakura` ou `Pluie` pour une atmosphère saisonnière.\n\n## Ordre personnalisé\n\nTrop de sections dans le popup et celles que vous utilisez le plus sont tout en bas ?\n\nSurvol une carte de paramètres pour voir les boutons ▲/▼ en haut à droite. Cliquez pour déplacer une carte vers le haut ou le bas. Votre disposition est sauvegardée automatiquement.\n\n## Atmosphère\n\nVoyager ne se limite pas aux améliorations utilitaires. Vous pouvez aussi changer l'ambiance de la page.\n\n- **Neige** : Flocons doux dérivant pour une sensation hivernale calme.\n- **Sakura** : Pétales de fleurs de cerisier flottants pour une ambiance printanière plus légère.\n- **Pluie** : Une couche de pluie cinématique avec des traînées obliques et de subtiles éclaboussures.\n- **Transition douce** : En désactivant ou changeant d'effet, les particules s'estompent naturellement.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Ouvrir les Paramètres</b></p>\n    <img src=\"/assets/gemini-open-settings-guide.png\" alt=\"Guide ouverture paramètres\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Ajuster la Largeur</b></p>\n    <img src=\"/assets/gemini-chatwidth.png\" alt=\"Ajustement largeur chat\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n"
  },
  {
    "path": "docs/fr/guide/sidebar-auto-hide.md",
    "content": "# Masquer automatiquement la barre latérale\n\nVous voulez une expérience de chat plus immersive ?\n\nNous proposons une fonctionnalité de **Masquage automatique de la barre latérale**. Lorsqu'elle est activée, la barre latérale se réduit automatiquement lorsque votre souris quitte la zone, et se développe automatiquement lorsque vous y revenez.\n\n### Démo\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/sidebar-auto-hide.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n### Comment activer\n\n1. Ouvrez le panneau de configuration de Voyager.\n2. Trouvez l'option **Masquer auto barre latérale** dans les **Paramètres généraux**.\n3. activez l'interrupteur.\n\n_Remarque : Cette fonctionnalité ne prend actuellement en charge que Google Gemini._\n"
  },
  {
    "path": "docs/fr/guide/sidebar.md",
    "content": "# Largeur de la barre latérale\n\nLes noms de dossiers sont trop longs ?\nOu la barre latérale prend-elle trop de place ?\n\nVous pouvez maintenant ajuster librement la largeur de la barre latérale.\n\n## Comment ajuster\n\n1. Ouvrez le panneau de configuration de Voyager (cliquez sur l'icône de l'extension en haut à droite).\n   <img src=\"/assets/extension-instruction.png\" alt=\"Comment ouvrir le panneau de configuration\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. Trouvez l'option **Largeur de la barre latérale**.\n3. Faites glisser le curseur pour choisir la largeur qui vous convient.\n\n- **Étroit** : Économisez de l'espace et concentrez-vous sur la conversation.\n- **Large** : Voyez les noms de dossiers complets en un coup d'œil.\n\n## Plateformes supportées\n\nCette fonctionnalité supporte :\n\n- **Google Gemini**\n- **Google AI Studio**\n\nVos paramètres sont enregistrés automatiquement.\n"
  },
  {
    "path": "docs/fr/guide/sponsor.md",
    "content": "# Sponsor\n\n> [!NOTE]\n> Si Voyager vous est utile, partagez-le sur X, Reddit, YouTube, etc. Chaque partage aide plus de personnes à découvrir le projet et à améliorer l'expérience Gemini. Merci.\n\nMaintenir des projets open-source est principalement motivé par la passion (et le café) ☕\n\n**[Voyager](https://github.com/Nagi-ovo/gemini-voyager)** est une extension de navigateur entièrement gratuite et open-source conçue pour améliorer votre expérience Gemini. Si cette extension vous aide à utiliser Gemini plus efficacement, envisagez de soutenir le développement continu et la maintenance de ce projet.\n\n---\n\n## Plateformes en Ligne\n\n<div class=\"sponsor-badges\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" style=\"height: 40px;\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" style=\"height: 40px;\">\n  </a>\n</div>\n\n<a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n    <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n    <img alt=\"Afdian\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n  </picture>\n</a>\n\n### 🎙️ Outil Recommandé : Typeless\n\nJe recommande vivement **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)**, un outil IA de voix-à-texte que j'ai utilisé intensivement durant le développement de Voyager. L'intégrer dans mon flux quotidien m'a fait gagner un temps énorme et a considérablement boosté ma productivité.\n\n> 🎁 **[Rejoignez via mon lien de parrainage](https://www.typeless.com/?via=gemini-voyager)** (Code : _`gemini-voyager`_) pour obtenir **$5 de crédits gratuits**. Cela me donne aussi des crédits pour continuer à maintenir ce projet—une façon gratuite de soutenir mon travail ! ❤️\n\n---\n\n## Buy Me a Coffee (QR) 🍵\n\n<div class=\"qr-container\">\n  <div class=\"qr-item\">\n    <img src=\"/assets/wechat-sponsor.png\" alt=\"WeChat Pay\" />\n    <span>WeChat Pay</span>\n  </div>\n  <div class=\"qr-item\">\n    <img src=\"/assets/alipay-sponsor.jpg\" alt=\"Alipay\" />\n    <span>Alipay</span>\n  </div>\n</div>\n\n<style>\n.sponsor-badges {\n  display: flex;\n  gap: 16px;\n  flex-wrap: wrap;\n  align-items: center;\n  margin: 16px 0;\n}\n\n.sponsor-badges a img {\n  transition: transform 0.2s ease;\n}\n\n.sponsor-badges a:hover img {\n  transform: scale(1.05);\n}\n\n.qr-container {\n  display: flex;\n  gap: 32px;\n  flex-wrap: wrap;\n  margin: 16px 0;\n}\n\n.qr-item {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 8px;\n  padding: 16px;\n  border-radius: 8px;\n  border: 1px solid var(--vp-c-divider);\n  background: var(--vp-c-bg-soft);\n}\n\n.qr-item img {\n  width: 200px;\n  height: 200px;\n  object-fit: contain;\n  border-radius: 4px;\n}\n\n.qr-item span {\n  font-weight: 600;\n  color: var(--vp-c-text-1);\n}\n\n@media (max-width: 640px) {\n  .qr-container {\n    justify-content: center;\n  }\n  \n  .qr-item img {\n    width: 150px;\n    height: 150px;\n  }\n}\n</style>\n\n---\n\nMerci pour votre soutien ! Chaque contribution est un grand encouragement pour moi ❤️\n"
  },
  {
    "path": "docs/fr/guide/tab-title.md",
    "content": "# Synchro du Titre d'Onglet\n\nSynchronise automatiquement le titre de l'onglet du navigateur avec le titre du chat Gemini™ actuel.\n\n## Fonctionnalités\n\n- **Synchro en temps réel** : Quand le titre du chat change (ex : l'IA génère un nouveau titre ou vous le renommez manuellement), le titre de l'onglet du navigateur se met à jour instantanément de \"Gemini\" au sujet spécifique de la conversation.\n- **Support Universel** : Fonctionne parfaitement avec les pages de chat standard, les conversations Gem, et les environnements multi-comptes.\n- **Contrôle d'Activation** : Si vous préférez le comportement par défaut, vous pouvez facilement désactiver cette fonctionnalité dans la section \"Options Générales\" du panneau de paramètres.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/tab-title.png\" alt=\"Synchro Titre Onglet\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## Comment Utiliser\n\n1. Cette fonctionnalité est activée par défaut lors de l'installation.\n2. Ouvrez n'importe quelle conversation Gemini et observez le titre de l'onglet du navigateur ; il se mettra automatiquement à jour pour correspondre au titre du chat.\n3. Pour désactiver :\n   - Cliquez sur l'icône de l'extension pour ouvrir le panneau de paramètres.\n   - Trouvez \"Options Générales\".\n   - Désactivez \"Mettre à jour le titre de l'onglet\".\n"
  },
  {
    "path": "docs/fr/guide/timeline.md",
    "content": "# Voyage dans le Temps\n\nLes longues conversations sont désordonnées. Vous faites défiler vers le haut, vers le bas, vous perdez le fil.\nVoyager transforme votre conversation en une chronologie.\n\n## Visualisez la Forme de Votre Chat\n\nRegardez sur le côté droit de votre écran.\nChaque nœud représente un message. La chronologie visualise le rythme de votre dialogue.\n\n## La Navigation, Résolue.\n\n- **Téléportation** : Cliquez sur un nœud pour sauter instantanément à ce message.\n- **Coup d'œil** : Survolez pour voir le contenu sans bouger.\n- **Signet** : Appui long sur un nœud pour le marquer d'une **Étoile**. C'est comme un marque-page pour votre cerveau.\n- **Niveaux (Expérimental)** : Clic droit sur un nœud pour définir différents niveaux (1-3) ou réduire les enfants. Parfait pour clarifier les conversations ramifiées.\n- **Clavier** : Naviguez à la vitesse de la pensée. Par défaut `j`/`k`, personnalisable à volonté.\n\n![Navigation Temporelle](/assets/teaser.png)\n\n## Encore Plus Vite au Clavier\n\nVous ne voulez pas utiliser la souris ? Utilisez votre clavier.\n\n**C'est comme activer le mode Vim dans Gemini.**\n\n### Raccourcis par Défaut\n\n- `k` - Sauter au nœud précédent\n- `j` - Sauter au nœud suivant\n\n### Personnalisez-le\n\nOuvrez les paramètres de l'extension, cliquez sur une case de raccourci, appuyez sur n'importe quelle touche.\nN'importe quelle touche, n'importe quelle combinaison. `n`/`p` ? `,`/`.` ? À vous de décider.\n\n**Mode Flux** : Les appuis rapides s'enchaînent de manière fluide.\n**Mode Saut** : Réponse instantanée, vitesse maximale.\n"
  },
  {
    "path": "docs/fr/index.md",
    "content": "---\nlayout: home\n\nhero:\n  name: 'Voyager'\n  text: \"L'OS manquant pour Gemini.\"\n  tagline: \"Nous aimons Gemini. Nous voulions juste qu'il soit parfait.\"\n  image:\n    src: /logo.png\n    alt: Logo Voyager\n  actions:\n    - theme: brand\n      text: Télécharger\n      link: ./guide/installation\n    - theme: alt\n      text: Commencer\n      link: ./guide/getting-started\n\nteaser:\n  title: 'Ça marche, tout simplement.'\n  description: 'Nous ne voulions pas créer une autre extension. Nous voulions créer une meilleure façon de penser.<br>Quand vous utilisez Voyager, vous arrêtez de vous battre avec l’interface pour couler avec elle.'\n  image: '/assets/teaser.png'\n  features:\n    - title: 'Chronologie'\n      details: 'Ne scrollez plus. Volez. Sautez à n’importe quel point de votre conversation instantanément.'\n    - title: 'Dossiers'\n      details: 'Enfin un système de fichiers pour votre IA. Natif, intuitif, puissant.'\n    - title: 'Liberté'\n      details: 'Vos données sont à vous. Exportez en JSON, Markdown ou PDF en un clic.'\n\nfeatures:\n  - icon: 🧭\n    title: Chronologie\n    details: Une carte pour votre esprit. Naviguez visuellement dans vos conversations.\n  - icon: 🗂️\n    title: Dossiers\n    details: De l'ordre dans le chaos. Glissez, déposez, c'est fait.\n  - icon: ✨\n    title: Coffre-fort\n    details: Votre génie, capturé. Enregistrez et réutilisez vos meilleurs prompts.\n  - icon: 💬\n    title: Réponse avec Citation\n    details: Sélectionnez pour citer. Réponses contextualisées pour une communication efficace.\n  - icon: ↔️\n    title: Largeur du chat\n    details: Voyez large. Ajustez librement la largeur du chat pour une meilleure expérience de visualisation.\n  - icon: 💾\n    title: Export de Chat\n    details: Souveraineté des données. Archivez en plusieurs formats pour ne rien perdre.\n  - icon: 🌦️\n    title: Effets Visuels\n    details: Créez l'ambiance. Basculez entre neige, pluie et pétales de sakura depuis la fenêtre popup.\n  - icon: 🍌\n    title: Suppression Filigrane\n    details: Suppression sans perte du filigrane. Gardez les moments IA purs.\n  - icon: 📐\n    title: Copie de Formules\n    details: Copie en un clic des codes sources LaTeX et MathML (Word).\n  - icon: 🧜‍♀️\n    title: Diagrammes Mermaid\n    details: Du code aux visuels. Organigrammes, diagrammes de séquence, diagrammes de Gantt rendus instantanément.\n  - icon: 🏷️\n    title: Synchro Titre Onglet\n    details: Sachez en un coup d'œil. Synchro auto du titre de l'onglet avec votre chat.\n  - icon: 🔀\n    title: Bifurcation de Conversation (Expérimental)\n    details: Pensée divergente. Séparez la conversation à n'importe quel point pour explorer d'autres possibilités.\n  - icon: 🗑️\n    title: Suppression par Lot\n    details: Nettoyage en masse. Sélectionnez plusieurs conversations et supprimez-les en une fois.\n  - icon: ☁️\n    title: Synchronisation Cloud\n    details: Toujours synchronisé. Sauvegardez dossiers et prompts sur Google Drive entre appareils.\n  - icon: ⚡️\n    title: Modèle par défaut\n    details: Arrêtez de vous répéter. Basculez automatiquement vers votre modèle préféré pour les nouveaux chats.\n  - icon: 🔬\n    title: Deep Research\n    details: Ouvrez la boîte noire. Extrayez les processus de recherche et liens des sessions Deep Research.\n---\n\n<div class=\"vp-doc\" style=\"margin: 2rem auto 0; max-width: 780px; padding: 0 16px;\">\n  <div style=\"background: rgba(234, 179, 8, 0.12); border: 1px solid rgba(234, 179, 8, 0.6); border-radius: 8px; padding: 12px 16px;\">\n    <strong>⚠️ Avis de changement de nom</strong> : En raison de problèmes de marque et de droits d'auteur, cette extension a été officiellement renommée <strong>Voyager</strong>. Cependant, en raison de la lenteur extrême du processus de révision du Chrome Web Store, le changement de nom n'a pas été approuvé dans les 7 jours — elle est temporairement indisponible sur le Chrome Web Store.\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 780px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 8px; font-weight: 600; font-size: 1.05em;\">Chaque installation est un vote de confiance</h3>\n  <p style=\"margin: 0 0 16px; opacity: 0.78; font-size: 0.95em;\">Données en direct du Chrome Web Store et GitHub. Merci de voyager avec nous.</p>\n  <div style=\"display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;\">\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Stars\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Forks\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Dernière Release\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub Downloads\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store Users\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store Rating\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoft-edge\" alt=\"Edge Add-ons\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons Users\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons Rating\">\n  </div>\n  <div style=\"margin-top: 16px; display: flex; justify-content: center; flex-wrap: wrap; gap: 12px;\">\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <!--<a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>-->\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 1000px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 24px; font-weight: 600; font-size: 1.2em;\">Remerciements Spéciaux</h3>\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" style=\"margin: 0 auto;\" />\n  </a>\n  <p style=\"margin-top: 24px; font-size: 1.05em; opacity: 0.86;\">✨ Nous sommes sur Product Hunt ! Nous serions ravis d'avoir vos avis et retours. ❤️</p>\n  <div style=\"margin-top: 12px; display: flex; justify-content: center;\">\n    <a href=\"https://www.producthunt.com/posts/gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager sur Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 3.5rem auto 2rem; max-width: 720px; padding: 0 16px;\">\n  <p style=\"font-size: 1.05em; font-weight: 600; opacity: 0.86; margin: 0 0 12px;\">“Ce n'est pas juste un outil. C'est une bicyclette pour l'esprit.”</p>\n  <a href=\"./guide/getting-started\" style=\"font-weight: 600; text-decoration: none;\">Voir ce qui est possible →</a>\n</div>\n\n<p align=\"center\">\n  <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n</p>\n"
  },
  {
    "path": "docs/fr/privacy.md",
    "content": "# Politique de Confidentialité\n\nDernière mise à jour : 16 mars 2026\n\n## Introduction\n\nVoyager (\"nous\", \"notre\", ou \"nos\") s'engage à protéger votre vie privée. Cette Politique de Confidentialité explique comment notre extension de navigateur collecte, utilise et protège vos informations.\n\n## Collecte et Utilisation des Données\n\n**Nous ne collectons aucune information personnelle.**\n\nVoyager fonctionne entièrement dans votre navigateur. Toutes les données générées ou gérées par l'extension (comme les dossiers, les modèles de prompts, les messages favoris et les paramètres) sont stockées :\n\n1. Localement sur votre appareil (`chrome.storage.local`)\n2. Dans le stockage synchronisé de votre navigateur (`chrome.storage.sync`) s'il est disponible, pour synchroniser les paramètres entre vos appareils.\n\nNous n'avons accès à aucune de vos données personnelles, historiques de chat ou autres informations privées. Nous ne suivons pas votre historique de navigation.\n\n## Synchronisation Google Drive (Optionnelle)\n\nSi vous activez explicitement la fonction de synchronisation Google Drive, l'extension utilise l'API Chrome Identity pour obtenir un jeton OAuth2 (avec le scope `drive.file` uniquement) afin de sauvegarder vos dossiers et prompts sur **votre propre Google Drive**. Ce transfert s'effectue directement entre votre navigateur et les serveurs de Google. Nous n'avons pas accès à ces données et elles ne sont jamais envoyées à un serveur que nous exploitons.\n\n## Permissions\n\nL'extension demande le minimum de permissions nécessaires pour fonctionner :\n\n- **Storage (Stockage)** : Pour enregistrer vos préférences, dossiers, prompts, messages favoris et options de personnalisation de l'interface localement et entre vos appareils.\n- **Identity (Identité)** : Pour l'authentification Google de la fonction optionnelle de synchronisation Google Drive. Utilisé uniquement lorsque vous activez explicitement la synchronisation cloud.\n- **Scripting (Scripts)** : Pour injecter dynamiquement des scripts de contenu sur les pages Gemini et sur les sites web personnalisés spécifiés par l'utilisateur pour la fonction Gestionnaire de Prompts. Seuls les scripts intégrés à l'extension sont injectés — aucun code distant n'est récupéré ou exécuté.\n- **Host Permissions (Permissions d'hôte)** (gemini.google.com, aistudio.google.com, etc.) : Pour injecter des scripts de contenu qui améliorent l'interface Gemini avec des fonctionnalités comme les dossiers, l'exportation, la timeline et la citation de réponse. Les domaines Google supplémentaires (googleapis.com, accounts.google.com) sont nécessaires pour l'authentification de la synchronisation Google Drive.\n- **Optional Host Permissions (Permissions d'hôte optionnelles)** (toutes les URL) : Demandées uniquement au moment de l'exécution lorsque vous ajoutez explicitement des sites web personnalisés pour le Gestionnaire de Prompts. Jamais activées sans votre action.\n\n## Services Tiers\n\nVoyager ne partage aucune donnée avec des services tiers, des annonceurs ou des fournisseurs d'analyse.\n\n## Modifications de cette Politique\n\nNous pouvons mettre à jour notre Politique de Confidentialité de temps à autre. Nous vous informerons de tout changement en publiant la nouvelle Politique de Confidentialité sur cette page.\n\n## Nous Contacter\n\nSi vous avez des questions concernant cette Politique de Confidentialité, veuillez nous contacter via notre [Dépôt GitHub](https://github.com/Nagi-ovo/gemini-voyager).\n"
  },
  {
    "path": "docs/guide/batch-delete.md",
    "content": "# 批量删除\n\n一次性删除多个对话，告别逐个删除的繁琐操作。\n\n## 功能介绍\n\n- **多选模式**：长按任意对话进入多选模式，可勾选多个要删除的对话。\n- **一键清理**：选中后点击删除按钮，批量删除所有选中的对话。\n- **进度反馈**：删除过程中显示实时进度，让你了解当前状态。\n- **安全确认**：删除前会弹出确认对话框，防止误操作。\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/batch-delete.png\" alt=\"批量删除\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## 如何使用\n\n1. 在侧边栏的对话列表中，**长按**任意一个对话项。\n2. 进入多选模式后，对话项左侧会出现复选框。\n3. 勾选你想要删除的对话（一次最多可选 50 个）。\n4. 点击出现的 **删除按钮**。\n5. 在**文件夹列表上方**出现的红色确认区域中点击“确定”，即可开始批量删除。\n\n::: tip 提示\n删除确认面板会直接覆盖在文件夹区域上方，以免遮挡对话列表。批量删除操作无法撤销，请谨慎操作。\n:::\n"
  },
  {
    "path": "docs/guide/cloud-sync.md",
    "content": "# 云同步\n\n将您的文件夹、灵感库（Prompts）等数据同步到 Google Drive，在不同设备间保持一致。\n\n## 功能特点\n\n- **多端同步**：利用 Google Drive 云端存储，在多台电脑上同步您的配置。\n- **全面覆盖**：支持同步 **文件夹结构**、**提示词库 (Prompts)** 等核心数据。\n- **数据安全**：数据存储在您自己的 Google Drive 空间中，不经过第三方服务器，确保隐私安全。\n- **灵活同步**：支持手动上传、下载合并数据。\n\n## 如何使用\n\n1. 在 Gemini™ 页面点击右下角的扩展图标，打开设置面板。\n2. 找到 **云同步** 区域。\n3. 点击 **使用 Google 登录** 并完成授权。\n4. 授权成功后，点击 **上传到云端** 将本地数据同步到云端，或点击 **从云端下载合并** 将云端数据同步到本地。\n\n### 💡 极速同步\n\n最简单的方法是在左侧侧边栏的**文件夹区域顶部**，直接点击“上传到云端”或“下载并合并”按钮。\n\n<img src=\"/assets/cloud-sync.png\" alt=\"云同步快捷按钮\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n::: warning\n**安全建议：双重保护**  \n虽然云同步提供了极大的便利，但为了您的数据万无一失，我们强烈建议您定期通过**本地文件方式**手动备份核心数据。\n\n1. **导出全量配置**：在设置面板底部的“备份与恢复”中导出包含所有设置、文件夹和提示词的完整备份。\n   <img src=\"/assets/manual-export-all.png\" alt=\"导出全量配置\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. **导出所有文件夹**：在设置面板的“文件夹”区域点击“导出”，仅备份所有文件夹结构及对话，不包含提示词。\n   <img src=\"/assets/manual-folder-export.png\" alt=\"导出所有文件夹\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n   :::\n"
  },
  {
    "path": "docs/guide/community.md",
    "content": "# 交流与反馈\n\n我们非常重视每一位用户的声音。无论你是遇到了 Bug、有功能建议，还是想分享你构建的指令宝库，都可以通过以下方式与我们联系。\n\n## 📢 关注动态\n\n关注我们的 X (Twitter) 账号，获取最新开发进展。\n\n- **新版本发布**：第一时间了解更新内容。\n- **功能预告**：提前知晓即将到来的功能。\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://x.com/Nag1ovo/status/2012609459663634589\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/badge/关注-𝕏-000000?style=for-the-badge&logo=x&logoColor=white\" alt=\"关注 X\">\n  </a>\n</div>\n\n## 💬 Discord 社区\n\n加入我们的 Discord 服务器，与其他 Voyager 交流心得！\n\n- **即时聊天**：与其他用户和开发者直接对话。\n- **提示词分享**：看看大家都在用什么样的 Prompts。\n- **开发进展**：第一时间获取新功能的开发动态。\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://discord.gg/TEUFxdMbGb\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/discord/1463273957120675973?style=for-the-badge&logo=discord&logoColor=white&label=加入%20Discord%20社区\" alt=\"Discord\">\n  </a>\n</div>\n\n## 🐙 GitHub Issues\n\n如果你发现了程序错误（Bug）或有明确的功能需求（Feature Request），建议在 GitHub 上提交 Issue：\n\n- [提交 Bug 报告](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=bug_report.yml)\n- [提交功能建议](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=feature_request.yml)\n\n感谢你对 Voyager 的支持！❤️\n"
  },
  {
    "path": "docs/guide/context-sync.md",
    "content": "# 记忆搬运：上下文同步（实验性）\n\n**不同次元，丝滑共享**\n\n在网页端推演逻辑，在 IDE 里落地代码。 Voyager 打通次元壁，让你的 IDE 瞬间拥有网页端的“思维过程”。\n\n## 告别反复横跳\n\n开发者最烦的事：在网页上聊透了方案，回到 VS Code/Trae/Cursor 却要像面对陌生人一样重新解释需求。 由于额度和响应速度，网页端是“大脑”，IDE 是“手”。 Voyager 让它们共用一个灵魂。\n\n## 极简三步，同频呼吸\n\n1. **安装并唤醒桥接器**：\n   安装 **CoBridge** 插件。它是连接网页与本地 IDE 的核心桥梁。\n   - **[前往插件市场安装](https://open-vsx.org/extension/windfall/co-bridge)**\n\n   ![CoBridge扩展](/assets/CoBridge-extension.png)\n\n   安装完成后，**打开任意工作目录**，点击右侧图标并启动服务器。\n   ![CoBridge服务器开启](/assets/CoBridge-on.png)\n\n2. **握手对接**：\n   - 在 Voyager 设置中开启“上下文同步”。\n   - 对齐端口号。看到 “IDE Online”，说明它们已经连上了。\n\n   ![上下文同步面板](/assets/context-sync-console.png)\n\n3. **一键同步**：点一下 **\"Sync to IDE\"**。无论是复杂的**数据表格**，还是直观的**参考图片**，都能瞬间瞬移到你的 IDE 中。\n\n   ![同步完成](/assets/sync-done.png)\n\n## 落地生根\n\n同步完成后，你的 IDE 工作目录会多出一个 `.cobridge/AI_CONTEXT.md`。 无论是 Trae、Cursor 还是 Copilot，它们会通过各自的 Rule 文件自动读取这份“记忆”。\n\n```\nyour-project/\n├── .cobridge/\n│   ├── images/\n│   │   ├── context_img_1_1.png\n│   │   └── context_img_1_2.png\n│   └── AI_CONTEXT.md\n├── .github/\n│   └── copilot-instructions.md\n├── .gitignore\n├── .traerules\n└── .cursorrules\n```\n\n## 它的原则\n\n- **零污染**：CoBridge 自动操作 `.gitignore`，不会把你这些私密对话推到 Git 仓库里。\n- **懂行**：全 Markdown 格式，IDE 里的 AI 读起来就像读说明书一样顺畅。\n- **小贴士**：如果对话太久远，先用【时间线】向上划一下，让网页把记忆“想起来”，再同步效果更佳。\n\n---\n\n## 立刻起航\n\n**思维已在云端就绪，现在，让它在本地落地生根。**\n\n- **[安装 CoBridge 插件](https://open-vsx.org/extension/windfall/co-bridge)**：找到你的次元传送门，一键开启“同频呼吸”。\n- **[访问 GitHub 仓库](https://github.com/Winddfall/CoBridge)**：深入了解 CoBridge 的底层逻辑，或者为这个“同步灵魂”的项目点个 Star。\n\n> **大模型从此不再失忆，上手即战。**\n"
  },
  {
    "path": "docs/guide/deep-research.md",
    "content": "# Deep Research 导出\n\n导出 Deep Research 生成的最终报告，或将其完整的“思考”过程保存为 Markdown 文件。\n\n## 1. 报告导出 (PDF / 图片)\n\nDeep Research 生成的报告支持导出为格式精美的 PDF 或方便分享的单张图片（同时也支持导出为 Markdown 和 JSON 格式）。\n\n![报告导出](/assets/deep-research-report-export.png)\n\n## 2. 思考过程导出 (Markdown)\n\n除了最终报告，您还可以将对话中的完整“思考”内容一键导出。\n\n### 功能特性\n\n- **一键导出**: 点击分享和导出按钮即可下载\n- **结构化格式**: 按原始顺序保留思考阶段、思考条目和研究网站\n- **双语标题**: Markdown 文件包含英文和当前语言的双语章节标题\n- **自动命名**: 文件使用时间戳命名,便于整理 (例如:`deep-research-thinking-20240128-153045.md`)\n\n### 使用方法\n\n1. 在 Gemini™ 上打开一个 Deep Research 对话\n2. 点击对话的**分享和导出**按钮\n3. 选择 \"下载 Thinking 内容\" (Download thinking content)\n4. Markdown 文件将自动下载\n\n![Deep Research 思考内容导出](/assets/deepresearch_download_thinking.png)\n\n### 导出文件格式\n\n导出的 Markdown 文件包含:\n\n- **标题**: 对话标题\n- **元数据**: 导出时间和思考阶段总数\n- **思考阶段**: 每个阶段包含:\n  - 思考条目 (包含标题和内容)\n  - 研究网站 (包含链接和标题)\n\n#### 示例结构\n\n```markdown\n# Deep Research 对话标题\n\n**导出时间 / Exported At:** 2025-12-28 17:25:35\n**总思考阶段 / Total Phases:** 3\n\n---\n\n## 思考阶段 1 / Thinking Phase 1\n\n### 思考标题 1\n\n思考内容...\n\n### 思考标题 2\n\n思考内容...\n\n#### 研究网站 / Researched Websites\n\n- [domain.com](https://example.com) - 页面标题\n- [another.com](https://another.com) - 另一个标题\n\n---\n\n## 思考阶段 2 / Thinking Phase 2\n\n...\n```\n\n## 隐私保护\n\n所有提取和格式化操作都 100% 在浏览器本地完成。不会向外部服务器发送任何数据。\n"
  },
  {
    "path": "docs/guide/default-model.md",
    "content": "# 默认模型\n\n::: info\n**注意**：该功能仅在 1.1.9 及后续版本中支持。\n:::\n\n为 Gemini™ 添加设置默认模型的功能，避免每次开启新对话时都需要手动切换。\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/default-model.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n## 功能特点\n\n- **交互式设置**：在 Gemini 的模型选择菜单中直接注入“星标”按钮。\n- **自动切换**：开启新对话时，插件会自动为您切换到预设的默认模型。\n- **持久化保存**：您的偏好会被保存，跨设备同步（如果开启了插件同步）。\n- **优化体验**：针对单页应用（SPA）优化，无论是点击“新对话”按钮、快捷键还是直接访问 `/app` 路径，均能准确触发。\n\n## 如何使用\n\n1. 点击 Gemini 输入框上方的**模型选择器**。\n2. 鼠标悬停在您想要设为默认的模型上，点击出现的**空心星标**。\n3. 星标变为**实心**后，该模型即被设为默认。\n4. 下次访问首页或发起新对话时，系统会自动为您选中该模型。\n5. 如需取消，再次点击实心星标即可。\n"
  },
  {
    "path": "docs/guide/export.md",
    "content": "# 彻底自由\n\n数据被锁死，是最坏的体验。\n我们信条很简单：你创造的，就是你的。\n\n## 带走一切\n\nVoyager 帮你把数据从云端拽回手心。\n\n### 格式随你选\n\n- **Markdown**：给 Obsidian 或 Notion 用。干净清爽。（Safari 用户注意：由于浏览器限制无法提取图片，建议使用 PDF 导出）\n- **PDF**：发给别人或打印。排版精美，图文并茂。\n- **JSON**：给开发者。原始数据，怎么玩随你。\n\n### 怎么导\n\n1. 鼠标悬停在 Gemini Logo 上，即可看到出现的 **导出图标**。\n2. 选格式。\n3. 拿走。\n\n你的数据，听你的。\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>1. 悬停 Logo</b></p>\n    <img src=\"/assets/gemini-export-guide-1.png\" alt=\"导出指南步骤 1\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>2. 选格式</b></p>\n    <img src=\"/assets/gemini-export-guide-2.png\" alt=\"导出指南步骤 2\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n### Safari PDF 导出特别说明\n\n在 Safari 上导出 PDF 步骤略有不同（需手动打印）：\n\n1. 点击 **导出** 按钮，选择 PDF 格式。\n2. **等待一秒左右**（让页面准备好打印样式）。\n3. 按 `Command + P` 调出打印界面。\n4. 在打印界面中选择 **\"Save to PDF\"**（或 \"存储为 PDF\"）。\n\n<img src=\"/assets/safari-export-pdf.png\" alt=\"Safari Export PDF\" style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 20px;\"/>\n"
  },
  {
    "path": "docs/guide/folders.md",
    "content": "# 文件夹，本该如此\n\n整理 AI 聊天记录，以前怎么那么难？\n我们修好了。给你的思绪，装个文件系统。\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap; margin-bottom: 40px;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Gemini™</b></p>\n    <img src=\"/assets/gemini-folders.png\" alt=\"Gemini 文件夹\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>AI Studio</b></p>\n    <img src=\"/assets/aistudio-folders.png\" alt=\"AI Studio 文件夹\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n## 整理的直觉\n\n手感对了，一切都对了。\n\n- **拖拽**：抓起来，扔进去。真实物理反馈。\n- **套娃**：大项目套小项目。无限层级，随你定义。\n- **间距**：自由调节侧边栏密度，从紧凑到宽松。\n  > _注：Mac Safari 上的调整可能不是实时的，刷新页面即可生效。_\n- **同步**：电脑上理好，笔记本上就能用。\n\n## 绝招\n\n- **多选**：长按对话项进入多选模式，批量操作，一次搞定。\n- **改名**：双击文件夹，直接改。\n- **识图**：代码、写作、闲聊... 我们自动识别 Gem 类型，配上图标。你只管用，剩下的交给我们。\n\n## 平台特性差异\n\n### 通用功能\n\n- **基础管理**：拖拽排序、重命名、多选操作。\n- **智能识别**：自动识别对话类型并匹配图标。\n- **多级目录**：支持文件夹嵌套，结构更深邃。\n- **AI Studio 适配**：上述高级功能即将支持 AI Studio。\n- **Google Drive 同步**：支持将文件夹结构同步到 Google Drive。\n\n### Gemini 专属增强\n\n#### 自定义颜色\n\n点击文件夹图标自定义颜色。内置 7 种默认配色，亦支持通过调色盘选取你的专属色彩。\n\n<img src=\"/assets/folder-color.png\" alt=\"文件夹配色\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### 账号隔离\n\n自动隔离不同 Google 账号的对话列表，防止数据混淆。\n\n<img src=\"/assets/current-user-only.png\" alt=\"账号隔离模式\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### AI 自动整理\n\n对话太多，懒得手动分？让 Gemini 替你想。\n\n一键复制当前对话结构，粘贴给 Gemini，它会帮你生成一份整理好的文件夹方案——直接导入，即刻生效。\n\n**第一步：复制对话结构**\n\n在扩展弹窗的文件夹区域底部，点击 **AI Organize** 按钮。它会自动收集你所有未归类的对话和现有文件夹结构，生成一段提示词并复制到剪贴板。\n\n<img src=\"/assets/ai-auto-folder.png\" alt=\"AI Organize 按钮\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n\n**第二步：让 Gemini 整理**\n\n将剪贴板内容粘贴到 Gemini 对话中，它会分析你的对话标题并输出一段 JSON 格式的文件夹方案。\n\n**第三步：导入分类结果**\n\n点击文件夹面板菜单中的 **Import folders**，选择 **Or paste JSON directly**，将 Gemini 返回的 JSON 粘贴进去，点击 **Import**。\n\n<div style=\"display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; margin-bottom: 24px;\">\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-2.png\" alt=\"导入菜单\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 240px;\"/>\n  </div>\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-3.png\" alt=\"粘贴 JSON 导入\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n  </div>\n</div>\n\n- **增量合并**：默认使用\"Merge\"策略，只添加新文件夹和新分配，不会破坏你已有的分类。\n- **多语言**：提示词会自动使用你设置的语言，文件夹名也会用对应语言生成。\n\n### AI Studio 专属增强\n\n- **侧边栏调节**：鼠标拖拽边缘，自由调整侧边栏宽度。\n- **库拖拽支持**：支持直接从 Library 列表中拖拽项目到文件夹。\n"
  },
  {
    "path": "docs/guide/fork.md",
    "content": "# 对话分支 (实验性)\n\n思维不应是一条单行道。在复杂的探索中，我们常常需要回到某个关键节点，尝试不同的可能性。\n\nVoyager 带来的 **对话分支** 功能，让你能够轻松发散思维，探索对话的平行宇宙。\n\n## 功能介绍\n\n> **⚠️ 提示**：该功能目前处于实验阶段。你需要先点击浏览器扩展图标打开设置弹窗，并开启 **启用对话分支** 开关。\n\n在任何你想要发散思绪的时刻，只需将鼠标悬停在你的提问上，点击 **对话分支** 按钮：\n\n![对话分支](/assets/branching.png)\n\nVoyager 会立刻截取从对话开头到该节点的所有上下文，并为你 **开启一段全新的对话**。\n\n你可以在这个新分支中尽情修改提问，尝试不同的方向，而不必担心破坏原有的对话历史。尽情释放你的创造力与好奇心吧！\n"
  },
  {
    "path": "docs/guide/formula-copy.md",
    "content": "# 公式复制\n\nVoyager 让数学公式和科学符号的复用变得异常简单。支持一键复制 LaTeX 源码以及兼容 Microsoft Word 的 MathML 格式。\n\n## 功能介绍\n\n当你要求 Gemini 推导公式或编写数学表达式时，Gemini 通常会使用 LaTeX 渲染。虽然看起来很美观，但如果你想将这些公式复制到自己的论文、文档或编辑器中，往往需要手动提取源码。\n\nVoyager 为此提供了无缝支持：\n\n1. **自动识别**：Voyager 会自动识别页面中渲染出的 LaTeX 公式。\n2. **复制按钮**：当你将鼠标悬停在公式上时，公式右侧会出现复制图标。\n3. **格式选择**：点击复制图标，你可以选择：\n   - **Copy LaTeX**: 复制标准的 LaTeX 源码，适用于 Overleaf、Markdown 编辑器等。\n   - **Copy MathML**: 复制 MathML 源码，这是最适合直接粘贴到 **Microsoft Word** 中的格式。\n\n![公式复制](/assets/gemini-math-copy.png)\n\n## 特性\n\n- **Word 完美兼容**：通过 MathML 支持，你可以将复杂的 AI 输出公式直接粘贴进 Word 文档，保持完美的可编辑公式格式。\n- **上下文保留**：不仅复制公式本身，还保留了公式的数学语境。\n- **极速响应**：完全在本地处理，点击即得。\n\n## 使用技巧\n\n- **论文写作**：在 Word 中编写论文时，让 Gemini 推导公式，然后使用 MathML 复制并粘贴，省去手动在 Word 公式编辑器中输入的烦恼。\n- **代码笔记**：在 Obsidian 或 Notion 中记笔记时，直接复制 LaTeX 源码即可。\n"
  },
  {
    "path": "docs/guide/getting-started.md",
    "content": "# 欢迎登船\n\n恭喜。你的工作流刚刚升舱了。\nVoyager 不只是工具，它是种习惯。给我 5 分钟，带你上手。\n\n## 1. 就位\n\n还没装？去 [安装指南](/guide/installation)。\n装好了？刷新 Gemini 页面。变化立竿见影。\n\n## 2. 穿梭\n\n聊个长的。比如聊聊汉字演变史，或者量子力学。\n看右边。\n**那串点，就是你的导航图。**\n\n- **指**：瞥一眼那会儿说了啥。\n- **点**：瞬间穿越回去。\n- **按**：长按加星，标记高光时刻。\n\n别再滚轮滚到手酸。思维多快，你就多快。\n\n## 3. 归档\n\n看左边的聊天列表。\n**文件夹来了。**\n\n拎起一个聊天，拖进去，松手。\n丝般顺滑。嵌套、重命名，随你心意。把脑子里的杂乱清空，只留清爽。\n\n## 4. 珍藏\n\n写了个绝妙的提示词？别让它滑走。\n点输入框里的 **✨ 图标**。\n存下来，打个标签。\n\n下次要用？\n点一下图标，搜一搜，插入。\n这不是聊天，这是在沉淀你的数字资产。\n\n---\n\n**起飞。**\n去深挖每个功能：\n\n- [玩转时间轴](/guide/timeline)\n- [精通文件夹](/guide/folders)\n- [管理提示词](/guide/prompts)\n- [掌握数据](/guide/export)\n"
  },
  {
    "path": "docs/guide/input-collapse.md",
    "content": "# 输入框折叠\n\n输入框为空时自动折叠，获得更多阅读空间。点击折叠后的按钮即可展开输入。\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/hide-input-area.png\" alt=\"输入框折叠\" style=\"max-width: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## 如何使用\n\n1. 当输入框为空且失去焦点时，会自动折叠为一个简洁的胶囊按钮\n2. 点击胶囊按钮即可展开输入框，开始输入\n3. 也可以按 <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>I</kbd> 快速展开输入框\n4. 在设置面板中可以开启或关闭此功能（默认关闭）\n"
  },
  {
    "path": "docs/guide/installation.md",
    "content": "# 安装\n\n::: info 新闻\n🍎 **Safari 浏览器原生插件已推出！** 现在支持一键安装并完全免费。\n:::\n\n选一条路。\n\n> ⚠️ 提示词管理器是唯一支持 Gemini™ 企业版的功能。\n\n## 1. 官方商店（推荐）\n\n最简单的方式，支持自动更新。\n\n**Chrome / Brave / Opera / Vivaldi：**\n\n[<img src=\"https://img.shields.io/badge/Chrome_应用店-前往下载-4285F4?style=for-the-badge&logo=google-chrome&logoColor=white\" alt=\"从 Chrome 网上应用店安装\" height=\"40\"/>](https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=docs&utm_campaign=organic_growth&utm_content=zh)\n\n::: warning ⚠️ Chrome Web Store 暂时不可用\n由于商标版权问题插件正式改名为 **Voyager**，Chrome Web Store 审核仍在进行中，暂时无法使用。详情见[此帖](https://x.com/Nag1ovo/status/2031561180213313944)。请使用下方 **Edge / Firefox** 或**手动安装**。\n:::\n\n**Microsoft Edge：**\n\n[<img src=\"https://img.shields.io/badge/Microsoft_Edge-前往下载-0078D7?style=for-the-badge&logo=microsoft-edge&logoColor=white\" alt=\"从 Microsoft Edge Add-ons 安装\" height=\"40\"/>](https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne)\n\n**Firefox：**\n\n[<img src=\"https://img.shields.io/badge/Firefox_Add--ons-前往下载-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"从 Firefox Add-ons 安装\" height=\"40\"/>](https://addons.mozilla.org/firefox/addon/gemini-voyager/)\n\n## 2. 手动（抢鲜版）\n\n应用店审核慢。如果你追求最新功能，走这条路。\n\n**Chrome / Edge / Brave / Opera：**\n\n1. 去 [GitHub Releases](https://github.com/Nagi-ovo/gemini-voyager/releases) 下最新的 `gemini-voyager-chrome-vX.Y.Z.zip`。\n2. 解压。\n3. 打开扩展页 (`chrome://extensions`)。\n4. 开 **开发者模式** (右上角)。\n5. 点 **加载已解压的扩展程序**，选刚才的文件夹。\n\n**Firefox：**\n\n1. 去 [Releases](https://github.com/Nagi-ovo/gemini-voyager/releases) 下最新的 `gemini-voyager-firefox-vX.Y.Z.xpi`。\n2. 打开扩展管理页 (`about:addons`)。\n3. 把下载的 `.xpi` 文件拖进去安装（或者点右上角齿轮 ⚙️ -> **从文件安装附加组件**）。\n\n> 💡 XPI 文件已获 Mozilla 官方签名，可在所有 Firefox 版本中永久安装。\n\n## 3. Safari (macOS)\n\nSafari 现在支持直接分发！下载预签名的应用：\n\n1. 下载 <SafariDownloadLink>最新 Safari 版本 (.dmg)</SafariDownloadLink>。\n2. 双击打开后按提示安装应用。\n3. 双击启动应用。\n4. 在 **Safari 设置 > 扩展** 中启用。\n\n> 💡 Safari 版本现已直接签名分发——不再需要 Xcode 转换！\n>\n> ⚠️ **已知限制**：由于 Safari 特性，(a) 水印去除 (b) 图片导出（推荐用 PDF）暂不支持。\n\n---\n\n_想贡献代码？开发者请移步 [贡献指南](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/CONTRIBUTING.md)。_\n"
  },
  {
    "path": "docs/guide/markdown-fix.md",
    "content": "# Markdown 渲染修复\n\nGemini™ 的网页界面有时会在文本中插入 HTML 元素（例如引用来源或高亮标记），这可能会破坏 Markdown 的加粗语法（`**text**`），导致文本无法正确加粗显示。\n\nVoyager 内置了自动修复功能，能够智能识别并修复这些断裂的加粗标签，确保文档渲染的整洁与准确。\n\n> [!INFO]\n> 此功能为自动启用，无需额外配置。\n"
  },
  {
    "path": "docs/guide/mermaid.md",
    "content": "# Mermaid 图表渲染\n\n自动将 Mermaid 代码渲染为可视化图表。\n\n## 功能介绍\n\n当 Gemini™ 输出 Mermaid 代码块时（如流程图、时序图、甘特图等），Voyager 会自动检测并渲染为交互式图表。\n\n### 主要特性\n\n- **自动检测**：支持 `graph`、`flowchart`、`sequenceDiagram`、`gantt`、`pie`、`classDiagram` 等所有主流 Mermaid 图表类型\n- **一键切换**：通过按钮在渲染图表和源代码之间自由切换\n- **全屏查看**：点击图表进入全屏模式，支持滚轮缩放和拖拽平移\n- **深色模式**：自动适配页面主题\n\n## 使用方法\n\n1. 让 Gemini 生成任意 Mermaid 图表代码\n2. 代码块会自动替换为渲染后的图表\n3. 点击 **</> Code** 按钮查看原始代码\n4. 点击 **📊 Diagram** 按钮切回图表视图\n5. 点击图表区域进入全屏查看\n\n## 全屏模式操作\n\n- **滚轮**：缩放图表\n- **拖拽**：移动图表位置\n- **+/-**：工具栏缩放按钮\n- **⊙**：重置视图\n- **✕ / ESC**：关闭全屏\n\n## 兼容性与故障排除\n\n::: warning 说明\n\n- **Firefox 限制**：由于环境限制，Firefox 使用 9.2.2 版本，暂不支持 **Timeline**、**Sankey** 等新特性。\n- **语法错误**：渲染失败通常是因为 Gemini 生成的代码有语法错误。我们正在收集 Bad Case，后续将通过补丁自动修复常见的生成错误。\n  :::\n\n<div align=\"center\">\n  <img src=\"/assets/mermaid-preview.png\" alt=\"Mermaid 图表渲染\" style=\"max-width: 100%; border-radius: 8px;\"/>\n</div>\n"
  },
  {
    "path": "docs/guide/nanobanana.md",
    "content": "# NanoBanana 选项\n\n::: warning 浏览器兼容性\n目前 **NanoBanana** 去水印功能由于浏览器 API 限制，**暂不支持 Safari 浏览器**。如果您需要使用此功能，建议使用 **Chrome** 或 **Firefox**。\n\nSafari 用户可以将下载的图片上传到 [banana.ovo.re](https://banana.ovo.re/) 等工具网站进行手动去除（但由于 Gemini™ 图片尺寸的多样性，不能保证每张图片都能成功还原）。\n:::\n\n**AI 图片，本该纯净。**\n\nGemini 生成的图片默认带有可见的水印。虽然这是出于安全考虑，但在某些创作场景下，你可能需要一张完全干净的底稿。\n\n## 无损还原\n\nNanoBanana 采用的是 **反向 Alpha 混合算法 (Reverse Alpha Blending)**。\n\n- **非 AI 重绘**：传统的去水印往往使用 AI 涂抹，会破坏图片细节。\n- **像素级精度**：我们通过数学计算，将叠加在像素上的水印透明层精确移除，还原出 100% 原始的像素点。\n- **零质量损失**：处理前后的图片在非水印区域完全一致。\n\n## 如何使用\n\n1. **开启功能**：在 Voyager 设置面板最后方找到 “NanoBanana 选项”，开启 “去除 NanoBanana 水印”。\n2. **自动触发**：此后你生成的每一张图片，我们都会在后台自动完成去水印处理。\n3. **直接下载**：\n   - 悬停在处理后的图片上，你会看到一个 🍌 按钮。\n   - **🍌 按钮已完全替代**了原生的下载按钮，点击即可直接下载 100% 无水印的图片。\n\n<div style=\"text-align: center; margin-top: 30px;\">\n  <img src=\"/assets/nanobanana.png\" alt=\"NanoBanana 示例\" style=\"border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); max-width: 100%;\"/>\n</div>\n\n## 特别鸣谢\n\n本功能基于 [journey-ad (Jad)](https://github.com/journey-ad) 开发的 [gemini-watermark-remover](https://github.com/journey-ad/gemini-watermark-remover) 项目。该项目是 [allenk](https://github.com/allenk) 开发的 [GeminiWatermarkTool C++ 版本](https://github.com/allenk/GeminiWatermarkTool) 的 JavaScript 移植版。感谢原作者们对开源社区的贡献。🧡\n\n## 隐私与安全\n\n所有的去水印处理均在你的 **浏览器本地** 完成。图片不会被上传到任何第三方服务器，保护你的隐私和创作安全。\n"
  },
  {
    "path": "docs/guide/prevent-auto-scroll.md",
    "content": "# 防自动跳转\n\n在查看过往对话时，如果您输入了新问题并按下回车，Gemini™ 默认会将页面强制滚动到最底部以显示最新生成的回答。这可能会打断您的阅读体验。\n\n**防自动跳转** 功能可以拦截这种不必要的滚动行为：\n\n- 当您向上滚动查看历史记录时，系统会自动阻止页面跳回底部。\n- 此功能在扩展的“时间轴选项”设置中默认**关闭**，您可以手动前往扩展弹窗开启。\n\n## 开启方法\n\n1. 点击浏览器工具栏的 Voyager 扩展图标打开弹窗。\n2. 找到“时间轴选项（Timeline Options）”区域。\n3. 开启“防自动跳转（Prevent Auto Scroll）”开关即可生效。\n"
  },
  {
    "path": "docs/guide/prompts.md",
    "content": "# 你的数字资产：提示词库\n\n磨了半天写出个神级 Prompt，帮大忙了。\n用完就丢？\n不，存起来。\n\n## 指令宝库\n\n这是你的指令宝库。\n\n### 1. 捕获\n\n写出好东西了？点输入框旁边的 **浮窗图标**。\n存入库中，落袋为安。\n\n### 2. 归类\n\n打上 `#代码`、`#邮件`、`#学术` 的标签。\n工具要趁手，也要整洁。\n\n### 3. 调遣\n\n下次要用，别再重敲。\n打开库，搜标签，点一下插入。\n一键调用，效率翻倍。\n\n![提示词管理器](/assets/gemini-prompt-manager.png)\n\n## 任何网站皆可用\n\n提示词管理器现在可以在您选择的任何网站上使用，不仅限于 Gemini™ 和 AI Studio。\n\n### 如何启用\n\n1. 点击浏览器扩展栏的 Voyager 图标。\n2. 滚动到 **提示词管理器** 部分。\n3. 输入网站 URL（例如：`chatgpt.com` 或 `claude.ai`）。\n4. 点击 **添加网站** 并授予权限。\n5. **刷新目标网页**，即可看到悬浮球。\n\n### 常见 AI 网站示例\n\n- `chatgpt.com` - ChatGPT\n- `claude.ai` - Claude\n- `copilot.microsoft.com` - Microsoft Copilot\n- `poe.com` - Poe\n\n::: tip\n在自定义网站上，**仅**激活提示词管理器功能。时间线、文件夹等其他功能是专为 Gemini 设计的，不会加载。\n:::\n"
  },
  {
    "path": "docs/guide/quote-reply.md",
    "content": "# 引用回复\n\nVoyager 提供了便捷的“引用回复”功能，让针对特定内容的回复更加精准高效。\n\n## 功能介绍\n\n在日常对话中，我们经常需要针对 AI 输出的某一段具体内容进行追问或反驳。传统的做法是复制那段话，然后在输入框里手打 `> ` 符号，非常繁琐。\n\nVoyager 简化了这一流程：\n\n1. **选中即引**：在对话页面（无论是你的提问还是 Gemini 的回答）中，用鼠标选中任意一段文字。\n2. **悬浮按钮**：选中文字附近会自动浮现一个“引用回复”按钮。\n3. **一键插入**：点击按钮，选中的文字会自动以标准的 Markdown 引用格式（`> 内容`）插入到你的输入框中。\n\n![引用回复](/assets/quote-reply.png)\n\n## 特性\n\n- **上下文感知**：智能识别对话内容，避免在无关区域（如输入框本身）误触发。\n- **标准格式**：使用通用的 Markdown 语法，Gemini 可以完美理解这种引用结构，从而做出更精准的回应。\n- **多行支持**：如果选中了多行文本，Voyager 会自动为每一行添加引用符号，保持格式整洁。\n\n## 使用技巧\n\n- **追问细节**：选中 Gemini 回答中不清楚的某个概念，点击引用，然后输入“请详细解释一下这个概念”。\n- **纠正错误**：选中回答中错误的代码或事实，引用后指出“这里不对，应该是...”。\n"
  },
  {
    "path": "docs/guide/recents-hider.md",
    "content": "# 隐藏最近项目和 Gem\n\n::: info\n**注意**：该功能仅在 1.1.9 及后续版本中支持。\n:::\n\n为 Gemini™ 首页的“最近保存”部分添加一个优雅的切换开关，让界面更加简洁。现在也支持隐藏侧边栏的 **Gems** 列表！\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>隐藏最近保存</b></p>\n    <video src=\"/assets/hide-my-stuff.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>隐藏 Gems 列表</b></p>\n    <video src=\"/assets/hide-gem-list.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n</div>\n\n## 功能特点\n\n- **上下文切换**：仅在鼠标悬停在“最近”区域时才显示微妙的隐藏按钮。\n- **极简状态**：隐藏后，该区域会被底部的极简“窥视条”取代。\n- **一键恢复**：只需悬停在“窥视条”上并点击即可立即恢复显示。\n- **隐私保护**：防止在公共场合或他人经过时泄露您最近的活动内容。\n- **持久化**：您的偏好会被保存，并在下次访问时自动应用。\n\n## 如何使用\n\n1. 悬停在 Gemini 首页的“最近”区域。\n2. 点击右上角出现的“隐藏”图标（划掉的眼睛）以折叠该区域。\n3. 如需恢复，悬停在该区域底部留下的细线上并点击。\n"
  },
  {
    "path": "docs/guide/settings.md",
    "content": "# 随心所欲\n\n默认的已经很好。但我们要的是完美。\n你的地盘，你做主。\n\n## 影院模式\n\n为什么要透过门缝看世界？\n把聊天框拉大。\n\n- **宽屏**：1400px。写代码、看大表，视野全开。\n- **专注**：800px。沉浸阅读，心无旁骛。\n- **随意**：拖动滑块，你觉得多宽舒服，就多宽。\n\n## 掌控\n\n点插件图标，进控制台。\n\n- **滚动**：自然顺滑，还是经典手感？\n- **位置**：时间轴放哪顺手，就放哪。\n- **视觉特效**：可切换 `飘雪`、`樱花`、`雨`，给页面加一点氛围感。\n\n## 自定义排序\n\n控制台里的功能区域太多，常用的被埋在下面？\n\n鼠标悬停在任意功能卡片上，右上角会出现 ▲/▼ 按钮。点一下，卡片就会上移或下移。调整后的顺序自动保存，下次打开还是你的布局。\n\n## 氛围感\n\nVoyager 不只是在做效率增强，也允许你顺手把页面气质调对。\n\n- **飘雪**：轻柔雪花缓慢飘落，适合安静阅读。\n- **樱花**：花瓣自然飘散，页面会更轻盈。\n- **雨**：电影感雨丝与细微水花，整体更有沉浸感。\n- **平滑切换**：关闭特效或切换到另一种效果时，粒子会自然退场，不会突然消失。\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>打开设置</b></p>\n    <img src=\"/assets/gemini-open-settings-guide.png\" alt=\"打开设置指南\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>调整视野</b></p>\n    <img src=\"/assets/gemini-chatwidth.png\" alt=\"聊天宽度调整\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n"
  },
  {
    "path": "docs/guide/sidebar-auto-hide.md",
    "content": "# 侧边栏自动收起\n\n想要更加沉浸式的对话体验？\n\n我们提供了**自动收起侧边栏**的功能。开启后，当你把鼠标移出侧边栏区域时，它会自动收起；当你把鼠标移入侧边栏区域时，它会自动展开。\n\n### 演示\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/sidebar-auto-hide.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n### 如何开启\n\n1. 打开 Voyager 的设置面板。\n2. 在 **通用设置** 中找到 **侧边栏自动收起** 开关。\n3. 打开开关即可生效。\n\n_注意：此功能目前仅支持 Google Gemini。_\n"
  },
  {
    "path": "docs/guide/sidebar.md",
    "content": "# 侧边栏宽度\n\n文件夹名字太长，显示不全？\n或者觉得侧边栏太宽，占用了宝贵的聊天空间？\n\n现在，你可以自由调整侧边栏的宽度。\n\n## 如何调整\n\n1. 打开 Voyager 的设置面板（点击浏览器右上角的插件图标）。\n   <img src=\"/assets/extension-instruction.png\" alt=\"如何打开设置面板\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. 在 **通用设置** 中找到 **侧边栏宽度** 选项。\n3. 拖动滑块，选择你觉得最舒适的宽度。\n\n- **窄**：节省空间，专注于对话。\n- **宽**：完整显示长文件夹名称，一目了然。\n\n## 支持平台\n\n此功能同时支持：\n\n- **Google Gemini**\n- **Google AI Studio**\n\n你的设置会自动保存，并在下次打开时生效。\n"
  },
  {
    "path": "docs/guide/sponsor.md",
    "content": "# 赞助\n\n> [!NOTE]\n> 如果 Voyager 帮到了你，欢迎分享到 X、即刻、小红书、Linux.do、V2EX 等等，也欢迎推荐给海外 KOL。每一次分享都能让更多人看到这个项目，从而改善 Gemini 的使用体验。谢谢。\n\n维护开源项目主要靠热情（和咖啡）驱动 ☕\n\n**[Voyager](https://github.com/Nagi-ovo/gemini-voyager)** 是一个完全免费且开源的浏览器扩展，旨在提升你的 Gemini 使用体验。如果这个扩展帮助你更高效地使用 Gemini，欢迎通过以下方式支持我继续开发和维护这个项目。\n\n---\n\n## 在线平台\n\n<div class=\"sponsor-badges\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" style=\"height: 40px;\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" style=\"height: 40px;\">\n  </a>\n</div>\n\n<a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n    <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n    <img alt=\"爱发电\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n  </picture>\n</a>\n\n## 扫码投喂 🍵\n\n<div class=\"qr-container\">\n  <div class=\"qr-item\">\n    <img src=\"/assets/wechat-sponsor.png\" alt=\"微信支付\" />\n    <span>微信支付</span>\n  </div>\n  <div class=\"qr-item\">\n    <img src=\"/assets/alipay-sponsor.jpg\" alt=\"支付宝\" />\n    <span>支付宝</span>\n  </div>\n</div>\n\n---\n\n### 🎙️ 特别推荐: Typeless\n\n我非常推荐 **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)** 这款 AI 语音输入工具。在开发 Voyager 的过程中，我将其整合进了日常工作流，极大地节省了我的时间并显著提升了整体开发效率。\n\n> 🎁 **[点击我的邀请链接注册](https://www.typeless.com/?via=gemini-voyager)**（邀请码 _`gemini-voyager`_）即可获得 **5 美元免费额度**，同时也能支持本项目的开发。❤️\n\n<style>\n.sponsor-badges {\n  display: flex;\n  gap: 16px;\n  flex-wrap: wrap;\n  align-items: center;\n  margin: 16px 0;\n}\n\n.sponsor-badges a img {\n  transition: transform 0.2s ease;\n}\n\n.sponsor-badges a:hover img {\n  transform: scale(1.05);\n}\n\n.qr-container {\n  display: flex;\n  gap: 32px;\n  flex-wrap: wrap;\n  margin: 16px 0;\n}\n\n.qr-item {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 8px;\n  padding: 16px;\n  border-radius: 8px;\n  border: 1px solid var(--vp-c-divider);\n  background: var(--vp-c-bg-soft);\n}\n\n.qr-item img {\n  width: 200px;\n  height: 200px;\n  object-fit: contain;\n  border-radius: 4px;\n}\n\n.qr-item span {\n  font-weight: 600;\n  color: var(--vp-c-text-1);\n}\n\n@media (max-width: 640px) {\n  .qr-container {\n    justify-content: center;\n  }\n  \n  .qr-item img {\n    width: 150px;\n    height: 150px;\n  }\n}\n</style>\n\n---\n\n感谢你的支持！你的每一份贡献都是对我最大的鼓励 ❤️\n"
  },
  {
    "path": "docs/guide/tab-title.md",
    "content": "# 标签页标题同步\n\n自动将浏览器标签页标题同步为当前 Gemini™ 对话的标题。\n\n## 功能介绍\n\n- **实时同步**：当对话标题发生变化时（例如 AI 生成了新标题或你手动重命名了对话），浏览器标签页标题不仅仅是 \"Gemini\"，而是会立即更新为具体的对话内容。\n- **多页面支持**：完美支持普通的对话页面、Gem 对话以及多账户环境。\n- **开关控制**：如果你不喜欢这个功能，可以在设置面板的“通用选项”中随时关闭。\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/tab-title.png\" alt=\"标签页标题同步\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## 如何使用\n\n1. 安装扩展后，该功能默认开启。\n2. 打开任意 Gemini 对话，观察浏览器标签页标题，它会自动变为当前的对话标题。\n3. 若要关闭：\n   - 点击扩展图标打开设置面板。\n   - 找到“通用选项” (General Options)。\n   - 关闭“同步标签页标题” (Update Tab Title) 开关。\n"
  },
  {
    "path": "docs/guide/timeline.md",
    "content": "# 时间旅行\n\n长对话是灾难。上上下下，找不着北。\nVoyager 把对话变成一条线。\n\n## 看见节奏\n\n看屏幕右侧。\n每个点都是一句话。那是你对话的脉搏。\n\n## 导航，一步到位\n\n- **瞬移**：点哪去哪，绝不拖泥带水。\n- **偷看**：鼠标放上去，不用跳转也能看内容。\n- **插眼**：长按节点 **加星**。给大脑打个书签。\n- **层级 (实验性)**：右键点击节点，设置不同层级（1-3 级）或折叠子节点。让深度分支对话一目了然。\n- **快捷键**：用键盘飞速穿梭。默认 `j`/`k` 上下跳转，想改就改。\n\n![时间轴导航](/assets/teaser.png)\n\n## 键盘，更快\n\n不想用鼠标？用键盘。\n\n**就像在 Gemini 里开了 Vim Mode。**\n\n### 默认快捷键\n\n- `k` - 跳到上一个节点\n- `j` - 跳到下一个节点\n\n### 自定义\n\n打开扩展设置，点击快捷键框，按下你想用的键。\n任意键，任意组合。`n`/`p`？`,`/`.`？随你。\n\n**流动模式**下连按会排队播放动画。\n**跳跃模式**下立即响应，速度拉满。\n"
  },
  {
    "path": "docs/index.md",
    "content": "---\nlayout: home\n\nhero:\n  name: 'Voyager'\n  text: '终于，它完整了。'\n  tagline: '思维有形，万物归位。'\n  image:\n    src: /logo.png\n    alt: Voyager Logo\n  actions:\n    - theme: brand\n      text: 下载安装\n      link: ./guide/installation\n    - theme: alt\n      text: 开始旅程\n      link: ./guide/getting-started\n\nteaser:\n  title: '重新定义交互。'\n  description: '我们不造扩展，我们重塑思考。<br>用 Voyager，不再是人适应界面，而是界面顺应心流。'\n  image: '/assets/teaser.png'\n  features:\n    - title: '时间轴'\n      details: '看见对话的脉搏。<br>让线性的时间，变成可触摸的空间。'\n    - title: '文件夹'\n      details: '给思想安个家。<br>哪怕是一闪而过的念头，也值得被郑重对待。'\n    - title: '掌控权'\n      details: '数据归你。<br>打破云端的围墙，让知识真正属于你。'\n\nfeatures:\n  - icon: 🧭\n    title: 时间轴\n    details: 别滚屏，去飞。瞬间抵达思维的任何落点。\n  - icon: 🗂️\n    title: 文件夹\n    details: 告别混沌。原生手感，直觉操作，井井有条。\n  - icon: ✨\n    title: 指令宝库\n    details: 捕捉灵光。珍藏你的每一次神来之笔。\n  - icon: 💬\n    title: 引用回复\n    details: 选中即引。上下文精确回复，沟通更高效。\n  - icon: ↔️\n    title: 对话宽度\n    details: 视野全开。自由调节对话框宽度，代码表格完整呈现。\n  - icon: 💾\n    title: 对话导出\n    details: 数据归你。多种格式一键存档，知识不再流失。\n  - icon: 🌦️\n    title: 视觉特效\n    details: 页面也有情绪。可在弹窗中切换飘雪、下雨与樱花花瓣效果。\n  - icon: 🍌\n    title: NanoBanana 水印去除\n    details: 无损去水印。让 AI 生成的瞬间回归纯净。\n  - icon: 📐\n    title: 公式复制\n    details: 一键复制 LaTeX 和 MathML (Word) 源码。\n  - icon: 🧜‍♀️\n    title: Mermaid 图表\n    details: 代码变图表。流程图、时序图、甘特图一键可视化。\n  - icon: 🏷️\n    title: 标签页标题同步\n    details: 一眼即知。自动将标签页标题同步为对话标题。\n  - icon: 🔀\n    title: 对话分支 (实验性)\n    details: 发散思维。在任意节点分叉对话，探索不同可能。\n  - icon: 🗑️\n    title: 批量删除对话\n    details: 一键清理。选中多个对话，批量删除，告别繁琐。\n  - icon: ☁️\n    title: 云同步\n    details: 永远在线。文件夹与提示词库同步至 Google Drive，多设备无缝衔接。\n  - icon: ⚡️\n    title: 默认模型\n    details: 拒绝重复劳动。新建对话自动切换至你最爱的模型。\n  - icon: 🔬\n    title: Deep Research\n    details: 拆开黑箱。一键提取 Deep Research 的思考过程与研究链接。\n---\n\n<div class=\"vp-doc\" style=\"margin: 2rem auto 0; max-width: 780px; padding: 0 16px;\">\n  <div style=\"background: rgba(234, 179, 8, 0.12); border: 1px solid rgba(234, 179, 8, 0.6); border-radius: 8px; padding: 12px 16px;\">\n    <strong>⚠️ 改名公告</strong>：由于商标版权问题，本插件已正式改名为 <strong>Voyager</strong>。但由于谷歌插件商店审核速度奇慢，七天内未能完成名称更新审核，暂时无法在 Chrome Web Store 使用。\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 780px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 8px; font-weight: 600; font-size: 1.05em;\">每一次下载，都是信任的刻度</h3>\n  <p style=\"margin: 0 0 16px; opacity: 0.78; font-size: 0.95em;\">来自 Chrome Web Store 与 GitHub 的实时数据。致敬每一位与我们同行的 Voyager。</p>\n  <div style=\"display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;\">\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Star\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Fork\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"最新版本\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub 下载量\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome 商店用户数\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome 商店评分\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoft-edge\" alt=\"Edge 商店\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox 商店用户数\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox 商店评分\">\n  </div>\n  <div style=\"margin-top: 16px; display: flex; justify-content: center; flex-wrap: wrap; gap: 12px;\">\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <!-- <a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a> -->\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 1000px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 24px; font-weight: 600; font-size: 1.2em;\">特别鸣谢</h3>\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" style=\"margin: 0 auto;\" />\n  </a>\n  <p style=\"margin-top: 24px; font-size: 1.05em; opacity: 0.86;\">✨ 我们已在 Product Hunt 上线！欢迎来分享你的想法和反馈。❤️</p>\n  <div style=\"margin-top: 12px; display: flex; justify-content: center;\">\n    <a href=\"https://www.producthunt.com/posts/gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager on Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 3.5rem auto 2rem; max-width: 720px; padding: 0 16px;\">\n  <p style=\"font-size: 1.05em; font-weight: 600; opacity: 0.86; margin: 0 0 12px;\">“它不只是工具，更是伴你思维远航的伙伴。”</p>\n  <a href=\"./guide/getting-started\" style=\"font-weight: 600; text-decoration: none;\">探索更多 →</a>\n</div>\n\n<p align=\"center\">\n  <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n</p>\n"
  },
  {
    "path": "docs/ja/guide/batch-delete.md",
    "content": "# 一括削除\n\n複数の会話を一度に削除し、一つずつ消す面倒な作業に別れを告げましょう。\n\n## 機能紹介\n\n- **複数選択モード**：任意の会話を長押しすると複数選択モードに入り、削除したい会話を複数チェックできます。\n- **一括削除**：選択後、削除ボタンをクリックするだけで、選択した会話をまとめて削除できます。\n- **進捗表示**：削除中は進捗が表示され、現在の状態を確認できます。\n- **安全確認**：削除前には確認ダイアログが表示され、誤操作を防ぎます。\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/batch-delete.png\" alt=\"Batch Delete\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## 使い方\n\n1. サイドバーの会話リストで、任意の会話項目を **長押し** します。\n2. 複数選択モードに入ると、会話項目の左側にチェックボックスが表示されます。\n3. 削除したい会話にチェックを入れます（一度に最大50件まで選択可能）。\n4. 出現した **削除ボタン** をクリックします。\n5. **フォルダリストの上** に表示される赤い確認エリアで「確定」をクリックすると、一括削除が開始されます。\n\n::: tip ヒント\n削除確認パネルはフォルダエリアの上に表示されます。これは会話リストを遮らないようにするためです。一括削除操作は取り消せませんので、慎重に操作してください。\n:::\n"
  },
  {
    "path": "docs/ja/guide/cloud-sync.md",
    "content": "# クラウド同期\n\nフォルダ、プロンプトライブラリ、その他のデータを Google ドライブに同期して、デバイス間で一貫した体験を維持します。\n\n## 機能の特徴\n\n- **マルチデバイス同期**: Google ドライブを使用して、複数のコンピュータで設定を同期させます。\n- **データのプライバシー**: データはご自身の Google ドライブ ストレージに直接保存されるため、サードパーティのサーバーを介さずプライバシーが確保されます。\n- **柔軟な同期**: 手動でのアップロードおよびデータのダウンロード・マージをサポートしています。\n\n::: info\n**近日公開**: 次のバージョンでは、スター付きの会話の同期をサポートする予定です。\n:::\n\n## 使用方法\n\n1. Gemini™ ページの右下隅にある拡張機能アイコンをクリックして、設定パネルを開きます。\n2. **クラウド同期**セクションを見つけます。\n3. **Google でサインイン**をクリックし、認証を完了します。\n4. 認証されたら、**クラウドにアップロード**をクリックしてローカルデータをクラウドに同期するか、**ダウンロードしてマージ**をクリックしてクラウドデータをローカルマシンに取り込みます。\n\n### 💡 クイック同期\n\n最も簡単な方法は、左側のサイドバーの**フォルダエリア上部**にある「クラウドにアップロード」または「ダウンロードしてマージ」ボタンをクリックすることです。\n\n<img src=\"/assets/cloud-sync.png\" alt=\"クラウド同期クイックボタン\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n::: warning\n**安全上の推奨事項：二重保護**  \nクラウド同期は非常に便利ですが、データの万全を期すため、定期的に**ローカルファイル形式**でコアデータを手動バックアップすることを強くお勧めします。\n\n1. **全設定をエクスポート**: 設定パネル下部の「バックアップと復元」から、すべての設定、フォルダー、プロンプトを含む完全なパッケージをエクスポートします。\n   <img src=\"/assets/manual-export-all.png\" alt=\"全設定をエクスポート\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. **すべてのフォルダーをエクスポート**: 設定パネル의「フォルダー」セクションで「エクスポート」をクリックし、すべてのフォルダー構造と会話のみをバックアップします（プロンプトは含まれません）。\n   <img src=\"/assets/manual-folder-export.png\" alt=\"すべてのフォルダーをエクスポート\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n   :::\n"
  },
  {
    "path": "docs/ja/guide/community.md",
    "content": "# コミュニティ\n\n私たちは、すべてのユーザーの声を大切にしています。バグを見つけたとき、機能のアイデアがあるとき、あるいは自慢のプロンプト集を共有したいときなど、以下の方法でいつでもご連絡ください。\n\n## 📢 最新情報\n\nX (Twitter) でフォローして、最新の開発状況をチェックしましょう。\n\n- **新バージョン**：アップデート情報をいち早くお届け。\n- **機能プレビュー**：今後追加される機能を先行公開。\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://x.com/Nag1ovo/status/2012610584722731188\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/badge/Follow%20on-𝕏-000000?style=for-the-badge&logo=x&logoColor=white\" alt=\"Follow on X\">\n  </a>\n</div>\n\n## 💬 Discord コミュニティ\n\nDiscord サーバーに参加して、他の Voyager ユーザーと交流しましょう！\n\n- **リアルタイムチャット**：他のユーザーや開発者と直接会話できます。\n- **プロンプト共有**：みんながどんなプロンプトを使っているか見てみましょう。\n- **開発状況**：新機能の開発状況をいち早くキャッチできます。\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://discord.gg/TEUFxdMbGb\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/discord/1463273957120675973?style=for-the-badge&logo=discord&logoColor=white&label=Discord%20コミュニティに参加\" alt=\"Discord\">\n  </a>\n</div>\n\n## 🐙 GitHub Issues\n\nバグを発見した場合や、具体的な機能リクエストがある場合は、GitHub で Issue を作成することをお勧めします：\n\n- [バグ報告を提出する](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=bug_report.yml)\n- [機能提案を提出する](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=feature_request.yml)\n\nVoyager をサポートしていただき、ありがとうございます！❤️\n"
  },
  {
    "path": "docs/ja/guide/context-sync.md",
    "content": "# 記憶の搬送：コンテキスト同期（実験的）\n\n**異なる次元、シームレスな共有**\n\nウェブでロジックを推敲し、IDEでコードを実装する。 Voyager は次元の壁を打ち破り、IDEにウェブ側の「思考プロセス」を即座に共有します。\n\n## 繰り返しの横断にさよなら\n\n開発者にとって最大の悩み：ウェブで解決策を徹底的に話し合った後、VS Code/Trae/Cursorに戻ると、見知らぬ人のように要件を再説明しなければならないこと。 利用制限やレスポンス速度の関係で、ウェブは「脳」、IDEは「手」となります。 Voyager は、それらに一つの魂を共有させます。\n\n## 極簡な3ステップ、同じ呼吸で\n\n1. **CoBridgeのインストールと起動**：\n   **CoBridge** 拡張機能をインストールします。これはウェブインターフェースとローカルIDEを接続する中核的な架け橋です。\n   - **[マーケットプレイスからインストール](https://open-vsx.org/extension/windfall/co-bridge)**\n\n   ![CoBridge拡張機能](/assets/CoBridge-extension.png)\n\n   インストール後、**任意の作業ディレクトリを開き**、右側のアイコンをクリックしてサーバーを起動します。\n   ![CoBridgeサーバー起動](/assets/CoBridge-on.png)\n\n2. **接続の確立**：\n   - Voyagerの設定で「コンテキスト同期」を有効にします。\n   - ポート番号を合わせます。「IDE オンライン」と表示されれば接続完了です。\n\n   ![コンテキスト同期コンソール](/assets/context-sync-console.png)\n\n3. **ワンクリック同期**：**\"IDEに同期\"**をクリックします。複雑な**データテーブル**も、直感的な**参考画像**も、瞬時にIDEへ同期されます。\n\n   ![同期完了](/assets/sync-done.png)\n\n## IDEへの定着\n\n同期が完了すると、IDEの作業ディレクトリに `.cobridge/AI_CONTEXT.md` が追加されます。 Trae、Cursor、Copilotのいずれであっても、それぞれのRuleファイルを介してこの「記憶」を自動的に読み取ります。\n\n```\nyour-project/\n├── .cobridge/\n│   ├── images/\n│   │   ├── context_img_1_1.png\n│   │   └── context_img_1_2.png\n│   └── AI_CONTEXT.md\n├── .github/\n│   └── copilot-instructions.md\n├── .gitignore\n├── .traerules\n└── .cursorrules\n```\n\n## その原則\n\n- **ゼロ汚染**：CoBridgeは自動的に `.gitignore` を処理し、プライベートな会話がGitリポジトリにプッシュされないようにします。\n- **実用的**：完全なMarkdown形式で、IDE内のAIが取扱説明書を読むようにスムーズに理解できます。\n- **ヒント**：会話が古い場合は、まず [タイムライン] で上にスクロールして、ウェブ側に記憶を「思い出させて」から同期すると、より効果的です。\n\n---\n\n## いざ、起動へ\n\n**クラウドで整った思考を、今こそローカルで具現化させましょう。**\n\n- **[CoBridge拡張機能をインストール](https://open-vsx.org/extension/windfall/co-bridge)**：次元の扉を開き、一クリックで「同期する呼吸」を体感してください。\n- **[GitHubリポジトリを訪ねる](https://github.com/Winddfall/CoBridge)**：CoBridgeの深層ロジックを探索し、この「魂を同期させる」プロジェクトにStarを贈りましょう。\n\n> **AIはこれでもう物忘れしない、手にした瞬間から即戦力。**\n"
  },
  {
    "path": "docs/ja/guide/deep-research.md",
    "content": "# Deep Research エクスポート\n\nDeep Research で生成された最終レポートをエクスポートしたり、その完全な「思考」プロセスを Markdown ファイルとして保存したりできます。\n\n## 1. レポートのエクスポート (PDF / 画像)\n\nDeep Research で生成されたレポートは、美しいフォーマットの PDF または共有に便利な単一の画像としてエクスポートできます（Markdown および JSON 形式もサポートしています）。\n\n![レポートのエクスポート](/assets/deep-research-report-export.png)\n\n## 2. 思考プロセスのエクスポート (Markdown)\n\n最終レポートに加えて、Deep Research の会話から完全な「思考」内容をエクスポートすることも可能です。\n\n### 機能の特長\n\n- **ワンクリック・エクスポート**: 共有・エクスポートボタンをクリックするだけでダウンロードできます。\n- **構造化されたフォーマット**: 思考フェーズ、思考項目、参照したウェブサイトを元の順序どおりに保持します。\n- **バイリンガル見出し**: Markdown ファイルには、英語と現在の言語のバイリンガル見出しが含まれます。\n- **自動ファイル名**: ファイル名にはタイムスタンプが使用され、整理が容易です（例: `deep-research-thinking-20240128-153045.md`）。\n\n### 使い方\n\n1. Gemini™ で Deep Research の会話を開きます。\n2. 会話の **共有とエクスポート** ボタンをクリックします。\n3. 「Download Thinking Content（思考内容をダウンロード）」を選択します。\n4. Markdown ファイルが自動的にダウンロードされます。\n\n![Deep Research 思考プロセスのエクスポート](/assets/deepresearch_download_thinking.png)\n\n### エクスポートファイルの形式\n\nエクスポートされる Markdown ファイルには以下が含まれます:\n\n- **タイトル**: 会話のタイトル\n- **メタデータ**: エクスポート日時、総思考フェーズ数\n- **思考フェーズ**: 各フェーズには以下が含まれます:\n  - 思考項目（タイトルと内容）\n  - 参照したウェブサイト（リンクとページタイトル）\n\n#### 出力例\n\n```markdown\n# Deep Research 会話タイトル\n\n**导出时间 / Exported At:** 2025-12-28 17:25:35\n**总思考阶段 / Total Phases:** 3\n\n---\n\n## 思考阶段 1 / Thinking Phase 1\n\n### 思考タイトル 1\n\n思考内容...\n\n### 思考タイトル 2\n\n思考内容...\n\n#### 研究网站 / Researched Websites\n\n- [domain.com](https://example.com) - ページタイトル\n- [another.com](https://another.com) - 別のタイトル\n\n---\n\n## 思考阶段 2 / Thinking Phase 2\n\n...\n```\n\n## プライバシー保護\n\n抽出とフォーマット処理はすべて、ブラウザ内で **100% ローカル** に行われます。外部サーバーにデータが送信されることはありません。\n"
  },
  {
    "path": "docs/ja/guide/default-model.md",
    "content": "# デフォルトモデル\n\n::: info\n**注意**: この機能はバージョン 1.1.9 以降でサポートされています。\n:::\n\nお気に入りの Gemini™ モデルをデフォルトとして設定し、新しい会話を始めるたびに手動で切り替える手間を省きます。\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/default-model.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n## 機能の特徴\n\n- **直感的な設定**：Gemini のモデル選択メニューに「スター」ボタンを直接追加します。\n- **自動切り替え**：新しい会話を開始すると、設定したデフォルトモデルに自動的に切り替わります。\n- **設定の保存**：設定は保存され、デバイス間で同期されます。\n- **SPA 最適化**：「新しいチャット」ボタンのクリック、ショートカットキーの使用、またはホームページへの移動を正確に検知して動作します。\n\n## 使い方\n\n1. Gemini 入力欄の上にある**モデルセレクター**をクリックします。\n2. デフォルトにしたいモデルにマウスを合わせ、表示される**スターアイコン**をクリックします。\n3. スターが**塗りつぶされた状態**になれば、そのモデルがデフォルトとして設定されます。\n4. 以降、新しいチャットを開始するたびに、システムが自動的にそのモデルを選択します。\n5. 解除するには、もう一度塗りつぶされたスターアイコンをクリックしてください。\n"
  },
  {
    "path": "docs/ja/guide/export.md",
    "content": "# 完全な自由\n\nデータに鍵をかけられるのは、最悪の体験です。\n私たちの信条はシンプルです：あなたが創ったものは、あなたのものです。\n\n## すべてを持ち出す\n\nVoyager は、データをクラウドからあなたの手元へと引き戻す手助けをします。\n\n### 選べるフォーマット\n\n- **Markdown**：Obsidian や Notion へ。クリーンで扱いやすい形式。（Safariユーザーへの注意：ブラウザの制限により画像を抽出できません。PDFエクスポートを推奨します）\n- **PDF**：共有や印刷に。画像も含まれた美しいレイアウト。\n- **JSON**：開発者向け。生のデータ、使い方はあなた次第。\n\n### エクスポート方法\n\n1. Geminiロゴにマウスを合わせると、**エクスポートアイコン**が表示されます。\n2. フォーマットを選びます。\n3. 持ち出します。\n\nあなたのデータは、あなたの意のままに。\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>1. ロゴにホバー</b></p>\n    <img src=\"/assets/gemini-export-guide-1.png\" alt=\"Export Guide Step 1\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>2. フォーマットを選ぶ</b></p>\n    <img src=\"/assets/gemini-export-guide-2.png\" alt=\"Export Guide Step 2\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n### Safari PDF エクスポートに関する注意\n\nSafari で PDF をエクスポートする手順は少し異なります（手動印刷が必要です）：\n\n1. **エクスポート**ボタンをクリックし、PDF 形式を選択します。\n2. **1秒ほど待ちます**（ページが印刷スタイルの準備をするため）。\n3. `Command + P` を押して印刷ダイアログを開きます。\n4. 印刷ダイアログで **\"Save to PDF\"**（または「PDFとして保存」）を選択します。\n\n<img src=\"/assets/safari-export-pdf.png\" alt=\"Safari Export PDF\" style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 20px;\"/>\n"
  },
  {
    "path": "docs/ja/guide/folders.md",
    "content": "# フォルダ、本来あるべき姿へ\n\nAI チャットの整理がなぜこれほど難しかったのでしょうか？\n私たちはそれを解決しました。あなたの思考のためのファイルシステムを構築しました。\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap; margin-bottom: 40px;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Gemini™</b></p>\n    <img src=\"/assets/gemini-folders.png\" alt=\"Gemini フォルダ\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>AI Studio</b></p>\n    <img src=\"/assets/aistudio-folders.png\" alt=\"AI Studio フォルダ\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n## 整理の直感\n\n手触りが良ければ、すべてがうまくいきます。\n\n- **ドラッグ＆ドロップ**：チャットを掴んで、フォルダに落とす。直感的な物理フィードバック。\n- **階層構造**：プロジェクトの中にサブプロジェクトを。フォルダの中にフォルダを作成し、自分流に構造化。\n- **フォルダ間隔**：コンパクトからゆったりまで、サイドバーの密度を自由に調整。\n  > _注：Mac Safari では調整がリアルタイムに反映されない場合があります。ページを再読み込みすると反映されます。_\n- **インスタント同期**：デスクトップで整理すれば、ノートパソコンでもすぐに反映。\n\n## プロの技\n\n- **複数選択**：チャット項目を長押しすると複数選択モードに入り、一括操作でまとめて処理できます。\n- **名前変更**：フォルダをダブルクリックするだけで、直接リネームできます。\n- **アイコン**: Gem のタイプ (コーディング、クリエイティブなど) を自動的に検出し、適切なアイコンを割り当てます。何もする必要はありません。\n\n## プラットフォームによる機能の違い\n\n### 共通機能\n\n- **基本管理**：ドラッグ＆ドロップ、名前変更、複数選択。\n- **スマート認識**：チャットの種類を自動判別し、アイコンを割り当て。\n- **階層構造**：フォルダの入れ子構造（ネスト）に対応。\n- **AI Studio 対応**：上記の高度な機能はまもなく AI Studio でも利用可能になります。\n- **Google Drive 同期**：フォルダ構造を Google Drive と同期。\n\n### Gemini 限定機能\n\n#### カスタムカラー\n\nフォルダーのアイコンをクリックして、色をカスタマイズします。7つのデフォルトカラーから選ぶか、カラーピッカーで好きな色を選択できます。\n\n<img src=\"/assets/folder-color.png\" alt=\"フォルダカラー\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### アカウント隔離\n\nヘッダーの「人物」アイコンをクリックすると、他の Google アカウントの会話を瞬時にフィルタリングします。複数のアカウントを使い分ける際、ワークスペースをクリーンに保ちます。\n\n<img src=\"/assets/current-user-only.png\" alt=\"アカウント隔離\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### AI 自動整理\n\nチャットが多すぎて、整理するのが面倒？Gemini に考えてもらいましょう。\n\nワンクリックで今の会話構造をコピーして、Gemini に貼り付けるだけ。すぐにインポートできるフォルダプランを生成してくれます——一瞬で整理完了。\n\n**ステップ 1：会話構造をコピー**\n\n拡張機能ポップアップのフォルダセクション下部にある **AI 整理** ボタンをクリック。未分類の会話と既存のフォルダ構造を自動的に収集し、プロンプトを生成してクリップボードにコピーします。\n\n<img src=\"/assets/ai-auto-folder.png\" alt=\"AI Organize Button\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n\n**ステップ 2：Gemini に分類してもらう**\n\nクリップボードの内容を Gemini の会話に貼り付けます。チャットのタイトルを分析して、JSON フォルダプランを出力してくれます。\n\n**ステップ 3：結果をインポート**\n\nフォルダパネルのメニューから **フォルダをインポート** をクリックし、**または JSON を直接貼り付け** を選択、Gemini が返した JSON を貼り付けて **インポート** をクリックします。\n\n<div style=\"display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; margin-bottom: 24px;\">\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-2.png\" alt=\"Import Menu\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 240px;\"/>\n  </div>\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-3.png\" alt=\"Paste JSON Import\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n  </div>\n</div>\n\n- **増分マージ**：デフォルトで「マージ」戦略を採用——新しいフォルダと割り当てを追加するだけで、既存の整理を壊すことはありません。\n- **多言語対応**：プロンプトは設定した言語を自動的に使用し、フォルダ名もその言語で生成されます。\n\n### AI Studio 限定機能\n\n- **サイドバー調整**：ドラッグでサイドバーの幅を自由に調整。\n- **Library 連携**：Library リストからフォルダへ直接ドラッグ＆ドロップ。\n"
  },
  {
    "path": "docs/ja/guide/fork.md",
    "content": "# 会話の分岐 (実験的)\n\n思考は一方通行であるべきではありません。複雑な探求の過程では、しばしば重要な分岐点に戻り、別の可能性を試したくなるものです。\n\nVoyager の **会話の分岐** 機能を使えば、思考を柔軟に広げ、チャットの並行宇宙を探索できます。\n\n## 機能紹介\n\n> **⚠️ 注意**: これは実験的な機能です。まず、ブラウザのツールバーにある拡張機能アイコンをクリックして設定ポップアップを開き、**「会話の分岐を有効にする」** スイッチをオンにする必要があります。\n\n別の道を試したくなった時は、あなたの質問にマウスを重ねて **分岐** ボタンをクリックするだけです。\n\n![会話の分岐](/assets/branching.png)\n\nVoyager は、会話の始まりからその時点までのすべての文脈を即座に抽出し、あなたのために **新しい会話を開始** します。\n\nこの新しい分岐では、元の会話の履歴を気にする必要はありません。自由に質問を修正し、様々な方向性へと探求を進めてください。あなたの創造力と好奇心を解き放ちましょう！\n"
  },
  {
    "path": "docs/ja/guide/formula-copy.md",
    "content": "# 数式コピー\n\nVoyager を使用すると、数式や科学記号の再利用が非常に簡単になります。LaTeX ソースコードの一クリックコピーに加え、Microsoft Word と互換性のある MathML 形式もサポートしています。\n\n## 機能紹介\n\nGemini に数式の導出や数学的表現の記述を依頼すると、通常は LaTeX を使用してレンダリングされます。見た目は美しいですが、これらの数式を自分の論文、ドキュメント、またはエディタにコピーしたい場合、これまでは手動でソースを抽出する必要がありました。\n\nVoyager は、そのためのシームレスなサポートを提供します。\n\n1. **自動認識**: Voyager は、ページ内にレンダリングされた LaTeX 数式を自動的に識別します。\n2. **コピーボタン**: 数式にマウスを合わせると、数式の右側にコピーアイコンが表示されます。\n3. **形式の選択**: コピーアイコンをクリックすると、以下を選択できます：\n   - **Copy LaTeX**: Overleaf や Markdown エディタなどに適した、標準的な LaTeX ソースをコピーします。\n   - **Copy MathML**: **Microsoft Word** に直接貼り付けるのに最適な MathML ソースをコピーします。\n\n![数式コピー](/assets/gemini-math-copy.png)\n\n## 特徴\n\n- **Word との完璧な互換性**: MathML サポートにより、複雑な AI 生成の数式を Word 文書に直接貼り付けることができ、編集可能な数式形式を維持できます。\n- **コンテキストの保持**: 数式そのものだけでなく、数式の数学的な文脈も保持します。\n- **高速なレスポンス**: すべてローカルで処理されるため、クリックするだけで瞬時に結果が得られます。\n\n## 活用シーン\n\n- **論文執筆**: Word で論文を書く際、Gemini に数式を導出させ、MathML でコピー＆ペーストすることで、Word の数式エディタで手入力する手間を省けます。\n- **ノート作成**: Obsidian や Notion でメモを取る際、LaTeX ソースを直接コピーできます。\n"
  },
  {
    "path": "docs/ja/guide/getting-started.md",
    "content": "# ようこそ、船上へ\n\nおめでとうございます。あなたのワークフローは、いまアップグレードされました。\nVoyager は単なるツールではなく、一つの「習慣」です。5分だけ時間をください。使い方をご案内します。\n\n## 1. 準備完了\n\nまだインストールしていませんか？ [インストールガイド](/ja/guide/installation) へどうぞ。\nインストール済みですか？ Gemini のページを更新してみてください。変化は一目瞭然でしょう。\n\n## 2. 時間を旅する\n\n長い会話をしてみましょう。例えば漢字の変遷史について、あるいは量子力学について。\n右側を見てください。\n**その点の繋がりが、あなたの航海図です。**\n\n- **見る**：マウスオーバーするだけで、その時の発言内容をのぞき見できます。\n- **飛ぶ**：クリックすれば、その瞬間へワープします。\n- **残す**：長押しでスターを付けて、ハイライトとして保存します。\n\nもうマウスホイールを回し続けて指を痛める必要はありません。思考の速度で、移動しましょう。\n\n## 3. 整理整頓\n\n左側のチャットリストを見てください。\n**フォルダの登場です。**\n\nチャットを掴んで、ドラッグして、ドロップ。\n絹のように滑らかです。階層化も、名前の変更も、思いのまま。脳内のノイズを一掃し、清々しさだけを残しましょう。\n\n## 4. 宝石を保存\n\n素晴らしいプロンプト（指示）が書けましたか？ そのまま流してしまうのはもったいない。\n入力欄にある **✨ アイコン** をクリックしてください。\n保存して、タグを付けましょう。\n\n次回使いたい時は？\nアイコンをクリックして、検索して、挿入するだけ。\nこれは単なるチャットではありません。あなたのデジタル資産を蓄積するプロセスなのです。\n\n---\n\n**発進。**\nそれぞれの機能を深く掘り下げてみましょう：\n\n- [タイムラインを使いこなす](/ja/guide/timeline)\n- [フォルダをマスターする](/ja/guide/folders)\n- [プロンプトを管理する](/ja/guide/prompts)\n- [データを掌握する](/ja/guide/export)\n"
  },
  {
    "path": "docs/ja/guide/input-collapse.md",
    "content": "# 入力欄の自動非表示\n\n入力欄が空の時に自動的に折りたたみ、より広い読書スペースを確保します。クリックすると展開して入力できます。\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/hide-input-area.png\" alt=\"Input Collapse\" style=\"max-width: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## 使い方\n\n1. 入力欄が空で、かつフォーカスが外れていると、自動的にシンプルなカプセル型のボタンに折りたたまれます。\n2. カプセルボタンをクリックすると、入力欄が展開され、入力を開始できます。\n3. <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>I</kbd> を押して素早く入力欄を展開することもできます。\n4. この機能は設定パネルでオン/オフを切り替えられます（デフォルトはオフ）。\n"
  },
  {
    "path": "docs/ja/guide/installation.md",
    "content": "# インストール\n\n::: info ニュース\n🍎 **Safari ネイティブ拡張機能が登場！** 完全無料でワンクリックインストールが可能です。\n:::\n\n方法を選択してください。\n\n> ⚠️ プロンプトマネージャーは Gemini™ Enterprise 版で唯一対応している機能です。\n\n## 1. 公式ストア（推奨）\n\n最も簡単な方法で、自動更新に対応しています。\n\n**Chrome / Brave / Opera / Vivaldi：**\n\n[<img src=\"https://img.shields.io/badge/Chrome_ウェブストア-ダウンロード-4285F4?style=for-the-badge&logo=google-chrome&logoColor=white\" alt=\"Chrome ウェブストアからインストール\" height=\"40\"/>](https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=docs&utm_campaign=organic_growth&utm_content=ja)\n\n::: warning ⚠️ Chrome Web Store は一時的に利用不可\n商標上の問題により、本拡張機能は **Voyager** に正式改名されました。Chrome Web Store の審査は進行中です。詳細は[こちらの投稿](https://x.com/Nag1ovo/status/2031561180213313944)をご覧ください。当面は **Edge / Firefox** または**手動インストール**をご利用ください。\n:::\n\n**Microsoft Edge：**\n\n[<img src=\"https://img.shields.io/badge/Microsoft_Edge-ダウンロード-0078D7?style=for-the-badge&logo=microsoft-edge&logoColor=white\" alt=\"Microsoft Edge Add-ons からインストール\" height=\"40\"/>](https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne)\n\n**Firefox：**\n\n[<img src=\"https://img.shields.io/badge/Firefox_Add--ons-ダウンロード-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Firefox Add-ons からインストール\" height=\"40\"/>](https://addons.mozilla.org/firefox/addon/gemini-voyager/)\n\n## 2. 手動インストール（最新版）\n\nストアの審査は時間がかかります。最新機能をいち早く試したい方は、こちらをどうぞ。\n\n**Chrome / Edge / Brave / Opera：**\n\n1. [GitHub Releases](https://github.com/Nagi-ovo/gemini-voyager/releases) から最新の `gemini-voyager-chrome-vX.Y.Z.zip` をダウンロードします。\n2. 解凍します。\n3. 拡張機能ページ (`chrome://extensions`) を開きます。\n4. **デベロッパーモード**（右上）をオンにします。\n5. **パッケージ化されていない拡張機能を読み込む** をクリックし、先ほど解凍したフォルダを選択します。\n\n**Firefox：**\n\n1. [Releases](https://github.com/Nagi-ovo/gemini-voyager/releases) から最新の `gemini-voyager-firefox-vX.Y.Z.xpi` をダウンロードします。\n2. アドオン管理ページ (`about:addons`) を開きます。\n3. ダウンロードした `.xpi` ファイルをドラッグ＆ドロップしてインストールします（または右上の歯車アイコン ⚙️ -> **ファイルからアドオンをインストール**）。\n\n> 💡 XPI ファイルは Mozilla 公式の署名済みであり、すべての Firefox バージョンで恒久的にインストール可能です。\n\n## 3. Safari (macOS)\n\nSafari が直接配布に対応しました！署名済みアプリをダウンロードできます：\n\n1. <SafariDownloadLink>最新の Safari バージョン (.dmg)</SafariDownloadLink>をダウンロードします。\n2. ダブルクリックして開き、指示に従ってインストールします。\n3. ダブルクリックしてアプリを起動します。\n4. **Safari の設定 > 拡張機能**で有効にします。\n\n> 💡 Safari ビルドが直接署名配布に対応しました。Xcode 変換は不要です！\n>\n> ⚠️ **制限事項**: Safari の特性上、(a) 透かしの削除 (b) 画像のエクスポート（PDF を推奨）はサポートされていません。\n\n---\n\n_コードに貢献したいですか？ 開発者の方は [貢献ガイド](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/CONTRIBUTING.md) へどうぞ。_\n"
  },
  {
    "path": "docs/ja/guide/markdown-fix.md",
    "content": "# Markdown レンダリングの修正\n\nGemini™ のウェブインターフェースでは、テキスト内に HTML 要素（引用元やハイライトマーカーなど）が挿入されることがあり、これにより Markdown の太字構文（`**text**`）が壊れ、テキストが正しく太字で表示されない場合があります。\n\nVoyager には自動修正機能が組み込まれており、これらの壊れた太字タグをインテリジェントに識別して修復し、ドキュメントが綺麗かつ正確に表示されるようにします。\n\n> [!INFO]\n> この機能は自動的に有効になり、追加の設定は必要ありません。\n"
  },
  {
    "path": "docs/ja/guide/mermaid.md",
    "content": "# Mermaid ダイアグラムレンダリング\n\nMermaid コードを図表として自動レンダリングします。\n\n## 機能紹介\n\nGemini™ が Mermaid コードブロック（フローチャート、シーケンス図、ガントチャートなど）を出力すると、Voyager はそれを自動的に検出し、インタラクティブな図表として描画します。\n\n### 主な特徴\n\n- **自動検出**: `graph`、`flowchart`、`sequenceDiagram`、`gantt`、`pie`、`classDiagram` など、主要な Mermaid 図表タイプをすべてサポートしています。\n- **ワンクリック切り替え**: ボタン一つで、レンダリングされた図表と元のソースコードを自由に切り替えられます。\n- **全画面表示**: 図表をクリックすると全画面モードになり、マウスホイールでのズームやドラッグによる移動が可能です。\n- **ダークモード**: ページのテーマに自動的に適応します。\n\n## 使い方\n\n1. Gemini に Mermaid の図表コードを生成させます。\n2. コードブロックが自動的にレンダリング後の図表に置き換わります。\n3. **</> Code** ボタンをクリックすると、元のコードを確認できます。\n4. **📊 Diagram** ボタンをクリックすると、図表ビューに戻ります。\n5. 図表エリアをクリックすると、全画面表示になります。\n\n## 全画面モードの操作\n\n- **ホイール**: 図表のズーム\n- **ドラッグ**: 図表の移動\n- **+/-**: ツールバーでのズーム\n- **⊙**: ビューのリセット\n- **✕ / ESC**: 全画面を閉じる\n\n## 互換性とトラブルシューティング\n\n::: warning 説明\n\n- **Firefox の制限**: 環境の制限により、Firefox では 9.2.2 バージョンを使用しており、**Timeline** や **Sankey** などの新機能は現在サポートされていません。\n- **構文エラー**: レンダリングの失敗は、通常 Gemini が生成したコードの構文エラーによるものです。現在 Bad Case を収集しており、今後のアップデートで一般的な生成エラーを自動修正するパッチを導入予定です。\n  :::\n\n<div align=\"center\">\n  <img src=\"/assets/mermaid-preview.png\" alt=\"Mermaid Diagram Rendering\" style=\"max-width: 100%; border-radius: 8px;\"/>\n</div>\n"
  },
  {
    "path": "docs/ja/guide/nanobanana.md",
    "content": "# NanoBanana オプション\n\n::: warning ブラウザの互換性\n現在、**NanoBanana** ウォーターマーク除去機能は、ブラウザの API の制限により **Safari ではサポートされていません**。この機能を使用する必要がある場合は、**Chrome** または **Firefox** を使用することをお勧めします。\n\nSafari ユーザーは、ダウンロードした画像を [banana.ovo.re](https://banana.ovo.re/) などのツールサイトにアップロードして手動で除去することも可能です（ただし、画像の解像度の違いにより、すべての画像で成功するとは限りません）。\n:::\n\n**AI 画像、あるべき純粋な姿へ。**\n\nGemini™ が生成する画像には、デフォルトで目に見える透かしが入っています。これは安全上の理由によるものですが、創作活動においては、完全にクリーンな素材が必要な場合もあるでしょう。\n\n## 無劣化復元\n\nNanoBanana は **逆アルファブレンドアルゴリズム (Reverse Alpha Blending)** を採用しています。\n\n- **AI による再描画なし**: 従来の透かし除去ツールが多用する AI による塗りつぶしは、画像の細部を破壊してしまいます。\n- **ピクセル精度の復元**: 私たちは計算によって、ピクセル上の透かしの透明層を正確に取り除き、オリジナルのピクセルを 100% 復元します。\n- **品質劣化ゼロ**: 処理前後の画像は、透かし以外の部分において完全に一致します。\n\n## 使い方\n\n1. **機能を有効化**: Voyager の設定パネル末尾にある「NanoBanana オプション」を見つけ、「NanoBanana 透かし除去」をオンにします。\n2. **自動処理**: これ以降、あなたが生成するすべての画像に対し、バックグラウンドで自動的に透かし除去処理が行われます。\n3. **直接ダウンロード**:\n   - 処理済みの画像にマウスを乗せると、🍌 ボタンが表示されます。\n   - **🍌 ボタンは標準のダウンロードボタンを完全に置き換えます**。クリックするだけで、100% 透かしのない画像を直接ダウンロードできます。\n\n<div style=\"text-align: center; margin-top: 30px;\">\n  <img src=\"/assets/nanobanana.png\" alt=\"NanoBanana Example\" style=\"border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); max-width: 100%;\"/>\n</div>\n\n## 特別謝辞\n\n本機能は、[journey-ad (Jad)](https://github.com/journey-ad) 氏が開発した [gemini-watermark-remover](https://github.com/journey-ad/gemini-watermark-remover) プロジェクトに基づいています。このプロジェクトは、[allenk](https://github.com/allenk) 氏による [GeminiWatermarkTool C++ 版](https://github.com/allenk/GeminiWatermarkTool) の JavaScript 移植版です。オープンソースコミュニティへの多大なる貢献に感謝します。🧡\n\n## プライバシーとセキュリティ\n\nすべての透かし除去処理は、あなたの **ブラウザ内でローカル** に完了します。画像がいかなる第三者のサーバーにアップロードされることはなく、あなたのプライバシーと創作の安全は守られます。\n"
  },
  {
    "path": "docs/ja/guide/prevent-auto-scroll.md",
    "content": "# 自動スクロール防止\n\n過去の会話を読んでいるときに新しいプロンプトを送信すると、Gemini™ は新しく生成された回答を追跡するためにページを一番下まで強制的にスクロールします。これにより読書体験が妨げられる可能性があります。\n\n**自動スクロール防止** 機能は、この迷惑なジャンプ動作を阻止します。\n\n- 履歴を読むために上にスクロールしている間、システムはページが一番下にジャンプするのをブロックします。\n- この機能はデフォルトで**無効**になっています。ポップアップ設定の「Timeline Options」から手動で有効にできます。\n\n## 有効にする方法\n\n1. ブラウザのツールバーにある Voyager 拡張機能アイコンをクリックしてポップアップを開きます。\n2. 「Timeline Options（タイムラインオプション）」セクションを見つけます。\n3. 「自動スクロール防止（Prevent Auto Scroll）」スイッチをオンにします。\n"
  },
  {
    "path": "docs/ja/guide/prompts.md",
    "content": "# あなたのデジタル資産：プロンプトヴォルト\n\n苦労して書き上げた神がかったプロンプト。大いに役立ちましたね。\n使い終わったら捨てますか？\nいいえ、保存しましょう。\n\n## プロンプトヴォルト\n\nこれは、あなたのヴォルトです。\n\n### 1. 保存\n\nいいプロンプトができましたか？ 入力欄の **✨ アイコン** をクリックしましょう。\nヴォルトに保存して、しっかり自分のものに。\n\n### 2. 分類\n\n`#コード`、`#メール`、`#学術` などのタグを付けましょう。\n道具は、使いやすく整えられてこそ意味があります。\n\n### 3. 呼び出し\n\n次回使いたい時、もう打ち直す必要はありません。\nヴォルトを開き、タグで検索し、ワンクリックで挿入。\n一瞬で呼び出し、効率を倍増させましょう。\n\n![Prompt Manager](/assets/gemini-prompt-manager.png)\n\n## あらゆるサイトで利用可能\n\nプロンプトマネージャーは、Gemini™ と AI Studio に限らず、あなたが選択したあらゆるウェブサイトで使用できるようになりました。\n\n### 有効にする方法\n\n1. ブラウザのツールバーにある Voyager アイコンをクリックします。\n2. **Prompt Manager** (プロンプトマネージャー) セクションまでスクロールします。\n3. ウェブサイトの URL を入力します（例：`chatgpt.com` や `claude.ai`）。\n4. **Add Website** (ウェブサイトを追加) をクリックして権限を許可します。\n5. **対象のページを再読み込み** すると、フローティングボタンが表示されます。\n\n### 人気の AI サイトの例\n\n- `chatgpt.com` - ChatGPT\n- `claude.ai` - Claude\n- `copilot.microsoft.com` - Microsoft Copilot\n- `poe.com` - Poe\n\n::: tip\nカスタムウェブサイトでは、**プロンプトマネージャー機能のみ** が有効になります。タイムラインやフォルダなど、Gemini 専用に設計された機能は読み込まれません。\n:::\n"
  },
  {
    "path": "docs/ja/guide/quote-reply.md",
    "content": "# 引用返信\n\nVoyager の「引用返信」機能を使うと、文脈を保った返信をより正確かつ効率的に行えます。\n\n## 機能紹介\n\n日常的な対話の中で、AI の出力の特定部分についてさらに質問したり、反論したりしたい場面がよくあります。従来の方法では、その部分をコピーし、入力欄で手動で `> ` 記号を打つ必要があり、非常に面倒でした。\n\nVoyager なら、もっとシンプルです：\n\n1. **選択して引用**：会話内（あなたの質問でも Gemini の回答でも）で、任意のテキストを選択します。\n2. **フローティングボタン**：選択したテキストの近くに「引用返信」ボタンが自動的に浮かび上がります。\n3. **ワンクリック挿入**：ボタンをクリックすると、選択したテキストが標準的な Markdown 引用形式（`> コンテンツ`）で自動的に入力欄に挿入されます。\n\n![引用返信](/assets/quote-reply.png)\n\n## 特徴\n\n- **コンテキスト認識**：会話内容をインテリジェントに認識し、無関係な領域（入力欄自体など）での誤作動を防ぎます。\n- **標準フォーマット**：一般的な Markdown 構文を使うため、Gemini が引用として正しく解釈しやすくなり、より的確な応答を引き出せます。\n- **複数行対応**：複数行のテキストを選択した場合、Voyager は各行に引用記号を自動的に追加し、フォーマットを整えます。\n\n## 活用テクニック\n\n- **詳細を掘り下げる**：Gemini の回答の中で不明確な概念を選択して引用し、「この概念について詳しく説明してください」と入力します。\n- **誤りを指摘する**：回答中の誤ったコードや事実を選択し、引用した上で「ここは正しくは...です」と指摘します。\n"
  },
  {
    "path": "docs/ja/guide/recents-hider.md",
    "content": "# 最近の項目と Gem を非表示\n\n::: info\n**注意**: この機能はバージョン 1.1.9 以降でサポートされています。\n:::\n\nGemini™ ホームページの「最近の保存」セクションを非表示にするエレガントなトグルを追加し、インターフェースをよりすっきりとさせます。サイドバーの **Gems** リストの非表示にも対応しました！\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>最近の保存を非表示</b></p>\n    <video src=\"/assets/hide-my-stuff.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Gems リストを非表示</b></p>\n    <video src=\"/assets/hide-gem-list.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n</div>\n\n## 特徴\n\n- **コンテキストトグル**: 「最近の項目」セクションにマウスを合わせたときだけ、控えめな非表示ボタンが表示されます。\n- **ミニマリスト状態**: 非表示にすると、下部の目立たない「ピークバー」に置き換わります。\n- **ワンクリック復元**: ピークバーにマウスを合わせてクリックするだけで、すぐに元の表示に戻ります。\n- **プライバシー保護**: 公共の場などで、周囲の人に最近のアクティビティを見られるのを防ぎます。\n- **永続性**: 設定は保存され、次回の訪問時に自動的に適用されます。\n\n## 使い方\n\n1. Gemini ホームページの「最近」セクションにマウスを合わせます。\n2. 右上に表示される非表示アイコン（斜線の入った目）をクリックして、セクションを折りたたみます。\n3. 復元するには、エリアの下部に残っている細い線にマウスを合わせてクリックします。\n"
  },
  {
    "path": "docs/ja/guide/settings.md",
    "content": "# 思うがままに\n\nデフォルトも優れていますが、私たちは完璧を求めます。\nあなたの環境は、あなた好みに整えましょう。\n\n## シネマモード\n\nドアの隙間から世界を覗く必要はありません。\nチャットボックスを広げましょう。\n\n- **ワイドスクリーン**: 1400px。コードを書いたり、大きな表を見たり、視界を全開に。\n- **集中モード**: 800px。読書に没頭し、余計なものを排除。\n- **フリースタイル**: スライダーをドラッグして、一番心地よい幅を見つけてください。\n\n## コントロール\n\n拡張機能のアイコンをクリックして、コントロールパネルへ。\n\n- **スクロール**: 自然な滑らかさか、クラシックな感触か？\n- **配置**: タイムラインは右利き用？ 左利き用？ 使いやすい場所に。\n- **ビジュアルエフェクト**: `雪`、`桜`、`雨` から季節の雰囲気を選べます。\n\n## カスタム並び替え\n\nポップアップの設定項目が多すぎて、よく使うものが下に埋もれていませんか？\n\n設定カードにマウスを合わせると、右上に ▲/▼ ボタンが表示されます。クリックでカードを上下に移動でき、並び順は自動保存されます。\n\n## アトモスフィア\n\nVoyager は効率化だけでなく、ページの気分を変えることもできます。\n\n- **雪**: やわらかな雪の結晶がゆっくり舞い落ちる、穏やかな冬の雰囲気。\n- **桜**: 花びらが自然に舞い散る、軽やかな春の雰囲気。\n- **雨**: 斜めの雨筋と微かな水しぶきが映画的な臨場感を演出。\n- **スムーズな切り替え**: エフェクトをオフにしたり切り替えたりすると、パーティクルは自然に退場。\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>設定を開く</b></p>\n    <img src=\"/assets/gemini-open-settings-guide.png\" alt=\"Open Settings Guide\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>視界を調整</b></p>\n    <img src=\"/assets/gemini-chatwidth.png\" alt=\"Chat Width Adjustment\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n"
  },
  {
    "path": "docs/ja/guide/sidebar-auto-hide.md",
    "content": "# サイドバー自動非表示\n\nもっと没入感のあるチャット体験をしたいですか？\n\n**サイドバー自動非表示**機能を提供しています。有効にすると、マウスがサイドバー領域から離れると自動的に折りたたまれ、マウスを戻すと自動的に展開されます。\n\n### デモ\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/sidebar-auto-hide.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n### 有効にする方法\n\n1. Voyager の設定パネルを開きます。\n2. **一般設定**にある**サイドバー自動非表示**のスイッチを見つけます。\n3. スイッチをオンにして有効にします。\n\n_注意: この機能は現在 Google Gemini のみをサポートしています。_\n"
  },
  {
    "path": "docs/ja/guide/sidebar.md",
    "content": "# サイドバーの幅\n\nフォルダ名が長すぎて表示されませんか？\nあるいは、サイドバーが広すぎてチャットスペースを圧迫していませんか？\n\nサイドバーの幅を自由に調整できるようになりました。\n\n## 調整方法\n\n1. Voyager の設定パネルを開きます（ブラウザ右上の拡張機能アイコンをクリック）。\n   <img src=\"/assets/extension-instruction.png\" alt=\"設定パネルの開き方\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. **一般設定** にある **サイドバーの幅** オプションを見つけます。\n3. スライダーをドラッグして、最適な幅を選択してください。\n\n- **狭い**: スペースを節約し、会話に集中できます。\n- **広い**: 長いフォルダ名も一目で確認できます。\n\n## 対応プラットフォーム\n\nこの機能は以下に対応しています：\n\n- **Google Gemini**\n- **Google AI Studio**\n\n設定は自動的に保存され、次回ページを開いたときに適用されます。\n"
  },
  {
    "path": "docs/ja/guide/sponsor.md",
    "content": "# スポンサー\n\n> [!NOTE]\n> もし Voyager が役に立っているなら、X、YouTube、Reddit などで共有してもらえると嬉しいです。シェアが増えるほど、このプロジェクトをより多くの人に届けられ、Gemini の体験改善にもつながります。ありがとう。\n\nオープンソースプロジェクトの維持は、主に情熱（とコーヒー）で支えられています ☕\n\n**[Voyager](https://github.com/Nagi-ovo/gemini-voyager)** は、Gemini の体験を向上させることを目的とした、完全無料かつオープンソースのブラウザ拡張機能です。もしこの拡張機能があなたの効率アップに役立ったなら、以下の方法で開発とメンテナンスの継続をご支援いただけると幸いです。\n\n---\n\n## オンラインプラットフォーム\n\n<div class=\"sponsor-badges\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" style=\"height: 40px;\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" style=\"height: 40px;\">\n  </a>\n</div>\n\n<a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n    <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n    <img alt=\"Afdian\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n  </picture>\n</a>\n\n### 🎙️ おすすめツール: Typeless\n\n**[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)** は、私が個人的に強く推奨する AI 音声入力ツールです。Voyager の開発において日常のワークフローに組み込むことで、大幅な時間短縮と開発効率の向上を実現できました。\n\n> 🎁 **[私の招待リンクから登録](https://www.typeless.com/?via=gemini-voyager)**（招待コード：_`gemini-voyager`_）すると、**5 ドルの無料クレジット**を獲得できます。同時に、このプロジェクトの継続的な開発を支援することにも繋がります。❤️\n\n---\n\n## QR コードで支援 🍵\n\n<div class=\"qr-container\">\n  <div class=\"qr-item\">\n    <img src=\"/assets/wechat-sponsor.png\" alt=\"WeChat Pay\" />\n    <span>WeChat Pay</span>\n  </div>\n  <div class=\"qr-item\">\n    <img src=\"/assets/alipay-sponsor.jpg\" alt=\"Alipay\" />\n    <span>Alipay</span>\n  </div>\n</div>\n\n<style>\n.sponsor-badges {\n  display: flex;\n  gap: 16px;\n  flex-wrap: wrap;\n  align-items: center;\n  margin: 16px 0;\n}\n\n.sponsor-badges a img {\n  transition: transform 0.2s ease;\n}\n\n.sponsor-badges a:hover img {\n  transform: scale(1.05);\n}\n\n.qr-container {\n  display: flex;\n  gap: 32px;\n  flex-wrap: wrap;\n  margin: 16px 0;\n}\n\n.qr-item {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 8px;\n  padding: 16px;\n  border-radius: 8px;\n  border: 1px solid var(--vp-c-divider);\n  background: var(--vp-c-bg-soft);\n}\n\n.qr-item img {\n  width: 200px;\n  height: 200px;\n  object-fit: contain;\n  border-radius: 4px;\n}\n\n.qr-item span {\n  font-weight: 600;\n  color: var(--vp-c-text-1);\n}\n\n@media (max-width: 640px) {\n  .qr-container {\n    justify-content: center;\n  }\n  \n  .qr-item img {\n    width: 150px;\n    height: 150px;\n  }\n}\n</style>\n\n---\n\nご支援ありがとうございます！あなたの貢献の一つひとつが、私にとって最大の励みです ❤️\n"
  },
  {
    "path": "docs/ja/guide/tab-title.md",
    "content": "# タブタイトルの同期\n\nブラウザのタブタイトルを、現在の Gemini™ の会話タイトルと自動的に同期します。\n\n## 機能紹介\n\n- **リアルタイム同期**: 会話タイトルが変わると（AI が新しいタイトルを生成したり、手動でリネームした場合など）、タブの表示も「Gemini」だけではなく、すぐに会話タイトルへ更新されます。\n- **マルチページ対応**: 通常の会話ページ、Gem 会話、およびマルチアカウント環境を完全にサポートしています。\n- **オン/オフ**: 不要な場合は、設定パネルの「一般オプション」からいつでもオフにできます。\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/tab-title.png\" alt=\"Tab Title Sync\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## 使い方\n\n1. 拡張機能をインストールすると、この機能はデフォルトでオンになっています。\n2. 任意の Gemini 会話を開き、ブラウザのタブタイトルを確認してください。自動的に現在の会話タイトルに変わります。\n3. オフにする場合：\n   - 拡張機能アイコンをクリックして設定パネルを開きます。\n   - 「一般オプション (General Options)」を探します。\n   - 「タブタイトルの同期 (Update Tab Title)」スイッチをオフにします。\n"
  },
  {
    "path": "docs/ja/guide/timeline.md",
    "content": "# 時間旅行\n\n長い会話は迷宮です。上へ下へとスクロールし、方向を見失います。\nVoyager は、会話を一筋の「線」に変えます。\n\n## リズムを見る\n\n画面の右端を見てください。\nそこにある点は、一つひとつが発言です。それは、あなたとAIの対話の脈動そのものです。\n\n## ナビゲーション、一足飛び\n\n- **瞬時に移動**：クリックした場所へ、即座に飛びます。もたつきません。\n- **のぞき見**：マウスを乗せるだけで、ジャンプせずに中身を確認できます。\n- **ブックマーク**：ノードを長押しして **スター** を付けましょう。脳のしおりです。\n- **階層化 (実験的)**：ノードを右クリックして、レベル（1-3）を設定したり、子ノードを折りたたんだりできます。深い議論の枝葉を一目で把握できます。\n- **ショートカット**：キーボードで高速移動。デフォルトは `j` / `k` ですが、自由に変更可能です。\n\n![タイムラインナビゲーション](/assets/teaser.png)\n\n## キーボードが生む速度\n\nマウスを使いたくない？ キーボードがあります。\n\n**まるで Gemini で Vim モードを使っているかのように。**\n\n### デフォルト・ショートカット\n\n- `k` - 前のノードへ移動\n- `j` - 次のノードへ移動\n\n### カスタマイズ\n\n拡張機能の設定を開き、ショートカットの入力欄をクリックして、使いたいキーを押すだけです。\n任意のキー、任意の組み合わせで。`n`/`p`？ `,`/`.`？ お好みでどうぞ。\n\n**流動モード**では、連打するとアニメーションがキューに入って滑らかに再生されます。\n**跳躍モード**では、即座に反応し、最高速度で移動します。\n"
  },
  {
    "path": "docs/ja/index.md",
    "content": "---\nlayout: home\n\nhero:\n  name: 'Voyager'\n  text: 'ついに、完成。'\n  tagline: '思考を形に、すべてをあるべき場所へ。'\n  image:\n    src: /logo.png\n    alt: Voyager Logo\n  actions:\n    - theme: brand\n      text: ダウンロード\n      link: ./guide/installation\n    - theme: alt\n      text: 旅を始める\n      link: ./guide/getting-started\n\nteaser:\n  title: 'インタラクションを再定義する。'\n  description: '私たちは拡張機能を作るのではありません。思考そのものを再構築するのです。<br>Voyager があれば、人がインターフェースに合わせる必要はありません。インターフェースが、あなたの思考の流れに寄り添うのです。'\n  image: '/assets/teaser.png'\n  features:\n    - title: 'タイムライン'\n      details: '会話の鼓動を見る。<br>直線的な時間を、触れられる空間へと変える。'\n    - title: 'フォルダ'\n      details: '思考に家を与える。<br>一瞬の閃きでさえ、丁重に扱われる価値がある。'\n    - title: '主導権'\n      details: 'データはあなたのもの。<br>クラウドの壁を越え、知識を真にあなたの手に。'\n\nfeatures:\n  - icon: 🧭\n    title: タイムライン\n    details: スクロールは不要、飛ぶだけ。思考の着地点へ、瞬時に到達。\n  - icon: 🗂️\n    title: フォルダ\n    details: 混沌に別れを。直感的な操作で、驚くほど整理整頓。\n  - icon: ✨\n    title: プロンプトヴォルト\n    details: 閃きを逃さない。珠玉のアイデアを、大切に保管。\n  - icon: 💬\n    title: 引用返信\n    details: テキストを選択してワンクリックで引用。文脈に応じた返信で、効率的なコミュニケーションを。\n  - icon: ↔️\n    title: チャット幅\n    details: 視野を広げよう。チャットの幅を自由に調整して、より良い閲覧体験を。\n  - icon: 💾\n    title: 会話のエクスポート\n    details: データは、あなたのもの。多様な形式でアーカイブし、知識を永続化。\n  - icon: 🌦️\n    title: ビジュアルエフェクト\n    details: 雰囲気を演出。ポップアップから雪、雨、桜の花びらエフェクトを切り替えられます。\n  - icon: 🍌\n    title: NanoBanana 透かし除去\n    details: 生成された瞬間を、純粋なままに。ノイズのない美しさを。\n  - icon: 📐\n    title: 数式コピー\n    details: LaTeX および MathML (Word) のソースコードを一クリックでコピー。\n  - icon: 🧜‍♀️\n    title: Mermaid\n    details: コードを視覚化。フローチャート、シーケンス図、ガントチャートを瞬時にレンダリング。\n  - icon: 🏷️\n    title: タブタイトルの同期\n    details: 一目で分かる。タブのタイトルが、会話の内容に自動で変化。\n  - icon: 🔀\n    title: 会話の分岐 (実験的)\n    details: 拡散的思考。任意のノードで会話を分岐させ、異なる可能性を探究。\n  - icon: 🗑️\n    title: 一括削除\n    details: 整理も一瞬。複数の会話を選んで、まとめて消去。\n  - icon: ☁️\n    title: クラウド同期\n    details: いつでも同期。フォルダとプロンプトを Google ドライブにバックアップ。\n  - icon: ⚡️\n    title: デフォルトモデル\n    details: 繰り返し作業は不要です。新しいチャットでお気に入りのモデルに自動切り替え。\n  - icon: 🔬\n    title: Deep Research\n    details: 思考を解き明かす。Deep Research の研究過程とリンクを抽出。\n---\n\n<div class=\"vp-doc\" style=\"margin: 2rem auto 0; max-width: 780px; padding: 0 16px;\">\n  <div style=\"background: rgba(234, 179, 8, 0.12); border: 1px solid rgba(234, 179, 8, 0.6); border-radius: 8px; padding: 12px 16px;\">\n    <strong>⚠️ 名称変更のお知らせ</strong>：商標・著作権上の問題により、本拡張機能は正式に <strong>Voyager</strong> へ改名されました。ただし、Chrome ウェブストアの審査が非常に遅いため、7 日以内に名称変更が承認されず、現在 Chrome Web Store では一時的にご利用いただけない状態です。\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 780px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 8px; font-weight: 600; font-size: 1.05em;\">すべてのダウンロードは、信頼の証</h3>\n  <p style=\"margin: 0 0 16px; opacity: 0.78; font-size: 0.95em;\">Chrome ウェブストアと GitHub からのリアルタイムデータ。共に歩んでくれるすべての Voyager に敬意を表します。</p>\n  <div style=\"display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;\">\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Star\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Fork\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"最新バージョン\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub ダウンロード数\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome ストア ユーザー数\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome ストア 評価\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoft-edge\" alt=\"Edge ストア\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox ストア ユーザー数\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox ストア 評価\">\n  </div>\n  <div style=\"margin-top: 16px; display: flex; justify-content: center; flex-wrap: wrap; gap: 12px;\">\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <!-- <a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a> -->\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 1000px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 24px; font-weight: 600; font-size: 1.2em;\">特別謝辞</h3>\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" style=\"margin: 0 auto;\" />\n  </a>\n  <p style=\"margin-top: 24px; font-size: 1.05em; opacity: 0.86;\">✨ Product Hunt にて公開中！ご意見やフィードバックをお待ちしております。❤️</p>\n  <div style=\"margin-top: 12px; display: flex; justify-content: center;\">\n    <a href=\"https://www.producthunt.com/posts/gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager on Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 3.5rem auto 2rem; max-width: 720px; padding: 0 16px;\">\n  <p style=\"font-size: 1.05em; font-weight: 600; opacity: 0.86; margin: 0 0 12px;\">「それは単なるツールではありません。思考の航海における、頼れるパートナーです。」</p>\n  <a href=\"./guide/getting-started\" style=\"font-weight: 600; text-decoration: none;\">探索する →</a>\n</div>\n\n<p align=\"center\">\n  <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n</p>\n"
  },
  {
    "path": "docs/ja/privacy.md",
    "content": "# プライバシーポリシー\n\n最終更新日：2026年3月16日\n\n## はじめに\n\nVoyager（以下「本拡張機能」）は、お客様のプライバシー保護に努めています。本プライバシーポリシーでは、本ブラウザ拡張機能がどのように情報を収集・利用・保護するかについて説明します。\n\n## データの収集と使用\n\n**本拡張機能では個人情報を一切収集しません。**\n\nVoyager は、完全にお客様のブラウザ内でローカルに動作します。拡張機能が生成・管理するすべてのデータ（フォルダ、プロンプトテンプレート、スター付きメッセージ、設定など）は、以下の場所に保存されます：\n\n1. お客様のローカルデバイス（`chrome.storage.local`）\n2. ブラウザの同期ストレージ（`chrome.storage.sync`、利用可能な場合）。これにより、デバイス間で設定が同期されます。\n\n開発者は、お客様の個人データ、チャット履歴、その他のプライバシー情報には一切アクセスできません。また、閲覧履歴を追跡することもありません。\n\n## Google ドライブ同期（オプション）\n\nGoogle ドライブ同期機能を明示的に有効にした場合、拡張機能は Chrome Identity API を使用して OAuth2 トークン（`drive.file` スコープのみ）を取得し、フォルダとプロンプトを**お客様自身の Google ドライブ**にバックアップします。この転送はお客様のブラウザと Google のサーバー間で直接行われます。開発者がこのデータにアクセスすることはなく、開発者が運営するサーバーに送信されることもありません。\n\n## 権限について\n\n本拡張機能は、機能維持に必要な最小限の権限のみを申請します：\n\n- **Storage（ストレージ）**：設定、フォルダ、プロンプト、スター付きメッセージ、UI カスタマイズオプションをローカルおよびデバイス間で保存するために使用します。\n- **Identity（認証）**：オプションの Google ドライブ同期機能の Google 認証に使用します。クラウド同期を明示的に有効にした場合のみ使用されます。\n- **Scripting（スクリプト注入）**：Gemini ページおよびユーザーが指定したカスタムウェブサイトにコンテンツスクリプトを動態的に注入するために使用します（プロンプトマネージャー機能）。拡張機能自身にバンドルされたスクリプトのみが注入され、リモートコードの取得や実行は行いません。\n- **Host Permissions（ホスト権限）**（gemini.google.com、aistudio.google.com など）：Gemini UI を拡張するコンテンツスクリプトを注入するために使用します（フォルダ、エクスポート、タイムライン、引用返信など）。Google 関連ドメイン（googleapis.com、accounts.google.com）は Google ドライブ同期の認証に必要です。\n- **Optional Host Permissions（オプションのホスト権限）**（すべての URL）：プロンプトマネージャーのカスタムウェブサイトを明示的に追加した場合のみ、ランタイムでリクエストされます。ユーザーの操作なしに有効化されることはありません。\n\n## 第三者サービス\n\n本拡張機能が、第三者サービス、広告主、または分析プロバイダーとデータを共有することは一切ありません。\n\n## ポリシーの変更\n\nプライバシーポリシーは随時更新される可能性があります。変更があった場合は、このページに新しいプライバシーポリシーを掲載することで通知します。\n\n## お問い合わせ\n\n本プライバシーポリシーについてご質問がある場合は、[GitHub リポジトリ](https://github.com/Nagi-ovo/gemini-voyager) を通じてお問い合わせください。\n"
  },
  {
    "path": "docs/ko/guide/batch-delete.md",
    "content": "# 일괄 삭제\n\n여러 대화를 한 번에 삭제하세요. 이제 더 이상 하나씩 삭제할 필요가 없습니다.\n\n## 주요 기능\n\n- **다중 선택 모드**: 대화를 길게 눌러 다중 선택 모드로 진입하고 삭제할 대화들을 여러 개 체크할 수 있습니다.\n- **클릭 한 번으로 정리**: 선택이 완료되면 삭제 버튼을 클릭하여 선택한 모든 대화를 한 번에 제거합니다.\n- **진행 상황 피드백**: 삭제 중에 실시간 진행 상황이 표시되어 현재 상태를 알 수 있습니다.\n- **안전한 확인**: 실수로 인한 작업을 방지하기 위해 삭제 전 확인 대화창이 나타납니다.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/batch-delete.png\" alt=\"일괄 삭제\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## 사용 방법\n\n1. 사이드바 대화 목록에서 아무 대화 항목이나 **길게 누릅니다**.\n2. 다중 선택 모드로 진입하면 각 대화의 왼쪽에 체크박스가 나타납니다.\n3. 삭제하려는 대화들을 체크합니다 (한 번에 최대 50개까지 가능).\n4. 나타나는 **삭제 버튼**을 클릭합니다.\n5. **폴더 목록 상단**에 나타나는 빨간색 확인 영역에서 \"확인\"을 클릭하여 삭제를 시작합니다.\n\n::: tip 참고\n확인 패널은 대화 목록을 가리지 않기 위해 폴더 영역 위에 겹쳐서 표시됩니다. 일괄 삭제 작업은 취소할 수 없으므로 주의해서 진행해 주세요.\n:::\n"
  },
  {
    "path": "docs/ko/guide/cloud-sync.md",
    "content": "# 클라우드 동기화\n\n폴더, 프롬프트 라이브러리 및 기타 데이터를 Google Drive에 동기화하여 여러 기기에서 일관된 환경을 유지하세요.\n\n## 주요 기능\n\n- **다중 기기 동기화**: Google Drive를 사용하여 여러 컴퓨터 간에 설정을 동기화 상태로 유지합니다.\n- **데이터 프라이버시**: 데이터가 본인의 Google Drive 저장소에 직접 저장되므로 제3자 서버 없이 프라이버시가 보장됩니다.\n- **유연한 동기화**: 수동 업로드 및 데이터 다운로드/병합을 지원합니다.\n\n::: info\n**출시 예정**: 다음 버전에서는 별표 표시된 대화 동기화가 지원될 예정입니다.\n:::\n\n## 사용 방법\n\n1. Gemini™ 페이지 우측 하단의 확장 프로그램 아이콘을 클릭하여 설정 패널을 엽니다.\n2. **클라우드 동기화 (Cloud Sync)** 섹션을 찾습니다.\n3. **Google 계정으로 로그인 (Sign in with Google)**을 클릭하고 권한 승인을 완료합니다.\n4. 승인이 완료되면 **클라우드에 업로드 (Upload to Cloud)**를 클릭하여 로컬 데이터를 클라우드로 동기화하거나, **다운로드 및 병합 (Download & Merge)**을 클릭하여 클라우드 데이터를 로컬로 가져옵니다.\n\n### 💡 빠른 동기화\n\n가장 쉬운 방법은 왼쪽 사이드바의 폴더 영역 상단에 있는 **\"클라우드에 업로드\"** 또는 **\"다운로드 및 병합\"** 버튼을 클릭하는 것입니다.\n\n<img src=\"/assets/cloud-sync.png\" alt=\"클라우드 동기화 빠른 버튼\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n::: warning\n**보안 권장 사항: 이중 보호**  \n클라우드 동기화는 매우 편리하지만, 정기적으로 **로컬 파일**을 사용하여 핵심 데이터를 백업하는 것을 강력히 권장합니다.\n\n1. **전체 내보내기**: 설정 패널 하단의 \"백업 및 복원 (Backup & Restore)\"에서 모든 설정, 폴더, 프롬프트가 포함된 전체 패키지를 내보냅니다.\n   <img src=\"/assets/manual-export-all.png\" alt=\"전체 내보내기\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. **모든 폴더 내보내기**: 설정 패널의 \"폴더 (Folders)\" 섹션에서 \"내보내기 (Export)\"를 클릭하여 프롬프트를 제외한 모든 폴더와 대화를 백업합니다.\n   <img src=\"/assets/manual-folder-export.png\" alt=\"모든 폴더 내보내기\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n   :::\n"
  },
  {
    "path": "docs/ko/guide/community.md",
    "content": "# 커뮤니티 및 피드백\n\n우리는 모든 사용자의 목소리를 소중하게 생각합니다. 버그를 발견했거나, 기능 제안이 있거나, 자신의 프롬프트 저장소를 공유하고 싶다면 여러 가지 방법을 통해 소통할 수 있습니다.\n\n## 📢 업데이트 팔로우\n\nX (Twitter)에서 우리를 팔로우하고 최신 개발 소식을 받아보세요.\n\n- **새로운 릴리스**: 업데이트 소식을 가장 먼저 확인하세요.\n- **기능 미리 보기**: 출시 예정인 기능을 미리 확인해 보세요.\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://x.com/Nag1ovo/status/2012610584722731188\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/badge/Follow%20on-𝕏-000000?style=for-the-badge&logo=x&logoColor=white\" alt=\"X 팔로우하기\">\n  </a>\n</div>\n\n## 💬 Discord 커뮤니티\n\nDiscord 서버에 가입하여 다른 Voyager들과 대화해 보세요!\n\n- **실시간 채팅**: 다른 사용자 및 개발자와 직접 소통하세요.\n- **프롬프트 공유**: 다른 사람들이 Gemini™를 어떻게 사용하는지 확인하고 자신의 최고의 프롬프트를 공유하세요.\n- **개발 업데이트**: 예정된 기능과 릴리스에 대한 최신 소식을 받으세요.\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://discord.gg/TEUFxdMbGb\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/discord/1463273957120675973?style=for-the-badge&logo=discord&logoColor=white&label=Discord 가입하기\" alt=\"Discord\">\n  </a>\n</div>\n\n## 🐙 GitHub 이슈\n\n버그를 발견했거나 구체적인 기능 제안이 있다면 GitHub에서 이슈를 열어주세요:\n\n- [버그 보고하기](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=bug_report.yml)\n- [기능 제안하기](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=feature_request.yml)\n\nVoyager를 응원해 주셔서 감사합니다! ❤️\n"
  },
  {
    "path": "docs/ko/guide/context-sync.md",
    "content": "# 기억 운반: 컨텍스트 동기화 (실험적)\n\n**다른 차원, 부드러운 공유**\n\n웹에서 로직을 추론하고, IDE에서 코드를 구현하세요. Voyager는 차원의 벽을 허물어 IDE가 웹의 \"사고 과정\"을 즉시 공유받을 수 있게 합니다.\n\n## 반복되는 화면 전환과 작별하세요\n\n개발자들이 가장 번거로워하는 일: 웹에서 해결책을 충분히 논의한 후 VS Code/Trae/Cursor로 돌아왔을 때, 마치 처음 보는 사람처럼 요구 사항을 다시 설명해야 하는 상황입니다. 할당량과 응답 속도 때문에 웹은 \"두뇌\", IDE는 \"손\"이 됩니다. Voyager는 그들이 하나의 영혼을 공유하게 합니다.\n\n## 아주 간단한 3단계, 같은 호흡으로\n\n1. **CoBridge 설치 및 실행**:\n   **CoBridge** 플러그인을 설치하세요. 웹 인터페이스와 로컬 IDE를 연결하는 핵심 브리지 역할을 합니다.\n   - **[마켓플레이스에서 설치](https://open-vsx.org/extension/windfall/co-bridge)**\n\n   ![CoBridge 확장 프로그램](/assets/CoBridge-extension.png)\n\n   설치 후 **임의의 작업 디렉토리를 열고**, 오른쪽 아이콘을 클릭하여 서버를 시작합니다.\n   ![CoBridge 서버 시작](/assets/CoBridge-on.png)\n\n2. **연결 확인 (Handshake)**:\n   - Voyager 설정에서 \"컨텍스트 동기화\"를 활성화합니다.\n   - 포트 번호를 맞춥니다. \"IDE Online\"이 표시되면 연결된 것입니다.\n\n   ![컨텍스트 동기화 패널](/assets/context-sync-console.png)\n\n3. **클릭 한 번으로 동기화**: **\"Sync to IDE\"**를 클릭합니다. 복잡한 **데이터 테이블**부터 직관적인 **참조 이미지**까지 모두 즉시 IDE로 전송됩니다.\n\n   ![동기화 완료](/assets/sync-done.png)\n\n## IDE에 뿌리 내리기\n\n동기화가 완료되면 IDE 작업 디렉토리에 `.cobridge/AI_CONTEXT.md` 파일이 생성됩니다. Trae, Cursor, Copilot 중 무엇을 사용하든 각각의 Rule 파일을 통해 이 \"기억\"을 자동으로 읽어옵니다.\n\n```\nyour-project/\n├── .cobridge/\n│   ├── images/\n│   │   ├── context_img_1_1.png\n│   │   └── context_img_1_2.png\n│   └── AI_CONTEXT.md\n├── .github/\n│   └── copilot-instructions.md\n├── .gitignore\n├── .traerules\n└── .cursorrules\n```\n\n## 그것의 원칙\n\n- **오염 제로**: CoBridge는 자동으로 `.gitignore`를 처리하여 개인적인 대화가 Git 저장소에 푸시되지 않도록 보장합니다.\n- **산업 표준 호환**: 전체 Markdown 형식을 사용하여 IDE의 AI가 마치 제품 설명서를 읽는 것처럼 부드럽게 내용을 파악할 수 있습니다.\n- **전문가 팁**: 대화가 오래전 내용이라면 [타임라인]을 사용하여 위로 스크롤해 웹이 컨텍스트를 \"기억\"하게 한 뒤 동기화하는 것이 더 효과적입니다.\n\n---\n\n## 지금 바로 시작하세요\n\n**생각은 이미 클라우드에서 준비되었습니다. 이제 로컬에 뿌리를 내리게 하세요.**\n\n- **[CoBridge 확장 프로그램 설치](https://open-vsx.org/extension/windfall/co-bridge)**: 당신의 차원 관문을 찾아 클릭 한 번으로 \"동기화된 호흡\"을 경험하세요.\n- **[GitHub 저장소 방문](https://github.com/Winddfall/CoBridge)**: CoBridge의 기저 로직을 깊이 탐구하거나 이 \"영혼 동기화\" 프로젝트에 Star를 눌러주세요.\n\n> **거대 모델은 이제 더 이상 기억을 잃지 않으며, 즉시 실전에 투입 가능합니다.**\n"
  },
  {
    "path": "docs/ko/guide/deep-research.md",
    "content": "# Deep Research 내보내기\n\nDeep Research에서 생성된 최종 보고서를 내보내거나, 전체 \"생각(Thinking)\" 과정을 Markdown 파일로 저장할 수 있습니다.\n\n## 1. 보고서 내보내기 (PDF / 이미지)\n\nDeep Research에서 생성된 보고서는 스타일리시한 PDF 또는 공유용 단일 이미지로 내보낼 수 있습니다 (Markdown 및 JSON 형식도 지원됩니다).\n\n![보고서 내보내기](/assets/deep-research-report-export.png)\n\n## 2. 생각 과정 내보내기 (Markdown)\n\n최종 보고서 외에도 Deep Research 대화의 전체 \"생각\" 내용을 한 번의 클릭으로 내보낼 수 있습니다.\n\n### 주요 기능\n\n- **클릭 한 번으로 내보내기**: 공유 및 내보내기 버튼을 클릭하면 다운로드 버튼이 나타납니다.\n- **구조화된 형식**: 생각 단계, 생각 항목, 조사한 웹사이트를 원래 순서대로 보존합니다.\n- **다국어 헤더**: Markdown 파일에는 영어와 현재 언어의 섹션 헤더가 모두 포함됩니다.\n- **자동 명명**: 파일은 정리가 용이하도록 타임스탬프와 함께 저장됩니다 (예: `deep-research-thinking-20240128-153045.md`).\n\n### 사용 방법\n\n1. Gemini™에서 Deep Research 대화를 엽니다.\n2. 대화에서 **공유 및 내보내기** 버튼을 클릭합니다.\n3. \"생각 내용 다운로드 (下载 Thinking 内容)\"를 선택합니다.\n4. Markdown 파일이 자동으로 다운로드됩니다.\n\n![Deep Research 생각 과정 내보내기](/assets/deepresearch_download_thinking.png)\n\n### 내보낸 파일 형식\n\n내보낸 Markdown 파일에는 다음이 포함됩니다:\n\n- **제목**: 대화 제목\n- **메타데이터**: 내보낸 시간 및 총 생각 단계 수\n- **생각 단계**: 각 단계는 다음을 포함합니다:\n  - 생각 항목 (제목 및 내용)\n  - 조사한 웹사이트 (링크 및 제목)\n\n#### 예시 구조\n\n```markdown\n# Deep Research 대화 제목\n\n**导出时间 / Exported At:** 2025-12-28 17:25:35\n**总思考阶段 / Total Phases:** 3\n\n---\n\n## 思考阶段 1 / Thinking Phase 1\n\n### 생각 제목 1\n\n생각 내용...\n\n### 생각 제목 2\n\n생각 내용...\n\n#### 研究网站 / Researched Websites\n\n- [domain.com](https://example.com) - 페이지 제목\n- [another.com](https://another.com) - 다른 제목\n\n---\n\n## 思考阶段 2 / Thinking Phase 2\n\n...\n```\n\n## 프라이버시\n\n모든 추출 및 형식 지정은 브라우저에서 100% 로컬로 이루어집니다. 외부 서버로 어떠한 데이터도 전송되지 않습니다.\n"
  },
  {
    "path": "docs/ko/guide/default-model.md",
    "content": "# 기본 모델\n\n::: info\n**참고**: 이 기능은 버전 1.1.9 이상에서 지원됩니다.\n:::\n\n새 대화를 시작할 때마다 모델을 수동으로 변경할 필요 없도록, 선호하는 Gemini™ 모델을 기본값으로 설정하세요.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/default-model.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n## 주요 기능\n\n- **대화형 설정**: Gemini의 기본 모델 선택 메뉴에 직접 \"별표\" 버튼을 추가합니다.\n- **자동 전환**: 새 대화를 시작할 때마다 선호하는 모델로 자동으로 전환합니다.\n- **영구적인 기본 설정**: 선택한 설정은 저장되어 기기 간에 동기화됩니다.\n- **SPA 최적화**: \"새 채팅\" 버튼을 클릭하거나, 단축키를 사용하거나, 홈 페이지로 돌아갈 때 정확하게 작동합니다.\n\n## 사용 방법\n\n1. Gemini 입력창 위의 **모델 선택기**를 클릭합니다.\n2. 선호하는 모델 위에 마우스를 올리고 **별표 아이콘**을 클릭합니다.\n3. 별표가 **채워지면** 해당 모델이 기본 모델로 설정된 것입니다.\n4. 이제 확장 프로그램이 모든 새 채팅에 대해 이 모델을 자동으로 선택합니다.\n5. 설정을 해제하려면 채워진 별표 아이콘을 다시 클릭하면 됩니다.\n"
  },
  {
    "path": "docs/ko/guide/export.md",
    "content": "# 완전한 자유\n\n데이터에 갇히는 것은 적입니다.\n우리는 당신이 만든 것이라면 당신이 소유해야 한다고 믿습니다.\n\n## 모든 것을 내보내기\n\nVoyager를 사용하면 데이터를 클라우드에서 꺼내어 당신의 손안으로 가져올 수 있습니다.\n\n### 형식\n\n- **Markdown**: Obsidian 저장소나 Notion을 위한 형식입니다. 깨끗하고 서식이 있는 텍스트입니다. (Safari 사용자 주의: 브라우저 제한으로 인해 이미지를 추출할 수 없습니다. PDF 내보내기를 권장합니다.)\n- **PDF**: 공유나 인쇄를 위한 형식입니다. 이미지가 포함된 아름다운 레이아웃을 제공합니다.\n- **JSON**: 원시 데이터입니다. 자신의 기록을 바탕으로 무언가를 구축하려는 개발자를 위한 형식입니다.\n\n### 내보내는 방법\n\n1. Gemini 로고 위에 마우스를 올리면 **내보내기 아이콘**이 나타납니다.\n2. 형식을 선택하세요.\n3. 끝입니다.\n\n이것은 당신의 데이터입니다. 원하는 대로 사용하세요.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>1단계: 로고 호버</b></p>\n    <img src=\"/assets/gemini-export-guide-1.png\" alt=\"내보내기 가이드 1단계\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>2단계: 선택</b></p>\n    <img src=\"/assets/gemini-export-guide-2.png\" alt=\"내보내기 가이드 2단계\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n### Safari PDF 내보내기 참고 사항\n\nSafari에서 PDF를 내보내는 과정은 조금 다릅니다(수동 인쇄 필요):\n\n1. **내보내기(Export)** 버튼을 클릭하고 PDF 형식을 선택합니다.\n2. **1초 정도 기다립니다** (페이지가 인쇄 스타일을 준비할 시간을 줍니다).\n3. `Command + P`를 눌러 인쇄 대화상자를 엽니다.\n4. 인쇄 대화상자에서 **\"Save to PDF\"**(또는 \"PDF로 저장\")를 선택합니다.\n\n<img src=\"/assets/safari-export-pdf.png\" alt=\"Safari Export PDF\" style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 20px;\"/>\n"
  },
  {
    "path": "docs/ko/guide/folders.md",
    "content": "# 제대로 된 폴더 관리\n\nAI 채팅을 정리하는 것이 왜 그렇게 힘들까요?\n우리가 해결했습니다. 당신의 생각을 위한 파일 시스템을 구축했습니다.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap; margin-bottom: 40px;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Gemini™</b></p>\n    <img src=\"/assets/gemini-folders.png\" alt=\"Gemini 폴더\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>AI Studio</b></p>\n    <img src=\"/assets/aistudio-folders.png\" alt=\"AI Studio 폴더\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n## 정리의 물리학\n\n단순히 직관적입니다.\n\n- **드래그 앤 드롭**: 대화를 선택하세요. 폴더에 넣으세요. 직접 만지는 듯한 느낌을 줍니다.\n- **계층 구조**: 프로젝트에는 하위 프로젝트가 있습니다. 폴더 안에 폴더를 만드세요. _당신만의_ 방식으로 구조화하세요.\n- **폴더 간격**: 사이드바 밀도를 자유롭게 조정할 수 있습니다.\n  > _참고: Mac Safari에서는 조정 사항이 실시간으로 반영되지 않을 수 있습니다. 페이지를 새로고침하면 적용됩니다._\n- **즉시 동기화**: 데스크탑에서 정리하세요. 노트북에서도 그대로 확인할 수 있습니다.\n\n## 전문가 팁\n\n- **다중 선택**: 대화를 길게 눌러 다중 선택 모드로 진입한 다음, 여러 채팅을 선택하여 한 번에 이동할 수 있습니다.\n- **이름 바꾸기**: 폴더를 더블 클릭하여 이름을 바꿉니다.\n- **아이콘**: Gem의 유형(코딩, 창의적 등)을 자동으로 감지하여 적절한 아이콘을 할당합니다. 당신은 아무것도 할 필요가 없습니다.\n\n## 플랫폼별 기능 차이\n\n### 공통 기능\n\n- **기본 관리**: 드래그 앤 드롭, 이름 바꾸기, 다중 선택.\n- **스마트 인식**: 대화 유형을 자동으로 감지하여 적절한 아이콘 할당.\n- **계층 구조**: 폴더 중첩(중첩 계층) 지원.\n- **AI Studio 지원**: 위의 고급 기능들은 곧 AI Studio에서도 지원될 예정입니다.\n- **Google Drive 동기화**: 폴더 구조를 Google Drive와 동기화.\n\n### Gemini 전용 기능\n\n#### 색상 맞춤 설정\n\n폴더 아이콘을 클릭하여 색상을 맞춤 설정하세요. 7가지 기본 색상 중에서 선택하거나 색상 피커를 사용하여 원하는 색상을 선택할 수 있습니다.\n\n<img src=\"/assets/folder-color.png\" alt=\"폴더 색상\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### 계정 분리\n\n헤더의 \"사람\" 아이콘을 클릭하면 다른 Google 계정의 채팅을 즉시 필터링할 수 있습니다. 여러 계정을 사용할 때 작업 공간을 깨끗하게 유지하세요.\n\n<img src=\"/assets/current-user-only.png\" alt=\"계정 분리\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### AI 자동 정리\n\n채팅이 너무 많고, 정리하기 귀찮다고요? Gemini한테 맡기세요.\n\n원클릭으로 현재 대화 구조를 복사하고, Gemini에 붙여넣으면 바로 가져올 수 있는 폴더 계획을 생성해 줍니다 — 순식간에 정리 완료.\n\n**1단계: 대화 구조 복사**\n\n확장 프로그램 팝업의 폴더 섹션 하단에서 **AI 정리** 버튼을 클릭하세요. 미분류 대화와 기존 폴더 구조를 자동으로 수집하고, 프롬프트를 생성하여 클립보드에 복사합니다.\n\n<img src=\"/assets/ai-auto-folder.png\" alt=\"AI Organize Button\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n\n**2단계: Gemini가 분류하게 하기**\n\n클립보드 내용을 Gemini 대화에 붙여넣으세요. 채팅 제목을 분석한 뒤 JSON 폴더 계획을 출력해 줍니다.\n\n**3단계: 결과 가져오기**\n\n폴더 패널 메뉴에서 **폴더 가져오기**를 클릭하고, **또는 JSON 직접 붙여넣기**를 선택한 다음, Gemini가 반환한 JSON을 붙여넣고 **가져오기**를 클릭하세요.\n\n<div style=\"display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; margin-bottom: 24px;\">\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-2.png\" alt=\"Import Menu\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 240px;\"/>\n  </div>\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-3.png\" alt=\"Paste JSON Import\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n  </div>\n</div>\n\n- **증분 병합**: 기본적으로 \"병합\" 전략을 사용합니다 — 새 폴더와 할당만 추가하며, 기존 정리를 절대 파괴하지 않습니다.\n- **다국어 지원**: 프롬프트는 설정된 언어를 자동으로 사용하고, 폴더 이름도 해당 언어로 생성됩니다.\n\n### AI Studio 전용 기능\n\n- **사이드바 너비 조절**: 사이드바 가장자리를 드래그하여 자유롭게 조절.\n- **라이브러리 드래그 지원**: Library 목록에서 폴더로 직접 드래그 앤 드롭 내보내기.\n"
  },
  {
    "path": "docs/ko/guide/fork.md",
    "content": "# 대화 분기 (실험적)\n\n생각은 일방통행이 되어서는 안 됩니다. 복잡한 탐색에서는 종종 중요한 노드로 돌아가 다른 가능성을 시도해야 할 때가 있습니다.\n\nVoyager의 **대화 분기** 기능을 사용하면 생각을 발산하고 대화의 평행 우주를 자유롭게 탐색할 수 있습니다.\n\n## 기능 소개\n\n> **⚠️ 참고**: 이 기능은 현재 실험적 기능입니다. 먼저 브라우저 툴바의 확장 프로그램 아이콘을 클릭하여 설정 팝업을 열고 **\"대화 분기 활성화\"** 스위치를 켜야 합니다.\n\n다른 방향으로 나아가고 싶을 때는 언제든지 질문 위에 마우스를 올리고 **분기** 버튼을 클릭하세요:\n\n![대화 분기](/assets/branching.png)\n\nVoyager는 처음부터 해당 지점까지의 모든 컨텍스트를 즉시 캡처하여 **완전히 새로운 대화를 시작**합니다.\n\n이 새로운 분기에서는 원래의 대화 기록을 훼손할 걱정 없이 질문을 자유롭게 수정하고 다양한 방향을 탐색할 수 있습니다. 당신의 창의력과 호기심을 마음껏 펼치세요!\n"
  },
  {
    "path": "docs/ko/guide/formula-copy.md",
    "content": "# 수식 복사\n\nVoyager를 사용하면 수학 공식과 과학 기호를 손쉽게 재사용할 수 있습니다. LaTeX 소스 코드와 Microsoft Word 호환 MathML 형식을 클릭 한 번으로 복사할 수 있도록 지원합니다.\n\n## 소개\n\nGemini에게 공식을 유도하거나 수학적 표현을 작성하도록 요청하면 보통 LaTeX를 사용하여 렌더링합니다. 보기에 아름답지만, 자신의 논문, 문서 또는 에디터에서 사용하기 위해 소스 코드를 추출하는 것은 종종 번거로운 수작업을 필요로 합니다.\n\nVoyager는 이를 위한 원활한 지원을 제공합니다:\n\n1. **자동 감지**: Voyager는 페이지에 렌더링된 LaTeX 수식을 자동으로 식별합니다.\n2. **복사 버튼**: 수식 위에 마우스를 올리면 오른쪽에 복사 아이콘이 나타납니다.\n3. **형식 옵션**: 아이콘을 클릭하여 다음을 선택할 수 있습니다:\n   - **LaTeX 복사**: Overleaf, Markdown 에디터 등에 적합한 표준 LaTeX 소스를 복사합니다.\n   - **MathML 복사**: **Microsoft Word**에 직접 붙여넣기에 가장 적합한 형식인 MathML 소스를 복사합니다.\n\n![수식 복사](/assets/gemini-math-copy.png)\n\n## 주요 기능\n\n- **Word 호환성**: MathML 지원을 통해 AI가 생성한 복잡한 공식을 편집 가능한 완벽한 서식을 유지하면서 Word 문서에 직접 붙여넣을 수 있습니다.\n- **문맥 보존**: 수식 자체뿐만 아니라 수학적 문맥도 보존하여 복사합니다.\n- **즉각적인 응답**: 모든 처리가 로컬에서 이루어지므로 결과가 즉시 나타납니다.\n\n## 사용 팁\n\n- **학술 글쓰기**: Word에서 논문을 작성할 때 Gemini에게 공식을 유도하게 한 후, MathML 복사-붙여넣기를 사용하여 Word 수식 에디터에서 수동으로 입력하는 번거로움을 피하세요.\n- **노트 필기**: Obsidian이나 Notion에서 노트를 필기할 때 LaTeX 소스를 직접 복사하여 사용하세요.\n"
  },
  {
    "path": "docs/ko/guide/getting-started.md",
    "content": "# 환영합니다\n\n축하합니다. 당신의 지능을 업그레이드하셨습니다.\nVoyager는 단순한 유틸리티가 아니라 워크플로우입니다. 처음 5분 동안 이를 최대한 활용하는 방법을 소개합니다.\n\n## 1. 설정\n\n아직 설치하지 않으셨다면 [설치 가이드](/ko/guide/installation)를 확인하세요.\n설치가 완료되면 Gemini 탭을 새로고침하세요. 즉시 차이점을 느끼실 수 있습니다.\n\n## 2. 타임라인\n\n대화를 시작해 보세요. 아주 긴 대화도 좋습니다. 타이포그래피의 역사나 블랙홀의 물리학에 대해 물어보세요.\n오른쪽을 보세요.\n**점들로 이루어진 띠가 보이시나요? 그것이 당신의 지도입니다.**\n\n- **마우스를 올리면** 무엇을 말했는지 미리 볼 수 있습니다.\n- **클릭하면** 해당 지점으로 순간 이동합니다.\n- **길게 누르면** 간직하고 싶은 순간에 별표를 표시할 수 있습니다.\n\n이제 끝없는 스크롤은 필요 없습니다. 당신은 이제 생각의 속도로 탐색하고 있습니다.\n\n## 3. 정리\n\n왼쪽의 대화 목록을 보세요. 새로운 것이 보이시나요?\n**폴더입니다.**\n\n대화 하나를 선택하세요. 드래그하세요. 폴더에 넣으세요.\n자연스럽지 않나요? 그것이 바로 이 기능의 본질입니다. 폴더를 중첩하거나 이름을 바꾸어 머릿속의 어수선함을 정리할 수 있습니다.\n\n## 4. 저장소 (Vault)\n\n방금 완벽한 프롬프트를 작성하셨나요? 그것이 허공으로 사라지게 두지 마세요.\n입력창 근처의 **반짝이는 아이콘**(✨)을 클릭하세요.\n저장하고 태그를 다세요.\n\n다음 번에는요? 아이콘을 클릭하여 찾아서 삽입하기만 하면 됩니다.\n당신은 이제 단순히 채팅을 하는 것이 아닙니다. 당신만의 천재성이 담긴 저장소를 구축하고 있습니다.\n\n---\n\n**준비가 되었습니다.**\n자세한 내용을 보려면 특정 가이드를 살펴보세요:\n\n- [타임라인 마스터하기](/ko/guide/timeline)\n- [폴더 관리](/ko/guide/folders)\n- [프롬프트 엔지니어링](/ko/guide/prompts)\n- [데이터 내보내기](/ko/guide/export)\n"
  },
  {
    "path": "docs/ko/guide/input-collapse.md",
    "content": "# 입력창 접기\n\n입력창이 비어 있을 때 이를 접어서 더 많은 읽기 공간을 확보하세요. 접힌 바를 클릭하면 다시 펼쳐져서 타이핑을 시작할 수 있습니다.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/hide-input-area.png\" alt=\"입력창 접기\" style=\"max-width: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## 사용 방법\n\n1. 입력창이 비어 있고 포커스를 잃으면, 자동으로 콤팩트한 알약 모양의 버튼으로 접힙니다.\n2. 알약 버튼을 클릭하면 입력창이 펼쳐지며 타이핑을 시작할 수 있습니다.\n3. <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>I</kbd> 를 눌러 빠르게 입력창을 펼칠 수도 있습니다.\n4. 설정 패널에서 이 기능을 켜거나 끌 수 있습니다 (기본적으로 비활성화되어 있습니다).\n"
  },
  {
    "path": "docs/ko/guide/installation.md",
    "content": "# 설치\n\n::: info 뉴스\n🍎 **Safari 네이티브 확장 프로그램이 출시되었습니다!** 완전 무료로 제공되며 클릭 한 번으로 설치할 수 있습니다.\n:::\n\n원하는 설치 방법을 선택하세요.\n\n> ⚠️ 참고: 프롬프트 관리자는 Gemini™ Enterprise를 지원하는 유일한 기능입니다.\n\n## 1. 확장 프로그램 스토어 (권장)\n\n가장 간단한 시작 방법입니다. 업데이트가 자동으로 이루어집니다.\n\n**Chrome / Brave / Opera / Vivaldi:**\n\n[<img src=\"https://img.shields.io/badge/Chrome_Web_Store-Download-4285F4?style=for-the-badge&logo=google-chrome&logoColor=white\" alt=\"Chrome 웹 스토어에서 설치\" height=\"40\"/>](https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=docs&utm_campaign=organic_growth&utm_content=ko)\n\n::: warning ⚠️ Chrome Web Store 일시적으로 이용 불가\n상표 문제로 인해 이 확장 프로그램이 **Voyager**로 공식 개명되었습니다. Chrome Web Store 심사가 진행 중입니다. 자세한 내용은 [이 게시물](https://x.com/Nag1ovo/status/2031561180213313944)을 확인하세요. 당분간 **Edge / Firefox** 또는 **수동 설치**를 이용해 주세요.\n:::\n\n**Microsoft Edge:**\n\n[<img src=\"https://img.shields.io/badge/Microsoft_Edge-Download-0078D7?style=for-the-badge&logo=microsoft-edge&logoColor=white\" alt=\"Microsoft Edge 추가 기능에서 설치\" height=\"40\"/>](https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne)\n\n**Firefox:**\n\n[<img src=\"https://img.shields.io/badge/Firefox_Add--ons-Download-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Firefox 추가 기능에서 설치\" height=\"40\"/>](https://addons.mozilla.org/firefox/addon/gemini-voyager/)\n\n## 2. 수동 설치 (최신 기능)\n\n웹 스토어의 심사 과정은 다소 느릴 수 있습니다. 최신 기능을 즉시 사용하고 싶다면 수동으로 설치하세요.\n\n**Chrome / Edge / Brave / Opera의 경우:**\n\n1. [GitHub Releases](https://github.com/Nagi-ovo/gemini-voyager/releases)에서 최신 `gemini-voyager-chrome-vX.Y.Z.zip`을 다운로드합니다.\n2. 파일의 압축을 풉니다.\n3. 브라우저의 확장 프로그램 페이지(`chrome://extensions`)를 엽니다.\n4. 우측 상단의 **개발자 모드**를 활성화합니다.\n5. **압축해제된 확장 프로그램을 로드합니다** 버튼을 클릭하고 압축을 푼 폴더를 선택합니다.\n\n**Firefox의 경우:**\n\n1. [Releases](https://github.com/Nagi-ovo/gemini-voyager/releases)에서 최신 `gemini-voyager-firefox-vX.Y.Z.xpi`를 다운로드합니다.\n2. 부가 기능 관리자(`about:addons`)를 엽니다.\n3. `.xpi` 파일을 드래그 앤 드롭하여 설치합니다 (또는 톱니바퀴 아이콘 ⚙️ -> **파일에서 부가 기능 설치** 클릭).\n\n> 💡 XPI 파일은 Mozilla에 의해 공식적으로 서명되었으며 모든 Firefox 버전에서 영구적으로 설치할 수 있습니다.\n\n## 3. Safari (macOS)\n\nSafari가 이제 직접 배포를 지원합니다! 사전 서명된 앱을 다운로드하세요:\n\n1. <SafariDownloadLink>최신 Safari 버전 (.dmg)</SafariDownloadLink>을 다운로드합니다.\n2. 파일을 더블 클릭하여 안내에 따라 설치합니다.\n3. 더블 클릭하여 앱을 실행합니다.\n4. **Safari 설정 > 확장 프로그램**에서 활성화합니다.\n\n> 💡 Safari 빌드가 직접 서명 배포를 지원합니다 — Xcode 변환이 필요 없습니다!\n>\n> ⚠️ **제한 사항**: Safari의 특성상 (a) 워터마크 제거 (b) 이미지 내보내기(PDF 권장)는 지원되지 않습니다.\n\n---\n\n_개발 설정이 궁금하신가요? 기여를 원하는 개발자라면 [기여 가이드](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/CONTRIBUTING.md)를 확인해 보세요._\n"
  },
  {
    "path": "docs/ko/guide/markdown-fix.md",
    "content": "# Markdown 렌더링 수정\n\nGemini™ 웹 인터페이스는 때때로 텍스트 내에 HTML 요소(인용 출처나 하이라이트 표시 등)를 삽입하는데, 이로 인해 Markdown의 굵게 표시 문법(`**텍스트**`)이 깨져서 텍스트가 정상적으로 굵게 렌더링되지 않는 경우가 발생합니다.\n\nVoyager에는 이러한 깨진 굵게 표시 태그를 지능적으로 식별하고 복구하는 자동 수정 기능이 내장되어 있어, 문서가 깔끔하고 정확하게 렌더링되도록 보장합니다.\n\n> [!INFO]\n> 이 기능은 자동으로 활성화되며 추가 설정이 필요하지 않습니다.\n"
  },
  {
    "path": "docs/ko/guide/mermaid.md",
    "content": "# Mermaid 다이어그램 렌더링\n\nMermaid 코드를 시각적 다이어그램으로 자동으로 렌더링합니다.\n\n## 개요\n\nGemini™가 Mermaid 코드 블록(플로우차트, 시퀀스 다이어그램, 간트 차트 등)을 출력하면, Voyager는 이를 자동으로 감지하여 대화형 다이어그램으로 렌더링합니다.\n\n### 주요 기능\n\n- **자동 감지**: `graph`, `flowchart`, `sequenceDiagram`, `gantt`, `pie`, `classDiagram` 및 모든 주요 Mermaid 다이어그램 유형을 지원합니다.\n- **보기 전환**: 클릭 한 번으로 렌더링된 다이어그램과 소스 코드 간을 전환할 수 있습니다.\n- **전체 화면 모드**: 다이어그램을 클릭하여 확대/축소 및 이동이 가능한 전체 화면 모드로 진입합니다.\n- **다크 모드**: 페이지 테마에 따라 자동으로 적응합니다.\n\n## 사용 방법\n\n1. Gemini에게 Mermaid 다이어그램 코드를 생성하도록 요청합니다.\n2. 코드 블록이 렌더링된 다이어그램으로 자동으로 교체됩니다.\n3. **</> 코드** 버튼을 클릭하여 소스 코드를 확인합니다.\n4. **📊 다이어그램** 버튼을 클릭하여 다이어그램 보기로 다시 전환합니다.\n5. 다이어그램 영역을 클릭하여 전체 화면으로 봅니다.\n\n## 전체 화면 컨트롤\n\n- **마우스 휠**: 확대/축소\n- **드래그**: 다이어그램 이동\n- **+/-**: 도구 모음 확대/축소 버튼\n- **⊙**: 보기 초기화\n- **✕ / ESC**: 전체 화면 닫기\n\n## 호환성 및 문제 해결\n\n::: warning 안내\n\n- **Firefox 제한**: 환경 제한으로 인해 Firefox는 9.2.2 버전을 사용하며, **Timeline**, **Sankey** 등 최신 기능은 지원하지 않습니다.\n- **구문 오류**: 렌더링 실패는 주로 Gemini가 생성한 코드의 구문 오류 때문입니다. 현재 Bad Case를 수집 중이며, 향후 패치를 통해 일반적인 생성 오류를 자동 수정할 예정입니다.\n  :::\n\n<div align=\"center\">\n  <img src=\"/assets/mermaid-preview.png\" alt=\"Mermaid 다이어그램 렌더링\" style=\"max-width: 100%; border-radius: 8px;\"/>\n</div>\n"
  },
  {
    "path": "docs/ko/guide/nanobanana.md",
    "content": "# NanoBanana 옵션\n\n::: warning 브라우저 호환성\n현재 **NanoBanana** 워터마크 제거 기능은 브라우저 API 제한으로 인해 **Safari에서 지원되지 않습니다**. 이 기능을 사용해야 하는 경우 **Chrome** 또는 **Firefox**를 사용하는 것을 권장합니다.\n\nSafari 사용자는 다운로드한 이미지를 [banana.ovo.re](https://banana.ovo.re/)와 같은 도구 사이트에 업로드하여 수동으로 제거할 수 있습니다(단, 이미지 해상도에 따라 모든 이미지가 성공적으로 복구되는 것은 아닙니다).\n:::\n\n**AI 이미지를 순수하게 유지하세요.**\n\nGemini™가 생성한 이미지에는 기본적으로 눈에 보이는 워터마크가 포함되어 있습니다. 이는 안전을 위한 조치이지만, 완벽하게 깨끗한 이미지가 필요한 창의적인 상황이 있을 수 있습니다.\n\n## 무손실 복구\n\nNanoBanana는 **역 알파 블렌딩 (Reverse Alpha Blending)** 알고리즘을 사용합니다.\n\n- **AI 인페인팅이 아닙니다**: 기존의 워터마크 제거 방식은 종종 AI를 사용하여 해당 영역을 \"뭉개는\" 방식을 사용하며, 이는 픽셀 세부 정보를 파괴합니다.\n- **완벽한 픽셀 복구**: 우리는 수학적 계산을 통해 투명한 워터마크 레이어를 정밀하게 제거하여 100% 원본 픽셀을 복원합니다.\n- **품질 저하 제로**: 처리된 이미지는 워터마크가 없는 모든 영역에서 원본과 동일하게 유지됩니다.\n\n## 사용 방법\n\n1. **활성화**: Voyager 설정 패널 끝부분에 있는 \"NanoBanana 옵션\"을 찾아 활성화합니다.\n2. **자동 처리**: 이제 생성하는 모든 이미지가 백그라운드에서 자동으로 처리됩니다.\n3. **직접 다운로드**:\n   - 처리된 이미지 위에 마우스를 올리면 🍌 버튼이 나타납니다.\n   - **🍌 버튼은 기존의 다운로드 버튼을 완전히 대체**하여 항상 100% 워터마크가 제거된 이미지를 직접 받을 수 있도록 보장합니다.\n\n<div style=\"text-align: center; margin-top: 30px;\">\n  <img src=\"/assets/nanobanana.png\" alt=\"NanoBanana 데모\" style=\"border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); max-width: 100%;\"/>\n</div>\n\n## 감사 인사\n\n이 기능은 [journey-ad (Jad)](https://github.com/journey-ad)의 [gemini-watermark-remover](https://github.com/journey-ad/gemini-watermark-remover) 프로젝트를 기반으로 하며, 이는 [allenk](https://github.com/allenk)의 [원본 C++ 구현](https://github.com/allenk/GeminiWatermarkTool)을 JavaScript로 포팅한 것입니다. 커뮤니티에 대한 그들의 기여에 감사드립니다. 🧡\n\n## 프라이버시 및 보안\n\n모든 처리는 **사용자의 브라우저 내에서 로컬로** 이루어집니다. 이미지는 제3자 서버로 절대 업로드되지 않으므로 프라이버시와 창의적 보안이 보장됩니다.\n"
  },
  {
    "path": "docs/ko/guide/prevent-auto-scroll.md",
    "content": "# 자동 스크롤 방지\n\n과거 대화를 읽고 있을 때 새로운 프롬프트를 전송하면, Gemini™는 새로 생성된 응답을 추적하기 위해 페이지를 강제로 가장 아래로 스크롤합니다. 이는 읽기 경험을 방해할 수 있습니다.\n\n**자동 스크롤 방지** 기능은 이러한 원치 않는 점프 동작을 차단합니다:\n\n- 기록을 읽기 위해 위로 스크롤한 상태에서는 시스템이 페이지가 아래로 점프하는 것을 방지합니다.\n- 이 기능은 기본적으로 **비활성화**되어 있습니다. 팝업 설정의 \"Timeline Options\"에서 수동으로 활성화할 수 있습니다.\n\n## 활성화 방법\n\n1. 브라우저 도구 모음에서 Voyager 확장 프로그램 아이콘을 클릭하여 팝업을 엽니다.\n2. \"Timeline Options (타임라인 옵션)\" 섹션을 찾습니다.\n3. \"자동 스크롤 방지 (Prevent Auto Scroll)\" 스위치를 켭니다.\n"
  },
  {
    "path": "docs/ko/guide/prompts.md",
    "content": "# 지적 자산: 프롬프트 라이브러리\n\n당신은 완벽한 프롬프트를 만듭니다. 그것은 복잡한 코딩 문제를 해결하거나 아름다운 이메일을 작성합니다.\n그것을 그냥 버리시겠습니까?\n아니요. 저장하세요.\n\n## 프롬프트 저장소 (Vault)\n\n이것은 당신의 천재성을 담은 개인 저장소입니다.\n\n### 1. 캡처 (Capture)\n\n훌륭한 프롬프트를 작성했다면 입력창 근처에 떠 있는 **프롬프트 관리자** 아이콘을 클릭하세요.\n이제 그것은 당신의 저장소의 일부가 되었습니다.\n\n### 2. 분류 (Categorize)\n\n`#coding`, `#email`, `#research`와 같은 태그를 추가하세요.\n당신의 도구들을 날카롭게 유지하고 분류하세요.\n\n### 3. 배포 (Deploy)\n\n다음에 필요할 때 다시 입력하지 마세요.\n관리자를 열고 태그나 키워드로 검색한 다음 클릭하여 삽입하세요.\n한 번의 클릭으로 무한한 지렛대 효과를 누리세요.\n\n![프롬프트 관리자](/assets/gemini-prompt-manager.png)\n\n## 어디서나 사용 가능\n\n프롬프트 관리자는 이제 Gemini™와 AI Studio뿐만 아니라 당신이 선택한 모든 웹사이트에서 사용할 수 있습니다.\n\n### 활성화 방법\n\n1. 브라우저 확장 프로그램 도구 모음에서 Voyager 아이콘을 클릭합니다.\n2. **프롬프트 관리자(Prompt Manager)** 섹션으로 스크롤합니다.\n3. 웹사이트 URL(예: `chatgpt.com` 또는 `claude.ai`)을 입력합니다.\n4. **웹사이트 추가(Add website)**를 클릭하고 권한을 허용합니다.\n5. **대상 페이지를 새로고침**하여 플로팅 버튼을 확인합니다.\n\n### 인기 AI 웹사이트\n\n- `chatgpt.com` - ChatGPT\n- `claude.ai` - Claude\n- `copilot.microsoft.com` - Microsoft Copilot\n- `poe.com` - Poe\n\n::: tip\n사용자 지정 웹사이트에서는 **프롬프트 관리자** 기능만 활성화됩니다. 타임라인 및 폴더와 같은 다른 기능은 Gemini 전용으로 설계되었으며 로드되지 않습니다.\n:::\n"
  },
  {
    "path": "docs/ko/guide/quote-reply.md",
    "content": "# 인용 답장\n\nVoyager는 특정 내용에 대해 더 정확하고 효율적으로 답장할 수 있는 편리한 \"인용 답장\" 기능을 제공합니다.\n\n## 소개\n\n일상적인 대화에서 우리는 AI 출력의 특정 부분에 대해 추가 질문을 하거나 반박해야 할 때가 많습니다. 기존 방식은 해당 텍스트를 복사하고 입력창에 수동으로 `> ` 기호를 입력하는 것이었는데, 이는 매우 번거로운 과정입니다.\n\nVoyager는 이 과정을 간소화합니다:\n\n1. **선택하여 인용**: 대화 페이지에서 마우스로 원하는 텍스트를 선택합니다 (자신의 질문이나 Gemini의 답변 모두 가능).\n2. **플로팅 버튼**: 선택한 텍스트 근처에 \"인용 답장\" 버튼이 자동으로 나타납니다.\n3. **클릭 한 번으로 삽입**: 버튼을 클릭하면 선택한 텍스트가 표준 Markdown 인용구 형식(`> 내용`)으로 입력창에 자동 삽입됩니다.\n\n![인용 답장](/assets/quote-reply.png)\n\n## 주요 기능\n\n- **문맥 인식**: 대화 내용을 지능적으로 식별하여 관련 없는 영역(예: 입력창 자체)에서 실수로 트리거되는 것을 방지합니다.\n- **표준 형식**: Gemini가 완벽하게 이해하는 범용 Markdown 구문을 사용하여 더 정확한 답변을 유도합니다.\n- **다중 행 지원**: 여러 줄의 텍스트를 선택하면 Voyager가 각 줄에 자동으로 인용 기호를 추가하여 서식을 깔끔하게 유지합니다.\n\n## 사용 팁\n\n- **세부 사항 후속 질문**: Gemini의 답변 중 혼란스러운 개념을 선택하고 인용을 클릭한 뒤, \"이 개념에 대해 자세히 설명해 주세요\"라고 입력하세요.\n- **오류 수정**: 답변에서 잘못된 코드나 사실을 선택하여 인용하고, \"이 부분은 틀렸습니다. 원래는... 이어야 합니다\"라고 지적하세요.\n"
  },
  {
    "path": "docs/ko/guide/recents-hider.md",
    "content": "# 최근 항목 및 Gem 숨기기\n\n::: info\n**참고**: 이 기능은 버전 1.1.9 이상에서 지원됩니다.\n:::\n\nGemini™ 홈 페이지의 \"최근에 저장됨\" 섹션을 숨겨 더 깔끔한 인터페이스를 유지할 수 있는 세련된 토글 기능을 추가하세요. 이제 사이드바의 **Gem** 목록을 숨기는 기능도 지원합니다!\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>최근 항목 숨기기</b></p>\n    <video src=\"/assets/hide-my-stuff.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Gem 목록 숨기기</b></p>\n    <video src=\"/assets/hide-gem-list.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n</div>\n\n## 주요 기능\n\n- **문맥 인식 토글**: 최근 항목 섹션 위에 마우스를 올릴 때만 미세한 숨기기 버튼이 나타납니다.\n- **미니멀한 상태**: 숨겨진 상태에서는 하단의 눈에 띄지 않는 \"피크 바 (peek bar)\"로 대체됩니다.\n- **클릭 한 번으로 복원**: 피크 바 위에 마우스를 올리고 클릭하면 최근 항목을 즉시 다시 불러올 수 있습니다.\n- **프라이버시 보호**: 근처의 다른 사람이 최근 활동을 볼 수 없도록 방지하여 공공장소에서 사용하기 적합합니다.\n- **설정 유지**: 사용자의 선택이 저장되어 다음 방문 시 자동으로 적용됩니다.\n\n## 사용 방법\n\n1. Gemini 홈 페이지의 \"최근\" 섹션 위에 마우스를 올립니다.\n2. 우측 상단에 나타나는 눈 가리기 아이콘을 클릭하여 섹션을 숨깁니다.\n3. 복원하려면 해당 영역 하단에 남아 있는 얇은 선 위에 마우스를 올리고 클릭합니다.\n"
  },
  {
    "path": "docs/ko/guide/settings.md",
    "content": "# 나만의 것으로 만들기\n\n기본 환경도 훌륭합니다. 하지만 당신은 완벽함을 원할 수도 있죠.\n모든 세부 사항을 맞춤 설정하세요.\n\n## 시네마 모드 (Cinema Mode)\n\n왜 미래를 작은 바늘구멍을 통해 보나요?\nVoyager를 사용하면 대화창 너비를 확장할 수 있습니다.\n\n- **넓게 (Wide)**: 코딩이나 복잡한 표를 볼 때 적합한 1400px.\n- **집중 (Focused)**: 읽기에 적합한 800px.\n- **당신의 결정**: 슬라이더를 사용하여 당신에게 딱 맞는 너비를 찾으세요.\n\n## 컨트롤 (Control)\n\n확장 프로그램 아이콘을 클릭하여 컨트롤 센터에 접속하세요.\n\n- **스크롤 모드**: 자연스러운 방식 또는 클래식 방식.\n- **타임라인 위치**: 당신이 편안하게 느끼는 곳에 두세요.\n- **시각 효과**: `눈`, `벚꽃`, `비`로 계절 분위기를 연출하세요.\n\n## 사용자 정의 순서\n\n팝업에 설정 항목이 너무 많아 자주 쓰는 것이 아래에 묻혀 있나요?\n\n설정 카드에 마우스를 올리면 오른쪽 상단에 ▲/▼ 버튼이 나타납니다. 클릭하면 카드가 위아래로 이동하며, 순서는 자동 저장됩니다.\n\n## 분위기\n\nVoyager는 효율 향상에만 그치지 않습니다. 페이지의 분위기도 바꿀 수 있습니다.\n\n- **눈**: 부드러운 눈송이가 천천히 내려 차분한 겨울 느낌을 줍니다.\n- **벚꽃**: 벚꽃 잎이 자연스럽게 흩날리며 가벼운 봄 분위기를 연출합니다.\n- **비**: 비스듬한 빗줄기와 은은한 물방울이 시네마틱한 몰입감을 줍니다.\n- **부드러운 전환**: 효과를 끄거나 전환하면 파티클이 자연스럽게 사라집니다.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>설정 열기</b></p>\n    <img src=\"/assets/gemini-open-settings-guide.png\" alt=\"설정 가이드 열기\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>너비 조절</b></p>\n    <img src=\"/assets/gemini-chatwidth.png\" alt=\"대화 너비 조절\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n"
  },
  {
    "path": "docs/ko/guide/sidebar-auto-hide.md",
    "content": "# 사이드바 자동 숨김\n\n더 몰입감 있는 채팅 경험을 원하시나요?\n\n우리는 **사이드바 자동 숨김** 기능을 제공합니다. 이 기능을 활성화하면 마우스가 사이드바 영역을 벗어날 때 사이드바가 자동으로 접히고, 마우스를 다시 사이드바 영역으로 가져가면 자동으로 펼쳐집니다.\n\n### 데모\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/sidebar-auto-hide.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n### 활성화 방법\n\n1. Voyager 설정 패널을 엽니다.\n2. **일반 설정 (General Settings)**에서 **사이드바 자동 숨김 (Auto-hide sidebar)** 토글을 찾습니다.\n3. 토글을 켜서 활성화합니다.\n\n_참고: 이 기능은 현재 Google Gemini만 지원합니다._\n"
  },
  {
    "path": "docs/ko/guide/sidebar.md",
    "content": "# 사이드바 너비\n\n폴더 이름이 너무 길어서 다 보이지 않나요?\n아니면 사이드바가 소중한 채팅 공간을 너무 많이 차지하고 있나요?\n\n이제 사이드바의 너비를 자유롭게 조절할 수 있습니다.\n\n## 조절 방법\n\n1. Voyager 설정 패널을 엽니다 (브라우저 우측 상단의 확장 프로그램 아이콘 클릭).\n   <img src=\"/assets/extension-instruction.png\" alt=\"설정 패널 여는 방법\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. **일반 설정 (General Settings)**에서 **사이드바 너비 (Sidebar width)** 옵션을 찾습니다.\n3. 슬라이더를 드래그하여 자신에게 적합한 너비를 선택합니다.\n\n- **좁게 (Narrow)**: 공간을 절약하고 대화에 집중하세요.\n- **넓게 (Wide)**: 긴 폴더 이름을 한눈에 명확하게 확인하세요.\n\n## 지원 플랫폼\n\n이 기능은 다음을 모두 지원합니다:\n\n- **Google Gemini**\n- **Google AI Studio**\n\n설정은 자동으로 저장되며 다음에 페이지를 열 때 적용됩니다.\n"
  },
  {
    "path": "docs/ko/guide/sponsor.md",
    "content": "# 후원하기\n\n> [!NOTE]\n> Voyager가 도움이 되었다면 X, YouTube, Reddit 등에서 공유해 주세요. 공유가 늘수록 더 많은 사용자가 프로젝트를 발견하고 Gemini 사용 경험도 함께 좋아집니다. 감사합니다.\n\n오픈 소스 프로젝트를 유지하는 것은 주로 열정(그리고 커피)에 의해 이루어집니다 ☕\n\n**[Voyager](https://github.com/Nagi-ovo/gemini-voyager)**는 Gemini 경험을 향상시키기 위해 설계된 완전 무료 오픈 소스 브라우저 확장 프로그램입니다. 이 확장 프로그램이 Gemini를 더 효율적으로 사용하는 데 도움이 되었다면, 프로젝트의 지속적인 개발과 유지 관리를 위해 후원을 고려해 주세요.\n\n---\n\n## 온라인 플랫폼\n\n<div class=\"sponsor-badges\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" style=\"height: 40px;\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" style=\"height: 40px;\">\n  </a>\n</div>\n\n<a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n    <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n    <img alt=\"Afdian\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n  </picture>\n</a>\n\n### 🎙️ 추천 도구: Typeless\n\nVoyager 개발 중에 광범위하게 사용한 AI 음성-텍스트 변환 도구인 **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)**를 강력히 추천합니다. 이를 매일의 워크플로우에 통합함으로써 엄청난 시간을 절약하고 생산성을 크게 높일 수 있었습니다.\n\n> 🎁 **[제 추천 링크를 통해 가입](https://www.typeless.com/?via=gemini-voyager)**(코드: _`gemini-voyager`_)하시면 **$5 무료 크레딧**을 받으실 수 있습니다. 이는 제가 이 프로젝트를 계속 유지 관리할 수 있는 크레딧을 제공하며, 저의 작업을 지원하는 무료 방법입니다! ❤️\n\n---\n\n## 커피 한 잔 후원하기 (QR) 🍵\n\n<div class=\"qr-container\">\n  <div class=\"qr-item\">\n    <img src=\"/assets/wechat-sponsor.png\" alt=\"WeChat Pay\" />\n    <span>WeChat Pay</span>\n  </div>\n  <div class=\"qr-item\">\n    <img src=\"/assets/alipay-sponsor.jpg\" alt=\"Alipay\" />\n    <span>Alipay</span>\n  </div>\n</div>\n\n<style>\n.sponsor-badges {\n  display: flex;\n  gap: 16px;\n  flex-wrap: wrap;\n  align-items: center;\n  margin: 16px 0;\n}\n\n.sponsor-badges a img {\n  transition: transform 0.2s ease;\n}\n\n.sponsor-badges a:hover img {\n  transform: scale(1.05);\n}\n\n.qr-container {\n  display: flex;\n  gap: 32px;\n  flex-wrap: wrap;\n  margin: 16px 0;\n}\n\n.qr-item {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 8px;\n  padding: 16px;\n  border-radius: 8px;\n  border: 1px solid var(--vp-c-divider);\n  background: var(--vp-c-bg-soft);\n}\n\n.qr-item img {\n  width: 200px;\n  height: 200px;\n  object-fit: contain;\n  border-radius: 4px;\n}\n\n.qr-item span {\n  font-weight: 600;\n  color: var(--vp-c-text-1);\n}\n\n@media (max-width: 640px) {\n  .qr-container {\n    justify-content: center;\n  }\n  \n  .qr-item img {\n    width: 150px;\n    height: 150px;\n  }\n}\n</style>\n\n---\n\n후원해 주셔서 감사합니다! 모든 기여는 저에게 큰 격려가 됩니다 ❤️\n"
  },
  {
    "path": "docs/ko/guide/tab-title.md",
    "content": "# 탭 제목 동기화\n\n브라우저 탭 제목을 현재 Gemini™ 채팅 제목과 자동으로 동기화합니다.\n\n## 주요 기능\n\n- **실시간 동기화**: 채팅 제목이 변경될 때(예: AI가 새 제목을 생성하거나 사용자가 직접 이름을 바꿀 때) 브라우저 탭 제목이 \"Gemini\"에서 특정 대화 주제로 즉시 업데이트됩니다.\n- **범용 지원**: 표준 채팅 페이지, Gem 대화 및 다중 계정 환경에서 완벽하게 작동합니다.\n- **토글 제어**: 기본 동작을 선호하는 경우 설정 패널의 \"일반 옵션 (General Options)\" 섹션에서 이 기능을 쉽게 비활성화할 수 있습니다.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/tab-title.png\" alt=\"탭 제목 동기화\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## 사용 방법\n\n1. 이 기능은 설치 시 기본적으로 활성화되어 있습니다.\n2. Gemini 대화를 열고 브라우저 탭 제목을 확인해 보세요. 채팅 제목과 일치하도록 자동으로 업데이트됩니다.\n3. 비활성화하려면:\n   - 확장 프로그램 아이콘을 클릭하여 설정 패널을 엽니다.\n   - \"일반 옵션 (General Options)\"을 찾습니다.\n   - \"탭 제목 업데이트 (Update Tab Title)\" 토글을 끕니다.\n"
  },
  {
    "path": "docs/ko/guide/timeline.md",
    "content": "# 시간 여행\n\n긴 대화는 어수선해지기 마련입니다. 위아래로 스크롤하다 보면 현재 위치를 잃어버리곤 하죠.\nVoyager는 당신의 대화를 타임라인으로 바꿔줍니다.\n\n## 대화의 형태를 확인하세요\n\n화면 오른쪽을 보세요.\n각 노드는 메시지를 나타냅니다. 타임라인은 당신의 대화 리듬을 시각화합니다.\n\n## 내비게이션의 해결책\n\n- **순간 이동(Teleport)**: 노드를 클릭하여 해당 메시지로 즉시 이동합니다.\n- **미리 보기(Peek)**: 마우스를 올려 이동하지 않고도 내용을 확인합니다.\n- **북마크(Bookmark)**: 노드를 길게 눌러 **별표**를 표시하세요. 당신의 뇌를 위한 북마크와 같습니다.\n- **레벨 (실험적)**: 노드를 우클릭하여 다양한 레벨(1-3)을 설정하거나 하위 노드를 접을 수 있습니다. 분기된 대화를 명확하게 정리하는 데 완벽합니다.\n- **키보드**: 생각의 속도로 탐색하세요. 기본값은 `j`/`k`이며, 원하는 대로 바꿀 수 있습니다.\n\n![타임라인 탐색](/assets/teaser.png)\n\n## 키보드로 더 빠르게\n\n마우스를 사용하고 싶지 않으신가요? 키보드를 사용하세요.\n\n**Gemini에서 Vim 모드를 켜는 것과 같습니다.**\n\n### 기본 단축키\n\n- `k` - 이전 노드로 이동\n- `j` - 다음 노드로 이동\n\n### 맞춤 설정\n\n확장 프로그램 설정을 열고 단축키 상자를 클릭한 다음 원하는 키를 누르세요.\n어떤 키나 조합도 가능합니다. `n`/`p`? `,`/`.`? 당신의 선택입니다.\n\n**플로우(Flow) 모드**: 빠르게 누르면 부드럽게 대기열에 추가됩니다.\n**점프(Jump) 모드**: 즉각적인 반응, 최대 속도.\n"
  },
  {
    "path": "docs/ko/index.md",
    "content": "---\nlayout: home\n\nhero:\n  name: 'Voyager'\n  text: 'Gemini를 위한 완벽한 운영체제.'\n  tagline: '우리는 Gemini를 사랑합니다. 단지 그것이 완벽해지기를 원했습니다.'\n  image:\n    src: /logo.png\n    alt: Voyager 로고\n  actions:\n    - theme: brand\n      text: 다운로드\n      link: ./guide/installation\n    - theme: alt\n      text: 시작하기\n      link: ./guide/getting-started\n\nteaser:\n  title: '그냥 작동합니다.'\n  description: '우리는 단순히 또 다른 확장 프로그램을 만들고 싶지 않았습니다. 더 나은 사고 방식을 구축하고 싶었습니다.<br>Voyager를 사용하면 인터페이스와 싸우는 대신 대화의 흐름에 집중할 수 있습니다.'\n  image: '/assets/teaser.png'\n  features:\n    - title: '타임라인'\n      details: '스크롤하지 마세요. 날아보세요. 대화의 어느 지점으로든 즉시 이동하세요.'\n    - title: '폴더'\n      details: '마침내 AI를 위한 파일 시스템이 생겼습니다. 기본적이고 직관적이며 강력합니다.'\n    - title: '자유'\n      details: '데이터는 당신의 것입니다. 클릭 한 번으로 JSON, Markdown 또는 PDF로 내보내세요.'\n\nfeatures:\n  - icon: 🧭\n    title: 타임라인\n    details: 당신의 생각을 위한 지도. 대화를 시각적으로 탐색하세요.\n  - icon: 🗂️\n    title: 폴더\n    details: 혼돈 속의 질서. 드래그 앤 드롭으로 끝내세요.\n  - icon: ✨\n    title: 저장소\n    details: 당신의 천재성을 캡처하세요. 최고의 프롬프트를 저장하고 재사용하세요.\n  - icon: 💬\n    title: 인용 답장\n    details: 텍스트를 선택하여 인용하세요. 효율적인 커뮤니케이션을 위한 문맥 인식 답장.\n  - icon: ↔️\n    title: 대화 너비\n    details: 더 넓게 보세요. 더 나은 시각적 경험을 위해 대화 너비를 자유롭게 조정하세요.\n  - icon: 💾\n    title: 대화 내보내기\n    details: 데이터 주권. 지식이 손실되지 않도록 다양한 형식으로 아카이브하세요.\n  - icon: 🌦️\n    title: 시각 효과\n    details: 분위기를 바꿔보세요. 팝업에서 눈, 비, 벚꽃잎 효과를 전환할 수 있습니다.\n  - icon: 🍌\n    title: NanoBanana 워터마크 제거\n    details: 무손실 워터마크 제거. AI가 만든 순간을 순수하게 유지하세요.\n  - icon: 📐\n    title: 수식 복사\n    details: LaTeX 및 MathML (Word) 소스 코드를 클릭 한 번으로 복사하세요.\n  - icon: 🧜‍♀️\n    title: Mermaid 다이어그램\n    details: 코드를 시각화로. 플로우차트, 시퀀스 다이어그램, 간트 차트를 즉시 렌더링하세요.\n  - icon: 🏷️\n    title: 탭 제목 동기화\n    details: 한눈에 파악하세요. 브라우저 탭 제목을 대화 제목과 자동으로 동기화합니다.\n  - icon: 🔀\n    title: 대화 분기 (실험적)\n    details: 발산적 사고. любой 지점에서 대화를 분기하여 다양한 가능성을 탐색하세요.\n  - icon: 🗑️\n    title: 일괄 삭제\n    details: 한꺼번에 정리하세요. 여러 대화를 선택하여 한 번에 삭제하세요.\n  - icon: ☁️\n    title: 클라우드 동기화\n    details: 항상 동기화. 폴더와 프롬프트를 Google Drive에 백업하세요.\n  - icon: ⚡️\n    title: 기본 모델\n    details: 반복하지 마세요. 새 대화에서 선호하는 모델로 자동 전환합니다.\n  - icon: 🔬\n    title: Deep Research\n    details: 사고를 해체하세요. Deep Research 세션에서 연구 과정과 링크를 추출합니다.\n---\n\n<div class=\"vp-doc\" style=\"margin: 2rem auto 0; max-width: 780px; padding: 0 16px;\">\n  <div style=\"background: rgba(234, 179, 8, 0.12); border: 1px solid rgba(234, 179, 8, 0.6); border-radius: 8px; padding: 12px 16px;\">\n    <strong>⚠️ 이름 변경 안내</strong>: 상표 및 저작권 문제로 인해 이 확장 프로그램이 공식적으로 <strong>Voyager</strong>로 이름이 변경되었습니다. 하지만 Chrome 웹 스토어의 심사 속도가 매우 느려 7일 이내에 이름 변경이 승인되지 않아, 현재 Chrome Web Store에서 일시적으로 이용할 수 없는 상태입니다.\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 780px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 8px; font-weight: 600; font-size: 1.05em;\">모든 설치는 신뢰의 투표입니다</h3>\n  <p style=\"margin: 0 0 16px; opacity: 0.78; font-size: 0.95em;\">Chrome 웹 스토어와 GitHub의 실시간 수치입니다. 우리와 함께해주시는 동료 Voyager분들께 감사드립니다.</p>\n  <div style=\"display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;\">\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Stars\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Forks\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Latest Release\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub Downloads\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store Users\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store Rating\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoft-edge\" alt=\"Edge Add-ons\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons Users\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons Rating\">\n  </div>\n  <div style=\"margin-top: 16px; display: flex; justify-content: center; flex-wrap: wrap; gap: 12px;\">\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 1000px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 24px; font-weight: 600; font-size: 1.2em;\">특별 감사</h3>\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" style=\"margin: 0 auto;\" />\n  </a>\n  <p style=\"margin-top: 24px; font-size: 1.05em; opacity: 0.86;\">✨ Product Hunt에 출시되었습니다! 여러분의 의견과 피드백을 환영합니다. ❤️</p>\n  <div style=\"margin-top: 12px; display: flex; justify-content: center;\">\n    <a href=\"https://www.producthunt.com/posts/gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager on Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 3.5rem auto 2rem; max-width: 720px; padding: 0 16px;\">\n  <p style=\"font-size: 1.05em; font-weight: 600; opacity: 0.86; margin: 0 0 12px;\">“단순한 도구가 아닙니다. 마음을 위한 자전거입니다.”</p>\n  <a href=\"./guide/getting-started\" style=\"font-weight: 600; text-decoration: none;\">가능한 것들을 확인해 보세요 →</a>\n</div>\n\n<p align=\"center\">\n  <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n</p>\n"
  },
  {
    "path": "docs/ko/privacy.md",
    "content": "# 개인정보 처리방침\n\n최종 업데이트: 2026년 3월 16일\n\n## 서론\n\nVoyager(\"당사\", \"우리\" 또는 \"저희\")는 사용자의 개인정보를 보호하기 위해 최선을 다하고 있습니다. 이 개인정보 처리방침은 당사의 브라우저 확장 프로그램이 사용자의 정보를 어떻게 수집, 사용 및 보호하는지 설명합니다.\n\n## 데이터 수집 및 사용\n\n**당사는 어떠한 개인정보도 수집하지 않습니다.**\n\nVoyager는 전적으로 사용자의 브라우저 내에서 작동합니다. 확장 프로그램에 의해 생성되거나 관리되는 모든 데이터(폴더, 프롬프트 템플릿, 즐겨찾기 메시지, 설정 등)는 다음 위치에 저장됩니다:\n\n1. 사용자의 기기에 로컬로 저장 (`chrome.storage.local`)\n2. 기기 간 설정을 동기화하기 위해 사용 가능한 경우 브라우저의 동기화된 저장소에 저장 (`chrome.storage.sync`)\n\n당사는 사용자의 개인 데이터, 대화 기록 또는 기타 개인 정보에 액세스할 수 없습니다. 당사는 사용자의 브라우징 기록을 추적하지 않습니다.\n\n## Google 드라이브 동기화 (선택 사항)\n\nGoogle 드라이브 동기화 기능을 명시적으로 활성화한 경우, 확장 프로그램은 Chrome Identity API를 사용하여 OAuth2 토큰(`drive.file` 범위만)을 획득하고 폴더와 프롬프트를 **사용자 자신의 Google 드라이브**에 백업합니다. 이 전송은 사용자의 브라우저와 Google 서버 간에 직접 이루어집니다. 당사는 이 데이터에 접근할 수 없으며, 당사가 운영하는 어떤 서버로도 전송되지 않습니다.\n\n## 권한\n\n확장 프로그램은 작동에 필요한 최소한의 권한만을 요청합니다:\n\n- **Storage (저장소)**: 사용자의 기본 설정, 폴더, 프롬프트, 즐겨찾기 메시지 및 UI 사용자 정의 옵션을 로컬 및 기기 간에 저장하기 위해 사용됩니다.\n- **Identity (인증)**: 선택적 Google 드라이브 동기화 기능을 위한 Google 인증에 사용됩니다. 클라우드 동기화를 명시적으로 활성화한 경우에만 사용됩니다.\n- **Scripting (스크립트 주입)**: Gemini 페이지 및 사용자가 지정한 커스텀 웹사이트에 콘텐츠 스크립트를 동적으로 주입하기 위해 사용됩니다 (프롬프트 매니저 기능). 확장 프로그램 자체에 번들된 스크립트만 주입되며, 원격 코드를 가져오거나 실행하지 않습니다.\n- **Host Permissions (호스트 권한)** (gemini.google.com, aistudio.google.com 등): Gemini UI를 향상시키는 콘텐츠 스크립트를 주입하기 위해 사용됩니다 (폴더, 내보내기, 타임라인, 인용 답장 등). Google 관련 도메인(googleapis.com, accounts.google.com)은 Google 드라이브 동기화 인증에 필요합니다.\n- **Optional Host Permissions (선택적 호스트 권한)** (모든 URL): 프롬프트 매니저의 커스텀 웹사이트를 명시적으로 추가한 경우에만 런타임에서 요청됩니다. 사용자의 조작 없이 활성화되지 않습니다.\n\n## 제3자 서비스\n\nVoyager는 제3자 서비스, 광고주 또는 분석 제공업체와 어떠한 데이터도 공유하지 않습니다.\n\n## 방침 변경\n\n당사는 때때로 개인정보 처리방침을 업데이트할 수 있습니다. 변경 사항은 이 페이지에 새로운 개인정보 처리방침을 게시함으로써 사용자에게 통지됩니다.\n\n## 문의하기\n\n이 개인정보 처리방침에 대해 궁금한 점이 있으면 [GitHub 저장소](https://github.com/Nagi-ovo/gemini-voyager)를 통해 문의해 주세요.\n"
  },
  {
    "path": "docs/privacy.md",
    "content": "# 隐私政策\n\n最后更新：2026年3月16日\n\n## 简介\n\nVoyager（以下简称\"我们\"）致力于保护您的隐私。本隐私政策说明了我们的浏览器扩展程序如何收集、使用和保护您的信息。\n\n## 数据收集与使用\n\n**我们不收集任何个人信息。**\n\nVoyager 完全在您的浏览器本地运行。扩展程序生成或管理的所有数据（如文件夹、提示词模板、星标消息和设置）均存储在：\n\n1. 您的本地设备上（`chrome.storage.local`）\n2. 您的浏览器同步存储中（`chrome.storage.sync`，如可用），以便在您的设备间同步设置。\n\n我们无法访问您的个人数据、聊天记录或其他任何隐私信息。我们也不会跟踪您的浏览历史。\n\n## Google Drive 同步（可选）\n\n如果您主动开启 Google Drive 同步功能，扩展程序会使用 Chrome Identity API 获取 OAuth2 令牌（仅限 `drive.file` 范围），将您的文件夹和提示词备份到**您自己的 Google Drive**。此传输直接发生在您的浏览器和 Google 服务器之间。我们无法访问此数据，且绝不会发送到我们运营的任何服务器。\n\n## 权限说明\n\n本扩展程序仅申请维持功能所需的最小权限：\n\n- **Storage（存储）**：用于在本地和跨设备保存您的偏好设置、文件夹、提示词、星标消息和界面自定义选项。\n- **Identity（身份认证）**：用于 Google Drive 同步功能的 Google 认证。仅在您主动启用云同步时使用。\n- **Scripting（脚本注入）**：用于在 Gemini 页面及用户指定的自定义网站上动态注入内容脚本（提示词管理器功能）。仅注入扩展自身打包的脚本，不会加载或执行任何远程代码。\n- **Host Permissions（主机权限）**（gemini.google.com、aistudio.google.com 等）：用于注入增强 Gemini 界面的内容脚本，提供文件夹、导出、时间线、引用回复等功能。Google 相关域名（googleapis.com、accounts.google.com）用于 Google Drive 同步认证。\n- **Optional Host Permissions（可选主机权限）**（所有 URL）：仅在您主动添加提示词管理器的自定义网站时按需请求，不会在未经您操作的情况下激活。\n\n## 第三方服务\n\nVoyager 不会与任何第三方服务、广告商或分析提供商共享数据。\n\n## 政策变更\n\n我们可能会不时更新隐私政策。我们将通过在此页面发布新的隐私政策来通知您任何变更。\n\n## 联系我们\n\n如果您对本隐私政策有任何疑问，请通过我们的 [GitHub 仓库](https://github.com/Nagi-ovo/gemini-voyager) 联系我们。\n"
  },
  {
    "path": "docs/pt/guide/batch-delete.md",
    "content": "# Eliminação em Lote\n\nElimine várias conversas de uma só vez, sem precisar de apagar uma a uma.\n\n## Funcionalidades\n\n- **Modo de Seleção Múltipla**: Pressione e segure qualquer conversa para entrar no modo de seleção múltipla e marque várias conversas para eliminar.\n- **Limpeza com um clique**: Após selecionar, clique no botão de eliminar para remover todas as conversas selecionadas de uma só vez.\n- **Feedback de Progresso**: O progresso em tempo real é exibido durante a eliminação para que saiba o estado atual.\n- **Confirmação Segura**: Um diálogo de confirmação aparece antes da eliminação para evitar operações acidentais.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/batch-delete.png\" alt=\"Eliminação em Lote\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## Como Usar\n\n1. Na lista de conversas da barra lateral, **pressione e segure** qualquer item de conversa.\n2. Após entrar no modo de seleção múltipla, aparecerão caixas de seleção no lado esquerdo de cada conversa.\n3. Marque as conversas que deseja eliminar (até 50 de uma vez).\n4. Clique no **botão Eliminar** que aparece.\n5. Clique em \"Confirmar\" na área de confirmação vermelha que aparece **acima da lista de pastas** para iniciar a eliminação.\n\n::: tip Nota\nO painel de confirmação sobrepõe a área de pastas para evitar bloquear a lista de conversas. As operações de eliminação em lote não podem ser desfeitas, por isso prossiga com cuidado.\n:::\n"
  },
  {
    "path": "docs/pt/guide/cloud-sync.md",
    "content": "# Sincronização na Nuvem\n\nSincronize suas pastas, biblioteca de prompts e outros dados no Google Drive para manter sua experiência consistente em todos os seus dispositivos.\n\n## Recursos\n\n- **Sincronização entre dispositivos**: Mantenha suas configurações sincronizadas em vários computadores usando o Google Drive.\n- **Privacidade de dados**: Os dados são armazenados diretamente no seu próprio armazenamento do Google Drive, garantindo a privacidade sem servidores de terceiros.\n- **Sincronização flexível**: Suporte para upload manual e download/mesclagem de dados.\n\n::: info\n**Em breve**: A próxima versão suportará a sincronização de conversas marcadas com estrela.\n:::\n\n## Como usar\n\n1. Clique no ícone da extensão no canto inferior direito da página do Gemini™ para abrir o painel de configurações.\n2. Localize a seção **Sincronização na Nuvem**.\n3. Clique em **Fazer login com o Google** e conclua a autorização.\n4. Uma vez autorizado, clique em **Fazer upload para a nuvem** para sincronizar seus dados locais com a nuvem, ou em **Baixar e mesclar** para trazer os dados da nuvem para sua máquina local.\n\n### 💡 Sincronização rápida\n\nA maneira mais fácil é clicar nos botões **\"Fazer upload para a nuvem\"** ou **\"Baixar e mesclar\"** na parte superior da área de pastas na barra lateral esquerda.\n\n<img src=\"/assets/cloud-sync.png\" alt=\"Botões de sincronização rápida na nuvem\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n::: warning\n**Recomendação de segurança: Proteção dupla**  \nEmbora a sincronização na nuvem ofereça grande conveniência, recomendamos enfaticamente que você também faça backups periódicos dos seus dados principais usando **arquivos locais**.\n\n1. **Exportação Completa**: Exporte um pacote completo contendo todas as configurações, pastas e prompts em \"Backup e Restauração\" na parte inferior do painel.\n   <img src=\"/assets/manual-export-all.png\" alt=\"Exportação Completa\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. **Exportar Todas as Pastas**: Clique em \"Exportar\" na seção \"Pastas\" do painel para fazer backup de todas as suas pastas e conversas, excluindo os prompts.\n   <img src=\"/assets/manual-folder-export.png\" alt=\"Exportar Todas as Pastas\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n   :::\n"
  },
  {
    "path": "docs/pt/guide/community.md",
    "content": "# Comunidade e Feedback\n\nValorizamos a voz de cada utilizador. Quer tenha encontrado um Bug, tenha uma sugestão de funcionalidade ou queira partilhar a sua biblioteca de prompts, pode contactar-nos através dos seguintes canais.\n\n## 📢 Siga as Novidades\n\nSiga a nossa conta no X (Twitter) para obter os últimos progressos de desenvolvimento.\n\n- **Novos Lançamentos**: Saiba das atualizações em primeira mão.\n- **Próximas Funcionalidades**: Espreite o que está para vir.\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://x.com/Nag1ovo/status/2012609459663634589\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/badge/Seguir-𝕏-000000?style=for-the-badge&logo=x&logoColor=white\" alt=\"Seguir X\">\n  </a>\n</div>\n\n## 💬 Comunidade Discord\n\nJunte-se ao nosso servidor Discord e troque ideias com outros Voyagers!\n\n- **Chat Instantâneo**: Fale diretamente com outros utilizadores e programadores.\n- **Partilha de Prompts**: Veja que tipos de Prompts as outras pessoas estão a usar.\n- **Progresso de Desenvolvimento**: Receba notícias sobre o desenvolvimento de novas funcionalidades.\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://discord.gg/TEUFxdMbGb\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/discord/1463273957120675973?style=for-the-badge&logo=discord&logoColor=white&label=Entrar%20no%20Discord\" alt=\"Discord\">\n  </a>\n</div>\n\n## 🐙 GitHub Issues\n\nSe encontrou um erro no programa (Bug) ou tem um pedido de funcionalidade claro (Feature Request), recomendamos submeter um Issue no GitHub:\n\n- [Submeter Relatório de Bug](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=bug_report.yml)\n- [Submeter Pedido de Funcionalidade](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=feature_request.yml)\n\nObrigado pelo seu apoio ao Voyager! ❤️\n"
  },
  {
    "path": "docs/pt/guide/context-sync.md",
    "content": "# Transporte de memória: Sincronização de Contexto (Experimental)\n\n**Diferentes Dimensões, Compartilhamento Contínuo**\n\nItere a lógica na web e implemente o código no IDE. O Voyager quebra as barreiras dimensionales, dando ao seu IDE o \"processo de pensamento\" da web instantaneamente.\n\n## Chega de pular entre abas\n\nA maior dor para os desenvolvedores: depois de discutir uma solução minuciosamente na web, você retorna ao VS Code/Trae/Cursor apenas para ter que reexplicar os requisitos como um estranho. Devido às cotas e velocidades de resposta, a web é o \"cérebro\" e o IDE são as \"mãos\". O Voyager permite que eles compartilhem a mesma alma.\n\n## Três Passos Simples para Sincronizar\n\n1. **Instale e ative o CoBridge**:\n   Instale a extensão **CoBridge** no VS Code. Ela serve como a ponte central conectando a interface web ao seu IDE local.\n   - **[Instalar via VS Code Marketplace](https://open-vsx.org/extension/windfall/co-bridge)**\n\n   ![Extensão CoBridge](/assets/CoBridge-extension.png)\n\n   Após a instalação, **abra qualquer diretório de trabalho**, clique no ícone à direita e inicie o servidor.\n   ![Servidor CoBridge Ligado](/assets/CoBridge-on.png)\n\n2. **Aperto de mão**:\n   - Ative a \"Sincronização de Contexto\" nas configurações do Voyager.\n   - Alinhe o número da porta. Quando vir \"IDE Online\", significa que eles estão conectados.\n\n   ![Painel de Sincronização de Contexto](/assets/context-sync-console.png)\n\n3. **Sincronização em um clique**: Clique em **\"Sync to IDE\"**. Sejam **tabelas de dados** complexas ou **imagens de referência** intuitivas, tudo pode ser sincronizado instantaneamente com o seu IDE.\n\n   ![Sincronização Concluída](/assets/sync-done.png)\n\n## Criando Raízes\n\nAssim que a sincronização for concluída, um arquivo `.cobridge/AI_CONTEXT.md` aparecerá no diretório de trabalho do seu IDE. Seja Trae, Cursor ou Copilot, eles lerão automaticamente essa 'memória' por meio de seus respectivos arquivos Rule.\n\n```\nyour-project/\n├── .cobridge/\n│   ├── images/\n│   │   ├── context_img_1_1.png\n│   │   └── context_img_1_2.png\n│   └── AI_CONTEXT.md\n├── .github/\n│   └── copilot-instructions.md\n├── .gitignore\n├── .traerules\n└── .cursorrules\n```\n\n## Seus Princípios\n\n- **Zero Poluição**: O CoBridge gerencia automaticamente o `.gitignore`, garantindo que suas conversas privadas não sejam enviadas para repositórios Git.\n- **Especialista**: Formato Markdown completo, fazendo com que a IA no IDE leia de forma tão fluida quanto um manual de instruções.\n- **Dica**: Se a conversa for antiga, use a [Linha do Tempo] para rolar para cima e fazer com que a web \"lembre\" do contexto antes de sincronizar para melhores resultados.\n\n---\n\n## Parta Agora\n\n**O pensamento já está pronto na nuvem, agora, deixe-o criar raízes localmente.**\n\n- **[Instalar o Plugin CoBridge](https://open-vsx.org/extension/windfall/co-bridge)**: Encontre seu portal dimensional e ative a \"respiração sincronizada\" com um clique.\n- **[Visitar o Repositório GitHub](https://github.com/Winddfall/CoBridge)**: Saiba mais sobre a lógica por trás do CoBridge ou dê uma Star para este projeto de \"sincronização de almas\".\n\n> **Grandes modelos não perdem mais a memória; prontos para ação imediata.**\n"
  },
  {
    "path": "docs/pt/guide/deep-research.md",
    "content": "# Exportação Deep Research\n\nExporte o relatório final gerado pelo Deep Research, ou guarde o seu processo completo de \"pensamento\" como um ficheiro Markdown.\n\n## 1. Exportação de Relatório (PDF / Imagem)\n\nOs relatórios gerados pelo Deep Research podem ser exportados como PDFs com formatação elegante ou como imagens individuais partilháveis (os formatos Markdown e JSON também são suportados).\n\n![Exportação de Relatório](/assets/deep-research-report-export.png)\n\n## 2. Exportação do Processo de Pensamento (Markdown)\n\nAlém do relatório final, também pode exportar o conteúdo completo de \"pensamento\" das conversas do Deep Research.\n\n### Funcionalidades\n\n- **Exportação num clique**: O botão de download aparece no menu da conversa (⋮)\n- **Formato estruturado**: Preserva as fases de pensamento, itens de pensamento e sites pesquisados na sua ordem original\n- **Cabeçalhos bilingues**: Ficheiros Markdown incluem cabeçalhos de secção em inglês e no seu idioma atual\n- **Nomeação automática**: Os ficheiros têm carimbo de data/hora para fácil organização (ex: `deep-research-thinking-20240128-153045.md`)\n\n### Como Usar\n\n1. Abra uma conversa Deep Research no Gemini™\n2. Clique no botão **Partilhar e Exportar** na conversa\n3. Selecione \"Transferir conteúdo de pensamento\" (Download thinking content)\n4. O ficheiro Markdown será transferido automaticamente\n\n![Exportação de Pensamento Deep Research](/assets/deepresearch_download_thinking.png)\n\n### Formato do Ficheiro Exportado\n\nO ficheiro Markdown exportado inclui:\n\n- **Título**: O título da conversa\n- **Metadados**: Data/hora da exportação e número total de fases de pensamento\n- **Fases de Pensamento**: Cada fase contém:\n  - Itens de pensamento (com cabeçalhos e conteúdo)\n  - Sites pesquisados (com links e títulos)\n\n#### Exemplo de Estrutura\n\n```markdown\n# Título da Conversa Deep Research\n\n**Hora de exportação / Exported At:** 2025-12-28 17:25:35\n**Total de fases / Total Phases:** 3\n\n---\n\n## Fase de Pensamento 1 / Thinking Phase 1\n\n### Título do Pensamento 1\n\nConteúdo do pensamento...\n\n### Título do Pensamento 2\n\nConteúdo do pensamento...\n\n#### Sites Pesquisados / Researched Websites\n\n- [domain.com](https://example.com) - Título da Página\n- [another.com](https://another.com) - Outro Título\n\n---\n\n## Fase de Pensamento 2 / Thinking Phase 2\n\n...\n```\n\n## Privacidade\n\nToda a extração e formatação ocorre 100% localmente no seu navegador. Nenhum dado é enviado para servidores externos.\n"
  },
  {
    "path": "docs/pt/guide/default-model.md",
    "content": "# Modelo Padrão\n\n::: info\n**Nota**: Este recurso é suportado na versão 1.1.9 e posteriores.\n:::\n\nDefina um modelo Gemini™ preferido como padrão para evitar a troca manual a cada nova conversa.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/default-model.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n## Recursos\n\n- **Configuração Interativa**: Adiciona um botão de \"Estrela\" diretamente no menu nativo de seleção de modelos do Gemini.\n- **Troca Automática**: Muda automaticamente para o seu modelo preferido sempre que você inicia uma nova conversa.\n- **Preferência Persistente**: Sua escolha é salva e sincronizada entre os seus dispositivos.\n- **Otimizado para SPA**: É ativado com precisão ao clicar no botão \"Novo chat\", usar atalhos ou navegar de volta para a página inicial.\n\n## Como usar\n\n1. Clique no **Seletor de Modelos** acima da área de entrada do Gemini.\n2. Passe o mouse sobre o seu modelo preferido e clique no **ícone de Estrela**.\n3. Assim que a estrela estiver **preenchida**, esse modelo será definido como seu padrão.\n4. A extensão selecionará automaticamente este modelo para todos os novos chats.\n5. Para desativar, basta clicar no ícone de estrela preenchida novamente.\n"
  },
  {
    "path": "docs/pt/guide/export.md",
    "content": "# Total Freedom\n\nData lock-in is the enemy.\nWe believe that if you create it, you own it.\n\n## Export Everything\n\nVoyager lets you pull your data out of the cloud and into your hands.\n\n### The Formats\n\n- **Markdown**: For your Obsidian vault or Notion. Clean, formatted text. (Usuários do Safari: Imagens não podem ser extraídas devido a limitações do navegador, use a exportação em PDF)\n- **PDF**: For sharing or printing. Beautifully laid out, images included.\n- **JSON**: Raw data. For developers who want to build on top of their history.\n\n### How to Export\n\n1. Passe o mouse sobre o logo do Gemini para ver o **Ícone de Exportação**.\n2. Choose your format.\n3. Done.\n\nIt’s your data. Do what you want with it.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Passo 1: Passar o mouse no Logo</b></p>\n    <img src=\"/assets/gemini-export-guide-1.png\" alt=\"Export guide step 1\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Step 2: The Choice</b></p>\n    <img src=\"/assets/gemini-export-guide-2.png\" alt=\"Export guide step 2\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n### Safari PDF Export Note\n\nExporting PDF on Safari requires a slightly different process (manual print):\n\n1. Click the **Export** button and select PDF format.\n2. **Wait for about a second** (allow the page to prepare print styles).\n3. Press `Command + P` to open the print dialog.\n4. Select **\"Save to PDF\"** in the print dialog.\n\n<img src=\"/assets/safari-export-pdf.png\" alt=\"Safari Export PDF\" style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 20px;\"/>\n"
  },
  {
    "path": "docs/pt/guide/folders.md",
    "content": "# Pastas Bem Feitas\n\nPor que organizar chats de IA é tão difícil?\nNós resolvemos isso. Construímos um sistema de arquivos para seus pensamentos.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap; margin-bottom: 40px;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Gemini™</b></p>\n    <img src=\"/assets/gemini-folders.png\" alt=\"Pastas Gemini\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>AI Studio</b></p>\n    <img src=\"/assets/aistudio-folders.png\" alt=\"Pastas AI Studio\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n## A física da organização\n\nSimplesmente parece certo.\n\n- **Arrastar e Soltar**: Pegue um chat. Solte-o em uma pasta. É tátil.\n- **Hierarquia Aninhada**: Projetos têm subprojetos. Crie pastas dentro de pastas. Estruture do _seu_ jeito.\n- **Espaçamento de pastas**: Ajuste a densidade da barra lateral, de compacto a espaçoso.\n  > _Nota: No Mac Safari, os ajustes podem não ser em tempo real; atualize a página para ver o efeito._\n- **Sincronização Instantânea**: Organize no seu desktop. Veja no seu laptop.\n\n## Dicas Profissionais\n\n- **Multi-Seleção**: Pressione e segure uma conversa para entrar no modo de multi-seleção, depois selecione vários chats e mova todos de uma vez.\n- **Renomear**: Clique duas vezes em qualquer pasta para renomeá-la.\n- **Ícones**: Detectamos automaticamente o tipo de Gem (Programação, Criativo, etc.) e atribuímos o ícone correto. Você não precisa fazer nada.\n\n## Diferenças de recursos por plataforma\n\n### Recursos comuns\n\n- **Gestão básica**: Arrastar e soltar, renomear, seleção múltipla.\n- **Reconhecimento inteligente**: Detecta automaticamente tipos de chat e atribui ícones.\n- **Hierarquia Aninhada**: Suporte para aninhamento de pastas.\n- **Adaptação para AI Studio**: Os recursos avançados estarão disponíveis em breve no AI Studio.\n- **Sincronização com Google Drive**: Sincroniza a estrutura de pastas com o Google Drive.\n\n### Exclusivo para Gemini\n\n#### Cores personalizadas\n\nClique no ícone da pasta para personalizar sua cor. Escolha entre 7 cores padrão ou use o seletor de cores para escolher qualquer cor.\n\n<img src=\"/assets/folder-color.png\" alt=\"Cores das pastas\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### Isolamento de conta\n\nClique no ícone \"pessoa\" no cabeçalho para filtrar instantaneamente os chats de outras contas do Google. Mantenha seu espaço de trabalho limpo ao usar várias contas.\n\n<img src=\"/assets/current-user-only.png\" alt=\"Isolamento de conta\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### Organização Automática com IA\n\nChats demais, preguiça de organizar? Deixe o Gemini pensar por você.\n\nUm clique copia sua estrutura de conversas atual, cole no Gemini, e ele gera um plano de pastas pronto para importar — organização instantânea.\n\n**Passo 1: Copie sua estrutura de conversas**\n\nNa parte inferior da seção de pastas no popup da extensão, clique no botão **AI Organize**. Ele coleta automaticamente todas as suas conversas não classificadas e a estrutura de pastas existente, gera um prompt e copia para a área de transferência.\n\n<img src=\"/assets/ai-auto-folder.png\" alt=\"AI Organize Button\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n\n**Passo 2: Deixe o Gemini classificar**\n\nCole o conteúdo da área de transferência em uma conversa do Gemini. Ele vai analisar os títulos dos seus chats e gerar um plano de pastas em JSON.\n\n**Passo 3: Importe os resultados**\n\nClique em **Importar pastas** no menu do painel de pastas, selecione **Ou colar JSON diretamente**, cole o JSON que o Gemini retornou e clique em **Importar**.\n\n<div style=\"display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; margin-bottom: 24px;\">\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-2.png\" alt=\"Import Menu\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 240px;\"/>\n  </div>\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-3.png\" alt=\"Paste JSON Import\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n  </div>\n</div>\n\n- **Mesclagem incremental**: Usa a estratégia de \"Mesclar\" por padrão — apenas adiciona novas pastas e atribuições, nunca destrói sua organização existente.\n- **Multilíngue**: O prompt usa automaticamente o idioma configurado, e os nomes das pastas também são gerados nesse idioma.\n\n### Exclusivo para AI Studio\n\n- **Ajuste da barra lateral**: Arraste para redimensionar a largura da barra lateral.\n- **Integração com Library**: Arraste diretamente de sua Library para pastas.\n"
  },
  {
    "path": "docs/pt/guide/fork.md",
    "content": "# Bifurcação de Conversa (Experimental)\n\nO pensamento não deveria ser de sentido único. Em explorações complexas, frequentemente precisamos de voltar a um nó crucial e tentar diferentes possibilidades.\n\nCom a funcionalidade de **Bifurcação de Conversa**, o Voyager permite-lhe expandir as suas ideias e explorar universos paralelos do seu chat.\n\n## Como Funciona\n\n> **⚠️ Nota**: Esta é uma funcionalidade experimental. Primeiro, precisa de a ativar clicando no ícone da extensão na barra de ferramentas do seu navegador para abrir o pop-up de configurações, e ativando o interruptor **\"Ativar Bifurcação de Conversa\"**.\n\nSempre que quiser seguir um caminho diferente, passe o rato sobre a sua pergunta e clique no botão **Bifurcar**:\n\n![Bifurcação](/assets/branching.png)\n\nO Voyager irá capturar instantaneamente todo o contexto desde o início até esse ponto e **iniciará uma nova conversa** para si.\n\nNeste novo ramo, pode modificar livremente a sua pergunta e explorar diferentes direções sem se preocupar em danificar o seu histórico de chat original. Liberte a sua criatividade e curiosidade!\n"
  },
  {
    "path": "docs/pt/guide/formula-copy.md",
    "content": "# Cópia de Fórmulas\n\nO Voyager torna fácil a reutilização de fórmulas matemáticas e símbolos científicos. Suporta a cópia com um clique do código-fonte LaTeX e do formato MathML compatível com o Microsoft Word.\n\n## Introdução\n\nQuando pede ao Gemini para derivar fórmulas ou escrever expressões matemáticas, ele geralmente apresenta-as usando LaTeX. Embora seja visualmente atraente, extrair o código-fonte para usar nos seus próprios artigos, documentos ou editores exige frequentemente um esforço manual.\n\nO Voyager oferece suporte total para isso:\n\n1. **Deteção Automática**: O Voyager identifica automaticamente as fórmulas LaTeX apresentadas na página.\n2. **Botão de Cópia**: Ao passar o rato sobre uma fórmula, aparece um ícone de cópia no lado direito.\n3. **Opções de Formato**: Clique no ícone para escolher:\n   - **Copy LaTeX**: Copia o código-fonte LaTeX padrão, ideal para Overleaf, editores Markdown, etc.\n   - **Copy MathML**: Copia o código-fonte MathML, o melhor formato para colar diretamente no **Microsoft Word**.\n\n![Cópia de Fórmulas](/assets/gemini-math-copy.png)\n\n## Características\n\n- **Compatibilidade com Word**: Com o suporte MathML, pode colar fórmulas complexas geradas por IA diretamente em documentos Word, mantendo um formato editável perfeito.\n- **Preservação do Contexto**: Não copia apenas a fórmula em si, mas também preserva o seu contexto matemático.\n- **Resposta Instantânea**: Processado inteiramente de forma local para resultados imediatos.\n\n## Dicas de Utilização\n\n- **Escrita Académica**: Ao escrever artigos no Word, peça ao Gemini para derivar fórmulas e, em seguida, utilize a cópia MathML para evitar o trabalho de introdução manual no Editor de Equações do Word.\n- **Notas**: Ao tirar notas no Obsidian ou Notion, basta copiar a fonte LaTeX diretamente.\n"
  },
  {
    "path": "docs/pt/guide/getting-started.md",
    "content": "# Bem-vindo a Bordo\n\nParabéns. Acabou de melhorar o seu intelecto.\nO Voyager não é apenas um utilitário; é um fluxo de trabalho. Veja como tirar o máximo partido dele nos seus primeiros 5 minutos.\n\n## 1. A Configuração\n\nSe ainda não o instalou, vá para o [Guia de Instalação](/pt/guide/installation).\nUma vez instalado, atualize o seu separador do Gemini. Verá a diferença imediatamente.\n\n## 2. A Linha do Tempo\n\nInicie uma conversa. Uma longa. Pergunte sobre a história da tipografia ou a física dos buracos negros.\nOlhe para a direita.\n**Aquela faixa de pontos? É o seu mapa.**\n\n- **Passe o rato** para espreitar o que disse.\n- **Clique** para se teletransportar para lá.\n- **Pressione longamente** para marcar com estrela um momento que queira guardar.\n\nAcabou-se o scroll interminável. Agora navega à velocidade do pensamento.\n\n## 3. Organização\n\nOlhe para a sua lista de chats à esquerda. Nota algo novo?\n**Pastas.**\n\nPegue num chat. Arraste-o. Solte-o numa pasta.\nParece natural, não é? É porque é. Pode aninhá-las, renomeá-las e, finalmente, limpar a desordem da sua mente.\n\n## 4. O Cofre\n\nAcabou de escrever o prompt perfeito. Não o deixe desaparecer no vazio.\nClique no **Ícone de Faísca** (✨) na caixa de entrada.\nGuarde-o. Etiquete-o.\n\nDa próxima vez? Basta clicar no ícone para o encontrar e inserir.\nJá não está apenas a conversar. Está a construir um cofre do seu próprio génio.\n\n---\n\n**Está pronto.**\nExplore os guias específicos para mergulhos profundos:\n\n- [Domínio da Linha do Tempo](/pt/guide/timeline)\n- [Gestão de Pastas](/pt/guide/folders)\n- [Engenharia de Prompts](/pt/guide/prompts)\n- [Exportação de Dados](/pt/guide/export)\n"
  },
  {
    "path": "docs/pt/guide/input-collapse.md",
    "content": "# Colapso de Entrada\n\nRecolha a área de entrada quando estiver vazia para ganhar mais espaço de leitura. Clique na barra recolhida para expandir e começar a escrever.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/hide-input-area.png\" alt=\"Colapso de entrada\" style=\"max-width: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## Como Usar\n\n1. Quando a área de entrada está vazia e perde o foco, recolhe-se automaticamente num botão compacto.\n2. Clique no botão para expandir a área de entrada e começar a escrever.\n3. Também pode pressionar <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>I</kbd> para expandir rapidamente a área de entrada.\n4. Pode ativar ou desativar esta funcionalidade no painel de definições (desativado por defeito).\n"
  },
  {
    "path": "docs/pt/guide/installation.md",
    "content": "# Instalação\n\n::: info Notícias\n🍎 **A extensão nativa do Safari já está disponível!** É totalmente gratuita e suporta a instalação com um clique.\n:::\n\nEscolha o seu caminho.\n\n> ⚠️ Nota: O Gestor de Prompts é a única funcionalidade que suporta Gemini™ para Enterprise.\n\n## 1. Lojas de Extensões (Recomendado)\n\nA forma mais simples de começar. As atualizações são automáticas.\n\n**Chrome / Brave / Opera / Vivaldi:**\n\n[<img src=\"https://img.shields.io/badge/Chrome_Web_Store-Download-4285F4?style=for-the-badge&logo=google-chrome&logoColor=white\" alt=\"Instalar da Chrome Web Store\" height=\"40\"/>](https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=docs&utm_campaign=organic_growth&utm_content=pt)\n\n::: warning ⚠️ Chrome Web Store temporariamente indisponível\nA extensão foi oficialmente renomeada para **Voyager** devido a problemas de marcas registadas. A atualização do nome no Chrome Web Store está pendente de revisão. Veja [esta publicação](https://x.com/Nag1ovo/status/2031561180213313944) para mais detalhes. Use **Edge / Firefox** ou a **instalação manual** entretanto.\n:::\n\n**Microsoft Edge:**\n\n[<img src=\"https://img.shields.io/badge/Microsoft_Edge-Download-0078D7?style=for-the-badge&logo=microsoft-edge&logoColor=white\" alt=\"Instalar do Microsoft Edge Add-ons\" height=\"40\"/>](https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne)\n\n**Firefox:**\n\n[<img src=\"https://img.shields.io/badge/Firefox_Add--ons-Download-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Instalar do Firefox Add-ons\" height=\"40\"/>](https://addons.mozilla.org/firefox/addon/gemini-voyager/)\n\n## 2. A Via Manual (Funcionalidades Mais Recentes)\n\nO processo de revisão da Web Store pode ser lento. Se quiser a versão de ponta imediatamente, instale manualmente.\n\n**Para Chrome / Edge / Brave / Opera:**\n\n1. Descarregue o último `gemini-voyager-chrome-vX.Y.Z.zip` das [Releases do GitHub](https://github.com/Nagi-ovo/gemini-voyager/releases).\n2. Descompacte o ficheiro.\n3. Abra a página de Extensões do seu navegador (`chrome://extensions`).\n4. Ative o **Modo de programador** (canto superior direito).\n5. Clique em **Carregar expandida** e selecione a pasta que acabou de descompactar.\n\n**Para Firefox:**\n\n1. Descarregue o último `gemini-voyager-firefox-vX.Y.Z.xpi` das [Releases](https://github.com/Nagi-ovo/gemini-voyager/releases).\n2. Abra o Gestor de Add-ons (`about:addons`).\n3. Arraste e largue o ficheiro `.xpi` para instalar (ou clique no ícone de engrenagem ⚙️ -> **Instalar Add-on de Ficheiro**).\n\n> 💡 O ficheiro XPI é oficialmente assinado pela Mozilla e pode ser instalado permanentemente em todas as versões do Firefox.\n\n## 3. Safari (macOS)\n\nO Safari agora suporta distribuição direta! Descarregue a aplicação pré-assinada:\n\n1. Descarregue a <SafariDownloadLink>última versão do Safari (.dmg)</SafariDownloadLink>.\n2. Abra o ficheiro e siga as instruções para instalar.\n3. Clique duas vezes para iniciar a aplicação.\n4. Ative a extensão nas **Definições do Safari > Extensões**.\n\n> 💡 A versão do Safari está agora diretamente assinada para distribuição — não é necessária conversão com Xcode!\n>\n> ⚠️ **Limitações**: Devido à natureza do Safari, (a) a remoção de marca d'água (b) a exportação de imagens (PDF recomendado) não são suportadas.\n\n---\n\n_Configuração de desenvolvimento? Se é um programador à procura de contribuir, consulte o nosso [Guia de Contribuição](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/CONTRIBUTING.md)._\n"
  },
  {
    "path": "docs/pt/guide/markdown-fix.md",
    "content": "# Correção de Renderização Markdown\n\nA interface web do Gemini™ às vezes insere elementos HTML (como fontes de citações ou marcadores de destaque) no meio do texto, o que pode quebrar a sintaxe de negrito do Markdown (`**texto**`), fazendo com que o texto não seja exibido corretamente em negrito.\n\nO Voyager possui um recurso de correção automática integrado que identifica e repara de forma inteligente essas tags de negrito quebradas, garantindo que seus documentos sejam renderizados de forma limpa e precisa.\n\n> [!INFO]\n> Este recurso é ativado automaticamente e não requer configuração adicional.\n"
  },
  {
    "path": "docs/pt/guide/mermaid.md",
    "content": "# Mermaid Diagram Rendering\n\nAutomatically render Mermaid code as visual diagrams.\n\n## Overview\n\nWhen Gemini™ outputs Mermaid code blocks (flowcharts, sequence diagrams, Gantt charts, etc.), Voyager automatically detects and renders them as interactive diagrams.\n\n### Key Features\n\n- **Auto-detection**: Supports `graph`, `flowchart`, `sequenceDiagram`, `gantt`, `pie`, `classDiagram`, and all major Mermaid diagram types\n- **Toggle view**: Switch between rendered diagram and source code with one click\n- **Fullscreen mode**: Click the diagram to enter fullscreen with zoom and pan support\n- **Dark mode**: Automatically adapts to page theme\n\n## How to Use\n\n1. Ask Gemini to generate any Mermaid diagram code\n2. The code block is automatically replaced with the rendered diagram\n3. Click the **</> Code** button to view source code\n4. Click the **📊 Diagram** button to switch back to diagram view\n5. Click the diagram area to enter fullscreen\n\n## Fullscreen Controls\n\n- **Scroll wheel**: Zoom in/out\n- **Drag**: Pan the diagram\n- **+/-**: Toolbar zoom buttons\n- **⊙**: Reset view\n- **✕ / ESC**: Close fullscreen\n\n## Compatibilidade e Solução de Problemas\n\n::: warning Nota\n\n- **Limitação do Firefox**: Devido a restrições do ambiente, o Firefox usa a versão 9.2.2 e não suporta novos recursos como **Timeline** ou **Sankey**.\n- **Erros de sintaxe**: Falhas de renderização geralmente ocorrem devido a erros de sintaxis na saída do Gemini. Estamos coletando \"bad cases\" para implementar correções automáticas em atualizações futuras.\n  :::\n\n<div align=\"center\">\n  <img src=\"/assets/mermaid-preview.png\" alt=\"Mermaid diagram rendering\" style=\"max-width: 100%; border-radius: 8px;\"/>\n</div>\n"
  },
  {
    "path": "docs/pt/guide/nanobanana.md",
    "content": "# NanoBanana Option\n\n::: warning Compatibilidade do navegador\nAtualmente, o recurso **NanoBanana** **não é compatível com o Safari** devido a limitações da API do navegador. Recomendamos o uso do **Chrome** ou **Firefox** se você precisar usar este recurso.\n\nOs usuários do Safari podem fazer o upload manual de suas imagens baixadas em sites de ferramentas como [banana.ovo.re](https://banana.ovo.re/) para processamento (embora o sucesso não seja garantido para todas as imagens devido a diferentes resoluções).\n:::\n\n**AI Images, kept pure.**\n\nImages generated by Gemini™ come with a visible watermark by default. While this is intended for safety, there are creative scenarios where you need a perfectly clean slate.\n\n## Lossless Reconstruction\n\nNanoBanana uses a **Reverse Alpha Blending** algorithm.\n\n- **Not AI Inpainting**: Traditional watermark removal often uses AI to \"smear\" the area, which destroys pixel details.\n- **Pixel Perfection**: We use mathematical calculations to precisely remove the transparent watermark layer, restoring the 100% original pixels.\n- **Zero Quality Loss**: The processed image remains identical to the original in all non-watermarked areas.\n\n## How to Use\n\n1. **Enable it**: Find \"NanoBanana Option\" at the end of the Voyager settings panel and toggle it on.\n2. **Auto-process**: Every image you generate will now be automatically processed in the background.\n3. **Download directly**:\n   - Hover over a processed image and you'll see a 🍌 button.\n   - **The 🍌 button completely replaces** the native download button to ensure you always get the 100% unwatermarked image directly.\n\n<div style=\"text-align: center; margin-top: 30px;\">\n  <img src=\"/assets/nanobanana.png\" alt=\"NanoBanana Demo\" style=\"border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); max-width: 100%;\"/>\n</div>\n\n## Acknowledgement\n\nThis feature is based on the [gemini-watermark-remover](https://github.com/journey-ad/gemini-watermark-remover) project by [journey-ad (Jad)](https://github.com/journey-ad), which is a JavaScript port of the [original C++ implementation](https://github.com/allenk/GeminiWatermarkTool) by [allenk](https://github.com/allenk). We are grateful for their contributions to the community. 🧡\n\n## Privacy & Security\n\nAll processing happens **locally in your browser**. Your images are never uploaded to any third-party servers, ensuring your privacy and creative security.\n"
  },
  {
    "path": "docs/pt/guide/prevent-auto-scroll.md",
    "content": "# Prevenir rolamento automático\n\nAo ler o histórico de uma conversa, enviar uma nova mensagem fará com que o Gemini™ role forçosamente a página até o fim para mostrar a nova resposta. Isso pode interromper a sua leitura.\n\nO recurso **Prevenir rolamento automático** intercepta esse salto indesejado:\n\n- Quando você subir na página para ler o histórico, o sistema bloqueará a descida abrupta da página.\n- Esse recurso vem **desativado** por padrão. Você pode ativá-lo nas configurações da extensão em \"Timeline Options\".\n\n## Como habilitar\n\n1. Clique no ícone de extensão do Voyager no seu navegador para abrir o popup.\n2. Procure a seção \"Timeline Options\" (Opções de Linha do Tempo).\n3. Ative a chave \"Prevent auto-scroll to bottom\" (Prevenir rolamento automático ao fim da página).\n"
  },
  {
    "path": "docs/pt/guide/prompts.md",
    "content": "# O Seu Ativo Digital: Biblioteca de Prompts\n\nPassou algum tempo a criar um Prompt divino que ajudou imenso.\nUsar e deitar fora?\nNão, guarde-o.\n\n## Cofre de Comandos\n\nEste é o seu cofre de comandos.\n\n### 1. Capturar\n\nEscreveu algo bom? Clique no **ícone flutuante** ao lado da caixa de entrada.\nGuarde no cofre, sinta-se seguro.\n\n### 2. Categorizar\n\nAdicione etiquetas como `#código`, `#email`, `#académico`.\nAs ferramentas devem ser úteis e também organizadas.\n\n### 3. Implementar\n\nDa próxima vez que precisar, não escreva tudo de novo.\nAbra a biblioteca, pesquise por etiqueta, clique para inserir.\nChamada num clique, duplique a eficiência.\n\n![Gestor de Prompts](/assets/gemini-prompt-manager.png)\n\n## Funciona em Qualquer Site\n\nO Gestor de Prompts agora pode ser usado em qualquer site à sua escolha, não limitado ao Gemini™ e AI Studio.\n\n### Como Ativar\n\n1. Clique no ícone do Voyager na barra de ferramentas de extensões do navegador.\n2. Role até à secção **Gestor de Prompts**.\n3. Introduza o URL do site (ex: `chatgpt.com` ou `claude.ai`).\n4. Clique em **Adicionar Site** e conceda permissão.\n5. **Atualize a página de destino**, e verá a bola flutuante.\n\n### Exemplos de Sites de IA Comuns\n\n- `chatgpt.com` - ChatGPT\n- `claude.ai` - Claude\n- `copilot.microsoft.com` - Microsoft Copilot\n- `poe.com` - Poe\n\n::: tip\nEm sites personalizados, **apenas** a funcionalidade de Gestor de Prompts é ativada. Outras funcionalidades como Linha do Tempo, Pastas, etc., são específicas do Gemini e não serão carregadas.\n:::\n"
  },
  {
    "path": "docs/pt/guide/quote-reply.md",
    "content": "# Resposta com Citação\n\nSelecione para citar, assim como no Discord ou Slack.\n\nSelecione qualquer texto na resposta do Gemini™, e um botão **\"Citar\"** aparecerá. Clique nele para inserir o conteúdo selecionado na caixa de entrada, formatado como uma citação Markdown.\n\nIsso é especialmente útil para fazer perguntas de acompanhamento sobre partes específicas de uma resposta longa.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/quote-reply.png\" alt=\"Resposta com Citação\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n"
  },
  {
    "path": "docs/pt/guide/recents-hider.md",
    "content": "# Ocultar itens recentes e Gems\n\n::: info\n**Nota**: Este recurso é suportado na versão 1.1.9 e posteriores.\n:::\n\nAdicione um botão elegante para ocultar a seção \"Salvos recentemente\" na página inicial do Gemini™ para uma interface mais limpa. Agora também suporta a ocultação da lista de **Gems** na barra lateral!\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Ocultar salvos recentemente</b></p>\n    <video src=\"/assets/hide-my-stuff.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Ocultar lista de Gems</b></p>\n    <video src=\"/assets/hide-gem-list.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n</div>\n\n## Características\n\n- **Alternância Contextual**: Um botão discreto aparece apenas quando você passa o mouse sobre a seção de Itens Recentes.\n- **Estado Minimalista**: Quando oculto, é substituído por uma discreta \"barra de visualização\" na parte inferior.\n- **Restauração com um clique**: Basta passar o mouse sobre a barra de visualização e clicar para trazer seus itens recentes de volta instantaneamente.\n- **Proteção de Privacidade**: Evita que outras pessoas próximas vejam suas atividades recentes, ideal para espaços públicos.\n- **Persistência**: Sua preferência é salva e aplicada automaticamente em sua próxima visita.\n\n## Como usar\n\n1. Passe o mouse sobre a seção \"Recentes\" na página inicial do Gemini.\n2. Clique no ícone de olho riscado que aparece no canto superior direito para ocultar a seção.\n3. Para restaurar, passe o mouse sobre a linha fina que permanece na parte inferior da área e clique.\n"
  },
  {
    "path": "docs/pt/guide/settings.md",
    "content": "# Largura do Chat\n\nUtilize totalmente o seu monitor ultrawide.\n\nO Gemini™ restringe o conteúdo principal a uma largura fixa. O Voyager liberta isso.\nAceda ao painel de definições e arraste o slider **\"Largura do Chat\"**.\n\n- **Alargar**: Perfeito para tabelas de código, análise de dados e janelas de ecrã dividido.\n- **Concentrar**: Estreite-o para leitura profunda sem distrações.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/gemini-chatwidth.png\" alt=\"Ajuste de Largura do Chat\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## Ordem personalizada\n\nMuitas seções no popup e as que você mais usa estão lá embaixo?\n\nPasse o mouse sobre qualquer cartão de configuração e os botões ▲/▼ aparecerão no canto superior direito. Clique para mover um cartão para cima ou para baixo. Sua disposição é salva automaticamente.\n\n## Efeitos Visuais\n\nEscolha `Neve`, `Sakura` ou `Chuva` para uma atmosfera sazonal.\n\nO Voyager não se limita a melhorias utilitárias. Pode também mudar o ambiente da página.\n\n- **Neve**: Flocos suaves para uma sensação calma de inverno.\n- **Sakura**: Pétalas de flor de cerejeira flutuando para um toque primaveril mais leve.\n- **Chuva**: Uma camada de chuva cinematográfica com gotas inclinadas e salpicos subtis.\n- **Transição suave**: Ao desativar ou trocar de efeito, as partículas desaparecem naturalmente.\n"
  },
  {
    "path": "docs/pt/guide/sidebar-auto-hide.md",
    "content": "# Ocultar barra lateral automaticamente\n\nQuer uma experiência de chat mais imersiva?\n\nOferecemos a funcionalidade de **Ocultar barra lateral automaticamente**. Quando ativada, a barra lateral contrai-se automaticamente quando o rato sai da área e expande-se automaticamente quando move o rato de volta para ela.\n\n### Demonstração\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/sidebar-auto-hide.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n### Como ativar\n\n1. Abra o painel de definições do Voyager.\n2. Encontre a opção **Ocultar barra lateral auto** nas **Definições Gerais**.\n3. Ative o interruptor.\n\n_Nota: Esta funcionalidade suporta atualmente apenas o Google Gemini._\n"
  },
  {
    "path": "docs/pt/guide/sidebar.md",
    "content": "# Largura da barra lateral\n\nOs nomes das pastas são muito longos?\nOu a barra lateral ocupa muito espaço?\n\nAgora você pode ajustar livremente a largura da barra lateral.\n\n## Como ajustar\n\n1. Abra o painel de configurações do Voyager (clique no ícone da extensão no canto superior direito).\n   <img src=\"/assets/extension-instruction.png\" alt=\"Como abrir o painel de configurações\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. Encontre a opção **Largura da barra lateral**.\n3. Arraste o controle deslizante para escolher a largura que preferir.\n\n- **Estreito**: Economize espaço e concentre-se na conversa.\n- **Largo**: Veja os nomes completos das pastas num piscar de olhos.\n\n## Plataformas suportadas\n\nEste recurso suporta:\n\n- **Google Gemini**\n- **Google AI Studio**\n\nSuas configurações são salvas automaticamente.\n"
  },
  {
    "path": "docs/pt/guide/sponsor.md",
    "content": "# Patrocinar o Projeto\n\n> [!NOTE]\n> Se o Voyager for útil para você, compartilhe no X, Reddit, YouTube, etc. Cada partilha ajuda mais pessoas a descobrir o projeto e a melhorar a experiência com Gemini. Obrigado.\n\nSe o Voyager melhora a sua produtividade diária, considere patrocinar o projeto. O seu apoio ajuda-nos a manter o desenvolvimento ativo, corrigir bugs e adicionar novas funcionalidades.\n\n## Formas de Apoiar\n\n### 💖 GitHub Sponsors\n\nO método preferido. 100% do seu patrocínio vai para o programador (o GitHub não cobra taxas).\n\n[<img src=\"https://img.shields.io/badge/Sponsor_via-GitHub_Sponsors-EA4AAA?style=for-the-badge&logo=github-sponsors&logoColor=white\" alt=\"GitHub Sponsors\" height=\"50\"/>](https://github.com/sponsors/Nagi-ovo)\n\n### ☕ Buy Me a Coffee\n\nUma forma simples de oferecer um café como agradecimento.\n\n[<img src=\"https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me a Coffee\" height=\"50\"/>](https://buymeacoffee.com/nagiovo)\n\n### ⭐ Star no GitHub\n\nÉ grátis e ajuda imenso! Dê uma estrela ao nosso repositório para nos ajudar a alcançar mais pessoas.\n\n[<img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=social\" alt=\"GitHub stars\" height=\"30\"/>](https://github.com/Nagi-ovo/gemini-voyager)\n\n## Para onde vai o dinheiro?\n\n- **Taxas de Programador Apple**: Para manter a versão Safari viva e assinada ($99/ano).\n- **Servidor e Domínio**: Para alojar esta documentação e serviços de backend.\n- **Café**: Combustível essencial para converter código em funcionalidades.\n\nObrigado por ser um Voyager! 🚀\n"
  },
  {
    "path": "docs/pt/guide/tab-title.md",
    "content": "# Sincronização de Título de Aba\n\nNunca mais se perca num mar de separadores \"Gemini™\".\n\nPor defeito, todos os separadores do Gemini dizem apenas \"Gemini\". Quando tem 10 abertos, encontrar o certo é um pesadelo.\nO Voyager muda automaticamente o título do separador do navegador para corresponder ao título da conversa atual.\n\n- **Antes**: Gemini, Gemini, Gemini\n- **Depois**: Física Quântica, Receita de Massa, Debugging Python\n\nEncontre o que precisa num relance.\n"
  },
  {
    "path": "docs/pt/guide/timeline.md",
    "content": "# Navegação na Linha do Tempo\n\nO recurso de assinatura do Voyager. Uma maneira visual e espacial de navegar nas suas conversas.\n\n## O Problema\n\nAs conversas de IA podem tornar-se longas. Muito longas.\nO scroll é lento. \"Cmd+F\" é desajeitado. Perde o contexto.\n\n## A Solução\n\nOlhe para a direita do ecrã. Essa é a Linha do Tempo.\nCada ponto representa uma troca (a sua pergunta + a resposta do Gemini™).\n\n### Interações\n\n- **Hover (Passar o rato)**: Veja uma pré-visualização instantânea do que foi discutido naquele ponto. Sem clicar, sem scroll. Apenas espreite.\n- **Clique**: Salte instantaneamente para essa mensagem. Teletransporte.\n- **Destaque de Contexto**: Enquanto faz scroll, o ponto correspondente acende-se, para que saiba sempre onde está no \"mapa\".\n\n## Favoritos (Stars)\n\nPressione longamente (ou clique com o botão direito) em qualquer ponto para o **Marcar com Estrela**.\nPontos com estrela tornam-se maiores e mais brilhantes. Use-os para marcar:\n\n- O código final funcional.\n- A melhor explicação de um conceito.\n- O prompt que desbloqueou tudo.\n\nA Linha do Tempo transforma uma parede de texto plano num mapa estruturado de conhecimento.\n"
  },
  {
    "path": "docs/pt/index.md",
    "content": "---\nlayout: home\n\nhero:\n  name: 'Voyager'\n  text: 'O sistema operativo que faltava ao Gemini.'\n  tagline: 'Adoramos o Gemini. Só queríamos que fosse perfeito.'\n  image:\n    src: /logo.png\n    alt: Logótipo do Voyager\n  actions:\n    - theme: brand\n      text: Descarregar\n      link: ./guide/installation\n    - theme: alt\n      text: Iniciar Jornada\n      link: ./guide/getting-started\n\nteaser:\n  title: 'Simplesmente funciona.'\n  description: 'Não queríamos criar apenas mais uma extensão. Queríamos criar uma melhor forma de pensar.<br>Quando usa o Voyager, deixa de lutar contra a interface e passa a fluir com ela.'\n  image: '/assets/teaser.png'\n  features:\n    - title: 'Linha do Tempo'\n      details: 'Não faça scroll. Voe. Salte para qualquer ponto da sua conversa instantaneamente.'\n    - title: 'Pastas'\n      details: 'Finalmente, um sistema de ficheiros para a sua IA. Nativo, intuitivo, poderoso.'\n    - title: 'Liberdade'\n      details: 'Os seus dados são seus. Exporte para JSON, Markdown ou PDF com um clique.'\n\nfeatures:\n  - icon: 🧭\n    title: Linha do Tempo\n    details: Um mapa para a sua mente. Navegue visualmente pelas conversas.\n  - icon: 🗂️\n    title: Pastas\n    details: Ordem no caos. Arrastar, largar, feito.\n  - icon: ✨\n    title: Cofre\n    details: O seu génio, capturado. Guarde e reutilize os seus melhores prompts.\n  - icon: 💬\n    title: Resposta com Citação\n    details: Selecione para citar. Respostas contextualizadas para uma comunicação eficiente.\n  - icon: ↔️\n    title: Largura do chat\n    details: Amplie sua visão. Ajuste livremente a largura do chat para uma melhor experiência de visualização.\n  - icon: 💾\n    title: Exportar Chat\n    details: Soberania dos dados. Arquive em vários formatos para que o conhecimento nunca se perca.\n  - icon: 🌦️\n    title: Efeitos Visuais\n    details: Defina o ambiente. Alterne entre neve, chuva e pétalas de sakura na janela popup.\n  - icon: 🍌\n    title: Remoção de Marca de Água NanoBanana\n    details: Remoção de marca de água sem perdas. Mantenha os momentos de IA puros.\n  - icon: 📐\n    title: Cópia de Fórmulas\n    details: Cópia com um clique de códigos-fonte LaTeX e MathML (Word).\n  - icon: 🧜‍♀️\n    title: Gráficos Mermaid\n    details: De código para visual. Fluxogramas, diagramas de sequência e gráficos de Gantt renderizados instantaneamente.\n  - icon: 🏷️\n    title: Sinc. Título da Aba\n    details: Saiba num relance. Sincronize automaticamente o título da aba do navegador com o seu chat.\n  - icon: 🔀\n    title: Bifurcação de Conversa (Experimental)\n    details: Pensamento divergente. Bifurque a conversa em qualquer nó para explorar diferentes possibilidades.\n  - icon: 🗑️\n    title: Eliminação em Lote\n    details: Limpeza em massa. Selecione várias conversas e elimine-as todas de uma vez.\n  - icon: ☁️\n    title: Sincronização na Nuvem\n    details: Sempre sincronizado. Faça backup de pastas e prompts no Google Drive entre dispositivos.\n  - icon: ⚡️\n    title: Modelo padrão\n    details: Pare de se repetir. Mude automaticamente para seu modelo preferido em novos chats.\n  - icon: 🔬\n    title: Deep Research\n    details: Abra a caixa preta. Extraia processos de pesquisa e links das sessões de Deep Research.\n---\n\n<div class=\"vp-doc\" style=\"margin: 2rem auto 0; max-width: 780px; padding: 0 16px;\">\n  <div style=\"background: rgba(234, 179, 8, 0.12); border: 1px solid rgba(234, 179, 8, 0.6); border-radius: 8px; padding: 12px 16px;\">\n    <strong>⚠️ Aviso de mudança de nome</strong>: Devido a problemas de marcas registradas e direitos autorais, esta extensão foi oficialmente renomeada para <strong>Voyager</strong>. No entanto, devido ao processo extremamente lento de revisão do Chrome Web Store, a mudança de nome não foi aprovada em 7 dias — está temporariamente indisponível no Chrome Web Store.\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 780px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 8px; font-weight: 600; font-size: 1.05em;\">Cada instalação é um voto de confiança</h3>\n  <p style=\"margin: 0 0 16px; opacity: 0.78; font-size: 0.95em;\">Números ao vivo da Chrome Web Store e GitHub. Obrigado por viajar connosco, companheiros Exploradores.</p>\n  <div style=\"display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;\">\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Estrelas GitHub\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Forks GitHub\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Último Lançamento\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"Downloads GitHub\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Utilizadores Chrome Web Store\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Avaliação Chrome Web Store\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoft-edge\" alt=\"Edge Add-ons\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Utilizadores Firefox Add-ons\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Avaliação Firefox Add-ons\">\n  </div>\n  <div style=\"margin-top: 16px; display: flex; justify-content: center; flex-wrap: wrap; gap: 12px;\">\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 1000px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 24px; font-weight: 600; font-size: 1.2em;\">Agradecimentos Especiais</h3>\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" style=\"margin: 0 auto;\" />\n  </a>\n  <p style=\"margin-top: 24px; font-size: 1.05em; opacity: 0.86;\">✨ Estamos ao vivo no Product Hunt! Adoraríamos ouvir as suas opiniões e feedback. ❤️</p>\n  <div style=\"margin-top: 12px; display: flex; justify-content: center;\">\n    <a href=\"https://www.producthunt.com/posts/gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager no Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 3.5rem auto 2rem; max-width: 720px; padding: 0 16px;\">\n  <p style=\"font-size: 1.05em; font-weight: 600; opacity: 0.86; margin: 0 0 12px;\">\"Não é apenas uma ferramenta. É uma bicicleta para a mente.\"</p>\n  <a href=\"./guide/getting-started\" style=\"font-weight: 600; text-decoration: none;\">Veja o que é possível →</a>\n</div>\n\n<p align=\"center\">\n  <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n</p>\n"
  },
  {
    "path": "docs/pt/privacy.md",
    "content": "# Política de Privacidade\n\nÚltima atualização: 16 de março de 2026\n\n## Introdução\n\nO Voyager (\"nós\", \"nosso\" ou \"nos\") está comprometido em proteger a sua privacidade. Esta Política de Privacidade explica como a nossa extensão de navegador recolhe, utiliza e protege as suas informações.\n\n## Recolha e Utilização de Dados\n\n**Não recolhemos nenhuma informação pessoal.**\n\nO Voyager opera inteiramente dentro do seu navegador. Todos os dados gerados ou geridos pela extensão (como pastas, modelos de prompts, mensagens favoritas e configurações) são armazenados:\n\n1. Localmente no seu dispositivo (`chrome.storage.local`)\n2. No armazenamento sincronizado do seu navegador (`chrome.storage.sync`) se disponível, para sincronizar configurações entre os seus dispositivos.\n\nNão temos acesso aos seus dados pessoais, histórico de chat ou qualquer outra informação privada. Não rastreamos o seu histórico de navegação.\n\n## Sincronização com Google Drive (Opcional)\n\nSe ativar explicitamente a função de sincronização com o Google Drive, a extensão utiliza a API Chrome Identity para obter um token OAuth2 (apenas com o scope `drive.file`) para fazer backup das suas pastas e prompts no **seu próprio Google Drive**. Esta transferência ocorre diretamente entre o seu navegador e os servidores da Google. Não temos acesso a estes dados e nunca são enviados para qualquer servidor que operemos.\n\n## Permissões\n\nA extensão solicita as permissões mínimas necessárias para funcionar:\n\n- **Storage (Armazenamento)**: Para guardar as suas preferências, pastas, prompts, mensagens favoritas e opções de personalização da interface localmente e entre dispositivos.\n- **Identity (Identidade)**: Para a autenticação Google da funcionalidade opcional de sincronização com o Google Drive. Usado apenas quando ativa explicitamente a sincronização na nuvem.\n- **Scripting (Scripts)**: Para injetar dinamicamente scripts de conteúdo nas páginas do Gemini e em sites personalizados especificados pelo utilizador para a funcionalidade Gestor de Prompts. Apenas scripts incluídos na própria extensão são injetados — nenhum código remoto é obtido ou executado.\n- **Host Permissions (Permissões de host)** (gemini.google.com, aistudio.google.com, etc.): Para injetar scripts de conteúdo que melhoram a interface do Gemini com funcionalidades como pastas, exportação, linha do tempo e citação de resposta. Os domínios adicionais da Google (googleapis.com, accounts.google.com) são necessários para a autenticação da sincronização com o Google Drive.\n- **Optional Host Permissions (Permissões de host opcionais)** (todos os URLs): Apenas solicitadas em tempo de execução quando adiciona explicitamente sites personalizados para o Gestor de Prompts. Nunca ativadas sem a sua ação.\n\n## Serviços de Terceiros\n\nO Voyager não partilha nenhuns dados com serviços de terceiros, anunciantes ou fornecedores de análises.\n\n## Alterações a Esta Política\n\nPodemos atualizar a nossa Política de Privacidade ocasionalmente. Iremos notificá-lo de quaisquer alterações publicando a nova Política de Privacidade nesta página.\n\n## Contacte-nos\n\nSe tiver alguma dúvida sobre esta Política de Privacidade, por favor contacte-nos através do nosso [Repositório GitHub](https://github.com/Nagi-ovo/gemini-voyager).\n"
  },
  {
    "path": "docs/public/google79cf501ea29c5eb1.html",
    "content": "google-site-verification: google79cf501ea29c5eb1.html\n"
  },
  {
    "path": "docs/ru/guide/batch-delete.md",
    "content": "# Пакетное удаление\n\nУдаляйте несколько разговоров одновременно, больше не нужно удалять по одному.\n\n## Особенности\n\n- **Режим множественного выбора**: Длительное нажатие на любой разговор, чтобы войти в режим множественного выбора и выбрать несколько разговоров для удаления.\n- **Очистка в один клик**: После выбора нажмите кнопку удаления, чтобы удалить все выбранные разговоры сразу.\n- **Обратная связь о прогрессе**: Прогресс отображается в реальном времени во время удаления, чтобы вы знали текущий статус.\n- **Безопасное подтверждение**: Диалоговое окно подтверждения появляется перед удалением, чтобы предотвратить случайные операции.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/batch-delete.png\" alt=\"Пакетное удаление\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## Как использовать\n\n1. В списке разговоров на боковой панели **длительно нажмите** на любой элемент разговора.\n2. После входа в режим множественного выбора слева от каждого разговора появятся чекбоксы.\n3. Отметьте разговоры, которые вы хотите удалить (до 50 за раз).\n4. Нажмите появившуюся **Кнопку удаления**.\n5. Нажмите \"Подтвердить\" в красной области подтверждения, которая появится **над списком папок**, чтобы начать удаление.\n\n::: tip Примечание\nПанель подтверждения перекрывает область папок, чтобы не блокировать список разговоров. Операции пакетного удаления нельзя отменить, поэтому, пожалуйста, будьте осторожны.\n:::\n"
  },
  {
    "path": "docs/ru/guide/cloud-sync.md",
    "content": "# Облачная синхронизация\n\nСинхронизируйте свои папки, библиотеку промптов и другие данные с Google Диском, чтобы ваш опыт оставался одинаковым на всех устройствах.\n\n## Возможности\n\n- **Синхронизация между устройствами**: Синхронизируйте свои конфигурации на нескольких компьютерах с помощью Google Диска.\n- **Конфиденциальность данных**: Данные хранятся непосредственно в вашем хранилище Google Диска, что обеспечивает конфиденциальность без использования сторонних серверов.\n- **Гибкая синхронизация**: Поддержка ручной загрузки и скачивания/объединения данных.\n\n::: info\n**Скоро**: Следующая версия будет поддерживать синхронизацию избранных диалогов.\n:::\n\n## Как использовать\n\n1. Нажмите на значок расширения в правом нижнем углу страницы Gemini™, чтобы открыть панель настроек.\n2. Найдите раздел **Облачная синхронизация**.\n3. Нажмите **Войти через Google** и завершите авторизацию.\n4. После авторизации нажмите **Загрузить в облако**, чтобы синхронизировать локальные данные с облаком, или **Скачать и объединить**, чтобы перенести облачные данные на свой компьютер.\n\n### 💡 Быстрая синхронизация\n\nСамый простой способ — нажать кнопки **«Загрузить в облако»** или **«Скачать и объединить»** в верхней части области папок в левой боковой панели.\n\n<img src=\"/assets/cloud-sync.png\" alt=\"Кнопки быстрой облачной синхронизации\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n::: warning\n**Рекомендация по безопасности: Двойная защита**  \nХотя облачная синхронизация обеспечивает большое удобство, мы настоятельно рекомендуем вам также периодически создавать резервные копии основных данных с помощью **локальных файлов**.\n\n1. **Полный экспорт**: Экспортируйте полный пакет, содержащий все настройки, папки и промпты в разделе «Резервное копирование и восстановление» в нижней части панели.\n   <img src=\"/assets/manual-export-all.png\" alt=\"Полный экспорт\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. **Экспорт всех папок**: Нажмите «Экспорт» в разделе «Папки», чтобы сохранить все папки и диалоги, исключая промпты.\n   <img src=\"/assets/manual-folder-export.png\" alt=\"Экспорт всех папок\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n   :::\n"
  },
  {
    "path": "docs/ru/guide/community.md",
    "content": "# Сообщество и Обратная связь\n\nМы ценим голос каждого пользователя. Если вы нашли ошибку, у вас есть предложение по функции или вы хотите поделиться своим хранилищем промптов, есть несколько способов связаться с нами.\n\n## 📢 Следите за обновлениями\n\nПодписывайтесь на нас в X (Twitter), чтобы получать последние новости разработки.\n\n- **Новые релизы**: Будьте первыми, кто узнает об обновлениях.\n- **Предпросмотр функций**: Узнавайте о предстоящих функциях заранее.\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://x.com/Nag1ovo/status/2012610584722731188\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/badge/Follow%20on-𝕏-000000?style=for-the-badge&logo=x&logoColor=white\" alt=\"Подписаться в X\">\n  </a>\n</div>\n\n## 💬 Сообщество Discord\n\nПрисоединяйтесь к нашему серверу Discord, чтобы общаться с другими Voyagers!\n\n- **Чат в реальном времени**: Общайтесь напрямую с другими пользователями и разработчиками.\n- **Обмен промптами**: Смотрите, как другие используют Gemini™, и делитесь своими лучшими промптами.\n- **Новости разработки**: Получайте последние новости о предстоящих функциях и релизах.\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://discord.gg/TEUFxdMbGb\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/discord/1463273957120675973?style=for-the-badge&logo=discord&logoColor=white&label=Join%20our%20Discord\" alt=\"Discord\">\n  </a>\n</div>\n\n## 🐙 GitHub Issues\n\nЕсли вы нашли ошибку или у вас есть конкретный запрос на функцию, пожалуйста, откройте issue на GitHub:\n\n- [Сообщить об ошибке](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=bug_report.yml)\n- [Предложить функцию](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=feature_request.yml)\n\nСпасибо за поддержку Voyager! ❤️\n"
  },
  {
    "path": "docs/ru/guide/context-sync.md",
    "content": "# Транспортировка памяти: Синхронизация контекста (Экспериментально)\n\n**Разные измерения, бесшовный обмен**\n\nПрорабатывайте логику в вебе, а код реализуйте в IDE. Voyager разрушает барьеры между измерениями, мгновенно передавая вашему IDE «мыслительный процесс» из веба.\n\n## Хватит прыгать между вкладками\n\nГлавная боль разработчиков: после детального обсуждения решения в вебе вы возвращаетесь в VS Code/Trae/Cursor, только чтобы заново объяснять требования, как незнакомцу. Из-за квот и скорости ответа веб — это «мозг», а IDE — «руки». Voyager позволяет им разделить одну душу.\n\n## Три простых шага для синхронизации\n\n1. **Установите и запустите CoBridge**:\n   Установите расширение **CoBridge** в VS Code. Оно служит основным мостом, соединяющим веб-интерфейс и вашу локальную IDE.\n   - **[Установить через VS Code Marketplace](https://open-vsx.org/extension/windfall/co-bridge)**\n\n   ![Расширение CoBridge](/assets/CoBridge-extension.png)\n\n   После установки, **откройте любой рабочий каталог**, нажмите на иконку справа и запустите сервер.\n   ![Сервер CoBridge включен](/assets/CoBridge-on.png)\n\n2. **Рукопожатие**:\n   - Включите «Синхронизацию контекста» в настройках Voyager.\n   - Совместите номера портов. Надпись «IDE Online» означает, что соединение установлено.\n\n   ![Панель синхронизации контекста](/assets/context-sync-console.png)\n\n3. **Синхронизация в один клик**: Нажмите **\"Sync to IDE\"**. Будь то сложные **таблицы данных** или наглядные **изображения**, всё будет мгновенно синхронизировано с вашим IDE.\n\n   ![Синхронизация завершена](/assets/sync-done.png)\n\n## Укоренение\n\nПосле завершения синхронизации в рабочем каталоге вашей IDE появится файл `.cobridge/AI_CONTEXT.md`. Будь то Trae, Cursor или Copilot, они будут автоматически считывать эту «память» через свои соответствующие файлы Rule.\n\n```\nyour-project/\n├── .cobridge/\n│   ├── images/\n│   │   ├── context_img_1_1.png\n│   │   └── context_img_1_2.png\n│   └── AI_CONTEXT.md\n├── .github/\n│   └── copilot-instructions.md\n├── .gitignore\n├── .traerules\n└── .cursorrules\n```\n\n## Принципы\n\n- **Чистота**: CoBridge автоматически правит `.gitignore`, чтобы ваши приватные диалоги не попали в Git-репозиторий.\n- **Профессионализм**: Полный формат Markdown, благодаря чему ИИ в IDE читает контекст так же легко, как инструкцию.\n- **Совет**: Если диалог был давно, сначала прокрутите его вверх с помощью [Таймлайна], чтобы веб-версия «вспомнила» контекست — тогда результат синхронизации будет лучше.\n\n---\n\n## В путь\n\n**Мысль уже готова в облаке, теперь дайте ей пустить корни локально.**\n\n- **[Установить плагин CoBridge](https://open-vsx.org/extension/windfall/co-bridge)**: Найдите свой портал между измерениями и включите «синхронное дыхание» одним кликом.\n- **[Посетить репозиторий на GitHub](https://github.com/Winddfall/CoBridge)**: Узнайте больше о логике CoBridge или поддержите проект «синхронизации душ» звездой.\n\n> **Большие модели больше не теряют память — готовы к делу здесь и сейчас.**\n"
  },
  {
    "path": "docs/ru/guide/deep-research.md",
    "content": "# Экспорт Deep Research\n\nЭкспортируйте финальный отчет, созданный Deep Research, или сохраните весь процесс «мышления» в виде файла Markdown.\n\n## 1. Экспорт отчетов (PDF / Изображение)\n\nОтчеты, созданные Deep Research, можно экспортировать в виде красиво оформленных PDF-файлов или отдельных изображений для удобного обмена (также поддерживаются форматы Markdown и JSON).\n\n![Экспорт отчетов](/assets/deep-research-report-export.png)\n\n## 2. Экспорт процесса мышления (Markdown)\n\nПомимо финального отчета, вы также можете экспортировать полное содержимое «мышления» из диалогов Deep Research.\n\n### Особенности\n\n- **Экспорт в один клик**: Кнопка загрузки появляется в меню разговора (⋮)\n- **Структурированный формат**: Сохраняет этапы мышления, элементы мыслей и исследованные веб-сайты в их исходном порядке\n- **Двуязычные заголовки**: Файлы Markdown включают заголовки разделов на английском и вашем текущем языке\n- **Автоматическое именование**: Файлы имеют отметку времени для легкой организации (например, `deep-research-thinking-20240128-153045.md`)\n\n### Как использовать\n\n1. Откройте разговор Deep Research в Gemini™\n2. Нажмите кнопку **Поделиться и экспортировать** в разговоре\n3. Выберите \"Скачать содержимое мыслей\" (Download thinking content)\n4. Файл Markdown будет автоматически загружен\n\n![Экспорт мышления Deep Research](/assets/deepresearch_download_thinking.png)\n\n### Формат экспортированного файла\n\nЭкспортированный файл Markdown включает:\n\n- **Заголовок**: Заголовок разговора\n- **Метаданные**: Отметка времени экспорта и общее количество этапов мышления\n- **Этапы мышления**: Каждый этап содержит:\n  - Элементы мыслей (с заголовками и содержимым)\n  - Исследованные веб-сайты (со ссылками и заголовками)\n\n#### Пример структуры\n\n```markdown\n# Заголовок разговора Deep Research\n\n**导出时间 / Exported At:** 2025-12-28 17:25:35\n**总思考阶段 / Total Phases:** 3\n\n---\n\n## 思考阶段 1 / Thinking Phase 1\n\n### Заголовок мысли 1\n\nСодержимое мысли...\n\n### Заголовок мысли 2\n\nСодержимое мысли...\n\n#### 研究网站 / Researched Websites\n\n- [domain.com](https://example.com) - Заголовок страницы\n- [another.com](https://another.com) - Другой заголовок\n\n---\n\n## 思考阶段 2 / Thinking Phase 2\n\n...\n```\n\n## Конфиденциальность\n\nВсё извлечение и форматирование происходит на 100% локально в вашем браузере. Данные не отправляются на внешние серверы.\n"
  },
  {
    "path": "docs/ru/guide/default-model.md",
    "content": "# Модель по умолчанию\n\n::: info\n**Примечание**: Эта функция поддерживается в версии 1.1.9 и более поздних.\n:::\n\nУстановите предпочитаемую модель Gemini™ в качестве модели по умолчанию, чтобы избежать ручного переключения при каждом новом диалоге.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/default-model.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n## Возможности\n\n- **Интерактивная настройка**: добавляет кнопку «Звезда» прямо в стандартное меню выбора модели Gemini.\n- **Автоматическое переключение**: автоматически переключается на выбранную модель при каждом запуске нового чата.\n- **Сохранение настроек**: ваш выбор сохраняется и синхронизируется между вашими устройствами.\n- **Оптимизация для SPA**: точно срабатывает при нажатии кнопки «Новый чат», использовании горячих клавиш или возврате на главную страницу.\n\n## Как использовать\n\n1. Нажмите на **селектор моделей** над полем ввода Gemini.\n2. Наведите курсор на нужную модель и нажмите на **иконку звезды**.\n3. Когда звезда станет **закрашенной**, модель будет установлена по умолчанию.\n4. Расширение будет автоматически выбирать эту модель для всех новых чатов.\n5. Чтобы отменить выбор, просто снова нажмите на закрашенную иконку звезды.\n"
  },
  {
    "path": "docs/ru/guide/export.md",
    "content": "# Полная свобода\n\nПривязка к данным — это враг.\nМы верим, что если вы создали это, вы владеете этим.\n\n## Экспортируйте все\n\nVoyager позволяет вам извлечь ваши данные из облака и взять их в свои руки.\n\n### Форматы\n\n- **Markdown**: Для вашего хранилища Obsidian или Notion. Чистый, отформатированный текст. (Пользователи Safari: изображения не могут быть извлечены из-за ограничений браузера, используйте экспорт в PDF)\n- **PDF**: Для обмена или печати. Красивая верстка, изображения включены.\n- **JSON**: Сырые данные. Для разработчиков, которые хотят строить поверх своей истории.\n\n### Как экспортировать\n\n1. Наведите курсор на логотип Gemini, чтобы увидеть **Значок экспорта**.\n2. Выберите формат.\n3. Готово.\n\nЭто ваши данные. Делайте с ними то, что хотите.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Шаг 1: Наведите на логотип</b></p>\n    <img src=\"/assets/gemini-export-guide-1.png\" alt=\"Руководство по экспорту шаг 1\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Шаг 2: Выбор</b></p>\n    <img src=\"/assets/gemini-export-guide-2.png\" alt=\"Руководство по экспорту шаг 2\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n### Примечание по экспорту в PDF для Safari\n\nЭкспорт в PDF в Safari требует немного другого процесса (ручная печать):\n\n1. Нажмите кнопку **Экспорт** и выберите формат PDF.\n2. **Подождите около секунды** (чтобы страница подготовила стили для печати).\n3. Нажмите `Command + P`, чтобы открыть диалоговое окно печати.\n4. Выберите **\"Сохранить как PDF\"** (Save to PDF) в диалоговом окне печати.\n\n<img src=\"/assets/safari-export-pdf.png\" alt=\"Safari Export PDF\" style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 20px;\"/>\n"
  },
  {
    "path": "docs/ru/guide/folders.md",
    "content": "# Папки, какими они должны быть\n\nПочему организовывать чаты с ИИ так сложно?\nМы это исправили. Мы создали файловую систему для ваших мыслей.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap; margin-bottom: 40px;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Gemini™</b></p>\n    <img src=\"/assets/gemini-folders.png\" alt=\"Папки Gemini\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>AI Studio</b></p>\n    <img src=\"/assets/aistudio-folders.png\" alt=\"Папки AI Studio\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n## Физика организации\n\nЭто просто удобно.\n\n- **Перетаскивание**: Возьмите чат. Перетащите его в папку. Тактильное ощущение порядка.\n- **Вложенная иерархия**: У проектов есть подпроекты. Создавайте папки внутри папок. Структурируйте так, как удобно _вам_.\n- **Расстояние между папками**: Свободно настраивайте плотность боковой панели.\n  > _Примечание: В Mac Safari изменения могут не отображаться в реальном времени; обновите страницу, чтобы увидеть эффект._\n- **Мгновенная синхронизация**: Организуйте на компьютере. Используйте на ноутбуке.\n\n## Профессиональные приемы\n\n- **Множественный выбор**: Длительное нажатие на разговор, чтобы войти в режим множественного выбора, затем выберите несколько чатов и переместите их все сразу.\n- **Переименование**: Дважды щелкните по любой папке, чтобы переименовать её.\n- **Значки**: Мы автоматически определяем тип Gem (Кодинг, Творчество и т.д.) и назначаем правильный значок. Вам ничего не нужно делать.\n\n## Различия возможностей по платформам\n\n### Общие функции\n\n- **Базовое управление**: Перетаскивание, переименование, множественный выбор.\n- **Умное распознавание**: Автоматическое определение типа чата и назначение значка.\n- **Вложенная иерархия**: Поддержка вложенности папок.\n- **Адаптация для AI Studio**: Продвинутые функции скоро появятся в AI Studio.\n- **Синхронизация с Google Drive**: Синхронизация структуры папок с Google Drive.\n\n### Эксклюзивно для Gemini\n\n#### Пользовательские цвета\n\nНажмите на иконку папки, чтобы настроить её цвет. Выберите один из 7 стандартных цветов или используйте палитру для выбора любого цвета.\n\n<img src=\"/assets/folder-color.png\" alt=\"Цвета папок\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### Изоляция аккаунтов\n\nНажмите на иконку «человек» в заголовке, чтобы мгновенно скрыть чаты из других аккаунтов Google. Поддерживайте чистоту рабочего пространства при использовании нескольких аккаунтов.\n\n<img src=\"/assets/current-user-only.png\" alt=\"Изоляция аккаунтов\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### Автоматическая организация с ИИ\n\nСлишком много чатов, лень сортировать? Пусть Gemini подумает за вас.\n\nОдин клик копирует вашу текущую структуру разговоров, вставьте её в Gemini — и он сгенерирует готовый к импорту план папок. Мгновенный порядок.\n\n**Шаг 1: Скопируйте структуру разговоров**\n\nВнизу раздела папок во всплывающем окне расширения нажмите кнопку **AI Organize**. Он автоматически соберёт все ваши неотсортированные разговоры и текущую структуру папок, сгенерирует промпт и скопирует его в буфер обмена.\n\n<img src=\"/assets/ai-auto-folder.png\" alt=\"AI Organize Button\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n\n**Шаг 2: Пусть Gemini разберётся**\n\nВставьте содержимое буфера обмена в разговор с Gemini. Он проанализирует заголовки ваших чатов и выдаст JSON-план папок.\n\n**Шаг 3: Импортируйте результат**\n\nНажмите **Импорт папок** в меню панели папок, выберите **Или вставить JSON напрямую**, вставьте JSON, который вернул Gemini, и нажмите **Импорт**.\n\n<div style=\"display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; margin-bottom: 24px;\">\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-2.png\" alt=\"Import Menu\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 240px;\"/>\n  </div>\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-3.png\" alt=\"Paste JSON Import\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n  </div>\n</div>\n\n- **Инкрементальное слияние**: По умолчанию используется стратегия «Слияние» — добавляются только новые папки и назначения, существующая организация никогда не разрушается.\n- **Многоязычность**: Промпт автоматически использует ваш настроенный язык, и названия папок тоже генерируются на этом языке.\n\n### Эксклюзивно для AI Studio\n\n- **Регулировка боковой панели**: Изменение ширины боковой панели перетаскиванием.\n- **Интеграция с Library**: Перетаскивание проектов напрямую из Library в папки.\n"
  },
  {
    "path": "docs/ru/guide/fork.md",
    "content": "# Ветвление разговора (Экспериментально)\n\nМышление не должно быть улицей с односторонним движением. При сложных исследованиях часто возникает необходимость вернуться к ключевой точке и опробовать различные варианты.\n\nС помощью функции **Ветвления**, Voyager позволяет вам расширять ход мыслей и исследовать параллельные вселенные вашего чата.\n\n## Как это работает\n\n> **⚠️ Примечание**: Это экспериментальная функция. Сперва вам нужно включить ее, нажав на значок расширения на панели инструментов браузера, чтобы открыть всплывающее окно настроек, и активировав переключатель **«Включить ветвление разговора»**.\n\nДля того чтобы пойти по иному пути, просто наведите курсор на ваш вопрос и нажмите кнопку **Ветвление**:\n\n![Ветвление](/assets/branching.png)\n\nVoyager мгновенно скопирует весь контекст с самого начала до этой точки и **начнет совершенно новый разговор** для вас.\n\nВ этой новой ветке вы можете свободно изменять свой вопрос и исследовать другие направления, не беспокоясь о том, что разрушите оригинальную историю чата. Дайте волю своей креативности и любопытству!\n"
  },
  {
    "path": "docs/ru/guide/formula-copy.md",
    "content": "# Копирование формул\n\nVoyager позволяет легко копировать математические формулы и научные символы. Поддерживается копирование исходного кода LaTeX и формата MathML, совместимого с Microsoft Word, в один клик.\n\n## Описание функции\n\nКогда вы просите Gemini вывести формулу или написать математическое выражение, он обычно использует LaTeX для рендеринга. Несмотря на эстетичный вид, извлечение исходного кода для использования в ваших статьях, документах или редакторах часто требует ручного труда.\n\nVoyager предоставляет удобное решение:\n\n1. **Автоматическое распознавание**: Voyager автоматически находит отрендеренные формулы LaTeX на странице.\n2. **Кнопка копирования**: При наведении курсора на формулу справа появляется значок копирования.\n3. **Выбор формата**: Нажмите на значок, чтобы выбрать:\n   - **Copy LaTeX**: Копирует стандартный исходный код LaTeX, подходящий для Overleaf, редакторов Markdown и т. д.\n   - **Copy MathML**: Копирует код MathML — это лучший формат для прямой вставки в **Microsoft Word**.\n\n![Копирование формул](/assets/gemini-math-copy.png)\n\n## Особенности\n\n- **Полная совместимость с Word**: Благодаря поддержке MathML вы можете вставлять сложные формулы, созданные ИИ, прямо в документы Word, сохраняя при этом идеальный редактируемый формат формул.\n- **Сохранение контекста**: Копируется не только сама формула, но и сохраняется ее математический контекст.\n- **Мгновенный отклик**: Обработка происходит полностью локально, результат доступен мгновенно.\n\n## Советы по использованию\n\n- **Написание научных работ**: При написании статьи в Word попросите Gemini вывести формулу, а затем используйте копирование MathML, чтобы не вводить ее вручную в редакторе формул Word.\n- **Заметки**: При ведении заметок в Obsidian или Notion просто копируйте исходный код LaTeX напрямую.\n"
  },
  {
    "path": "docs/ru/guide/getting-started.md",
    "content": "# Добро пожаловать на борт\n\nПоздравляем. Вы только что улучшили свой интеллект.\nVoyager — это не просто утилита, это рабочий процесс. Вот как извлечь из него максимум пользы за первые 5 минут.\n\n## 1. Настройка\n\nЕсли вы еще не установили его, перейдите к [Руководству по установке](/ru/guide/installation).\nПосле установки обновите вкладку Gemini. Вы сразу увидите разницу.\n\n## 2. Таймлайн\n\nНачните разговор. Длинный. Спросите об истории типографики или физике черных дыр.\nПосмотрите направо.\n**Эта полоса точек? Это ваша карта.**\n\n- **Наведите курсор**, чтобы посмотреть, что вы сказали.\n- **Нажмите**, чтобы телепортироваться туда.\n- **Длительное нажатие**, чтобы добавить в избранное момент, который вы хотите сохранить.\n\nБольше никакой бесконечной прокрутки. Теперь вы перемещаетесь со скоростью мысли.\n\n## 3. Организация\n\nПосмотрите на свой список чатов слева. Заметили что-то новое?\n**Папки.**\n\nВозьмите чат. Перетащите его. Бросьте его в папку.\nЭто кажется естественным, не так ли? Потому что так и есть. Вы можете вкладывать их, переименовывать и, наконец, очистить свой разум от беспорядка.\n\n## 4. Хранилище\n\nВы только что написали идеальный промпт. Не дайте ему исчезнуть в пустоте.\nНажмите **Значок Искры** (✨) в поле ввода.\nСохраните его. Добавьте тег.\n\nВ следующий раз? Просто нажмите значок, чтобы найти и вставить его.\nВы больше не просто болтаете. Вы создаете хранилище своего собственного гения.\n\n---\n\n**Вы готовы.**\nИзучите конкретные руководства для глубокого погружения:\n\n- [Мастерство таймлайна](/ru/guide/timeline)\n- [Управление папками](/ru/guide/folders)\n- [Промпт-инжиниринг](/ru/guide/prompts)\n- [Экспорт данных](/ru/guide/export)\n"
  },
  {
    "path": "docs/ru/guide/input-collapse.md",
    "content": "# Сворачивание ввода\n\nСворачивайте поле ввода, когда оно пустое, чтобы получить больше пространства для чтения. Нажмите на свернутую панель, чтобы развернуть и начать печатать.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/hide-input-area.png\" alt=\"Сворачивание ввода\" style=\"max-width: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## Как использовать\n\n1. Когда поле ввода пустое и теряет фокус, оно автоматически сворачивается в компактную кнопку-таблетку\n2. Нажмите на кнопку-таблетку, чтобы развернуть поле ввода и начать печатать\n3. Также можно нажать <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>I</kbd> для быстрого развертывания поля ввода\n4. Вы можете включить или отключить эту функцию в панели настроек (отключено по умолчанию)\n"
  },
  {
    "path": "docs/ru/guide/installation.md",
    "content": "# Установка\n\n::: info Новости\n🍎 **Нативное расширение для Safari уже вышло!** Оно совершенно бесплатно и поддерживает установку в один клик.\n:::\n\nВыберите способ установки.\n\n> ⚠️ Примечание: Менеджер промптов — единственная функция, поддерживающая Gemini™ для Enterprise.\n\n## 1. Магазины расширений (Рекомендуется)\n\nСамый простой способ начать. Обновления происходят автоматически.\n\n**Chrome / Brave / Opera / Vivaldi:**\n\n[<img src=\"https://img.shields.io/badge/Chrome_Web_Store-Download-4285F4?style=for-the-badge&logo=google-chrome&logoColor=white\" alt=\"Установить из Chrome Web Store\" height=\"40\"/>](https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=docs&utm_campaign=organic_growth&utm_content=ru)\n\n::: warning ⚠️ Chrome Web Store временно недоступен\nРасширение официально переименовано в **Voyager** из-за проблем с товарными знаками. Обновление названия в Chrome Web Store ожидает проверки. Подробности в [этом посте](https://x.com/Nag1ovo/status/2031561180213313944). Пока что используйте **Edge / Firefox** или **ручную установку**.\n:::\n\n**Microsoft Edge:**\n\n[<img src=\"https://img.shields.io/badge/Microsoft_Edge-Download-0078D7?style=for-the-badge&logo=microsoft-edge&logoColor=white\" alt=\"Установить из Microsoft Edge Add-ons\" height=\"40\"/>](https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne)\n\n**Firefox:**\n\n[<img src=\"https://img.shields.io/badge/Firefox_Add--ons-Download-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"Установить из Firefox Add-ons\" height=\"40\"/>](https://addons.mozilla.org/firefox/addon/gemini-voyager/)\n\n## 2. Ручной способ (Новейшие функции)\n\nПроцесс проверки в Web Store может быть медленным. Если вы хотите получить самую свежую версию немедленно, установите её вручную.\n\n**Для Chrome / Edge / Brave / Opera:**\n\n1. Скачайте последний `gemini-voyager-chrome-vX.Y.Z.zip` из [GitHub Releases](https://github.com/Nagi-ovo/gemini-voyager/releases).\n2. Распакуйте файл.\n3. Откройте страницу Расширений вашего браузера (`chrome://extensions`).\n4. Включите **Режим разработчика** (вверху справа).\n5. Нажмите **Загрузить распакованное расширение** и выберите папку, которую вы только что распаковали.\n\n**Для Firefox:**\n\n1. Скачайте последний `gemini-voyager-firefox-vX.Y.Z.xpi` из [Releases](https://github.com/Nagi-ovo/gemini-voyager/releases).\n2. Откройте Менеджер дополнений (`about:addons`).\n3. Перетащите файл `.xpi` для установки (или нажмите значок шестеренки ⚙️ -> **Установить дополнение из файла**).\n\n> 💡 Файл XPI официально подписан Mozilla и может быть постоянно установлен во всех версиях Firefox.\n\n## 3. Safari (macOS)\n\nSafari теперь поддерживает прямое распространение! Скачайте предварительно подписанное приложение:\n\n1. Скачайте <SafariDownloadLink>последнюю версию Safari (.dmg)</SafariDownloadLink>.\n2. Откройте файл и следуйте инструкциям по установке.\n3. Дважды кликните для запуска приложения.\n4. Включите расширение в **Настройки Safari > Расширения**.\n\n> 💡 Версия Safari теперь непосредственно подписана для распространения — конвертация в Xcode не требуется!\n>\n> ⚠️ **Ограничения**: Из-за особенностей Safari (a) удаление водяных знаков (b) экспорт изображений (рекомендуется PDF) не поддерживаются.\n\n---\n\n_Настройка для разработки? Если вы разработчик и хотите внести свой вклад, ознакомьтесь с нашим [Руководством по участию](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/CONTRIBUTING.md)._\n"
  },
  {
    "path": "docs/ru/guide/markdown-fix.md",
    "content": "# Исправление рендеринга Markdown\n\nВеб-интерфейс Gemini™ иногда вставляет HTML-элементы (такие как источники цитат или маркеры выделения) внутрь текста, что может нарушить синтаксис жирного шрифта Markdown (`**текст**`), в результате чего текст не отображается жирным шрифтом правильно.\n\nVoyager имеет встроенную функцию автоматического исправления, которая интеллектуально идентифицирует и восстанавливает эти поврежденные теги жирного шрифта, обеспечивая чистое и точное отображение ваших документов.\n\n> [!INFO]\n> Эта функция включается автоматически и не требует дополнительной настройки.\n"
  },
  {
    "path": "docs/ru/guide/mermaid.md",
    "content": "# Рендеринг диаграмм Mermaid\n\nАвтоматически рендерит код Mermaid в визуальные диаграммы.\n\n## Обзор\n\nКогда Gemini™ выдает блоки кода Mermaid (блок-схемы, диаграммы последовательности, диаграммы Ганта и т.д.), Voyager автоматически обнаруживает и рендерит их как интерактивные диаграммы.\n\n### Ключевые особенности\n\n- **Авто-обнаружение**: Поддерживает `graph`, `flowchart`, `sequenceDiagram`, `gantt`, `pie`, `classDiagram` и все основные типы диаграмм Mermaid\n- **Переключение вида**: Переключайтесь между отрендеренной диаграммой и исходным кодом одним кликом\n- **Полноэкранный режим**: Нажмите на диаграмму, чтобы войти в полноэкранный режим с поддержкой масштабирования и панорамирования\n- **Темный режим**: Автоматически адаптируется к теме страницы\n\n## Как использовать\n\n1. Попросите Gemini сгенерировать любой код диаграммы Mermaid\n2. Блок кода автоматически заменяется отрендеренной диаграммой\n3. Нажмите кнопку **</> Code** для просмотра исходного кода\n4. Нажмите кнопку **📊 Diagram** для переключения обратно в режим диаграммы\n5. Нажмите на область диаграммы, чтобы войти в полноэкранный режим\n\n## Управление в полноэкранном режиме\n\n- **Колесо прокрутки**: Масштабирование\n- **Перетаскивание**: Панорамирование диаграммы\n- **+/-**: Кнопки масштабирования на панели инструментов\n- **⊙**: Сбросить вид\n- **✕ / ESC**: Закрыть полноэкранный режим\n\n## Совместимость и устранение неполадок\n\n::: warning Примечание\n\n- **Ограничения Firefox**: Из-за ограничений среды Firefox использует версию 9.2.2 и не поддерживает новые функции, такие как **Timeline** или **Sankey**.\n- **Ошибки синтаксиса**: Ошибки рендеринга часто связаны с синтаксическими ошибками в коде Gemini. Мы собираем \"bad cases\" для внедрения автоматических исправлений в будущих обновлениях.\n  :::\n\n<div align=\"center\">\n  <img src=\"/assets/mermaid-preview.png\" alt=\"Рендеринг диаграмм Mermaid\" style=\"max-width: 100%; border-radius: 8px;\"/>\n</div>\n"
  },
  {
    "path": "docs/ru/guide/nanobanana.md",
    "content": "# Опция NanoBanana\n\n::: warning Совместимость с браузерами\nВ настоящее время функция **NanoBanana** **не поддерживается в Safari** из-за ограничений API браузера. Мы рекомендуем использовать **Chrome** или **Firefox**, если вам нужно использовать эту функцию.\n\nПользователи Safari могут вручную загружать скачанные изображения на сайты-инструменты, такие как [banana.ovo.re](https://banana.ovo.re/), для обработки (однако успех не гарантируется для всех изображений из-за разного разрешения).\n:::\n\n**Изображения ИИ, сохраняющие чистоту.**\n\nИзображения, сгенерированные Gemini™, по умолчанию имеют видимый водяной знак. Хотя это сделано в целях безопасности, существуют творческие сценарии, когда вам нужен идеально чистый холст.\n\n## Реконструкция без потерь\n\nNanoBanana использует алгоритм **Обратного альфа-смешивания** (Reverse Alpha Blending).\n\n- **Не AI Inpainting**: Традиционное удаление водяных знаков часто использует ИИ для \"размазывания\" области, что уничтожает детали пикселей.\n- **Пиксельная точность**: Мы используем математические вычисления для точного удаления прозрачного слоя водяного знака, восстанавливая 100% исходных пикселей.\n- **Нулевая потеря качества**: Обработанное изображение остается идентичным оригиналу во всех областях без водяных знаков.\n\n## Как использовать\n\n1. **Включите**: Найдите \"Опция NanoBanana\" в конце панели настроек Voyager и включите её.\n2. **Авто-обработка**: Каждое сгенерированное вами изображение теперь будет автоматически обрабатываться в фоновом режиме.\n3. **Скачивайте напрямую**:\n   - Наведите курсор на обработанное изображение, и вы увидите кнопку 🍌.\n   - **Кнопка 🍌 полностью заменяет** родную кнопку загрузки, чтобы гарантировать, что вы всегда получаете изображение без водяного знака напрямую.\n\n<div style=\"text-align: center; margin-top: 30px;\">\n  <img src=\"/assets/nanobanana.png\" alt=\"Демонстрация NanoBanana\" style=\"border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); max-width: 100%;\"/>\n</div>\n\n## Благодарность\n\nЭта функция основана на проекте [gemini-watermark-remover](https://github.com/journey-ad/gemini-watermark-remover) от [journey-ad (Jad)](https://github.com/journey-ad), который является портом на JavaScript [оригинальной реализации на C++](https://github.com/allenk/GeminiWatermarkTool) от [allenk](https://github.com/allenk). Мы благодарны за их вклад в сообщество. 🧡\n\n## Конфиденциальность и безопасность\n\nВся обработка происходит **локально в вашем браузере**. Ваши изображения никогда не загружаются на сторонние серверы, что обеспечивает вашу конфиденциальность и безопасность творчества.\n"
  },
  {
    "path": "docs/ru/guide/prevent-auto-scroll.md",
    "content": "# Предотвращение автопрокрутки\n\nКогда вы читаете прошлые беседы, отправка нового сообщения (Prompt) заставит Gemini™ принудительно прокрутить страницу до конца, чтобы показать новый сгенерированный ответ. Это может сильно мешать чтению.\n\nФункция **Предотвращение автопрокрутки** блокирует этот нежелательный прыжок страницы:\n\n- Когда вы прокрутили страницу вверх, чтобы перечитать историю, система запрещает возвращение и пролистывание вниз.\n- Эта функция по умолчанию **отключена**. Вы можете включить её вручную в параметрах расширения в разделе \"Timeline Options\".\n\n## Как включить\n\n1. Щелкните по иконке расширения Voyager в браузере.\n2. Найдите раздел \"Timeline Options\" (Опции таймлайна).\n3. Включите параметр \"Prevent auto-scroll to bottom\" (Предотвращать автопрокрутку).\n"
  },
  {
    "path": "docs/ru/guide/prompts.md",
    "content": "# Ваши интеллектуальные активы: Библиотека промптов\n\nВы создаете идеальный промпт. Он решает сложную задачу кодирования или пишет прекрасное электронное письмо.\nВы выбрасываете его?\nНет. Вы сохраняете его.\n\n## Хранилище промптов\n\nЭто ваш личный репозиторий гения.\n\n### 1. Захват\n\nКогда вы пишете что-то замечательное, нажмите значок **Менеджер промптов** (плавающий рядом с полем ввода).\nТеперь это часть вашего хранилища.\n\n### 2. Категоризация\n\nДобавляйте теги, такие как `#coding`, `#email` или `#research`.\nДержите свои инструменты заточенными и отсортированными.\n\n### 3. Развертывание\n\nВ следующий раз, когда он вам понадобится, не печатайте его снова.\nОткройте менеджер, найдите по тегу или ключевому слову и нажмите, чтобы вставить.\nОдин клик. Бесконечный рычаг.\n\n![Менеджер промптов](/assets/gemini-prompt-manager.png)\n\n## Доступно везде\n\nМенеджер промптов теперь можно использовать на любом веб-сайте по вашему выбору, а не только в Gemini™ и AI Studio.\n\n### Как включить\n\n1. Нажмите на значок Voyager на панели инструментов вашего браузера.\n2. Прокрутите до раздела **Менеджер промптов**.\n3. Введите URL веб-сайта (например, `chatgpt.com` или `claude.ai`).\n4. Нажмите **Добавить сайт** и предоставьте разрешение.\n5. **Перезагрузите целевую страницу**, чтобы увидеть плавающую кнопку.\n\n### Популярные сайты ИИ\n\n- `chatgpt.com` - ChatGPT\n- `claude.ai` - Claude\n- `copilot.microsoft.com` - Microsoft Copilot\n- `poe.com` - Poe\n\n::: tip\nНа пользовательских сайтах активируется **только** функция Менеджера промптов. Другие функции, такие как Таймлайн и Папки, специально разработаны для Gemini и не будут загружаться.\n:::\n"
  },
  {
    "path": "docs/ru/guide/quote-reply.md",
    "content": "# Ответ с цитированием\n\nVoyager предлагает удобную функцию \"Ответ с цитированием\", делающую ответы на конкретный контент более точными и эффективными.\n\n## Введение\n\nВ ежедневных разговорах нам часто нужно продолжить или опровергнуть определенную часть ответа ИИ. Традиционный метод включает копирование этого текста и ручной ввод символа `> ` в поле ввода, что утомительно.\n\nVoyager упрощает этот процесс:\n\n1. **Выделите для цитирования**: Используйте мышь, чтобы выделить любой текст на странице разговора (будь то ваш вопрос или ответ Gemini).\n2. **Плавающая кнопка**: Кнопка \"Цитировать\" автоматически появится рядом с выделенным текстом.\n3. **Вставка в один клик**: Нажмите кнопку, и выделенный текст будет автоматически вставлен в ваше поле ввода в стандартном формате цитирования Markdown (`> контент`).\n\n![Quote Reply](/assets/quote-reply.png)\n\n## Особенности\n\n- **Контекстно-зависимая**: Интеллектуально определяет контент разговора, чтобы избежать случайного срабатывания в несвязанных областях (например, в самом поле ввода).\n- **Стандартный формат**: Использует универсальный синтаксис Markdown, который Gemini прекрасно понимает, что приводит к более точным ответам.\n- **Поддержка многострочности**: Если выделено несколько строк текста, Voyager автоматически добавляет символы цитирования к каждой строке, чтобы сохранить чистоту формата.\n\n## Советы\n\n- **Уточняйте детали**: Выделите непонятное понятие в ответе Gemini, нажмите цитировать, а затем введите \"Пожалуйста, объясните это понятие подробно.\"\n- **Исправляйте ошибки**: Выделите неправильный код или факты в ответе, процитируйте их и укажите \"Это неверно, должно быть...\"\n"
  },
  {
    "path": "docs/ru/guide/recents-hider.md",
    "content": "# Скрытие недавних элементов и Gems\n\n::: info\n**Примечание**: Эта функция поддерживается в версии 1.1.9 и более поздних.\n:::\n\nДобавьте элегантный переключатель для скрытия раздела «Недавние сохранения» на главной странице Gemini™ для более чистого интерфейса. Теперь также поддерживается скрытие списка Gems на боковой панели!\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Скрыть недавние сохранения</b></p>\n    <video src=\"/assets/hide-my-stuff.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Скрыть список Gems</b></p>\n    <video src=\"/assets/hide-gem-list.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n</div>\n\n## Особенности\n\n- **Контекстный переключатель**: Ненавязчивая кнопка скрытия появляется только при наведении курсора на раздел недавних элементов.\n- **Минималистичное состояние**: В скрытом состоянии раздел заменяется на едва заметную полоску в нижней части.\n- **Восстановление в один клик**: Просто наведите курсор на полоску и нажмите, чтобы мгновенно вернуть недавние элементы.\n- **Защита конфиденциальности**: Предотвращает просмотр ваших последних действий окружающими, идеально для общественных мест.\n- **Сохранение настроек**: Ваш выбор сохраняется и применяется автоматически при следующем посещении.\n\n## Как использовать\n\n1. Наведите курсор на раздел «Недавние» на главной странице Gemini.\n2. Нажмите на иконку перечеркнутого глаза, которая появится в правом верхнем углу, чтобы скрыть раздел.\n3. Чтобы восстановить его, наведите курсор на тонкую линию в нижней части области и нажмите на нее.\n"
  },
  {
    "path": "docs/ru/guide/settings.md",
    "content": "# Сделайте его своим\n\nСтандартный опыт великолепен. Но вы можете захотеть, чтобы он был идеальным.\nНастройте каждый пиксель.\n\n## Режим кинотеатра\n\nЗачем смотреть в будущее через крошечную замочную скважину?\nVoyager позволяет расширить ширину чата.\n\n- **Широкий**: 1400px для кодирования и сложных таблиц.\n- **Сфокусированный**: 800px для чтения.\n- **Решать вам**: Используйте ползунок, чтобы найти свою золотую середину.\n\n## Управление\n\nНажмите значок расширения, чтобы получить доступ к центру управления.\n\n- **Режим прокрутки**: Естественный или классический.\n- **Позиция таймлайна**: Поместите его там, где вам удобно.\n- **Визуальные эффекты**: Выберите `Снег`, `Сакура` или `Дождь` для сезонной атмосферы.\n\n## Пользовательский порядок\n\nСлишком много секций в попапе, а нужные оказались внизу?\n\nНаведите курсор на любую карточку настроек — в правом верхнем углу появятся кнопки ▲/▼. Нажмите, чтобы переместить карточку вверх или вниз. Порядок сохраняется автоматически.\n\n## Атмосфера\n\nVoyager не ограничивается утилитарными улучшениями. Вы также можете изменить настроение страницы.\n\n- **Снег**: Мягкие снежинки для спокойной зимней атмосферы.\n- **Сакура**: Парящие лепестки вишни для лёгкого весеннего настроения.\n- **Дождь**: Кинематографический дождь с косыми каплями и лёгкими брызгами.\n- **Плавное переключение**: При отключении или смене эффекта частицы плавно исчезают.\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Открыть настройки</b></p>\n    <img src=\"/assets/gemini-open-settings-guide.png\" alt=\"Руководство по открытию настроек\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>Настройка ширины</b></p>\n    <img src=\"/assets/gemini-chatwidth.png\" alt=\"Настройка ширины чата\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n"
  },
  {
    "path": "docs/ru/guide/sidebar-auto-hide.md",
    "content": "# Автоматическое скрытие боковой панели\n\nХотите более глубокого погружения в чат?\n\nМы предлагаем функцию **Автоматическое скрытие боковой панели**. Если она включена, боковая панель автоматически сворачивается, когда мышь покидает ее область, и автоматически разворачивается, когда вы наводите на нее курсор.\n\n### Демонстрация\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/sidebar-auto-hide.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n### Как включить\n\n1. Откройте панель настроек Voyager.\n2. Найдите переключатель **Авто-скрытие боковой панели** в **Общих настройках**.\n3. Включите его для активации.\n\n_Примечание: Эта функция в настоящее время поддерживает только Google Gemini._\n"
  },
  {
    "path": "docs/ru/guide/sidebar.md",
    "content": "# Ширина боковой панели\n\nИмена папок слишком длинные?\nИли боковая панель занимает слишком много места?\n\nТеперь вы можете свободно регулировать ширину боковой панели.\n\n## Как настроить\n\n1. Откройте панель настроек Voyager (нажмите на значок расширения в правом верхнем углу браузера).\n   <img src=\"/assets/extension-instruction.png\" alt=\"Как открыть панель настроек\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. Найдите опцию **Ширина боковой панели**.\n3. Перетащите ползунок, чтобы выбрать удобную ширину.\n\n- **Узкая**: Экономьте место и сосредоточьтесь на беседе.\n- **Широкая**: Видите полные имена папок с первого взгляда.\n\n## Поддерживаемые платформы\n\nЭта функция поддерживается на:\n\n- **Google Gemini**\n- **Google AI Studio**\n\nВаши настройки сохраняются автоматически.\n"
  },
  {
    "path": "docs/ru/guide/sponsor.md",
    "content": "# Поддержать\n\n> [!NOTE]\n> Если Voyager вам полезен, поделитесь им в X, Reddit, YouTube и т.д. Каждый репост помогает большему числу людей узнать о проекте и улучшать опыт использования Gemini. Спасибо.\n\nПоддержка проектов с открытым исходным кодом в основном движется энтузиазмом (и кофе) ☕\n\n**[Voyager](https://github.com/Nagi-ovo/gemini-voyager)** — это полностью бесплатное расширение браузера с открытым исходным кодом, разработанное для улучшения вашего опыта работы с Gemini. Если это расширение помогает вам использовать Gemini более эффективно, пожалуйста, рассмотрите возможность поддержки дальнейшей разработки и обслуживания этого проекта.\n\n---\n\n## Онлайн-платформы\n\n<div class=\"sponsor-badges\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" style=\"height: 40px;\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Поддержать на GitHub\" style=\"height: 40px;\">\n  </a>\n</div>\n\n<a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n    <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n    <img alt=\"Afdian\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n  </picture>\n</a>\n\n### 🎙️ Рекомендуемый инструмент: Typeless\n\nЯ настоятельно рекомендую **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)**, инструмент преобразования голоса в текст с использованием ИИ, который я широко использовал во время разработки Voyager. Интеграция его в мой ежедневный рабочий процесс сэкономила мне огромное количество времени и значительно повысила мою продуктивность.\n\n> 🎁 **[Присоединяйтесь по моей реферальной ссылке](https://www.typeless.com/?via=gemini-voyager)** (Код: _`gemini-voyager`_), чтобы получить **$5 бесплатных кредитов**. Это также дает мне кредиты для поддержания этого проекта — бесплатный способ поддержать мою работу! ❤️\n\n---\n\n## Купить мне кофе (QR) 🍵\n\n<div class=\"qr-container\">\n  <div class=\"qr-item\">\n    <img src=\"/assets/wechat-sponsor.png\" alt=\"WeChat Pay\" />\n    <span>WeChat Pay</span>\n  </div>\n  <div class=\"qr-item\">\n    <img src=\"/assets/alipay-sponsor.jpg\" alt=\"Alipay\" />\n    <span>Alipay</span>\n  </div>\n</div>\n\n<style>\n.sponsor-badges {\n  display: flex;\n  gap: 16px;\n  flex-wrap: wrap;\n  align-items: center;\n  margin: 16px 0;\n}\n\n.sponsor-badges a img {\n  transition: transform 0.2s ease;\n}\n\n.sponsor-badges a:hover img {\n  transform: scale(1.05);\n}\n\n.qr-container {\n  display: flex;\n  gap: 32px;\n  flex-wrap: wrap;\n  margin: 16px 0;\n}\n\n.qr-item {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 8px;\n  padding: 16px;\n  border-radius: 8px;\n  border: 1px solid var(--vp-c-divider);\n  background: var(--vp-c-bg-soft);\n}\n\n.qr-item img {\n  width: 200px;\n  height: 200px;\n  object-fit: contain;\n  border-radius: 4px;\n}\n\n.qr-item span {\n  font-weight: 600;\n  color: var(--vp-c-text-1);\n}\n\n@media (max-width: 640px) {\n  .qr-container {\n    justify-content: center;\n  }\n  \n  .qr-item img {\n    width: 150px;\n    height: 150px;\n  }\n}\n</style>\n\n---\n\nСпасибо за вашу поддержку! Каждый вклад является огромным стимулом для меня ❤️\n"
  },
  {
    "path": "docs/ru/guide/tab-title.md",
    "content": "# Синхронизация заголовка вкладки\n\nАвтоматически синхронизирует заголовок вкладки браузера с текущим заголовком чата Gemini™.\n\n## Особенности\n\n- **Синхронизация в реальном времени**: Когда заголовок чата меняется (например, ИИ генерирует новый заголовок или вы вручную переименовываете его), заголовок вкладки браузера мгновенно обновляется с \"Gemini\" на конкретную тему разговора.\n- **Универсальная поддержка**: Отлично работает со стандартными страницами чата, разговорами Gem и мульти-аккаунтными средами.\n- **Управление переключением**: Если вы предпочитаете стандартное поведение, вы можете легко отключить эту функцию в разделе \"Общие настройки\" панели настроек.\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/tab-title.png\" alt=\"Синхронизация заголовка вкладки\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## Как использовать\n\n1. Эта функция включена по умолчанию после установки.\n2. Откройте любой разговор Gemini и обратите внимание на заголовок вкладки браузера; он автоматически обновится в соответствии с заголовком чата.\n3. Чтобы отключить:\n   - Нажмите на значок расширения, чтобы открыть панель настроек.\n   - Найдите \"Общие настройки\".\n   - Выключите \"Обновлять заголовок вкладки\".\n"
  },
  {
    "path": "docs/ru/guide/timeline.md",
    "content": "# Путешествие во времени\n\nДлинные разговоры могут быть запутанными. Вы прокручиваете вверх, прокручиваете вниз, теряете место.\nVoyager превращает ваш разговор в таймлайн.\n\n## Увидьте форму вашего чата\n\nПосмотрите на правую часть экрана.\nКаждый узел представляет сообщение. Таймлайн визуализирует ритм вашего диалога.\n\n## Навигация решена.\n\n- **Телепортация**: Нажмите на узел, чтобы мгновенно перейти к этому сообщению.\n- **Предпросмотр**: Наведите курсор, чтобы увидеть содержимое, не двигаясь.\n- **Закладка**: Длительное нажатие на узел, чтобы **пометить его звездочкой**. Это как закладка для вашего мозга.\n- **Уровни (Экспериментально)**: Щелкните правой кнопкой мыши по узлу, чтобы установить различные уровни (1-3) или свернуть дочерние элементы. Идеально подходит для того, чтобы сделать разветвленные разговоры понятными.\n- **Клавиатура**: Перемещайтесь со скоростью мысли. По умолчанию `j`/`k`, настройте на любые клавиши.\n\n![Timeline Navigation](/assets/teaser.png)\n\n## Еще быстрее с клавишами\n\nНе хотите использовать мышь? Используйте клавиатуру.\n\n**Это как включить режим Vim в Gemini.**\n\n### Горячие клавиши по умолчанию\n\n- `k` - Перейти к предыдущему узлу\n- `j` - Перейти к следующему узлу\n\n### Настройте это\n\nОткройте настройки расширения, нажмите на поле горячей клавиши, нажмите любую клавишу, которую хотите.\nЛюбая клавиша, любая комбинация. `n`/`p`? `,`/`.`? Решать вам.\n\n**Режим потока**: Быстрые нажатия выстраиваются в очередь плавно.\n**Режим прыжка**: Мгновенный отклик, максимальная скорость.\n"
  },
  {
    "path": "docs/ru/index.md",
    "content": "---\nlayout: home\n\nhero:\n  name: 'Voyager'\n  text: 'Недостающая ОС для Gemini.'\n  tagline: 'Мы любим Gemini. Мы просто хотели, чтобы он был идеальным.'\n  image:\n    src: /logo.png\n    alt: Логотип Voyager\n  actions:\n    - theme: brand\n      text: Скачать\n      link: ./guide/installation\n    - theme: alt\n      text: Начать путешествие\n      link: ./guide/getting-started\n\nteaser:\n  title: 'Это просто работает.'\n  description: 'Мы не хотели создавать очередное расширение. Мы хотели создать лучший способ мышления.<br>Когда вы используете Voyager, вы перестаете бороться с интерфейсом и начинаете плыть вместе с ним.'\n  image: '/assets/teaser.png'\n  features:\n    - title: 'Таймлайн'\n      details: 'Не прокручивайте. Летайте. Мгновенно переходите к любой точке вашего разговора.'\n    - title: 'Папки'\n      details: 'Наконец-то, файловая система для вашего ИИ. Нативная, интуитивная, мощная.'\n    - title: 'Свобода'\n      details: 'Ваши данные принадлежат вам. Экспорт в JSON, Markdown или PDF одним кликом.'\n\nfeatures:\n  - icon: 🧭\n    title: Таймлайн\n    details: Карта для вашего разума. Визуальная навигация по разговорам.\n  - icon: 🗂️\n    title: Папки\n    details: Порядок из хаоса. Перетащите, отпустите, готово.\n  - icon: ✨\n    title: Хранилище промптов\n    details: Ваш гений, сохраненный. Сохраняйте и используйте повторно ваши лучшие промпты.\n  - icon: 💬\n    title: Ответ с цитированием\n    details: Цитирование выделенного текста при ответе для эффективного общения.\n  - icon: ↔️\n    title: Ширина чата\n    details: Шире взгляд. Свободно регулируйте ширину чата для удобного просмотра.\n  - icon: 💾\n    title: Экспорт чата\n    details: Суверенитет данных. Архивация в нескольких форматах, чтобы знания никогда не терялись.\n  - icon: 🌦️\n    title: Визуальные Эффекты\n    details: Задайте настроение. Переключайте снег, дождь и лепестки сакуры из всплывающего окна.\n  - icon: 🍌\n    title: Удаление водяных знаков NanoBanana\n    details: Удаление водяных знаков без потерь. Сохраняйте моменты ИИ чистыми.\n  - icon: 📐\n    title: Копирование формул\n    details: Копирование исходного кода LaTeX и MathML (Word) в один клик.\n  - icon: 🧜‍♀️\n    title: Mermaid диаграммы\n    details: Код в визуализацию. Блок-схемы, диаграммы последовательностей, диаграммы Ганта рендерятся мгновенно.\n  - icon: 🏷️\n    title: Синхронизация заголовка вкладки\n    details: Знайте с первого взгляда. Авто-синхронизация заголовка вкладки браузера с вашим чатом.\n  - icon: 🔀\n    title: Ветвление разговора (экспериментально)\n    details: Дивергентное мышление. Ветвление разговора в любом узле для изучения различных возможностей.\n  - icon: 🗑️\n    title: Пакетное удаление\n    details: Массовая очистка. Выберите несколько разговоров и удалите их все сразу.\n  - icon: ☁️\n    title: Облачная синхронизация\n    details: Всегда на связи. Резервное копирование папок и промптов в Google Drive на всех устройствах.\n  - icon: ⚡️\n    title: Модель по умолчанию\n    details: Хватит повторяться. Автопереключение на любимую модель в новых чатах.\n  - icon: 🔬\n    title: Deep Research\n    details: Откройте чёрный ящик. Извлеките процессы исследования и ссылки из сессий Deep Research.\n---\n\n<div class=\"vp-doc\" style=\"margin: 2rem auto 0; max-width: 780px; padding: 0 16px;\">\n  <div style=\"background: rgba(234, 179, 8, 0.12); border: 1px solid rgba(234, 179, 8, 0.6); border-radius: 8px; padding: 12px 16px;\">\n    <strong>⚠️ Уведомление о переименовании</strong>: В связи с проблемами товарных знаков и авторских прав это расширение официально переименовано в <strong>Voyager</strong>. Однако из-за крайне медленного процесса проверки Chrome Web Store обновление названия не было одобрено в течение 7 дней — расширение временно недоступно в Chrome Web Store.\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 780px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 8px; font-weight: 600; font-size: 1.05em;\">Каждая установка — это голос доверия</h3>\n  <p style=\"margin: 0 0 16px; opacity: 0.78; font-size: 0.95em;\">Живые цифры из Chrome Web Store и GitHub. Спасибо, что путешествуете с нами, попутчики Voyagers.</p>\n  <div style=\"display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;\">\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Stars\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Forks\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"Latest Release\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub Downloads\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store Users\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome Web Store Rating\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoft-edge\" alt=\"Edge Add-ons\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons Users\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox Add-ons Rating\">\n  </div>\n  <div style=\"margin-top: 16px; display: flex; justify-content: center; flex-wrap: wrap; gap: 12px;\">\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 1000px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 24px; font-weight: 600; font-size: 1.2em;\">Особая благодарность</h3>\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" style=\"margin: 0 auto;\" />\n  </a>\n  <p style=\"margin-top: 24px; font-size: 1.05em; opacity: 0.86;\">✨ Мы на Product Hunt! Будем рады услышать ваши мысли и отзывы. ❤️</p>\n  <div style=\"margin-top: 12px; display: flex; justify-content: center;\">\n    <a href=\"https://www.producthunt.com/posts/gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager on Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 3.5rem auto 2rem; max-width: 720px; padding: 0 16px;\">\n  <p style=\"font-size: 1.05em; font-weight: 600; opacity: 0.86; margin: 0 0 12px;\">“Это не просто инструмент. Это велосипед для разума.”</p>\n  <a href=\"./guide/getting-started\" style=\"font-weight: 600; text-decoration: none;\">Посмотрите, что возможно →</a>\n</div>\n\n<p align=\"center\">\n  <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n</p>\n"
  },
  {
    "path": "docs/ru/privacy.md",
    "content": "# Политика конфиденциальности\n\nПоследнее обновление: 16 марта 2026 г.\n\n## Введение\n\nVoyager (\"мы\", \"наш\" или \"нас\") стремится защищать вашу конфиденциальность. Эта Политика конфиденциальности объясняет, как наше расширение браузера собирает, использует и защищает вашу информацию.\n\n## Сбор и использование данных\n\n**Мы не собираем никакой личной информации.**\n\nVoyager работает полностью в вашем браузере. Все данные, генерируемые или управляемые расширением (такие как папки, шаблоны промптов, избранные сообщения и настройки), хранятся:\n\n1. Локально на вашем устройстве (`chrome.storage.local`)\n2. В синхронизированном хранилище вашего браузера (`chrome.storage.sync`), если доступно, для синхронизации настроек между вашими устройствами.\n\nМы не имеем доступа к вашим личным данным, истории чатов или любой другой частной информации. Мы не отслеживаем вашу историю просмотра.\n\n## Синхронизация с Google Диском (опционально)\n\nЕсли вы явно включите функцию синхронизации с Google Диском, расширение использует Chrome Identity API для получения токена OAuth2 (только с областью `drive.file`) для резервного копирования ваших папок и промптов на **ваш собственный Google Диск**. Эта передача происходит непосредственно между вашим браузером и серверами Google. Мы не имеем доступа к этим данным, и они никогда не отправляются на какой-либо сервер, который мы эксплуатируем.\n\n## Разрешения\n\nРасширение запрашивает минимально необходимые разрешения для функционирования:\n\n- **Storage (Хранилище)**: Для сохранения ваших предпочтений, папок, промптов, избранных сообщений и настроек интерфейса локально и между устройствами.\n- **Identity (Идентификация)**: Для аутентификации Google при использовании опциональной функции синхронизации с Google Диском. Используется только когда вы явно включаете облачную синхронизацию.\n- **Scripting (Скрипты)**: Для динамического внедрения контентных скриптов на страницы Gemini и на пользовательские веб-сайты, указанные пользователем для функции Менеджера промптов. Внедряются только скрипты, входящие в комплект расширения — удалённый код не загружается и не выполняется.\n- **Host Permissions (Разрешения хоста)** (gemini.google.com, aistudio.google.com и др.): Для внедрения контентных скриптов, улучшающих интерфейс Gemini функциями, такими как папки, экспорт, временная шкала и цитирование ответов. Дополнительные домены Google (googleapis.com, accounts.google.com) необходимы для аутентификации синхронизации с Google Диском.\n- **Optional Host Permissions (Опциональные разрешения хоста)** (все URL): Запрашиваются только во время выполнения, когда вы явно добавляете пользовательские веб-сайты для Менеджера промптов. Никогда не активируются без вашего действия.\n\n## Сторонние сервисы\n\nVoyager не передает никакие данные сторонним сервисам, рекламодателям или поставщикам аналитики.\n\n## Изменения в этой политике\n\nМы можем время от времени обновлять нашу Политику конфиденциальности. Мы уведомим вас о любых изменениях, опубликовав новую Политику конфиденциальности на этой странице.\n\n## Свяжитесь с нами\n\nЕсли у вас есть какие-либо вопросы об этой Политике конфиденциальности, пожалуйста, свяжитесь с нами через наш [Репозиторий GitHub](https://github.com/Nagi-ovo/gemini-voyager).\n"
  },
  {
    "path": "docs/zh_TW/guide/batch-delete.md",
    "content": "# 批次刪除\n\n一次性刪除多個對話，告別逐個刪除的繁瑣操作。\n\n## 功能介紹\n\n- **多選模式**：長按任意對話進入多選模式，可勾選多個要刪除的對話。\n- **一鍵清理**：選中後點擊刪除按鈕，批次刪除所有選中的對話。\n- **進度回饋**：刪除過程中顯示即時進度，讓你了解目前狀態。\n- **安全確認**：刪除前會彈出確認對話框，防止誤操作。\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/batch-delete.png\" alt=\"批次刪除\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## 如何使用\n\n1. 在側邊欄的對話列表中，**長按**任意一個對話項。\n2. 進入多選模式後，對話項左側會出現核取方塊。\n3. 勾選你想要刪除的對話（一次最多可選 50 個）。\n4. 點擊出現的 **刪除按鈕**。\n5. 在**資料夾列表上方**出現的紅色確認區域中點擊「確定」，即可開始批次刪除。\n\n::: tip 提示\n刪除確認面板會直接覆蓋在資料夾區域上方，以免遮擋對話列表。批次刪除操作無法撤銷，請謹慎操作。\n:::\n"
  },
  {
    "path": "docs/zh_TW/guide/cloud-sync.md",
    "content": "# 雲同步\n\n將您的資料夾、靈感庫（Prompts）等數據同步到 Google Drive，在不同設備間保持一致。\n\n## 功能特點\n\n- **多端同步**：利用 Google Drive 雲端存儲，在多台電腦上同步您的配置。\n- **數據安全**：數據存儲在您自己的 Google Drive 空間中，不經過第三方服務器，確保隱私安全。\n- **靈活同步**：支持手動上傳、下載合併數據。\n\n::: info\n**下個版本預告**：下一個版本將支持同步星標對話。\n:::\n\n## 如何使用\n\n1. 在 Gemini™ 頁面點擊右下角的擴展圖標，打開設置面板。\n2. 找到 **雲同步** 區域。\n3. 點擊 **使用 Google 登錄** 並完成授權。\n4. 授權成功後，點擊 **上傳到雲端** 將本地數據同步到雲端，或點擊 **從雲端下載合併** 將雲端數據同步到本地。\n\n### 💡 極速同步\n\n最簡單的方法是在左側側邊欄的**資料夾區域頂部**，直接點擊「上傳到雲端」或「下載並合併」按鈕。\n\n<img src=\"/assets/cloud-sync.png\" alt=\"雲同步快捷按鈕\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n::: warning\n**安全建議：雙重保護**  \n雖然雲同步提供了極大的便利，但為了您的數據萬無一失，我們强烈建議您定期通過**本地文件方式**手動備份核心數據。\n\n1. **匯出全量配置**：在設置面板底部的「備份與恢復」中匯出包含所有設置、資料夾和提示詞的完整備份。\n   <img src=\"/assets/manual-export-all.png\" alt=\"匯出全量配置\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. **匯出所有資料夾**：在設置面板的「資料夾」區域點擊「匯出」，僅備份所有資料夾結構及對話，不包含提示詞。\n   <img src=\"/assets/manual-folder-export.png\" alt=\"匯出所有資料夾\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n   :::\n"
  },
  {
    "path": "docs/zh_TW/guide/community.md",
    "content": "# 交流與回饋\n\n我們非常重視每一位使用者的聲音。無論你是遇到了 Bug、有功能建議，還是想分享你構建的指令寶庫，都可以通過以下方式與我們聯繫。\n\n## 📢 關注動態\n\n關注我們的 X (Twitter) 帳號，獲取最新開發進展。\n\n- **新版本發布**：第一時間了解更新內容。\n- **功能預告**：提前知曉即將到來的功能。\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://x.com/Nag1ovo/status/2012609459663634589\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/badge/關注-𝕏-000000?style=for-the-badge&logo=x&logoColor=white\" alt=\"關注 X\">\n  </a>\n</div>\n\n## 💬 Discord 社群\n\n加入我們的 Discord 伺服器，與其他 Voyager 交流心得！\n\n- **即時聊天**：與其他使用者和開發者直接對話。\n- **提示詞分享**：看看大家都在用什麼樣的 Prompts。\n- **開發進展**：第一時間獲取新功能的開發動態。\n\n<div style=\"margin: 2rem 0;\">\n  <a href=\"https://discord.gg/TEUFxdMbGb\" target=\"_blank\" style=\"text-decoration: none;\">\n    <img src=\"https://img.shields.io/discord/1463273957120675973?style=for-the-badge&logo=discord&logoColor=white&label=加入%20Discord%20社群\" alt=\"Discord\">\n  </a>\n</div>\n\n## 🐙 GitHub Issues\n\n如果你發現了程式錯誤（Bug）或有明確的功能需求（Feature Request），建議在 GitHub 上提交 Issue：\n\n- [提交 Bug 報告](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=bug_report.yml)\n- [提交功能建議](https://github.com/Nagi-ovo/gemini-voyager/issues/new?template=feature_request.yml)\n\n感謝你對 Voyager 的支持！❤️\n"
  },
  {
    "path": "docs/zh_TW/guide/context-sync.md",
    "content": "# 記憶搬運：上下文同步（實驗性）\n\n**不同次元，絲滑共享**\n\n在網頁端推演邏輯，在 IDE 裡落地程式碼。 Voyager 打通次元壁，讓您的 IDE 瞬間擁有網頁端的「思維過程」。\n\n## 告別反覆橫跳\n\n開發者最煩的事：在網頁上聊透了方案，回到 VS Code/Trae/Cursor 卻要像面對陌生人一樣重新解釋需求。 由於額度和回應速度，網頁端是「大腦」，IDE 是「手」。 Voyager 讓它們共用一個靈魂。\n\n## 極簡三步，同頻呼吸\n\n1. **安裝並喚醒橋接器**：\n   安裝 **CoBridge** 插件。它是連接網頁與本地 IDE 的核心橋梁。\n   - **[前往插件市場安裝](https://open-vsx.org/extension/windfall/co-bridge)**\n\n   ![CoBridge 擴充功能](/assets/CoBridge-extension.png)\n\n   安裝完成後，**打開任意工作目錄**，點擊右側圖示並啟動伺服器。\n   ![CoBridge 伺服器開啟](/assets/CoBridge-on.png)\n\n2. **握手對接**：\n   - 在 Voyager 設定中開啟「上下文同步」。\n   - 對齊埠號。看到「IDE Online」，說明它們已經連上了。\n\n   ![上下文同步面板](/assets/context-sync-console.png)\n\n3. **一鍵同步**：點擊 **「Sync to IDE」**。無論是複雜的**數據表格**，還是直觀的**參考圖片**，都能瞬間瞬移到您的 IDE 中。\n\n   ![同步完成](/assets/sync-done.png)\n\n## 落地生根\n\n同步完成後，您的 IDE 工作目錄會多出一個 `.cobridge/AI_CONTEXT.md`。 無論是 Trae、Cursor 還是 Copilot，它們會透過各自的 Rule 文件自動讀取這份「記憶」。\n\n```\nyour-project/\n├── .cobridge/\n│   ├── images/\n│   │   ├── context_img_1_1.png\n│   │   └── context_img_1_2.png\n│   └── AI_CONTEXT.md\n├── .github/\n│   └── copilot-instructions.md\n├── .gitignore\n├── .traerules\n└── .cursorrules\n```\n\n## 它的原則\n\n- **零污染**：CoBridge 自動操作 `.gitignore`，不會把您這些私密對話推到 Git 倉庫裡。\n- **懂行**：全 Markdown 格式，IDE 裡的 AI 讀起來就像讀說明書一樣順暢。\n- **小貼士**：如果對話太久遠，先用【時間線】向上劃一下，讓網頁把記憶「想起來」，再同步效果更佳。\n\n---\n\n## 立刻起航\n\n**思維已在雲端就緒，現在，讓他在本地落地生根。**\n\n- **[安裝 CoBridge 插件](https://open-vsx.org/extension/windfall/co-bridge)**：找到您的次元傳送門，一鍵開啟「同頻呼吸」。\n- **[訪問 GitHub 倉庫](https://github.com/Winddfall/CoBridge)**：深入了解 CoBridge 的底層邏輯，或者為這個「同步靈魂」的項目點個 Star。\n\n> **大模型從此不再失憶，上手即戰。**\n"
  },
  {
    "path": "docs/zh_TW/guide/deep-research.md",
    "content": "# Deep Research 匯出\n\n匯出 Deep Research 生成的最終報告，或將其完整的「思考」過程保存為 Markdown 文件。\n\n## 1. 報告匯出 (PDF / 圖片)\n\nDeep Research 生成的報告支持匯出為格式精美的 PDF 或方便分享的單張圖片（同时也支持匯出為 Markdown 和 JSON 格式）。\n\n![報告匯出](/assets/deep-research-report-export.png)\n\n## 2. 思考過程匯出 (Markdown)\n\n除了最終報告，您還可以將對話中的完整「思考」內容一鍵匯出。\n\n### 功能特性\n\n- **一鍵匯出**: 點擊分享和匯出按鈕即可下載\n- **結構化格式**: 按原始順序保留思考階段、思考條目和研究網站\n- **雙語標題**: Markdown 文件包含英文和當前語言的雙語章節標題\n- **自動命名**: 文件使用時間戳命名,便於整理 (例如:`deep-research-thinking-20240128-153045.md`)\n\n### 使用方法\n\n1. 在 Gemini™ 上打開一個 Deep Research 對話\n2. 點擊對話的**分享和匯出**按鈕\n3. 選擇 \"下載 Thinking 內容\" (Download thinking content)\n4. Markdown 文件將自動下載\n\n![Deep Research 思考內容匯出](/assets/deepresearch_download_thinking.png)\n\n### 匯出文件格式\n\n匯出的 Markdown 文件包含:\n\n- **標題**: 對話標題\n- **元數據**: 匯出時間和思考階段總數\n- **思考階段**: 每個階段包含:\n  - 思考條目 (包含標題和內容)\n  - 研究網站 (包含連結和標題)\n\n#### 示例結構\n\n```markdown\n# Deep Research 對話標題\n\n**導出時間 / Exported At:** 2025-12-28 17:25:35\n**總思考階段 / Total Phases:** 3\n\n---\n\n## 思考階段 1 / Thinking Phase 1\n\n### 思考標題 1\n\n思考內容...\n\n### 思考標題 2\n\n思考內容...\n\n#### 研究網站 / Researched Websites\n\n- [domain.com](https://example.com) - 頁面標題\n- [another.com](https://another.com) - 另一個標題\n\n---\n\n## 思考階段 2 / Thinking Phase 2\n\n...\n```\n\n## 隱私保護\n\n所有提取和格式化操作都 100% 在瀏覽器本地完成。不會向外部伺服器發送任何數據。\n"
  },
  {
    "path": "docs/zh_TW/guide/default-model.md",
    "content": "# 預設模型\n\n::: info\n**注意**：該功能僅在 1.1.9 及後續版本中支援。\n:::\n\n為 Gemini™ 添加設置預設模型的功能，避免每次開啟新對話時都需要手動切換。\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/default-model.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n## 功能特點\n\n- **互動式設置**：在 Gemini 的模型選擇選單中直接注入「星標」按鈕。\n- **自動切換**：開啟新對話時，外掛程式會自動為您切換到預設的模型。\n- **持久化保存**：您的偏好會被保存，跨設備同步。\n- **優化體驗**：針對單頁應用（SPA）優化，無論是點擊「新對話」按鈕、快捷鍵還是直接訪問首頁，均能準確觸發。\n\n## 如何使用\n\n1. 點擊 Gemini 輸入框上方的**模型選擇器**。\n2. 滑鼠懸停在您想要設為預設的模型上，點擊出現的**空心星標**。\n3. 星標變為**實心**後，該模型即被設為預設。\n4. 下次訪問首頁或發起新對話時，系統會自動為您選中該模型。\n5. 如需取消，再次點擊實心星標即可。\n"
  },
  {
    "path": "docs/zh_TW/guide/export.md",
    "content": "# 徹底自由\n\n數據被鎖死，是最壞的體驗。\n我們的信條很簡單：你創造的，就是你的。\n\n## 帶走一切\n\nVoyager 幫你把數據從雲端拽回手心。\n\n### 格式隨你選\n\n- **Markdown**：給 Obsidian 或 Notion 用。乾淨清爽。（Safari 使用者注意：由於瀏覽器限制無法提取圖片，建議使用 PDF 匯出）\n- **PDF**：發給別人或列印。排版精美，圖文並茂。\n- **JSON**：給開發者。原始數據，怎麼玩隨你。\n\n### 怎麼導\n\n1. 滑鼠懸停在 Gemini Logo 上，即可看到出現的 **匯出圖示**。\n2. 選格式。\n3. 拿走。\n\n你的數據，聽你的。\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>1. 懸停 Logo</b></p>\n    <img src=\"/assets/gemini-export-guide-1.png\" alt=\"導出指南步驟 1\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>2. 選格式</b></p>\n    <img src=\"/assets/gemini-export-guide-2.png\" alt=\"導出指南步驟 2\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n### Safari PDF 匯出特別說明\n\n在 Safari 上匯出 PDF 步驟略有不同（需手動列印）：\n\n1. 點擊 **匯出** 按鈕，選擇 PDF 格式。\n2. **等待一秒左右**（讓頁面準備好列印樣式）。\n3. 按 `Command + P` 呼叫列印介面。\n4. 在列印介面中選擇 **\"Save to PDF\"**（或 \"儲存為 PDF\"）。\n\n<img src=\"/assets/safari-export-pdf.png\" alt=\"Safari Export PDF\" style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 20px;\"/>\n"
  },
  {
    "path": "docs/zh_TW/guide/folders.md",
    "content": "# 資料夾，本該如此\n\n整理 AI 聊天記錄，以前怎麼那麼難？\n我們修好了。給你的思緒，裝個檔案系統。\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap; margin-bottom: 40px;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>Gemini™</b></p>\n    <img src=\"/assets/gemini-folders.png\" alt=\"Gemini 資料夾\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>AI Studio</b></p>\n    <img src=\"/assets/aistudio-folders.png\" alt=\"AI Studio 資料夾\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n\n## 整理的直覺\n\n手感對了，一切都對了。\n\n- **拖拽**：抓起來，扔進去。真實物理回饋。\n- **套娃**：大項目套小項目。無限層級，隨你怎麼。\n- **間距**：自由調整側邊欄密度，從緊湊到寬鬆。\n  > _注：Mac Safari 上的調整可能不是即時的，重新整理頁面即可生效。_\n- **同步**：電腦上理好，筆記本上就能用。\n\n## 絕招\n\n- **多選**：長按對話項進入多選模式，批次操作，一次搞定。\n- **改名**：雙擊資料夾，直接改。\n- **識圖**：代碼、寫作、閒聊... 我們自動識別 Gem 類型，配上圖標。你只管用，剩下的交給我們。\n\n## 平台特性差異\n\n### 通用功能\n\n- **基礎管理**：拖拽排序、重命名、多選操作。\n- **智能識別**：自動識別對話類型並匹配圖標。\n- **多級目錄**：支持資料夾嵌套，結構更深邃。\n- **AI Studio 適配**：上述進階功能即將支持 AI Studio。\n- **Google Drive 同步**：支持將資料夾結構同步到 Google Drive。\n\n### Gemini 專屬增強\n\n#### 自定義顏色\n\n點擊資料夾圖標自定義顏色。內置 7 種默認配色，亦支持通過調色盤選取你的專屬色彩。\n\n<img src=\"/assets/folder-color.png\" alt=\"資料夾配色\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### 帳號隔離\n\n點擊頂欄的「人像」圖標，即可自動屏蔽其他 Google 帳號的對話。在多帳號共用瀏覽器時，讓你的工作區保持純淨。\n\n<img src=\"/assets/current-user-only.png\" alt=\"帳號隔離模式\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n\n#### AI 自動整理\n\n聊天太多，懶得分類？讓 Gemini 幫你動腦。\n\n一鍵複製你現有的對話結構，貼進 Gemini，它就會生成一份可以直接匯入的資料夾方案——秒速整理。\n\n**第一步：複製你的對話結構**\n\n在擴充套件彈窗的資料夾區塊底部，點擊 **AI 整理** 按鈕。它會自動收集所有未歸類的對話和現有資料夾結構，生成提示詞並複製到剪貼簿。\n\n<img src=\"/assets/ai-auto-folder.png\" alt=\"AI Organize Button\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n\n**第二步：讓 Gemini 來分類**\n\n將剪貼簿內容貼進 Gemini 對話。它會分析你的聊天標題，然後輸出一份 JSON 資料夾方案。\n\n**第三步：匯入結果**\n\n在資料夾面板選單中點擊 **匯入資料夾**，選擇 **或直接貼上 JSON**，貼上 Gemini 回傳的 JSON，然後點擊 **匯入**。\n\n<div style=\"display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; margin-bottom: 24px;\">\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-2.png\" alt=\"Import Menu\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 240px;\"/>\n  </div>\n  <div style=\"text-align: center;\">\n    <img src=\"/assets/ai-auto-folder-3.png\" alt=\"Paste JSON Import\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 400px;\"/>\n  </div>\n</div>\n\n- **增量合併**：預設採用「合併」策略——只新增資料夾和分配，絕不破壞你現有的組織結構。\n- **多語言支援**：提示詞會自動使用你設定的語言，資料夾名稱也會以該語言生成。\n\n### AI Studio 專屬增強\n\n- **側邊欄調節**：鼠標拖拽邊緣，自由調整側邊欄寬度。\n- **庫拖拽支持**：支持直接從 Library 列表中拖拽項目到資料夾。\n"
  },
  {
    "path": "docs/zh_TW/guide/fork.md",
    "content": "# 對話分支 (實驗性)\n\n思維不應是一條單行道。在複雜的探索中，我們常常需要回到某個關鍵節點，嘗試不同的可能性。\n\nVoyager 帶來的 **對話分支** 功能，讓你能夠輕鬆發散思維，探索對話的平行宇宙。\n\n## 功能介紹\n\n> **⚠️ 提示**：該功能目前處於實驗階段。你需要先點擊瀏覽器擴充功能圖示打開設定彈窗，並開啟 **啟用對話分支** 開關。\n\n在任何你想要發散思緒的時刻，只需將滑鼠懸停在你的提問上，點擊 **對話分支** 按鈕：\n\n![對話分支](/assets/branching.png)\n\nVoyager 會立刻截取從對話開頭到該節點的全部上下文，並為你 **開啟一段全新的對話**。\n\n你可以在這個新分支中盡情修改提問，嘗試不同的方向，而不必擔心破壞原有的對話歷史。盡情釋放你的創造力與好奇心吧！\n"
  },
  {
    "path": "docs/zh_TW/guide/formula-copy.md",
    "content": "# 公式複製\n\nVoyager 讓數學公式和科學符號的複用變得異常簡單。支持一鍵複製 LaTeX 源碼以及兼容 Microsoft Word 的 MathML 格式。\n\n## 功能介紹\n\n當你要求 Gemini 推導公式或編寫數學表達式時，Gemini 通常會使用 LaTeX 渲染。雖然看起來很美觀，但如果你想將這些公式複製到自己的論文、文檔或編輯器中，往往需要手動提取源碼。\n\nVoyager 為此提供了無縫支持：\n\n1. **自動識別**：Voyager 會自動識別頁面中渲染出的 LaTeX 公式。\n2. **複製按鈕**：當你將滑鼠懸停在公式上時，公式右側會出現複製圖標。\n3. **格式選擇**：點擊複製圖標，你可以選擇：\n   - **Copy LaTeX**: 複製標準的 LaTeX 源碼，適用於 Overleaf、Markdown 編輯器等。\n   - **Copy MathML**: 複製 MathML 源碼，這是最適合直接粘貼到 **Microsoft Word** 中的格式。\n\n![公式複製](/assets/gemini-math-copy.png)\n\n## 特性\n\n- **Word 完美兼容**：通過 MathML 支持，你可以將複雜的 AI 輸出公式直接粘貼進 Word 文檔，保持完美的可編輯公式格式。\n- **上下文保留**：不僅複製公式本身，還保留了公式的數學語境。\n- **極速響應**：完全在本地處理，點擊即得。\n\n## 使用技巧\n\n- **論文寫作**：在 Word 中編寫論文時，讓 Gemini 推導公式，然後使用 MathML 複製並粘貼，省去手動在 Word 公式編輯器中輸入的煩惱。\n- **代碼筆記**：在 Obsidian 或 Notion 中記筆記時，直接複製 LaTeX 源碼即可。\n"
  },
  {
    "path": "docs/zh_TW/guide/getting-started.md",
    "content": "# 歡迎登船\n\n恭喜。你的工作流剛剛升艙了。\nVoyager 不只是工具，它是種習慣。給我 5 分鐘，帶你上手。\n\n## 1. 就位\n\n還沒裝？去 [安裝指南](/zh_TW/guide/installation)。\n裝好了？刷新 Gemini 頁面。變化立竿見影。\n\n## 2. 穿梭\n\n聊個長的。比如聊聊漢字演變史，或者量子力學。\n看右邊。\n**那串點，就是你的導航圖。**\n\n- **指**：瞥一眼那會兒說了啥。\n- **點**：瞬間穿越回去。\n- **按**：長按加星，標記高光時刻。\n\n別再滾輪滾到手酸。思維多快，你就多快。\n\n## 3. 歸檔\n\n看左邊的聊天列表。\n**資料夾來了。**\n\n拎起一個聊天，拖進去，鬆手。\n絲般順滑。嵌套、重命名，隨你心意。把腦子裡的雜亂清空，只留清爽。\n\n## 4. 珍藏\n\n寫了個絕妙的提示詞？別讓它滑走。\n點輸入框裡的 **✨ 圖標**。\n存下來，打個標籤。\n\n下次要用？\n點一下圖標，搜一搜，插入。\n這不是聊天，這是在沉澱你的數位資產。\n\n---\n\n**起飛。**\n去深挖每個功能：\n\n- [玩轉時間軸](/zh_TW/guide/timeline)\n- [精通資料夾](/zh_TW/guide/folders)\n- [管理提示詞](/zh_TW/guide/prompts)\n- [掌握數據](/zh_TW/guide/export)\n"
  },
  {
    "path": "docs/zh_TW/guide/input-collapse.md",
    "content": "# 輸入框摺疊\n\n輸入框為空時自動摺疊，獲得更多閱讀空間。點擊摺疊後的按鈕即可展開輸入。\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/hide-input-area.png\" alt=\"輸入框摺疊\" style=\"max-width: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## 如何使用\n\n1. 當輸入框為空且失去焦點時，會自動摺疊為一個簡潔的膠囊按鈕\n2. 點擊膠囊按鈕即可展開輸入框，開始輸入\n3. 也可以按 <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>I</kbd> 快速展開輸入框\n4. 在設置面板中可以開啟或關閉此功能（默認關閉）\n"
  },
  {
    "path": "docs/zh_TW/guide/installation.md",
    "content": "# 安裝\n\n::: info 新聞\n🍎 **Safari 瀏覽器原生外掛已推出！** 現在支援完全免費且一鍵安裝。\n:::\n\n選一條路。\n\n> ⚠️ 提示詞管理器是唯一支持 Gemini™ 企業版的功能。\n\n## 1. 官方商店（推薦）\n\n最簡單的方式，支持自動更新。\n\n**Chrome / Brave / Opera / Vivaldi：**\n\n[<img src=\"https://img.shields.io/badge/Chrome_應用店-前往下載-4285F4?style=for-the-badge&logo=google-chrome&logoColor=white\" alt=\"從 Chrome 線上應用店安裝\" height=\"40\"/>](https://chromewebstore.google.com/detail/kjdpnimcnfinmilocccippmododhceol?utm_source=github&utm_medium=docs&utm_campaign=organic_growth&utm_content=zh_tw)\n\n::: warning ⚠️ Chrome Web Store 暫時不可用\n由於商標版權問題，插件已正式改名為 **Voyager**，Chrome Web Store 審核仍在進行中，暫時無法使用。詳情見[此帖](https://x.com/Nag1ovo/status/2031561180213313944)。請使用下方 **Edge / Firefox** 或**手動安裝**。\n:::\n\n**Microsoft Edge：**\n\n[<img src=\"https://img.shields.io/badge/Microsoft_Edge-前往下載-0078D7?style=for-the-badge&logo=microsoft-edge&logoColor=white\" alt=\"從 Microsoft Edge Add-ons 安裝\" height=\"40\"/>](https://microsoftedge.microsoft.com/addons/detail/gemini-voyager/gibmkggjijalcjinbdhcpklodjkhhlne)\n\n**Firefox：**\n\n[<img src=\"https://img.shields.io/badge/Firefox_Add--ons-前往下載-FF7139?style=for-the-badge&logo=firefox&logoColor=white\" alt=\"從 Firefox Add-ons 安裝\" height=\"40\"/>](https://addons.mozilla.org/firefox/addon/gemini-voyager/)\n\n## 2. 手動（搶鮮版）\n\n應用店審核慢。如果你追求最新功能，走這條路。\n\n**Chrome / Edge / Brave / Opera：**\n\n1. 去 [GitHub Releases](https://github.com/Nagi-ovo/gemini-voyager/releases) 下最新的 `gemini-voyager-chrome-vX.Y.Z.zip`。\n2. 解壓。\n3. 打開擴充功能頁 (`chrome://extensions`)。\n4. 開 **開發者模式** (右上角)。\n5. 點 **載入已解壓的擴充功能**，選剛才的資料夾。\n\n**Firefox：**\n\n1. 去 [Releases](https://github.com/Nagi-ovo/gemini-voyager/releases) 下最新的 `gemini-voyager-firefox-vX.Y.Z.xpi`。\n2. 打開擴充功能管理頁 (`about:addons`)。\n3. 把下載的 `.xpi` 文件拖進去安裝（或者點右上角齒輪 ⚙️ -> **從檔案安裝附加組件**）。\n\n> 💡 XPI 文件已獲 Mozilla 官方簽名，可在所有 Firefox 版本中永久安裝。\n\n## 3. Safari (macOS)\n\nSafari 現在支持直接分發！下載預簽名的應用：\n\n1. 下載 <SafariDownloadLink>最新 Safari 版本 (.dmg)</SafariDownloadLink>。\n2. 雙擊打開後按提示安裝應用。\n3. 雙擊啟動應用。\n4. 在 **Safari 設置 > 擴充功能** 中啟用。\n\n> 💡 Safari 版本現已直接簽名分發——不再需要 Xcode 轉換！\n>\n> ⚠️ **已知限制**：由於 Safari 特性，(a) 水印去除 (b) 圖片導出（推薦用 PDF）暫不支持。\n\n---\n\n_想貢獻代碼？開發者請移步 [貢獻指南](https://github.com/Nagi-ovo/gemini-voyager/blob/main/.github/CONTRIBUTING.md)。_\n"
  },
  {
    "path": "docs/zh_TW/guide/markdown-fix.md",
    "content": "# Markdown 渲染修復\n\nGemini™ 的網頁介面有時會在文本中插入 HTML 元素（例如引用來源或高亮標記），這可能會破壞 Markdown 的加粗語法（`**text**`），導致文本無法正確加粗顯示。\n\nVoyager 內置了自動修復功能，能夠智能識別並修復這些斷裂的加粗標籤，確保文檔渲染的整潔與準確。\n\n> [!INFO]\n> 此功能為自動啟用，無需額外配置。\n"
  },
  {
    "path": "docs/zh_TW/guide/mermaid.md",
    "content": "# Mermaid 圖表渲染\n\n自動將 Mermaid 代碼渲染為可視化圖表。\n\n## 功能介紹\n\n當 Gemini™ 輸出 Mermaid 代碼塊時（如流程圖、時序圖、甘特圖等），Voyager 會自動檢測並渲染為交互式圖表。\n\n### 主要特性\n\n- **自動檢測**：支持 `graph`、`flowchart`、`sequenceDiagram`、`gantt`、`pie`、`classDiagram` 等所有主流 Mermaid 圖表類型\n- **一鍵切換**：通過按鈕在渲染圖表和原始碼之間自由切換\n- **全屏查看**：點擊圖表進入全屏模式，支持滾輪縮放和拖拽平移\n- **深色模式**：自動適配頁面主題\n\n## 使用方法\n\n1. 讓 Gemini 生成任意 Mermaid 圖表代碼\n2. 代碼塊會自動替換為渲染後的圖表\n3. 點擊 **</> Code** 按鈕查看原始代碼\n4. 點擊 **📊 Diagram** 按鈕切回圖表視圖\n5. 點擊圖表區域進入全屏查看\n\n## 全屏模式操作\n\n- **滾輪**：縮放圖表\n- **拖拽**：移動圖表位置\n- **+/-**：工具欄縮放按鈕\n- **⊙**：重置視圖\n- **✕ / ESC**：關閉全屏\n\n## 相容性與故障排除\n\n::: warning 說明\n\n- **Firefox 限制**：由於環境限制，Firefox 使用 9.2.2 版本，暫不支援 **Timeline**、**Sankey** 等新特性。\n- **語法錯誤**：渲染失敗通常是因為 Gemini 生成的代碼有語法錯誤。我們正在收集 Bad Case，後續將透過補丁自動修復常見的生成錯誤。\n  :::\n\n<div align=\"center\">\n  <img src=\"/assets/mermaid-preview.png\" alt=\"Mermaid 圖表渲染\" style=\"max-width: 100%; border-radius: 8px;\"/>\n</div>\n"
  },
  {
    "path": "docs/zh_TW/guide/nanobanana.md",
    "content": "# NanoBanana 選項\n\n::: warning 瀏覽器相容性\n目前 **NanoBanana** 去浮水印功能由於瀏覽器 API 限制，**暫不支持 Safari 瀏覽器**。如果您需要使用此功能，建議使用 **Chrome** 或 **Firefox**。\n\nSafari 用戶可以將下載的圖片上傳到 [banana.ovo.re](https://banana.ovo.re/) 等工具網站進行手動去除（但由於 Gemini™ 圖片尺寸的多樣性，不能保證每張圖片都能成功還原）。\n:::\n\n**AI 圖片，本該純淨。**\n\nGemini 生成的圖片默認帶有可見的水印。雖然這是出於安全考慮，但在某些創作場景下，你可能需要一張完全乾淨的底稿。\n\n## 無損還原\n\nNanoBanana 採用的是 **反向 Alpha 混合算法 (Reverse Alpha Blending)**。\n\n- **非 AI 重繪**：傳統的去水印往往使用 AI 塗抹，會破壞圖片細節。\n- **像素級精度**：我們通過數學計算，將疊加在像素上的水印透明層精確移除，還原出 100% 原始的像素點。\n- **零質量損失**：處理前後的圖片在非水印區域完全一致。\n\n## 如何使用\n\n1. **開啟功能**：在 Voyager 設置面板最後方找到 「NanoBanana 選項」，開啟 「去除 NanoBanana 水印」。\n2. **自動觸發**：此後你生成的每一張圖片，我們都會在後台自動完成去水印處理。\n3. **直接下載**：\n   - 懸停在處理後的圖片上，你會看到一個 🍌 按鈕。\n   - **🍌 按鈕已完全替代**了原生的下載按鈕，點擊即可直接下載 100% 無水印的圖片。\n\n<div style=\"text-align: center; margin-top: 30px;\">\n  <img src=\"/assets/nanobanana.png\" alt=\"NanoBanana 示例\" style=\"border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); max-width: 100%;\"/>\n</div>\n\n## 特別鳴謝\n\n本功能基於 [journey-ad (Jad)](https://github.com/journey-ad) 開發的 [gemini-watermark-remover](https://github.com/journey-ad/gemini-watermark-remover) 項目。該項目是 [allenk](https://github.com/allenk) 開發的 [GeminiWatermarkTool C++ 版本](https://github.com/allenk/GeminiWatermarkTool) 的 JavaScript 移植版。感謝原作者們對開源社區的貢獻。🧡\n\n## 隱私與安全\n\n所有的去水印處理均在你的 **瀏覽器本地** 完成。圖片不會被上傳到任何第三方伺服器，保護你的隱私和創作安全。\n"
  },
  {
    "path": "docs/zh_TW/guide/prevent-auto-scroll.md",
    "content": "# 防自動跳轉\n\n在查看過往對話時，如果您輸入了新問題並按下 Enter 鍵，Gemini™ 預設會將頁面強制滾動到最底部以顯示最新生成的回答。這可能會打斷您的閱讀體驗。\n\n**防自動跳轉** 功能可以攔截這種不必要的滾動行為：\n\n- 當您向上滾動查看歷史紀錄時，系統會自動阻止頁面跳回底部。\n- 此功能在擴充功能的「時間軸選項」設定中預設**關閉**，您可以手動前往擴充功能彈出視窗開啟。\n\n## 開啟方法\n\n1. 點擊瀏覽器工具列的 Voyager 擴充功能圖示開啟彈出視窗。\n2. 找到「時間軸選項（Timeline Options）」區域。\n3. 開啟「防自動跳轉（Prevent Auto Scroll）」開關即可生效。\n"
  },
  {
    "path": "docs/zh_TW/guide/prompts.md",
    "content": "# 你的數位資產：提示詞庫\n\n磨了半天寫出個神級 Prompt，幫大忙了。\n用完就丟？\n不，存起來。\n\n## 指令寶庫\n\n這是你的指令寶庫。\n\n### 1. 捕獲\n\n寫出好東西了？點輸入框旁邊的 **浮窗圖標**。\n存入庫中，落袋為安。\n\n### 2. 歸類\n\n打上 `#代碼`、`#郵件`、`#學術` 的標籤。\n工具要趁手，也要整潔。\n\n### 3. 調遣\n\n下次要用，別再重敲。\n打開庫，搜標籤，點一下插入。\n一鍵調用，效率翻倍。\n\n![提示詞管理器](/assets/gemini-prompt-manager.png)\n\n## 任何網站皆可用\n\n提示詞管理器現在可以在您選擇的任何網站上使用，不僅限於 Gemini™ 和 AI Studio。\n\n### 如何啟用\n\n1. 點擊瀏覽器擴充功能欄的 Voyager 圖標。\n2. 滾動到 **提示詞管理器** 部分。\n3. 輸入網站 URL（例如：`chatgpt.com` 或 `claude.ai`）。\n4. 點擊 **添加網站** 並授予權限。\n5. **刷新目標網頁**，即可看到懸浮球。\n\n### 常見 AI 網站示例\n\n- `chatgpt.com` - ChatGPT\n- `claude.ai` - Claude\n- `copilot.microsoft.com` - Microsoft Copilot\n- `poe.com` - Poe\n\n::: tip\n在自定義網站上，**僅**激活提示詞管理器功能。時間線、資料夾等其他功能是專為 Gemini 設計的，不會加載。\n:::\n"
  },
  {
    "path": "docs/zh_TW/guide/quote-reply.md",
    "content": "# 引用回覆\n\nVoyager 提供了便捷的「引用回覆」功能，讓針對特定內容的回覆更加精準高效。\n\n## 功能介紹\n\n在日常對話中，我們經常需要針對 AI 輸出某一段具體內容進行追問或反駁。傳統的做法是複製那段話，然後在輸入框裡手打 `> ` 符號，非常繁瑣。\n\nVoyager 簡化了這一流程：\n\n1. **選中即引**：在對話頁面（無論是你的提問還是 Gemini 的回答）中，用滑鼠選中任意一段文字。\n2. **懸浮按鈕**：選中文字附近會自動浮現一個「引用回覆」按鈕。\n3. **一鍵插入**：點擊按鈕，選中的文字會自動以標準的 Markdown 引用格式（`> 內容`）插入到你的輸入框中。\n\n![引用回覆](/assets/quote-reply.png)\n\n## 特性\n\n- **上下文感知**：智能識別對話內容，避免在無關區域（如輸入框本身）誤觸發。\n- **標準格式**：使用通用的 Markdown 語法，Gemini 可以完美理解這種引用結構，從而做出更精準的回應。\n- **多行支持**：如果選中了多行文本，Voyager 會自動為每一行添加引用符號，保持格式整潔。\n\n## 使用技巧\n\n- **追問細節**：選中 Gemini 回答中不清楚的某個概念，點擊引用，然後輸入「請詳細解釋一下這個概念」。\n- **糾正錯誤**：選中回答中錯誤的代碼或事實，引用後指出「這裡不對，應該是...」。\n"
  },
  {
    "path": "docs/zh_TW/guide/recents-hider.md",
    "content": "# 隱藏最近項目和 Gem\n\n::: info\n**注意**：該功能僅在 1.1.9 及後續版本中支援。\n:::\n\n為 Gemini™ 首頁的「最近保存」部分添加一個優雅的切換開關，讓界面更加簡潔。現在也支持隱藏側邊欄的 Gems 列表！\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px; flex-wrap: wrap;\">\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>隱藏最近保存</b></p>\n    <video src=\"/assets/hide-my-stuff.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n  <div style=\"flex: 1; min-width: 300px; text-align: center;\">\n    <p><b>隱藏 Gems 列表</b></p>\n    <video src=\"/assets/hide-gem-list.mp4\" autoplay loop muted playsinline style=\"width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n  </div>\n</div>\n\n## 功能特點\n\n- **上下文切換**：僅在滑鼠懸停在「最近」區域時才顯示微妙的隱藏按鈕。\n- **極簡狀態**：隱藏後，該區域會被底部的極簡「窺視條」取代。\n- **一鍵恢復**：只需懸停在「窺視條」上並點擊即可立即恢復顯示。\n- **隱私保護**：防止在公共场合或他人经过时泄露您最近的活动内容。\n- **持久化**：您的偏好會被保存，並在下次訪問时自动应用。\n\n## 如何使用\n\n1. 懸停在 Gemini 首頁的「最近」區域。\n2. 點擊右上角出現的「隱藏」圖示（劃掉的眼睛）以折疊該區域。\n3. 如需恢復，懸停在該區域底部留下的細線上並點擊。\n"
  },
  {
    "path": "docs/zh_TW/guide/settings.md",
    "content": "# 隨心所欲\n\n默認的已經很好。但我們要的是完美。\n你的地盤，你做主。\n\n## 影院模式\n\n為什麼要透過門縫看世界？\n把聊天框拉大。\n\n- **寬屏**：1400px。寫代碼、看大表，視野全開。\n- **專注**：800px。沉浸閱讀，心無旁儐。\n- **隨意**：拖動滑塊，你覺得多寬舒服，就多寬。\n\n## 掌控\n\n點插件圖標，進控制台。\n\n- **滾動**：自然順滑，還是經典手感？\n- **位置**：時間軸放哪順手，就放哪。\n- **視覺特效**：可切換 `飄雪`、`櫻花`、`雨`，給頁面加一點氛圍感。\n\n## 自訂排序\n\n控制台裡的功能區塊太多，常用的被埋在下面？\n\n滑鼠懸停在任意功能卡片上，右上角會出現 ▲/▼ 按鈕。點一下，卡片就會上移或下移。調整後的順序自動保存，下次打開還是你的佈局。\n\n## 氛圍感\n\nVoyager 不只是在做效率增強，也允許你順手把頁面氣質調對。\n\n- **飄雪**：輕柔雪花緩慢飄落，適合安靜閱讀。\n- **櫻花**：花瓣自然飄散，頁面會更輕盈。\n- **雨**：電影感雨絲與細微水花，整體更有沉浸感。\n- **平滑切換**：關閉特效或切換到另一種效果時，粒子會自然退場，不會突然消失。\n\n<div style=\"display: flex; gap: 20px; margin-top: 20px;\">\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>打開設置</b></p>\n    <img src=\"/assets/gemini-open-settings-guide.png\" alt=\"打開設置指南\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n  <div style=\"flex: 1; text-align: center;\">\n    <p><b>調整視野</b></p>\n    <img src=\"/assets/gemini-chatwidth.png\" alt=\"聊天寬度調整\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n  </div>\n</div>\n"
  },
  {
    "path": "docs/zh_TW/guide/sidebar-auto-hide.md",
    "content": "# 側邊欄自動收起\n\n想要更加沉浸式的對話體驗？\n\n我們提供了**自動收起側邊欄**的功能。開啟後，當您的滑鼠移出側邊欄區域時，它會自動收起；當您將滑鼠移入側邊欄區域時，它會自動展開。\n\n### 示範\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <video src=\"/assets/sidebar-auto-hide.mp4\" autoplay loop muted playsinline style=\"max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"></video>\n</div>\n\n### 如何開啟\n\n1. 打開 Voyager 的設定面板。\n2. 在 **通用設定** 中找到 **側邊欄自動收起** 開關。\n3. 打開開關即可生效。\n\n_注意：此功能目前僅支援 Google Gemini。_\n"
  },
  {
    "path": "docs/zh_TW/guide/sidebar.md",
    "content": "# 側邊欄寬度\n\n資料夾名稱太長，顯示不全？\n或者覺得側邊欄太寬，佔用了寶貴的聊天空間？\n\n現在，您可以自由調整側邊欄的寬度。\n\n## 如何調整\n\n1. 打開 Voyager 的設定面板（點擊瀏覽器右上角的擴充功能圖示）。\n   <img src=\"/assets/extension-instruction.png\" alt=\"如何打開設定面板\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 10px; max-width: 600px;\"/>\n2. 在 **通用設定** 中找到 **側邊欄寬度** 選項。\n3. 拖動滑桿，選擇您覺得最舒適的寬度。\n\n- **窄**：節省空間，專注於對話。\n- **寬**：完整顯示長資料夾名稱，一目瞭然。\n\n## 支援平台\n\n此功能同時支援：\n\n- **Google Gemini**\n- **Google AI Studio**\n\n您的設定會自動儲存，並在下次打開時生效。\n"
  },
  {
    "path": "docs/zh_TW/guide/sponsor.md",
    "content": "# 贊助\n\n> [!NOTE]\n> 如果 Voyager 有幫助，歡迎分享到 X、Facebook、YouTube、Threads、Dcard 等等。每一次分享都能讓更多人看見這個專案，從而改善 Gemini 的使用體驗。謝謝。\n\n維護開源項目主要靠熱情（和咖啡）驅動 ☕\n\n**[Voyager](https://github.com/Nagi-ovo/gemini-voyager)** 是一個完全免費且開源的瀏覽器擴充功能，旨在提升你的 Gemini 使用體驗。如果這個擴充功能幫助你更高效地使用 Gemini，歡迎通過以下方式支持我繼續開發和維護這個項目。\n\n---\n\n## 在線平台\n\n<div class=\"sponsor-badges\">\n  <a href=\"https://www.buymeacoffee.com/Nag1ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black\" alt=\"Buy Me A Coffee\" style=\"height: 40px;\">\n  </a>\n  <a href=\"https://github.com/sponsors/Nagi-ovo\" target=\"_blank\">\n    <img src=\"https://img.shields.io/badge/Sponsor%20me-GitHub-ea4aaa?style=for-the-badge&logo=github&logoColor=white\" alt=\"Sponsor on GitHub\" style=\"height: 40px;\">\n  </a>\n</div>\n\n<a href=\"https://afdian.com/a/nagi-ovo\" target=\"_blank\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo&bg_color=%230d1117&text_color=%23dedbd7&border_color=%232e343d\" />\n    <source media=\"(prefers-color-scheme: light)\" srcset=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" />\n    <img alt=\"愛發電\" src=\"https://afdian-connect.deno.dev/profile.svg?slug=nagi-ovo\" height=\"160\" />\n  </picture>\n</a>\n\n### 🎙️ 特別推薦: Typeless\n\n我非常推薦 **[Typeless (typeless.com)](https://www.typeless.com/?via=gemini-voyager)** 這款 AI 語音輸入工具。在開發 Voyager 的過程中，我將其整合進了日常工作流，極大地節省了我的時間並顯著提升了整體開發效率。\n\n> 🎁 **[點擊我的邀請連結註冊](https://www.typeless.com/?via=gemini-voyager)**（邀請碼 _`gemini-voyager`_）即可獲得 **5 美元免費額度**，同時也能支持本項目的開發。❤️\n\n---\n\n## 掃碼投喂 🍵\n\n<div class=\"qr-container\">\n  <div class=\"qr-item\">\n    <img src=\"/assets/wechat-sponsor.png\" alt=\"微信支付\" />\n    <span>微信支付</span>\n  </div>\n  <div class=\"qr-item\">\n    <img src=\"/assets/alipay-sponsor.jpg\" alt=\"支付寶\" />\n    <span>支付寶</span>\n  </div>\n</div>\n\n<style>\n.sponsor-badges {\n  display: flex;\n  gap: 16px;\n  flex-wrap: wrap;\n  align-items: center;\n  margin: 16px 0;\n}\n\n.sponsor-badges a img {\n  transition: transform 0.2s ease;\n}\n\n.sponsor-badges a:hover img {\n  transform: scale(1.05);\n}\n\n.qr-container {\n  display: flex;\n  gap: 32px;\n  flex-wrap: wrap;\n  margin: 16px 0;\n}\n\n.qr-item {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 8px;\n  padding: 16px;\n  border-radius: 8px;\n  border: 1px solid var(--vp-c-divider);\n  background: var(--vp-c-bg-soft);\n}\n\n.qr-item img {\n  width: 200px;\n  height: 200px;\n  object-fit: contain;\n  border-radius: 4px;\n}\n\n.qr-item span {\n  font-weight: 600;\n  color: var(--vp-c-text-1);\n}\n\n@media (max-width: 640px) {\n  .qr-container {\n    justify-content: center;\n  }\n  \n  .qr-item img {\n    width: 150px;\n    height: 150px;\n  }\n}\n</style>\n\n---\n\n感謝你的支持！你的每一份貢獻都是對我最大的鼓勵 ❤️\n"
  },
  {
    "path": "docs/zh_TW/guide/tab-title.md",
    "content": "# 標籤頁標題同步\n\n自動將瀏覽器標籤頁標題同步為當前 Gemini™ 對話的標題。\n\n## 功能介紹\n\n- **即時同步**：當對話標題發生變化時（例如 AI 生成了新標題或你手動重命名了對話），瀏覽器標籤頁標題不僅僅是 \"Gemini\"，而是會立即更新為具體的對話內容。\n- **多頁面支持**：完美支持普通的對話頁面、Gem 對話以及多帳戶環境。\n- **開關控制**：如果你不喜歡這個功能，可以在設置面板的「通用選項」中隨時關閉。\n\n<div style=\"text-align: center; margin-top: 20px;\">\n  <img src=\"/assets/tab-title.png\" alt=\"標籤頁標題同步\" style=\"max-width: 600px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\"/>\n</div>\n\n## 如何使用\n\n1. 安裝擴充功能後，該功能默認開啟。\n2. 打開任意 Gemini 對話，觀察瀏覽器標籤頁標題，它會自動變為當前的對話標題。\n3. 若要關閉：\n   - 點擊擴充功能圖標打開設置面板。\n   - 找到「通用選項」 (General Options)。\n   - 關閉「同步標籤頁標題」 (Update Tab Title) 開關。\n"
  },
  {
    "path": "docs/zh_TW/guide/timeline.md",
    "content": "# 時間旅行\n\n長對話是災難。上上下下，找不著北。\nVoyager 把對話變成一條線。\n\n## 看見節奏\n\n看螢幕右側。\n每個點都是一句話。那是你對話的脈搏。\n\n## 導航，一步到位\n\n- **瞬移**：點哪去哪，絕不拖泥帶水。\n- **偷看**：滑鼠放上去，不用跳轉也能看內容。\n- **插眼**：長按節點 **加星**。給大腦打個書籤。\n- **層級 (實驗性)**：右鍵點擊節點，設置不同層級（1-3 級）或摺疊子節點。讓深度分支對話一目了然。\n- **快捷鍵**：用鍵盤飛速穿梭。默認 `j`/`k` 上下跳轉，想改就改。\n\n![時間軸導航](/assets/teaser.png)\n\n## 鍵盤，更快\n\n不想用滑鼠？用鍵盤。\n\n**就像在 Gemini 裡開了 Vim Mode。**\n\n### 默認快捷鍵\n\n- `k` - 跳到上一個節點\n- `j` - 跳到下一個節點\n\n### 自定義\n\n打開擴充功能設置，點擊快捷鍵框，按下你想用的鍵。\n任意鍵，任意組合。`n`/`p`？`,`/`.`？隨你。\n\n**流動模式**下連按會排隊播放動畫。\n**跳躍模式**下立即響應，速度拉滿。\n"
  },
  {
    "path": "docs/zh_TW/index.md",
    "content": "---\nlayout: home\n\nhero:\n  name: 'Voyager'\n  text: '終於，它完整了。'\n  tagline: '思維有形，萬物歸位。'\n  image:\n    src: /logo.png\n    alt: Voyager Logo\n  actions:\n    - theme: brand\n      text: 下載安裝\n      link: ./guide/installation\n    - theme: alt\n      text: 開始旅程\n      link: ./guide/getting-started\n\nteaser:\n  title: '重新定義交互。'\n  description: '我們不造擴充功能，我們重塑思考。<br>用 Voyager，不再是人適應介面，而是介面順應心流。'\n  image: '/assets/teaser.png'\n  features:\n    - title: '時間軸'\n      details: '看見對話的脈搏。<br>讓線性的時間，變成可觸摸的空間。'\n    - title: '資料夾'\n      details: '給思想安個家。<br>哪怕是一閃而過的念頭，也值得被鄭重對待。'\n    - title: '掌控權'\n      details: '數據歸你。<br>打破雲端的圍牆，讓知識真正屬於你。'\n\nfeatures:\n  - icon: 🧭\n    title: 時間軸\n    details: 別滾屏，去飛。瞬間抵達思維的任何落點。\n  - icon: 🗂️\n    title: 資料夾\n    details: 告別混沌。原生手感，直覺操作，井井有條。\n  - icon: ✨\n    title: 指令寶庫\n    details: 捕捉靈光。珍藏你的每一次神來之筆。\n  - icon: 💬\n    title: 引用回覆\n    details: 選中即引。上下文精確回覆，溝通更高效。\n  - icon: ↔️\n    title: 對話寬度\n    details: 視野全開。自由調節對話框寬度，程式碼表格完整呈現。\n  - icon: 💾\n    title: 對話導出\n    details: 數據歸你。多種格式一鍵存檔，知識不再流失。\n  - icon: 🌦️\n    title: 視覺特效\n    details: 頁面也有情緒。可在彈窗中切換飄雪、下雨與櫻花花瓣效果。\n  - icon: 🍌\n    title: NanoBanana 水印去除\n    details: 無損去水印。讓 AI 生成的瞬間回歸純淨。\n  - icon: 📐\n    title: 公式複製\n    details: 一鍵複製 LaTeX 和 MathML (Word) 源碼。\n  - icon: 🧜‍♀️\n    title: Mermaid 圖表\n    details: 代碼變圖表。流程圖、時序圖、甘特圖一鍵可視化。\n  - icon: 🏷️\n    title: 標籤頁標題同步\n    details: 一眼即知。自動將標籤頁標題同步為對話標題。\n  - icon: 🔀\n    title: 對話分支 (實驗性)\n    details: 發散思維。在任意節點分叉對話，探索不同可能。\n  - icon: 🗑️\n    title: 批次刪除對話\n    details: 一鍵清理。選中多個對話，批次刪除，告別繁瑣。\n  - icon: ☁️\n    title: 雲同步\n    details: 永遠在線。資料夾與提示詞庫同步至 Google Drive，多裝置無縫銜接。\n  - icon: ⚡️\n    title: 預設模型\n    details: 拒絕重複勞動。新建對話自動切換至你最愛的模型。\n  - icon: 🔬\n    title: Deep Research\n    details: 拆開黑箱。一鍵提取 Deep Research 的思考過程與研究連結。\n---\n\n<div class=\"vp-doc\" style=\"margin: 2rem auto 0; max-width: 780px; padding: 0 16px;\">\n  <div style=\"background: rgba(234, 179, 8, 0.12); border: 1px solid rgba(234, 179, 8, 0.6); border-radius: 8px; padding: 12px 16px;\">\n    <strong>⚠️ 改名公告</strong>：由於商標版權問題，本插件已正式改名為 <strong>Voyager</strong>。但由於 Chrome Web Store 審核速度極慢，七天內未能完成名稱更新審核，暫時無法在 Chrome Web Store 使用。\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 780px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 8px; font-weight: 600; font-size: 1.05em;\">每一次下載，都是信任的刻度</h3>\n  <p style=\"margin: 0 0 16px; opacity: 0.78; font-size: 0.95em;\">來自 Chrome Web Store 與 GitHub 的實時數據。致敬每一位與我們同行的 Voyager。</p>\n  <div style=\"display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;\">\n    <img src=\"https://img.shields.io/github/stars/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Star\">\n    <img src=\"https://img.shields.io/github/forks/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"GitHub Fork\">\n    <img src=\"https://img.shields.io/github/v/release/Nagi-ovo/gemini-voyager?style=flat-square&logo=github\" alt=\"最新版本\">\n    <img src=\"https://img.shields.io/github/downloads/Nagi-ovo/gemini-voyager/total?style=flat-square&logo=github\" alt=\"GitHub 下載量\">\n    <img src=\"https://img.shields.io/chrome-web-store/users/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome 商店用戶數\">\n    <img src=\"https://img.shields.io/chrome-web-store/rating/kjdpnimcnfinmilocccippmododhceol?style=flat-square&logo=google-chrome\" alt=\"Chrome 商店評分\">\n    <img src=\"https://img.shields.io/badge/Edge-✓-0078D7?style=flat-square&logo=microsoft-edge\" alt=\"Edge 商店\">\n    <img src=\"https://img.shields.io/amo/users/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox 商店用戶數\">\n    <img src=\"https://img.shields.io/amo/rating/gemini-voyager?style=flat-square&logo=firefox\" alt=\"Firefox 商店評分\">\n  </div>\n  <div style=\"margin-top: 16px; display: flex; justify-content: center; flex-wrap: wrap; gap: 12px;\">\n    <a href=\"https://trendshift.io/repositories/16094\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/16094\" alt=\"Nagi-ovo%2Fgemini-voyager | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n    <!-- <a href=\"https://www.producthunt.com/products/gemini-voyager?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager - All-in-one Gemini suite: folders, chat export and much more | Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a> -->\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 4rem auto 3rem; max-width: 1000px; padding: 0 16px;\">\n  <h3 style=\"margin: 0 0 24px; font-weight: 600; font-size: 1.2em;\">特別鳴謝</h3>\n  <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">\n    <img src=\"https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/sponsors.svg\" width=\"1000px\" style=\"margin: 0 auto;\" />\n  </a>\n  <p style=\"margin-top: 24px; font-size: 1.05em; opacity: 0.86;\">✨ 我們已在 Product Hunt 上線！歡迎來分享你的想法和反饋。❤️</p>\n  <div style=\"margin-top: 12px; display: flex; justify-content: center;\">\n    <a href=\"https://www.producthunt.com/posts/gemini-voyager\" target=\"_blank\" rel=\"noopener noreferrer\"><img alt=\"Voyager on Product Hunt\" width=\"250\" height=\"54\" src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1064704&amp;theme=light&amp;t=1768842096186\"></a>\n  </div>\n</div>\n\n<div class=\"vp-doc\" style=\"text-align: center; margin: 3.5rem auto 2rem; max-width: 720px; padding: 0 16px;\">\n  <p style=\"font-size: 1.05em; font-weight: 600; opacity: 0.86; margin: 0 0 12px;\">“它不只是工具，更是伴你思維遠航的夥伴。”</p>\n  <a href=\"./guide/getting-started\" style=\"font-weight: 600; text-decoration: none;\">探索更多 →</a>\n</div>\n\n<p align=\"center\">\n  <img src=\"https://count.getloli.com/@gemini-voyager?name=gemini-voyager&theme=rule34&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto\" width=\"400\">\n</p>\n"
  },
  {
    "path": "docs/zh_TW/privacy.md",
    "content": "# 隱私政策\n\n最後更新：2026年3月16日\n\n## 簡介\n\nVoyager（以下簡稱「我們」）致力於保護您的隱私。本隱私政策說明了我們的瀏覽器擴充功能如何收集、使用和保護您的資訊。\n\n## 資料收集與使用\n\n**我們不收集任何個人資訊。**\n\nVoyager 完全在您的瀏覽器本地運作。擴充功能產生或管理的所有資料（如資料夾、提示詞範本、星標訊息和設定）均儲存在：\n\n1. 您的本地裝置上（`chrome.storage.local`）\n2. 您的瀏覽器同步儲存空間中（`chrome.storage.sync`，如可用），以便在您的裝置間同步設定。\n\n我們無法存取您的個人資料、聊天記錄或其他任何隱私資訊。我們也不會追蹤您的瀏覽歷史。\n\n## Google Drive 同步（選用）\n\n如果您主動啟用 Google Drive 同步功能，擴充功能會使用 Chrome Identity API 取得 OAuth2 權杖（僅限 `drive.file` 範圍），將您的資料夾和提示詞備份到**您自己的 Google Drive**。此傳輸直接發生在您的瀏覽器和 Google 伺服器之間。我們無法存取此資料，且絕不會傳送到我們營運的任何伺服器。\n\n## 權限說明\n\n本擴充功能僅申請維持功能所需的最小權限：\n\n- **Storage（儲存）**：用於在本地和跨裝置儲存您的偏好設定、資料夾、提示詞、星標訊息和介面自訂選項。\n- **Identity（身分驗證）**：用於 Google Drive 同步功能的 Google 驗證。僅在您主動啟用雲端同步時使用。\n- **Scripting（腳本注入）**：用於在 Gemini 頁面及使用者指定的自訂網站上動態注入內容腳本（提示詞管理器功能）。僅注入擴充功能自身打包的腳本，不會載入或執行任何遠端程式碼。\n- **Host Permissions（主機權限）**（gemini.google.com、aistudio.google.com 等）：用於注入增強 Gemini 介面的內容腳本，提供資料夾、匯出、時間線、引用回覆等功能。Google 相關網域（googleapis.com、accounts.google.com）用於 Google Drive 同步驗證。\n- **Optional Host Permissions（選用主機權限）**（所有 URL）：僅在您主動新增提示詞管理器的自訂網站時按需請求，不會在未經您操作的情況下啟用。\n\n## 第三方服務\n\nVoyager 不會與任何第三方服務、廣告商或分析提供商共享資料。\n\n## 政策變更\n\n我們可能會不時更新隱私政策。我們將透過在此頁面發佈新的隱私政策來通知您任何變更。\n\n## 聯絡我們\n\n如果您對本隱私政策有任何疑問，請透過我們的 [GitHub 儲存庫](https://github.com/Nagi-ovo/gemini-voyager) 聯絡我們。\n"
  },
  {
    "path": "eslint.config.js",
    "content": "import tsPlugin from '@typescript-eslint/eslint-plugin';\nimport tsParser from '@typescript-eslint/parser';\nimport prettierConfig from 'eslint-config-prettier';\nimport reactPlugin from 'eslint-plugin-react';\nimport reactHooks from 'eslint-plugin-react-hooks';\n\nexport default [\n  // Global ignores\n  {\n    ignores: [\n      'dist_*/**',\n      'node_modules/**',\n      'docs/.vitepress/cache/**',\n      'docs/.vitepress/dist/**',\n      'coverage/**',\n      'gemini-voyager-sync/**',\n      'gemini-voyager-formal/**',\n      '.github/sponsors/**',\n      'public/**',\n      'safari/Models/**',\n      'Gemini Voyager/**',\n    ],\n  },\n\n  // TypeScript/React files\n  {\n    files: ['**/*.{ts,tsx,js,jsx}'],\n    languageOptions: {\n      parser: tsParser,\n      ecmaVersion: 'latest',\n      sourceType: 'module',\n      parserOptions: {\n        ecmaFeatures: {\n          jsx: true,\n        },\n      },\n    },\n    plugins: {\n      '@typescript-eslint': tsPlugin,\n      react: reactPlugin,\n      'react-hooks': reactHooks,\n    },\n    settings: {\n      react: { version: 'detect' },\n    },\n    rules: {\n      // React rules\n      'react/react-in-jsx-scope': 'off',\n      'react/prop-types': 'off',\n\n      // React Hooks rules\n      'react-hooks/rules-of-hooks': 'error',\n      'react-hooks/exhaustive-deps': 'warn',\n\n      // TypeScript rules\n      '@typescript-eslint/no-unused-vars': [\n        'warn',\n        {\n          argsIgnorePattern: '^_',\n          varsIgnorePattern: '^_',\n        },\n      ],\n      '@typescript-eslint/no-explicit-any': 'warn',\n\n      // General best practices\n      'no-console': ['warn', { allow: ['warn', 'error'] }],\n\n      // NOTE: Import ordering is handled by Prettier's @trivago/prettier-plugin-sort-imports\n      // Do NOT add 'import/order' rule here - it will conflict with Prettier!\n    },\n  },\n\n  // Disable all formatting rules that conflict with Prettier\n  // This MUST be the last config to override other rules\n  prettierConfig,\n];\n"
  },
  {
    "path": "manifest.dev.json",
    "content": "{\n  \"action\": {\n    \"default_icon\": \"public/dev-icon-32.png\",\n    \"default_popup\": \"src/pages/popup/index.html\"\n  },\n  \"icons\": {\n    \"128\": \"public/dev-icon-128.png\"\n  },\n  \"web_accessible_resources\": [\n    {\n      \"resources\": [\n        \"contentStyle.css\",\n        \"dev-icon-128.png\",\n        \"dev-icon-32.png\",\n        \"icon-32.png\",\n        \"icon-128.png\",\n        \"fetchInterceptor.js\",\n        \"prevent-auto-scroll.js\",\n        \"assets/*.js\",\n        \"assets/*.png\"\n      ],\n      \"matches\": [\"<all_urls>\"]\n    }\n  ],\n  \"content_scripts\": [\n    {\n      \"matches\": [\n        \"https://gemini.google.com/*\",\n        \"https://business.gemini.google/*\",\n        \"https://aistudio.google.com/*\",\n        \"https://aistudio.google.cn/*\"\n      ],\n      \"js\": [\"src/pages/content/index.tsx\"],\n      \"css\": [\"contentStyle.css\"]\n    }\n  ],\n  \"host_permissions\": [\n    \"https://gemini.google.com/*\",\n    \"https://business.gemini.google/*\",\n    \"https://aistudio.google.com/*\",\n    \"https://aistudio.google.cn/*\",\n    \"https://*.googleapis.com/*\",\n    \"https://accounts.google.com/*\",\n    \"https://*.googleusercontent.com/*\",\n    \"https://lh3.google.com/*\",\n    \"https://*.ggpht.com/*\"\n  ],\n  \"optional_host_permissions\": [\"https://*/*\", \"http://*/*\"],\n  \"background\": {\n    \"service_worker\": \"src/pages/background/index.ts\",\n    \"type\": \"module\"\n  },\n  \"version\": \"1.3.6\"\n}\n"
  },
  {
    "path": "manifest.json",
    "content": "{\n  \"manifest_version\": 3,\n  \"default_locale\": \"en\",\n  \"name\": \"__MSG_extName__\",\n  \"description\": \"__MSG_extDescription__\",\n  \"version\": \"1.3.6\",\n  \"key\": \"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqyGtKj1peaqzYlrf+cu2b9PKAapZJ7FCuIAh9N8ZQf0c9NKgt2R0G6t+QwXtmqNBFqnerqZrzVgH37cjK93Nt+/WCMCFksGH48nxKAGNpYzeALQnqa7s1fHVMleIGuHSQmKMqzT9YBy9hLqzK2DhX1jQPQBOiSLnhsqhGHTaoTEArerCopHmjfu3Ksl9paXFX/1ybi9HG3MnJKSY3X9m2+zbe370ntUEYxWTBN91ikKRveB1w6U5KqaMNFka2XblzN7nAajWjSLGUdZNpbcF1gC9MKnu3oZsABh65zFmiZeW21vUGUrsJt7mgkLcoV2/gjQCXEtsk23Ty/b+ZTNxWwIDAQAB\",\n  \"action\": {\n    \"default_popup\": \"src/pages/popup/index.html\",\n    \"default_icon\": {\n      \"32\": \"icon-32.png\"\n    }\n  },\n  \"browser_specific_settings\": {\n    \"gecko\": {\n      \"id\": \"gemini-voyager@nagi-ovo\",\n      \"strict_min_version\": \"109.0\"\n    }\n  },\n  \"options_ui\": {\n    \"page\": \"src/pages/options/index.html\",\n    \"open_in_tab\": true\n  },\n  \"icons\": {\n    \"128\": \"icon-128.png\"\n  },\n  \"permissions\": [\"storage\", \"identity\", \"scripting\"],\n  \"oauth2\": {\n    \"client_id\": \"462948120910-vvunu0k3b4vi37u23mqnh77mbgdjcg78.apps.googleusercontent.com\",\n    \"scopes\": [\"https://www.googleapis.com/auth/drive.file\"]\n  },\n  \"host_permissions\": [\n    \"https://gemini.google.com/*\",\n    \"https://business.gemini.google/*\",\n    \"https://aistudio.google.com/*\",\n    \"https://aistudio.google.cn/*\",\n    \"https://*.googleapis.com/*\",\n    \"https://accounts.google.com/*\",\n    \"https://*.googleusercontent.com/*\",\n    \"https://lh3.google.com/*\",\n    \"https://*.ggpht.com/*\"\n  ],\n  \"optional_host_permissions\": [\"https://*/*\", \"http://*/*\"],\n  \"content_security_policy\": {\n    \"extension_pages\": \"script-src 'self'; object-src 'self'; worker-src 'self'\"\n  },\n  \"background\": {\n    \"service_worker\": \"src/pages/background/index.ts\",\n    \"type\": \"module\"\n  },\n  \"content_scripts\": [\n    {\n      \"matches\": [\n        \"https://gemini.google.com/*\",\n        \"https://business.gemini.google/*\",\n        \"https://aistudio.google.com/*\",\n        \"https://aistudio.google.cn/*\"\n      ],\n      \"js\": [\"src/pages/content/index.tsx\"],\n      \"css\": [\"contentStyle.css\"]\n    }\n  ],\n  \"web_accessible_resources\": [\n    {\n      \"resources\": [\n        \"contentStyle.css\",\n        \"icon-128.png\",\n        \"icon-32.png\",\n        \"katex-config.js\",\n        \"fetchInterceptor.js\",\n        \"prevent-auto-scroll.js\",\n        \"assets/*.js\",\n        \"assets/*.png\"\n      ],\n      \"matches\": [\"<all_urls>\"]\n    }\n  ]\n}\n"
  },
  {
    "path": "nodemon.chrome.json",
    "content": "{\n  \"env\": {\n    \"__DEV__\": \"true\"\n  },\n  \"watch\": [\n    \"src\",\n    \"utils\",\n    \"vite.config.base.ts\",\n    \"vite.config.chrome.ts\",\n    \"manifest.json\",\n    \"manifest.dev.json\"\n  ],\n  \"ext\": \"tsx,css,html,ts,json\",\n  \"ignore\": [\"src/**/*.spec.ts\"],\n  \"exec\": \"npx vite build --config vite.config.chrome.ts --mode development\"\n}\n"
  },
  {
    "path": "nodemon.firefox.json",
    "content": "{\n  \"env\": {\n    \"__DEV__\": \"true\"\n  },\n  \"watch\": [\n    \"src\",\n    \"utils\",\n    \"vite.config.base.ts\",\n    \"vite.config.firefox.ts\",\n    \"manifest.json\",\n    \"manifest.dev.json\"\n  ],\n  \"ext\": \"tsx,css,html,ts,json\",\n  \"ignore\": [\"src/**/*.spec.ts\"],\n  \"exec\": \"vite build --config vite.config.firefox.ts --mode development\"\n}\n"
  },
  {
    "path": "nodemon.safari.json",
    "content": "{\n  \"watch\": [\n    \"src\",\n    \"public\",\n    \"vite.config.safari.ts\",\n    \"vite.config.base.ts\",\n    \"manifest.json\",\n    \"manifest.dev.json\"\n  ],\n  \"ext\": \"ts,tsx,js,jsx,json,css,html\",\n  \"exec\": \"vite build --config vite.config.safari.ts --mode development --watch\",\n  \"env\": {\n    \"__DEV__\": \"true\"\n  }\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"gemini-voyager\",\n  \"version\": \"1.3.6\",\n  \"description\": \"Supercharge your Gemini experience with timeline navigation, folder organization, prompt vault, and chat export.\",\n  \"license\": \"GPL-3.0\",\n  \"author\": \"Jesse Zhang\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/Nagi-ovo/gemini-voyager.git\"\n  },\n  \"scripts\": {\n    \"bump\": \"bun scripts/bump-version.js\",\n    \"build\": \"vite build --config vite.config.chrome.ts\",\n    \"build:chrome\": \"vite build --config vite.config.chrome.ts\",\n    \"build:edge\": \"bun scripts/build-edge.js\",\n    \"build:firefox\": \"vite build --config vite.config.firefox.ts\",\n    \"build:safari\": \"vite build --config vite.config.safari.ts\",\n    \"build:all\": \"bun run build:chrome && bun run build:firefox && bun run build:safari\",\n    \"dev\": \"nodemon --config nodemon.chrome.json\",\n    \"dev:chrome\": \"nodemon --config nodemon.chrome.json\",\n    \"dev:chrome-open\": \"bun scripts/launch-chrome.cjs\",\n    \"dev:firefox\": \"nodemon --config nodemon.firefox.json\",\n    \"dev:safari\": \"nodemon --config nodemon.safari.json\",\n    \"test\": \"vitest run\",\n    \"test:ui\": \"vitest --ui\",\n    \"test:coverage\": \"vitest run --coverage\",\n    \"test:watch\": \"vitest watch\",\n    \"lint\": \"eslint . --fix\",\n    \"format\": \"prettier --write .\",\n    \"typecheck\": \"tsc --noEmit\",\n    \"commitlint\": \"commitlint --edit $GIT_PARAMS\",\n    \"docs:dev\": \"vitepress dev docs\",\n    \"docs:build\": \"vitepress build docs\",\n    \"docs:preview\": \"vitepress preview docs\",\n    \"sponsors:update\": \"bun run scripts/generate-sponsors.cjs\"\n  },\n  \"type\": \"module\",\n  \"dependencies\": {\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"dompurify\": \"^3\",\n    \"html-to-image\": \"^1.11.13\",\n    \"jszip\": \"^3.10.1\",\n    \"katex\": \"^0.16\",\n    \"lucide-react\": \"^0.553.0\",\n    \"marked\": \"16.4.1\",\n    \"marked-katex-extension\": \"^4\",\n    \"mermaid\": \"11.12.2\",\n    \"mermaid-legacy\": \"npm:mermaid@9.2.2\",\n    \"prettier\": \"^3.6.2\",\n    \"react\": \"^19.1.0\",\n    \"react-dom\": \"^19.1.0\",\n    \"tailwind-merge\": \"^3.3.1\",\n    \"temml\": \"^0.13.1\",\n    \"webextension-polyfill\": \"^0.12.0\"\n  },\n  \"devDependencies\": {\n    \"@crxjs/vite-plugin\": \"2.2.1\",\n    \"@nolebase/vitepress-plugin-git-changelog\": \"^2.18.2\",\n    \"@tailwindcss/vite\": \"^4.1.15\",\n    \"@trivago/prettier-plugin-sort-imports\": \"^6.0.2\",\n    \"@types/chrome\": \"^0.1.24\",\n    \"@types/jsdom\": \"^27.0.0\",\n    \"@types/jszip\": \"^3.4.1\",\n    \"@types/node\": \"^24.9.0\",\n    \"@types/react\": \"^19.2.2\",\n    \"@types/react-dom\": \"^19.2.2\",\n    \"@types/webextension-polyfill\": \"^0.12.3\",\n    \"@typescript-eslint/eslint-plugin\": \"8.46.2\",\n    \"@typescript-eslint/parser\": \"^8.46.2\",\n    \"@vitejs/plugin-react\": \"5.1.0\",\n    \"@vitest/coverage-v8\": \"^4.0.6\",\n    \"@vitest/ui\": \"^4.0.6\",\n    \"eslint\": \"^9.38.0\",\n    \"eslint-config-prettier\": \"^10.1.8\",\n    \"eslint-plugin-import\": \"^2.32.0\",\n    \"eslint-plugin-jsx-a11y\": \"^6.10.2\",\n    \"eslint-plugin-react\": \"^7.37.5\",\n    \"eslint-plugin-react-hooks\": \"7.0.1\",\n    \"fs-extra\": \"^11.3.0\",\n    \"jsdom\": \"^27.1.0\",\n    \"nodemon\": \"^3.1.10\",\n    \"prettier-plugin-tailwindcss\": \"^0.7.2\",\n    \"tailwindcss\": \"^4.1.15\",\n    \"ts-node\": \"^10.9.2\",\n    \"typescript\": \"^5.8.3\",\n    \"vite\": \"^7.1.11\",\n    \"vite-tsconfig-paths\": \"^5.1.4\",\n    \"vitepress\": \"^1.6.4\",\n    \"vitest\": \"^4.0.6\",\n    \"vue\": \"^3.5.24\",\n    \"vue3-marquee\": \"^4.2.2\",\n    \"web-ext-run\": \"^0.2.4\"\n  },\n  \"overrides\": {\n    \"rollup\": \"^4.24.0\"\n  }\n}\n"
  },
  {
    "path": "public/contentStyle.css",
    "content": ".gv-pm-panel,\n.gv-pm-trigger,\n.gv-pm-header,\n.gv-pm-list,\n.gv-pm-item,\n.gv-pm-chip,\n.gv-pm-tag,\n.gv-pm-footer,\n.gv-pm-search input,\n.gv-pm-input-text,\n.gv-pm-input-tags,\n.gv-pm-import-btn,\n.gv-pm-export-btn,\n.gv-pm-save,\n.gv-pm-cancel,\n.gv-pm-settings {\n  -webkit-user-select: none;\n  user-select: none;\n}\n\n.gv-pm-input-text,\n.gv-pm-input-tags {\n  -webkit-user-select: text;\n  user-select: text;\n  box-sizing: border-box;\n  width: 100%;\n}\n\n/* Gemini-like timeline styling with CSS variables and theme awareness */\n:root {\n  --timeline-dot-color: #94a3b8;\n  --timeline-dot-active-color: oklch(0.55 0.17 155);\n  --timeline-star-color: #f59e0b;\n  --timeline-tooltip-bg: #ffffff;\n  --timeline-tooltip-text: #0f172a;\n  --timeline-tooltip-border: #e2e8f0;\n  --timeline-tooltip-radius: 14px;\n  --timeline-tooltip-shadow: 0 12px 36px rgba(2, 8, 23, 0.18), 0 3px 8px rgba(2, 8, 23, 0.08);\n  --timeline-tooltip-lh: 18px;\n  --timeline-tooltip-pad-y: 10px;\n  --timeline-tooltip-pad-x: 12px;\n  --timeline-tooltip-border-w: 1px;\n  --timeline-tooltip-arrow-size: 8px;\n  --timeline-tooltip-arrow-outside: 4px;\n  --timeline-tooltip-anim-in: 140ms cubic-bezier(0.2, 0.8, 0.2, 1);\n  --timeline-tooltip-anim-out: 100ms linear;\n  --timeline-bar-bg: rgba(248, 250, 252, 0.88);\n  --timeline-dot-size: 12px;\n  --timeline-active-ring: 3px;\n  --timeline-track-padding: 16px;\n  --timeline-tooltip-max: 288px;\n  --timeline-min-gap: 24px;\n  --timeline-hit-size: 30px;\n  --timeline-tooltip-gap-visual: 8px;\n  --timeline-tooltip-gap-box: 4px;\n  --timeline-hold-ms: 550ms;\n}\n\n@media (prefers-color-scheme: dark) {\n  :root {\n    --timeline-dot-color: #475569;\n    --timeline-dot-active-color: oklch(0.7 0.16 155);\n    --timeline-star-color: #f59e0b;\n    --timeline-tooltip-bg: #0b1220;\n    --timeline-tooltip-text: #e2e8f0;\n    --timeline-tooltip-border: #1f2937;\n    --timeline-bar-bg: rgba(2, 6, 23, 0.75);\n  }\n}\n\n/* Gemini theme support - Overrides system preferences on Gemini site */\n.theme-host.dark-theme {\n  --timeline-dot-color: #475569;\n  --timeline-dot-active-color: oklch(0.7 0.16 155);\n  --timeline-star-color: #f59e0b;\n  --timeline-tooltip-bg: #0b1220;\n  --timeline-tooltip-text: #e2e8f0;\n  --timeline-tooltip-border: #1f2937;\n  --timeline-bar-bg: rgba(2, 6, 23, 0.75);\n}\n\n.theme-host.light-theme {\n  --timeline-dot-color: #94a3b8;\n  --timeline-dot-active-color: oklch(0.55 0.17 155);\n  --timeline-star-color: #f59e0b;\n  --timeline-tooltip-bg: #ffffff;\n  --timeline-tooltip-text: #0f172a;\n  --timeline-tooltip-border: #e2e8f0;\n  --timeline-bar-bg: rgba(248, 250, 252, 0.88);\n}\n\n/* Gemini theme hosts — background moved to ::before pseudo-element */\n.theme-host.dark-theme .gemini-timeline-bar:not(.timeline-no-container)::before {\n  background-color: rgba(2, 6, 23, 0.72);\n  backdrop-filter: blur(4px);\n  -webkit-backdrop-filter: blur(4px);\n}\n\n.theme-host.light-theme .gemini-timeline-bar:not(.timeline-no-container)::before {\n  background-color: rgba(248, 250, 252, 0.85);\n  backdrop-filter: blur(4px);\n  -webkit-backdrop-filter: blur(4px);\n}\n\n.gemini-timeline-bar {\n  position: fixed;\n  top: 60px;\n  right: 15px;\n  width: 24px;\n  height: calc(100vh - 100px);\n  z-index: 2147483646;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  border-radius: 12px;\n  overflow: visible;\n  contain: layout;\n  pointer-events: none;\n}\n\n/* Visual background layer — width adjustable via edge drag, dots unaffected */\n.gemini-timeline-bar::before {\n  content: '';\n  position: absolute;\n  top: 0;\n  bottom: 0;\n  left: 50%;\n  transform: translateX(-50%);\n  width: var(--timeline-bar-width, 24px);\n  border-radius: calc(var(--timeline-bar-width, 24px) / 2);\n  background-color: var(--timeline-bar-bg);\n  backdrop-filter: blur(6px);\n  -webkit-backdrop-filter: blur(6px);\n  box-shadow: 0 2px 12px oklch(0 0 0 / 0.06);\n  transition:\n    background-color 0.3s ease,\n    width 0.2s ease,\n    border-radius 0.2s ease;\n  pointer-events: auto;\n}\n\n/* Disable transitions during active resize for immediate feedback */\n.gemini-timeline-bar.timeline-resizing::before {\n  transition: none !important;\n}\n\n/* Prevent text selection during resize drag */\n.gemini-timeline-bar.timeline-resizing {\n  user-select: none;\n  -webkit-user-select: none;\n}\n\n.timeline-track {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  overflow-y: auto;\n  overflow-x: visible;\n  background: transparent;\n  padding-left: 2px;\n  padding-right: 2px;\n}\n\n.timeline-track-content {\n  position: relative;\n  width: 100%;\n  height: 100%;\n}\n\n.timeline-dot {\n  position: absolute;\n  left: 50%;\n  top: calc(\n    var(--timeline-track-padding) + (100% - 2 * var(--timeline-track-padding)) * var(--n, 0)\n  );\n  transform: translate(-50%, -50%);\n  width: var(--timeline-hit-size);\n  height: var(--timeline-hit-size);\n  background: transparent;\n  border: none;\n  cursor: pointer;\n  padding: 0;\n  pointer-events: auto;\n}\n\n.timeline-dot::after {\n  content: '';\n  position: absolute;\n  left: 50%;\n  top: 50%;\n  width: var(--timeline-dot-size);\n  height: var(--timeline-dot-size);\n  transform: translate(-50%, -50%);\n  border-radius: 50%;\n  background-color: var(--timeline-dot-color);\n  transition:\n    transform 0.15s ease,\n    box-shadow 0.15s ease;\n}\n\n/* true punch-through: remove filled dot and draw only inner stroke to make host page visible */\nhtml.dark .timeline-track,\n[data-theme='dark'] .timeline-track,\n[data-color-scheme='dark'] .timeline-track {\n  background: transparent;\n}\n\nhtml.dark .timeline-dot:not(.active):not(.starred)::after,\n[data-theme='dark'] .timeline-dot:not(.active):not(.starred)::after,\n[data-color-scheme='dark'] .timeline-dot:not(.active):not(.starred)::after {\n  background: #000;\n  box-shadow: none;\n}\n\n.timeline-dot:hover::after {\n  transform: translate(-50%, -50%) scale(1.15);\n}\n\n.timeline-dot:focus-visible::after {\n  box-shadow: 0 0 6px var(--timeline-dot-active-color);\n}\n\n.timeline-dot.active::after {\n  box-shadow:\n    0 0 0 var(--timeline-active-ring) var(--timeline-dot-active-color),\n    0 0 14px oklch(0.55 0.17 155 / 0.5);\n}\n\n.timeline-dot.starred::after {\n  background-color: var(--timeline-star-color);\n}\n\n/* ===== Timeline Marker Levels (shape-based: circle, triangle, square) ===== */\n.timeline-dot[data-level='1']::after {\n  width: var(--timeline-dot-size);\n  height: var(--timeline-dot-size);\n  border-radius: 50%;\n  clip-path: none;\n}\n\n.timeline-dot[data-level='2']::after {\n  width: var(--timeline-dot-size);\n  height: var(--timeline-dot-size);\n  border-radius: 0;\n  clip-path: none;\n}\n\n.timeline-dot[data-level='3']::after {\n  width: var(--timeline-dot-size);\n  height: var(--timeline-dot-size);\n  border-radius: 0;\n  clip-path: polygon(50% 0%, 0% 100%, 100% 100%);\n}\n\n/* ===== Timeline Context Menu ===== */\n.timeline-context-menu {\n  position: fixed;\n  background-color: var(--timeline-tooltip-bg);\n  border: 1px solid var(--timeline-tooltip-border);\n  border-radius: 8px;\n  box-shadow: var(--timeline-tooltip-shadow);\n  padding: 4px 0;\n  z-index: 2147483647;\n  min-width: 160px;\n  animation: timeline-menu-in 0.12s ease-out;\n}\n\n@keyframes timeline-menu-in {\n  from {\n    opacity: 0;\n    transform: scale(0.95);\n  }\n\n  to {\n    opacity: 1;\n    transform: scale(1);\n  }\n}\n\n.timeline-context-menu-title {\n  padding: 8px 12px 4px 12px;\n  font-size: 11px;\n  font-weight: 600;\n  color: var(--timeline-tooltip-text);\n  opacity: 0.6;\n  text-transform: uppercase;\n  letter-spacing: 0.5px;\n  border-bottom: 1px solid var(--timeline-tooltip-border);\n  margin-bottom: 4px;\n}\n\n.timeline-context-menu-item {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  width: 100%;\n  padding: 8px 12px;\n  border: none;\n  background: transparent;\n  color: var(--timeline-tooltip-text);\n  font-size: 13px;\n  cursor: pointer;\n  text-align: left;\n  transition: background-color 0.1s ease;\n}\n\n.timeline-context-menu-item:hover {\n  background-color: var(--timeline-dot-active-color);\n}\n\n.timeline-context-menu-item.active {\n  background-color: var(--timeline-dot-active-color);\n  font-weight: 500;\n}\n\n.timeline-context-menu-item .level-indicator {\n  width: 16px;\n  height: 16px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.timeline-context-menu-item .level-dot {\n  width: 10px;\n  height: 10px;\n  background-color: var(--timeline-dot-color);\n}\n\n/* Level 1: Circle */\n.timeline-context-menu-item[data-level='1'] .level-dot {\n  border-radius: 50%;\n  clip-path: none;\n}\n\n/* Level 2: Square */\n.timeline-context-menu-item[data-level='2'] .level-dot {\n  border-radius: 0;\n  clip-path: none;\n}\n\n/* Level 3: Triangle */\n.timeline-context-menu-item[data-level='3'] .level-dot {\n  border-radius: 0;\n  clip-path: polygon(50% 0%, 0% 100%, 100% 100%);\n}\n\n.timeline-context-menu-item .check-icon {\n  margin-left: auto;\n  color: var(--timeline-dot-active-color);\n  font-size: 14px;\n}\n\n/* Context menu separator */\n.timeline-context-menu-separator {\n  height: 1px;\n  background-color: var(--timeline-tooltip-border);\n  margin: 4px 0;\n}\n\n/* Collapse menu item */\n.timeline-context-menu-item.collapse-item {\n  margin-top: 2px;\n}\n\n.timeline-context-menu-item .collapse-icon {\n  font-size: 12px;\n  color: var(--timeline-dot-color);\n  transition: transform 0.2s ease;\n}\n\n/* Collapsed dot style - visual indicator */\n.timeline-dot.collapsed::before {\n  content: '';\n  position: absolute;\n  left: 50%;\n  top: 50%;\n  width: calc(var(--timeline-dot-size) + 10px);\n  height: calc(var(--timeline-dot-size) + 10px);\n  transform: translate(-50%, -50%);\n  border-radius: 50%;\n  border: 2px dashed var(--timeline-dot-active-color);\n  opacity: 0.5;\n  pointer-events: none;\n}\n\n.timeline-dot.holding::before {\n  content: '';\n  position: absolute;\n  left: 50%;\n  top: 50%;\n  width: calc(var(--timeline-dot-size) + 8px);\n  height: calc(var(--timeline-dot-size) + 8px);\n  transform: translate(-50%, -50%);\n  border-radius: 50%;\n  box-shadow: 0 0 0 2px var(--timeline-dot-active-color) inset;\n  opacity: 0.15;\n  animation: timeline-hold-fade var(--timeline-hold-ms) linear forwards;\n  pointer-events: none;\n}\n\n@keyframes timeline-hold-fade {\n  from {\n    opacity: 0.15;\n  }\n\n  to {\n    opacity: 0.85;\n  }\n}\n\n.timeline-tooltip {\n  position: fixed;\n  max-width: var(--timeline-tooltip-max);\n  background-color: var(--timeline-tooltip-bg);\n  color: var(--timeline-tooltip-text);\n  padding: var(--timeline-tooltip-pad-y) var(--timeline-tooltip-pad-x);\n  border-radius: var(--timeline-tooltip-radius);\n  border: var(--timeline-tooltip-border-w) solid var(--timeline-tooltip-border);\n  font-size: 13px;\n  font-family: 'Google Sans', Roboto, Arial, sans-serif;\n  line-height: var(--timeline-tooltip-lh);\n  max-height: calc(\n    3 * var(--timeline-tooltip-lh) + 2 * var(--timeline-tooltip-pad-y) + 2 *\n      var(--timeline-tooltip-border-w)\n  );\n  box-shadow: var(--timeline-tooltip-shadow);\n  pointer-events: none;\n  z-index: 2147483647;\n  display: -webkit-box;\n  -webkit-line-clamp: 3;\n  line-clamp: 3;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n  word-break: break-word;\n  white-space: pre-line;\n  text-align: start;\n  unicode-bidi: plaintext;\n  opacity: 0;\n  will-change: opacity, transform;\n  transition: opacity var(--timeline-tooltip-anim-in);\n}\n\n.timeline-tooltip.visible {\n  opacity: 1;\n  animation: timeline-tooltip-in var(--timeline-tooltip-anim-in);\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .timeline-tooltip,\n  .timeline-tooltip.visible {\n    transition: opacity 120ms linear;\n    transform: none !important;\n  }\n}\n\n.timeline-tooltip[data-placement='left'] {\n  transform-origin: right center;\n}\n\n.timeline-tooltip[data-placement='right'] {\n  transform-origin: left center;\n}\n\n@keyframes timeline-tooltip-in {\n  from {\n    transform: scale(0.96);\n  }\n\n  to {\n    transform: scale(1);\n  }\n}\n\n.timeline-tooltip::after {\n  content: '';\n  position: absolute;\n  width: var(--timeline-tooltip-arrow-size);\n  height: var(--timeline-tooltip-arrow-size);\n  background: var(--timeline-tooltip-bg);\n  border: var(--timeline-tooltip-border-w) solid var(--timeline-tooltip-border);\n  transform: rotate(45deg);\n}\n\n.timeline-tooltip[data-placement='left']::after {\n  right: calc(-1 * var(--timeline-tooltip-arrow-outside));\n  top: 50%;\n  transform: translateY(-50%) rotate(45deg);\n  border-left: none;\n  border-bottom: none;\n}\n\n.timeline-tooltip[data-placement='right']::after {\n  left: calc(-1 * var(--timeline-tooltip-arrow-outside));\n  top: 50%;\n  transform: translateY(-50%) rotate(45deg);\n  border-right: none;\n  border-top: none;\n}\n\n.timeline-track::-webkit-scrollbar {\n  width: 0;\n  height: 0;\n}\n\n.timeline-track {\n  scrollbar-width: none;\n}\n\n.timeline-left-slider {\n  position: fixed;\n  top: 0;\n  width: 12px;\n  height: 160px;\n  opacity: 0;\n  transition: opacity 180ms ease;\n  z-index: 2147483646;\n  pointer-events: none;\n}\n\n.timeline-left-slider.visible {\n  opacity: 1;\n  pointer-events: auto;\n}\n\n.timeline-left-slider::before {\n  content: '';\n  position: absolute;\n  left: 5px;\n  top: 0;\n  bottom: 0;\n  width: 2px;\n  background: rgba(0, 0, 0, 0.08);\n  border-radius: 9999px;\n}\n\nhtml.dark .timeline-left-slider::before {\n  background: rgba(255, 255, 255, 0.1);\n}\n\n.timeline-left-handle {\n  position: absolute;\n  left: 2px;\n  width: 8px;\n  height: 22px;\n  background: rgba(16, 163, 127, 0.28);\n  border-radius: 9999px;\n  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);\n  pointer-events: auto;\n  cursor: grab;\n  transition: background-color 120ms ease;\n}\n\n.timeline-left-handle:hover {\n  background: rgba(16, 163, 127, 0.45);\n}\n\n.timeline-left-handle:active {\n  cursor: grabbing;\n}\n\n.timeline-runner-ring {\n  will-change: top, opacity;\n}\n\n.gemini-timeline-bar.timeline-no-container {\n  contain: none;\n}\n\n.gemini-timeline-bar.timeline-no-container::before {\n  opacity: 0 !important;\n  backdrop-filter: none !important;\n  -webkit-backdrop-filter: none !important;\n  box-shadow: none !important;\n}\n\n/* ── Timeline Preview Panel ── */\n\n.timeline-preview-toggle {\n  position: fixed;\n  width: 24px;\n  height: 24px;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--timeline-bar-bg);\n  border: 1px solid var(--timeline-tooltip-border);\n  cursor: pointer;\n  padding: 0;\n  color: var(--timeline-dot-color);\n  border-radius: 6px;\n  transition:\n    color 0.15s ease,\n    background-color 0.15s ease,\n    opacity 0.15s ease;\n  z-index: 2147483646;\n  opacity: 0;\n}\n\n.gemini-timeline-bar:hover ~ .timeline-preview-toggle,\n.timeline-preview-toggle:hover,\n.timeline-preview-toggle.active {\n  opacity: 1;\n}\n\n.timeline-preview-toggle:hover {\n  color: var(--timeline-dot-active-color);\n  background-color: var(--timeline-tooltip-bg);\n}\n\n.timeline-preview-toggle.active {\n  color: var(--timeline-dot-active-color);\n  background-color: var(--timeline-tooltip-bg);\n}\n\n.timeline-preview-panel {\n  position: fixed;\n  width: 320px;\n  direction: ltr;\n  font-family: 'Google Sans', Roboto, Arial, sans-serif;\n  background-color: var(--timeline-tooltip-bg);\n  border: 1px solid var(--timeline-tooltip-border);\n  border-radius: var(--timeline-tooltip-radius);\n  box-shadow: var(--timeline-tooltip-shadow);\n  z-index: 2147483645;\n  display: flex;\n  flex-direction: column;\n  opacity: 0;\n  transform: scale(0.96) translateX(8px);\n  transition:\n    opacity var(--timeline-tooltip-anim-in),\n    transform var(--timeline-tooltip-anim-in);\n  pointer-events: none;\n  overflow: hidden;\n}\n\n.timeline-preview-panel.visible {\n  opacity: 1;\n  transform: scale(1) translateX(0);\n  pointer-events: auto;\n}\n\n.timeline-preview-search {\n  padding: 8px 10px;\n  border-bottom: 1px solid var(--timeline-tooltip-border);\n  flex-shrink: 0;\n}\n\n.timeline-preview-search input {\n  width: 100%;\n  box-sizing: border-box;\n  border: 1px solid var(--timeline-tooltip-border);\n  border-radius: 6px;\n  padding: 5px 8px;\n  font-family: inherit;\n  font-size: 13px;\n  line-height: 1.4;\n  background: transparent;\n  color: var(--timeline-tooltip-text);\n  text-align: start;\n  outline: none;\n  transition: border-color 0.15s ease;\n}\n\n.timeline-preview-search input::placeholder {\n  color: var(--timeline-dot-color);\n  opacity: 0.7;\n}\n\n.timeline-preview-search input:focus {\n  border-color: var(--timeline-dot-active-color);\n}\n\n.timeline-preview-list {\n  flex: 1;\n  overflow-y: auto;\n  overscroll-behavior: contain;\n  padding: 4px 0;\n  max-height: calc(100% - 42px);\n}\n\n.timeline-preview-item {\n  padding: 6px 10px 6px 6px;\n  cursor: pointer;\n  color: var(--timeline-tooltip-text);\n  font-size: 13px;\n  line-height: 1.4;\n  border-left: 3px solid transparent;\n  display: flex;\n  align-items: baseline;\n  gap: 6px;\n  transition: background-color 0.1s ease;\n}\n\n.timeline-preview-item:hover {\n  background-color: rgba(59, 130, 246, 0.08);\n}\n\n.timeline-preview-item.active {\n  border-left-color: var(--timeline-dot-active-color);\n  background-color: rgba(59, 130, 246, 0.1);\n}\n\n.timeline-preview-item.starred .timeline-preview-index::after {\n  content: '\\2605';\n  margin-inline-start: 2px;\n  color: var(--timeline-star-color);\n  font-size: 11px;\n}\n\n.timeline-preview-starred-time {\n  flex-shrink: 0;\n  font-size: 10px;\n  color: var(--timeline-star-color);\n  margin-inline-start: 6px;\n  white-space: nowrap;\n  opacity: 0.8;\n}\n\n.timeline-preview-index {\n  display: inline-flex;\n  align-items: center;\n  justify-content: flex-start;\n  flex-shrink: 0;\n  font-size: 11px;\n  color: var(--timeline-dot-color);\n  min-width: 14px;\n  text-align: left;\n}\n\n.timeline-preview-text {\n  flex: 1;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  text-align: start;\n  unicode-bidi: plaintext;\n}\n\n.timeline-preview-empty {\n  padding: 16px 12px;\n  text-align: center;\n  color: var(--timeline-dot-color);\n  font-size: 13px;\n}\n\n/* Search highlights — preview panel */\n.timeline-preview-highlight {\n  background-color: rgba(59, 130, 246, 0.2);\n  color: inherit;\n  border-radius: 2px;\n  padding: 0;\n  pointer-events: none;\n}\n\n/* Search highlights — SPA conversation page */\nmark.timeline-search-highlight {\n  background-color: oklch(0.55 0.17 155 / 0.3);\n  color: inherit;\n  border-radius: 3px;\n  padding: 0 1px;\n}\n\n/* Dark theme adjustments for preview panel */\n.theme-host.dark-theme .timeline-preview-panel {\n  background-color: rgba(11, 18, 32, 0.95);\n  backdrop-filter: blur(8px);\n  -webkit-backdrop-filter: blur(8px);\n}\n\n.theme-host.dark-theme .timeline-preview-item:hover {\n  background-color: rgba(96, 165, 250, 0.08);\n}\n\n.theme-host.dark-theme .timeline-preview-item.active {\n  background-color: rgba(96, 165, 250, 0.12);\n}\n\n.theme-host.dark-theme .timeline-preview-highlight {\n  background-color: rgba(96, 165, 250, 0.25);\n}\n\n.theme-host.dark-theme mark.timeline-search-highlight {\n  background-color: oklch(0.7729 0.1535 163.2231 / 0.3);\n}\n\n.theme-host.light-theme .timeline-preview-panel {\n  background-color: rgba(255, 255, 255, 0.95);\n  backdrop-filter: blur(8px);\n  -webkit-backdrop-filter: blur(8px);\n}\n\n/* Prompt Manager styles (scoped) */\n.gv-pm-trigger {\n  position: fixed;\n  right: 18px;\n  bottom: 18px;\n  width: 46px;\n  height: 46px;\n  border-radius: 9999px;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  background: linear-gradient(135deg, oklch(0.55 0.17 155), oklch(0.58 0.18 162));\n  border: 1.5px solid oklch(0.55 0.17 155 / 0.6);\n  box-shadow:\n    0 6px 24px oklch(0.55 0.17 155 / 0.35),\n    0 2px 8px oklch(0.55 0.17 155 / 0.2),\n    inset 0 1px 0 oklch(1 0 0 / 0.15);\n  color: #fff;\n  z-index: 998;\n  cursor: pointer;\n  transition:\n    transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),\n    box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.gv-pm-trigger:hover {\n  transform: scale(1.08) translateY(-1px);\n  box-shadow:\n    0 10px 32px oklch(0.55 0.17 155 / 0.45),\n    0 4px 12px oklch(0.55 0.17 155 / 0.3),\n    inset 0 1px 0 oklch(1 0 0 / 0.2);\n}\n\n.gv-pm-trigger:active {\n  transform: scale(0.94);\n}\n\n.theme-host.light-theme .gv-pm-trigger {\n  background: linear-gradient(135deg, oklch(0.55 0.17 155), oklch(0.58 0.18 162));\n  border-color: oklch(0.55 0.17 155 / 0.6);\n}\n\n.theme-host.dark-theme .gv-pm-trigger {\n  background: linear-gradient(135deg, oklch(0.7 0.16 155), oklch(0.55 0.17 155));\n  border-color: oklch(0.7 0.16 155 / 0.6);\n}\n\n/* AI Studio body theme classes */\nbody.light-theme .gv-pm-trigger,\nhtml.light-theme .gv-pm-trigger {\n  background: rgba(248, 250, 252, 0.9);\n  color: #111827;\n  border-color: rgba(0, 0, 0, 0.15);\n}\n\nbody.dark-theme .gv-pm-trigger,\nhtml.dark-theme .gv-pm-trigger {\n  background: rgba(17, 24, 39, 0.75);\n  color: #fff;\n  border-color: rgba(255, 255, 255, 0.15);\n}\n\n.gv-pm-trigger img {\n  pointer-events: none;\n}\n\n/* Changelog badge on trigger */\n.gv-pm-trigger-new::after {\n  content: 'NEW';\n  position: absolute;\n  top: -8px;\n  right: -6px;\n  padding: 2px 6px;\n  border-radius: 8px;\n  font-size: 9px;\n  font-weight: 800;\n  letter-spacing: 0.06em;\n  line-height: 1.3;\n  color: oklch(0.95 0.02 95);\n  background: linear-gradient(120deg, oklch(0.76 0.18 50), oklch(0.68 0.2 30));\n  box-shadow: 0 4px 12px oklch(0 0 0 / 0.16);\n  animation: gv-pm-float 1.8s ease-in-out infinite;\n  pointer-events: none;\n  white-space: nowrap;\n  z-index: 1;\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-pm-trigger-new::after {\n    background: linear-gradient(120deg, oklch(0.72 0.18 45), oklch(0.62 0.2 28));\n    color: oklch(0.98 0.01 96);\n    box-shadow: 0 6px 16px oklch(0 0 0 / 0.24);\n  }\n}\n\n/* Logo hover dropdown for export */\n.gv-logo-dropdown-wrapper {\n  position: relative;\n  display: inline-flex;\n  align-items: center;\n  width: fit-content;\n  max-width: max-content;\n  pointer-events: none;\n}\n\n.gv-logo-dropdown-wrapper [data-test-id='logo'],\n.gv-logo-dropdown-wrapper .logo {\n  pointer-events: auto;\n}\n\n/* Invisible bridge between logo and dropdown so hover doesn't break in the gap */\n.gv-logo-dropdown-wrapper::after {\n  content: '';\n  position: absolute;\n  top: 100%;\n  left: 0;\n  right: 0;\n  height: 12px;\n  pointer-events: none;\n}\n\n.gv-logo-dropdown-wrapper:hover::after {\n  pointer-events: auto;\n}\n\n.gv-logo-dropdown {\n  position: absolute;\n  top: 100%;\n  left: 0;\n  margin-top: 4px;\n  padding: 4px;\n  min-width: 140px;\n  background: rgba(255, 255, 255, 0.95);\n  backdrop-filter: blur(12px);\n  -webkit-backdrop-filter: blur(12px);\n  border: 1px solid rgba(0, 0, 0, 0.08);\n  border-radius: 12px;\n  box-shadow:\n    0 4px 24px rgba(0, 0, 0, 0.12),\n    0 2px 8px rgba(0, 0, 0, 0.08);\n  opacity: 0;\n  visibility: hidden;\n  transform: translateY(-8px) scale(0.96);\n  transform-origin: top left;\n  transition:\n    opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),\n    visibility 0.2s cubic-bezier(0.4, 0, 0.2, 1),\n    transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n  transition-delay: 0.12s;\n  z-index: 9999;\n  pointer-events: none;\n}\n\n.gv-logo-dropdown-wrapper:hover .gv-logo-dropdown,\n.gv-logo-dropdown:hover {\n  opacity: 1;\n  visibility: visible;\n  transform: translateY(0) scale(1);\n  pointer-events: auto;\n  transition-delay: 0.05s;\n}\n\n.gv-export-dropdown-btn {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  width: 100%;\n  padding: 10px 14px;\n  border: none;\n  border-radius: 8px;\n  cursor: pointer;\n  background: transparent;\n  color: #1f2937;\n  font-size: 14px;\n  font-weight: 500;\n  font-family: inherit;\n  text-align: left;\n  transition:\n    background-color 0.15s ease,\n    color 0.15s ease;\n}\n\n.gv-export-dropdown-btn:hover {\n  background: oklch(0.55 0.17 155 / 0.1);\n}\n\n.gv-export-dropdown-btn:active {\n  background: oklch(0.55 0.17 155 / 0.16);\n}\n\n.gv-export-dropdown-icon {\n  width: 18px;\n  height: 18px;\n  display: block;\n  flex-shrink: 0;\n  background: currentColor;\n  -webkit-mask: url('data:image/svg+xml,%3csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22%3e%3cpath fill=%22%23000%22 d=%22M12 3a1 1 0 011 1v8.586l2.293-2.293a1 1 0 111.414 1.414l-4.007 4.007a1 1 0 01-1.414 0L7.279 11.707a1 1 0 011.414-1.414L11 12.586V4a1 1 0 011-1zm-7 14a1 1 0 011-1h12a1 1 0 011 1v2a2 2 0 01-2 2H7a2 2 0 01-2-2v-2z%22/%3e%3c/svg%3e')\n    center / contain no-repeat;\n  mask: url('data:image/svg+xml,%3csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22%3e%3cpath fill=%22%23000%22 d=%22M12 3a1 1 0 011 1v8.586l2.293-2.293a1 1 0 111.414 1.414l-4.007 4.007a1 1 0 01-1.414 0L7.279 11.707a1 1 0 011.414-1.414L11 12.586V4a1 1 0 011-1zm-7 14a1 1 0 011-1h12a1 1 0 011 1v2a2 2 0 01-2 2H7a2 2 0 01-2-2v-2z%22/%3e%3c/svg%3e')\n    center / contain no-repeat;\n}\n\n.gv-export-dropdown-label {\n  white-space: nowrap;\n}\n\n/* Dark theme support */\nhtml.dark-theme .gv-logo-dropdown,\nbody.dark-theme .gv-logo-dropdown,\n.theme-host.dark-theme .gv-logo-dropdown {\n  background: rgba(30, 30, 30, 0.95);\n  border-color: rgba(255, 255, 255, 0.1);\n  box-shadow:\n    0 4px 24px rgba(0, 0, 0, 0.4),\n    0 2px 8px rgba(0, 0, 0, 0.3);\n}\n\nhtml.dark-theme .gv-export-dropdown-btn,\nbody.dark-theme .gv-export-dropdown-btn,\n.theme-host.dark-theme .gv-export-dropdown-btn {\n  color: #e5e7eb;\n}\n\nhtml.dark-theme .gv-export-dropdown-btn:hover,\nbody.dark-theme .gv-export-dropdown-btn:hover,\n.theme-host.dark-theme .gv-export-dropdown-btn:hover {\n  background: oklch(0.7 0.16 155 / 0.14);\n}\n\n/* Add subtle indicator on logo when hovering */\n.gv-logo-dropdown-wrapper [data-test-id='logo']::after {\n  content: '';\n  display: inline-block;\n  width: 0;\n  height: 0;\n  margin-left: 4px;\n  border-left: 4px solid transparent;\n  border-right: 4px solid transparent;\n  border-top: 4px solid currentColor;\n  opacity: 0;\n  transform: translateY(-2px);\n  transition:\n    opacity 0.2s ease,\n    transform 0.2s ease;\n  vertical-align: middle;\n}\n\n.gv-logo-dropdown-wrapper:hover [data-test-id='logo']::after {\n  opacity: 0.5;\n  transform: translateY(0);\n}\n\n.gv-pm-panel {\n  position: fixed;\n  display: flex;\n  flex-direction: column;\n  width: 440px;\n  max-width: calc(100vw - 20px);\n  max-height: min(75vh, 600px);\n  overflow: hidden;\n  --gv-pm-surface: oklch(0.995 0.002 250);\n  border-radius: 18px;\n  background: oklch(0.995 0.002 250);\n  border: 1px solid oklch(0.92 0.004 250 / 0.7);\n  box-shadow:\n    0 16px 48px oklch(0 0 0 / 0.12),\n    0 6px 20px oklch(0 0 0 / 0.06);\n  z-index: 999;\n  color: oklch(0.14 0.004 285);\n  font-family:\n    ui-sans-serif,\n    system-ui,\n    -apple-system,\n    Segoe UI,\n    Roboto,\n    Helvetica,\n    Arial,\n    'Apple Color Emoji',\n    'Segoe UI Emoji';\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-pm-panel {\n    --gv-pm-surface: oklch(0.2 0.008 285);\n    background: oklch(0.2 0.008 285);\n    color: oklch(0.92 0.004 250);\n    border-color: oklch(0.3 0.008 285 / 0.7);\n    box-shadow:\n      0 16px 48px oklch(0 0 0 / 0.4),\n      0 6px 20px oklch(0 0 0 / 0.25);\n  }\n}\n\n.theme-host.dark-theme .gv-pm-panel,\nbody.dark-theme .gv-pm-panel {\n  --gv-pm-surface: oklch(0.2 0.008 285);\n  background: oklch(0.2 0.008 285);\n  color: oklch(0.92 0.004 250);\n  border-color: oklch(0.3 0.008 285 / 0.7);\n}\n\n.theme-host.light-theme .gv-pm-panel,\nbody.light-theme .gv-pm-panel {\n  --gv-pm-surface: oklch(0.995 0.002 250);\n  background: oklch(0.995 0.002 250);\n  color: oklch(0.14 0.004 285);\n  border-color: oklch(0.92 0.004 250 / 0.7);\n}\n\n.gv-hidden {\n  display: none !important;\n}\n\n.gv-pm-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 16px 18px;\n  gap: 6px;\n  border-bottom: 1px solid oklch(0.92 0.004 250 / 0.5);\n  cursor: default;\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-pm-header {\n    border-bottom-color: oklch(0.3 0.008 285 / 0.5);\n  }\n}\n\n.gv-pm-title {\n  font-weight: 800;\n  font-size: 17px;\n  letter-spacing: -0.02em;\n  color: oklch(0.14 0.004 285);\n  flex: 1;\n  min-width: 0;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  position: relative;\n  z-index: 1;\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-pm-title {\n    color: oklch(0.92 0.004 250);\n  }\n}\n\n.theme-host.dark-theme .gv-pm-title,\nbody.dark-theme .gv-pm-title {\n  color: oklch(0.92 0.004 250);\n}\n\n.theme-host.light-theme .gv-pm-title,\nbody.light-theme .gv-pm-title {\n  color: oklch(0.14 0.004 285);\n}\n\n.gv-pm-title-row {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  flex: 1;\n  min-width: 0;\n}\n\n.gv-pm-version {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  padding: 3px 9px;\n  flex-shrink: 0;\n  position: relative;\n  border-radius: 999px;\n  background: oklch(0.95 0.004 250 / 0.6);\n  color: oklch(0.4 0.006 250);\n  font-size: 12px;\n  font-weight: 700;\n  letter-spacing: 0.01em;\n  text-decoration: none;\n  border: 1px solid oklch(0.92 0.004 250 / 0.5);\n  box-shadow: 0 1px 4px oklch(0 0 0 / 0.04);\n  transition:\n    transform 120ms cubic-bezier(0.4, 0, 0.2, 1),\n    box-shadow 160ms ease,\n    background 160ms ease,\n    border-color 160ms ease;\n  white-space: nowrap;\n}\n\n.gv-pm-version:hover {\n  transform: translateY(-1px);\n  box-shadow: 0 4px 12px oklch(0 0 0 / 0.08);\n  border-color: oklch(0.55 0.17 155 / 0.4);\n  background: oklch(0.95 0.004 250 / 0.8);\n}\n\n.gv-pm-version-outdated {\n  border-color: oklch(0.55 0.17 155 / 0.6);\n}\n\n.gv-pm-version-outdated::after {\n  content: 'NEW';\n  position: absolute;\n  top: -14px;\n  right: -2px;\n  transform: translateX(35%);\n  padding: 3px 7px;\n  border-radius: 10px;\n  font-size: 10px;\n  font-weight: 800;\n  letter-spacing: 0.06em;\n  color: oklch(0.95 0.02 95);\n  background: linear-gradient(120deg, oklch(0.76 0.18 50), oklch(0.68 0.2 30));\n  box-shadow: 0 8px 18px oklch(0 0 0 / 0.16);\n  animation: gv-pm-float 1.8s ease-in-out infinite;\n  pointer-events: none;\n  white-space: nowrap;\n}\n\n@keyframes gv-pm-float {\n  0%,\n  100% {\n    transform: translateY(0);\n    opacity: 1;\n  }\n\n  50% {\n    transform: translateY(-2px);\n    opacity: 0.92;\n  }\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-pm-title-row {\n    color: oklch(0.92 0.004 250);\n  }\n\n  .gv-pm-version {\n    background: oklch(0.26 0.008 285 / 0.7);\n    color: oklch(0.72 0.006 250);\n    border-color: oklch(0.3 0.008 285 / 0.6);\n    box-shadow: 0 1px 4px oklch(0 0 0 / 0.2);\n  }\n\n  .gv-pm-version:hover {\n    border-color: oklch(0.7 0.16 155 / 0.5);\n    background: oklch(0.28 0.008 285 / 0.8);\n  }\n\n  .gv-pm-version-outdated::after {\n    background: linear-gradient(120deg, oklch(0.72 0.18 45), oklch(0.62 0.2 28));\n    color: oklch(0.98 0.01 96);\n    box-shadow: 0 6px 16px oklch(0 0 0 / 0.3);\n  }\n}\n\n.theme-host.dark-theme .gv-pm-version,\nbody.dark-theme .gv-pm-version {\n  background: oklch(0.26 0.008 285 / 0.7);\n  color: oklch(0.72 0.006 250);\n  border-color: oklch(0.3 0.008 285 / 0.6);\n  box-shadow: 0 1px 4px oklch(0 0 0 / 0.2);\n}\n\n.theme-host.dark-theme .gv-pm-version:hover,\nbody.dark-theme .gv-pm-version:hover {\n  border-color: oklch(0.7 0.16 155 / 0.5);\n  background: oklch(0.28 0.008 285 / 0.8);\n}\n\n.gv-pm-header .gv-pm-controls {\n  margin-left: auto;\n}\n\n.gv-pm-drag {\n  margin-left: 4px;\n  margin-right: 8px;\n}\n\n.gv-pm-controls {\n  display: inline-flex;\n  gap: 6px;\n  align-items: center;\n}\n\n.gv-pm-lang {\n  background: oklch(0.995 0.002 250);\n  color: oklch(0.38 0.006 250);\n  border: 1px solid oklch(0.92 0.004 250);\n  border-radius: 10px;\n  padding: 6px 10px;\n  font-size: 12px;\n  font-weight: 500;\n  box-shadow: 0 1px 3px oklch(0 0 0 / 0.05);\n  transition: all 0.2s ease;\n}\n\n.gv-pm-lang:hover {\n  border-color: oklch(0.55 0.17 155 / 0.4);\n  box-shadow: 0 2px 6px oklch(0 0 0 / 0.06);\n}\n\n.theme-host.light-theme .gv-pm-lang,\nbody.light-theme .gv-pm-lang {\n  background: oklch(0.995 0.002 250);\n  color: oklch(0.38 0.006 250);\n  border-color: oklch(0.92 0.004 250);\n}\n\n.theme-host.light-theme .gv-pm-lang:hover,\nbody.light-theme .gv-pm-lang:hover {\n  border-color: oklch(0.55 0.17 155 / 0.4);\n  box-shadow: 0 2px 6px oklch(0 0 0 / 0.06);\n}\n\n.theme-host.dark-theme .gv-pm-lang,\nbody.dark-theme .gv-pm-lang {\n  background: oklch(0.24 0.008 285);\n  color: oklch(0.72 0.006 250);\n  border-color: oklch(0.3 0.008 285);\n}\n\n.theme-host.dark-theme .gv-pm-lang:hover,\nbody.dark-theme .gv-pm-lang:hover {\n  border-color: oklch(0.7 0.16 155 / 0.4);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-pm-lang {\n    background: oklch(0.24 0.008 285);\n    color: oklch(0.72 0.006 250);\n    border-color: oklch(0.3 0.008 285);\n  }\n\n  .gv-pm-lang:hover {\n    border-color: oklch(0.7 0.16 155 / 0.4);\n  }\n}\n\n.gv-pm-lock {\n  width: 32px;\n  height: 32px;\n  border-radius: 10px;\n  background: oklch(0.995 0.002 250);\n  border: 1px solid oklch(0.92 0.004 250);\n  position: relative;\n  box-shadow: 0 1px 3px oklch(0 0 0 / 0.05);\n  transition: all 0.2s ease;\n}\n\n.gv-pm-lock::before {\n  content: attr(data-icon);\n  position: absolute;\n  inset: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.gv-pm-lock:hover {\n  background: oklch(0.96 0.003 250);\n  border-color: oklch(0.55 0.17 155 / 0.3);\n}\n\n.gv-pm-lock.active {\n  background: oklch(0.94 0.015 155);\n  border-color: oklch(0.55 0.17 155);\n  box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.1);\n}\n\n.theme-host.light-theme .gv-pm-lock,\nbody.light-theme .gv-pm-lock {\n  background: oklch(0.995 0.002 250);\n  border-color: oklch(0.92 0.004 250);\n}\n\n.theme-host.light-theme .gv-pm-lock:hover,\nbody.light-theme .gv-pm-lock:hover {\n  background: oklch(0.96 0.003 250);\n  border-color: oklch(0.55 0.17 155 / 0.3);\n}\n\n.theme-host.light-theme .gv-pm-lock.active,\nbody.light-theme .gv-pm-lock.active {\n  background: oklch(0.94 0.015 155);\n  border-color: oklch(0.55 0.17 155);\n  box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.1);\n}\n\n.theme-host.dark-theme .gv-pm-lock,\nbody.dark-theme .gv-pm-lock {\n  background: oklch(0.24 0.008 285);\n  border-color: oklch(0.3 0.008 285);\n}\n\n.theme-host.dark-theme .gv-pm-lock:hover,\nbody.dark-theme .gv-pm-lock:hover {\n  background: oklch(0.28 0.008 285);\n  border-color: oklch(0.7 0.16 155 / 0.3);\n}\n\n.theme-host.dark-theme .gv-pm-lock.active,\nbody.dark-theme .gv-pm-lock.active {\n  background: oklch(0.28 0.008 285);\n  border-color: oklch(0.7 0.16 155);\n  box-shadow: 0 0 0 3px oklch(0.7 0.16 155 / 0.1);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-pm-lock {\n    background: oklch(0.24 0.008 285);\n    border-color: oklch(0.3 0.008 285);\n  }\n\n  .gv-pm-lock:hover {\n    background: oklch(0.28 0.008 285);\n    border-color: oklch(0.7 0.16 155 / 0.3);\n  }\n\n  .gv-pm-lock.active {\n    background: oklch(0.28 0.008 285);\n    border-color: oklch(0.7 0.16 155);\n    box-shadow: 0 0 0 3px oklch(0.7 0.16 155 / 0.1);\n  }\n}\n\n.gv-pm-add {\n  height: 33px;\n  border-radius: 10px;\n  padding: 0 16px;\n  background: oklch(0.55 0.17 155);\n  color: oklch(0.99 0.01 155);\n  border: none;\n  font-weight: 700;\n  box-shadow: 0 3px 10px oklch(0.55 0.17 155 / 0.35);\n  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n  position: relative;\n  overflow: hidden;\n}\n\n.gv-pm-add::before {\n  content: '';\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  width: 0;\n  height: 0;\n  border-radius: 50%;\n  background: rgba(255, 255, 255, 0.5);\n  transform: translate(-50%, -50%);\n  transition:\n    width 0.6s,\n    height 0.6s;\n}\n\n.gv-pm-add:active::before {\n  width: 300px;\n  height: 300px;\n  transition:\n    width 0s,\n    height 0s;\n}\n\n.gv-pm-add:hover {\n  background: oklch(0.6 0.2 158);\n  box-shadow: 0 6px 18px oklch(0.55 0.17 155 / 0.45);\n  transform: translateY(-2px);\n}\n\n.gv-pm-add:active {\n  transform: translateY(0);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-pm-add {\n    background: oklch(0.7 0.16 155);\n    color: oklch(0.15 0.025 160);\n  }\n\n  .gv-pm-add:hover {\n    background: oklch(0.7 0.2 155);\n  }\n}\n\n.gv-pm-search {\n  padding: 10px 16px;\n}\n\n.gv-pm-search input[type='search'] {\n  width: 100%;\n  border-radius: 10px;\n  background: rgba(31, 41, 55, 0.5);\n  color: #e5e7eb;\n  border: 1.5px solid rgba(255, 255, 255, 0.08);\n  padding: 9px 12px;\n  font-size: 13px;\n  box-sizing: border-box;\n  transition:\n    border-color 0.2s ease,\n    background 0.2s ease,\n    box-shadow 0.2s ease;\n  outline: none;\n}\n\n.gv-pm-search input[type='search']:focus {\n  border-color: oklch(0.55 0.17 155 / 0.5);\n  background: rgba(31, 41, 55, 0.7);\n  box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.1);\n}\n\n.theme-host.light-theme .gv-pm-search input[type='search'],\nbody.light-theme .gv-pm-search input[type='search'] {\n  background: rgba(0, 0, 0, 0.04);\n  color: #1f2937;\n  border: 1.5px solid rgba(0, 0, 0, 0.08);\n}\n\n.theme-host.light-theme .gv-pm-search input[type='search']:focus,\nbody.light-theme .gv-pm-search input[type='search']:focus {\n  border-color: oklch(0.55 0.17 155 / 0.5);\n  background: rgba(0, 0, 0, 0.02);\n  box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.08);\n}\n\n.theme-host.light-theme .gv-pm-search input[type='search']::placeholder,\nbody.light-theme .gv-pm-search input[type='search']::placeholder {\n  color: #6b7280;\n  opacity: 0.7;\n}\n\n.theme-host.dark-theme .gv-pm-search input[type='search'],\nbody.dark-theme .gv-pm-search input[type='search'] {\n  background: rgba(31, 41, 55, 0.5);\n  color: #e5e7eb;\n  border: 1.5px solid rgba(255, 255, 255, 0.08);\n}\n\n.theme-host.dark-theme .gv-pm-search input[type='search']:focus,\nbody.dark-theme .gv-pm-search input[type='search']:focus {\n  border-color: oklch(0.7 0.16 155 / 0.5);\n  background: rgba(31, 41, 55, 0.7);\n  box-shadow: 0 0 0 3px oklch(0.7 0.16 155 / 0.1);\n}\n\n.theme-host.dark-theme .gv-pm-search input[type='search']::placeholder,\nbody.dark-theme .gv-pm-search input[type='search']::placeholder {\n  color: #9ca3af;\n  opacity: 0.7;\n}\n\n.gv-pm-tags-wrap {\n  position: relative;\n  padding: 0 16px 8px 16px;\n}\n\n.gv-pm-tags-wrap::before {\n  content: '';\n  position: absolute;\n  left: 12px;\n  right: 12px;\n  bottom: 8px;\n  height: 22px;\n  background: linear-gradient(to bottom, oklch(0 0 0 / 0), var(--gv-pm-surface));\n  pointer-events: none;\n  opacity: 0;\n  transition: opacity 0.2s ease;\n}\n\n.gv-pm-tags-wrap.gv-pm-tags-scrollable:not(.gv-pm-tags-scroll-end)::before {\n  opacity: 1;\n}\n\n.gv-pm-tags {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 6px;\n  max-height: min(24vh, 180px);\n  overflow-y: auto;\n  overscroll-behavior: contain;\n  scrollbar-gutter: stable;\n}\n\n.gv-pm-tags-scroll-hint {\n  position: absolute;\n  right: 18px;\n  bottom: 10px;\n  width: 20px;\n  height: 20px;\n  border-radius: 9999px;\n  display: grid;\n  place-items: center;\n  font-size: 12px;\n  line-height: 1;\n  background: oklch(0.2 0.008 285 / 0.72);\n  color: oklch(0.92 0.004 250);\n  box-shadow: 0 4px 10px oklch(0 0 0 / 0.2);\n  pointer-events: none;\n  opacity: 0;\n  transform: translateY(2px);\n  transition:\n    opacity 0.2s ease,\n    transform 0.2s ease;\n}\n\n.gv-pm-tags-wrap.gv-pm-tags-scrollable:not(.gv-pm-tags-scroll-end) .gv-pm-tags-scroll-hint {\n  opacity: 0.9;\n  transform: translateY(0);\n}\n\n.gv-pm-tag {\n  border-radius: 9999px;\n  padding: 4px 10px;\n  font-size: 12px;\n  font-weight: 500;\n  background: rgba(55, 65, 81, 0.4);\n  color: rgba(229, 231, 235, 0.8);\n  border: none;\n  transition: all 0.15s ease;\n}\n\n.gv-pm-tag:hover {\n  background: rgba(55, 65, 81, 0.6);\n  color: #e5e7eb;\n}\n\n.gv-pm-tag.active {\n  background: oklch(0.55 0.17 155 / 0.18);\n  color: oklch(0.85 0.1 155);\n  font-weight: 600;\n}\n\n.theme-host.light-theme .gv-pm-tag,\nbody.light-theme .gv-pm-tag {\n  background: rgba(0, 0, 0, 0.05);\n  color: rgba(31, 41, 55, 0.7);\n  border: none;\n}\n\n.theme-host.light-theme .gv-pm-tag:hover,\nbody.light-theme .gv-pm-tag:hover {\n  background: rgba(0, 0, 0, 0.08);\n  color: #1f2937;\n}\n\n.theme-host.light-theme .gv-pm-tag.active,\nbody.light-theme .gv-pm-tag.active {\n  background: oklch(0.55 0.17 155 / 0.12);\n  color: oklch(0.4 0.15 155);\n}\n\n.theme-host.dark-theme .gv-pm-tag,\nbody.dark-theme .gv-pm-tag {\n  background: rgba(55, 65, 81, 0.4);\n  color: rgba(229, 231, 235, 0.8);\n  border: none;\n}\n\n.theme-host.dark-theme .gv-pm-tag:hover,\nbody.dark-theme .gv-pm-tag:hover {\n  background: rgba(55, 65, 81, 0.6);\n  color: #e5e7eb;\n}\n\n.theme-host.dark-theme .gv-pm-tag.active,\nbody.dark-theme .gv-pm-tag.active {\n  background: oklch(0.7 0.16 155 / 0.18);\n  color: oklch(0.85 0.1 155);\n}\n\n.gv-pm-add-form {\n  padding: 10px 16px 14px 16px;\n  display: grid;\n  gap: 8px;\n}\n\n.gv-pm-input-text {\n  width: 100%;\n  resize: vertical;\n  min-height: 68px;\n  max-height: 200px;\n  border-radius: 10px;\n  background: rgba(31, 41, 55, 0.5);\n  color: #e5e7eb;\n  border: 1.5px solid rgba(255, 255, 255, 0.08);\n  padding: 9px 12px;\n  font-size: 13px;\n  line-height: 1.5;\n  outline: none;\n  transition:\n    border-color 0.2s ease,\n    background 0.2s ease,\n    box-shadow 0.2s ease;\n}\n\n.gv-pm-input-text:focus {\n  border-color: oklch(0.55 0.17 155 / 0.5);\n  box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.1);\n}\n\n.gv-pm-input-tags {\n  width: 100%;\n  border-radius: 10px;\n  background: rgba(31, 41, 55, 0.5);\n  color: #e5e7eb;\n  border: 1.5px solid rgba(255, 255, 255, 0.08);\n  padding: 9px 12px;\n  font-size: 13px;\n  outline: none;\n  transition:\n    border-color 0.2s ease,\n    background 0.2s ease,\n    box-shadow 0.2s ease;\n}\n\n.gv-pm-input-tags:focus {\n  border-color: oklch(0.55 0.17 155 / 0.5);\n  box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.1);\n}\n\n.theme-host.light-theme .gv-pm-input-text,\n.theme-host.light-theme .gv-pm-input-tags,\nbody.light-theme .gv-pm-input-text,\nbody.light-theme .gv-pm-input-tags {\n  background: rgba(0, 0, 0, 0.04);\n  color: #1f2937;\n  border: 1.5px solid rgba(0, 0, 0, 0.08);\n}\n\n.theme-host.light-theme .gv-pm-input-text:focus,\n.theme-host.light-theme .gv-pm-input-tags:focus,\nbody.light-theme .gv-pm-input-text:focus,\nbody.light-theme .gv-pm-input-tags:focus {\n  border-color: oklch(0.55 0.17 155 / 0.5);\n  box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.08);\n}\n\n.theme-host.light-theme .gv-pm-input-text::placeholder,\n.theme-host.light-theme .gv-pm-input-tags::placeholder,\nbody.light-theme .gv-pm-input-text::placeholder,\nbody.light-theme .gv-pm-input-tags::placeholder {\n  color: #6b7280;\n  opacity: 0.7;\n}\n\n.theme-host.dark-theme .gv-pm-input-text,\n.theme-host.dark-theme .gv-pm-input-tags,\nbody.dark-theme .gv-pm-input-text,\nbody.dark-theme .gv-pm-input-tags {\n  background: rgba(31, 41, 55, 0.5);\n  color: #e5e7eb;\n  border: 1.5px solid rgba(255, 255, 255, 0.08);\n}\n\n.theme-host.dark-theme .gv-pm-input-text:focus,\n.theme-host.dark-theme .gv-pm-input-tags:focus,\nbody.dark-theme .gv-pm-input-text:focus,\nbody.dark-theme .gv-pm-input-tags:focus {\n  border-color: oklch(0.7 0.16 155 / 0.5);\n  box-shadow: 0 0 0 3px oklch(0.7 0.16 155 / 0.1);\n}\n\n.theme-host.dark-theme .gv-pm-input-text::placeholder,\n.theme-host.dark-theme .gv-pm-input-tags::placeholder,\nbody.dark-theme .gv-pm-input-text::placeholder,\nbody.dark-theme .gv-pm-input-tags::placeholder {\n  color: #9ca3af;\n  opacity: 0.7;\n}\n\n.gv-pm-add-actions {\n  display: flex;\n  gap: 8px;\n  justify-content: flex-end;\n}\n\n.gv-pm-inline-hint {\n  margin-right: auto;\n  font-size: 12px;\n  color: #fca5a5;\n  opacity: 0.95;\n}\n\n.gv-pm-inline-hint.ok {\n  color: #86efac;\n}\n\n.gv-pm-save {\n  background: oklch(0.55 0.17 155);\n  color: oklch(0.99 0.01 155);\n  border-radius: 9px;\n  padding: 6px 12px;\n  font-weight: 700;\n  box-shadow: 0 2px 8px oklch(0.55 0.17 155 / 0.3);\n  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.gv-pm-save:hover {\n  background: oklch(0.6 0.2 158);\n  box-shadow: 0 4px 14px oklch(0.55 0.17 155 / 0.4);\n  transform: translateY(-1px);\n}\n\n.theme-host.dark-theme .gv-pm-save,\nbody.dark-theme .gv-pm-save {\n  background: oklch(0.7 0.16 155);\n  color: oklch(0.15 0.025 160);\n  box-shadow: 0 2px 8px oklch(0.7 0.16 155 / 0.3);\n}\n\n.theme-host.dark-theme .gv-pm-save:hover,\nbody.dark-theme .gv-pm-save:hover {\n  background: oklch(0.7 0.2 155);\n  box-shadow: 0 4px 14px oklch(0.7 0.16 155 / 0.4);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-pm-save {\n    background: oklch(0.7 0.16 155);\n    color: oklch(0.15 0.025 160);\n    box-shadow: 0 2px 8px oklch(0.7 0.16 155 / 0.3);\n  }\n\n  .gv-pm-save:hover {\n    background: oklch(0.7 0.2 155);\n    box-shadow: 0 4px 14px oklch(0.7 0.16 155 / 0.4);\n  }\n}\n\n.gv-pm-cancel {\n  background: transparent;\n  color: #e5e7eb;\n  border-radius: 8px;\n  padding: 6px 10px;\n  border: 1px solid rgba(255, 255, 255, 0.12);\n}\n\n.gv-pm-cancel:hover {\n  background: rgba(255, 255, 255, 0.06);\n}\n\n.theme-host.light-theme .gv-pm-cancel,\nbody.light-theme .gv-pm-cancel {\n  color: #1f2937;\n  border: 1px solid rgba(0, 0, 0, 0.12);\n}\n\n.theme-host.light-theme .gv-pm-cancel:hover,\nbody.light-theme .gv-pm-cancel:hover {\n  background: rgba(0, 0, 0, 0.06);\n}\n\n.theme-host.dark-theme .gv-pm-cancel,\nbody.dark-theme .gv-pm-cancel {\n  color: #e5e7eb;\n  border: 1px solid rgba(255, 255, 255, 0.12);\n}\n\n.theme-host.dark-theme .gv-pm-cancel:hover,\nbody.dark-theme .gv-pm-cancel:hover {\n  background: rgba(255, 255, 255, 0.06);\n}\n\n.gv-pm-list {\n  padding: 6px 16px 12px 16px;\n  min-height: 120px;\n  flex: 1 1 auto;\n  overflow: auto;\n}\n\n.gv-pm-empty {\n  opacity: 0.5;\n  font-size: 13px;\n  padding: 24px 16px;\n  text-align: center;\n  line-height: 1.5;\n}\n\n.gv-pm-item {\n  display: grid;\n  grid-template-columns: 1fr;\n  grid-template-rows: auto auto;\n  gap: 6px;\n  align-items: start;\n  padding: 10px 12px;\n  border-radius: 10px;\n  border: none;\n  background: rgba(255, 255, 255, 0.05);\n  position: relative;\n  transition: background 0.15s ease;\n}\n\n.gv-pm-item:hover {\n  background: rgba(255, 255, 255, 0.09);\n}\n\n/* Hide action buttons by default, reveal on hover */\n.gv-pm-item .gv-pm-actions {\n  opacity: 0;\n  transition: opacity 0.15s ease;\n}\n\n.gv-pm-item:hover .gv-pm-actions {\n  opacity: 1;\n}\n\n.gv-pm-item-text-container {\n  grid-column: 1;\n  grid-row: 1;\n  display: flex;\n  align-items: flex-start;\n  gap: 8px;\n  width: 100%;\n}\n\n.gv-pm-item-text {\n  background: transparent;\n  border: none;\n  padding: 0;\n  color: #e5e7eb;\n  text-align: left;\n  white-space: normal;\n  word-break: break-word;\n  overflow-wrap: anywhere;\n  flex: 1;\n  min-width: 0;\n  cursor: pointer;\n}\n\n.gv-pm-item-text:hover {\n  opacity: 0.85;\n}\n\n.gv-md-collapsed {\n  max-height: 7.5em;\n  overflow: hidden;\n  position: relative;\n  display: -webkit-box;\n  -webkit-line-clamp: 5;\n  -webkit-box-orient: vertical;\n  line-clamp: 5;\n}\n\n.gv-md-collapsed::after {\n  content: '...';\n  position: absolute;\n  right: 0;\n  bottom: 0;\n  padding-left: 1.5em;\n  background: linear-gradient(to right, transparent, oklch(0.22 0.006 285) 50%);\n}\n\n/* Ensure markdown content overflow handling */\n.gv-md {\n  width: 100%;\n  min-width: 0;\n}\n\n.gv-md pre,\n.gv-md code {\n  white-space: pre-wrap;\n  word-wrap: break-word;\n  overflow-wrap: anywhere;\n  max-width: 100%;\n}\n\n.gv-md pre {\n  margin: 0;\n  padding: 4px;\n  background: rgba(0, 0, 0, 0.2);\n  border-radius: 4px;\n}\n\n.theme-host.light-theme .gv-md pre,\nbody.light-theme .gv-md pre {\n  background: rgba(0, 0, 0, 0.05);\n}\n\n.theme-host.light-theme .gv-md-collapsed::after,\nbody.light-theme .gv-md-collapsed::after {\n  background: linear-gradient(to right, transparent, oklch(0.955 0.002 250) 50%);\n}\n\n.theme-host.dark-theme .gv-md-collapsed::after,\nbody.dark-theme .gv-md-collapsed::after {\n  background: linear-gradient(to right, transparent, oklch(0.22 0.006 285) 50%);\n}\n\n.gv-pm-expand-btn {\n  background: transparent;\n  border: none;\n  color: rgba(156, 163, 175, 0.6);\n  padding: 2px 4px;\n  border-radius: 4px;\n  font-size: 10px;\n  cursor: pointer;\n  flex-shrink: 0;\n  transition: all 0.15s ease;\n  line-height: 1;\n  height: 20px;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.gv-pm-expand-btn:hover {\n  background: rgba(255, 255, 255, 0.06);\n  color: #d1d5db;\n}\n\n.theme-host.light-theme .gv-pm-expand-btn,\nbody.light-theme .gv-pm-expand-btn {\n  border: none;\n  color: rgba(107, 114, 128, 0.5);\n}\n\n.theme-host.light-theme .gv-pm-expand-btn:hover,\nbody.light-theme .gv-pm-expand-btn:hover {\n  background: rgba(0, 0, 0, 0.04);\n  color: #374151;\n}\n\n.theme-host.dark-theme .gv-pm-expand-btn,\nbody.dark-theme .gv-pm-expand-btn {\n  border: none;\n  color: rgba(156, 163, 175, 0.6);\n}\n\n.theme-host.dark-theme .gv-pm-expand-btn:hover,\nbody.dark-theme .gv-pm-expand-btn:hover {\n  background: rgba(255, 255, 255, 0.06);\n  color: #d1d5db;\n}\n\n.theme-host.light-theme .gv-pm-item,\nbody.light-theme .gv-pm-item {\n  background: rgba(0, 0, 0, 0.04);\n  border: none;\n}\n\n.theme-host.light-theme .gv-pm-item:hover,\nbody.light-theme .gv-pm-item:hover {\n  background: rgba(0, 0, 0, 0.07);\n}\n\n.theme-host.light-theme .gv-pm-item-text,\nbody.light-theme .gv-pm-item-text {\n  color: #1f2937;\n}\n\n.theme-host.dark-theme .gv-pm-item,\nbody.dark-theme .gv-pm-item {\n  background: rgba(255, 255, 255, 0.05);\n  border: none;\n}\n\n.theme-host.dark-theme .gv-pm-item:hover,\nbody.dark-theme .gv-pm-item:hover {\n  background: rgba(255, 255, 255, 0.09);\n}\n\n.theme-host.dark-theme .gv-pm-item-text,\nbody.dark-theme .gv-pm-item-text {\n  color: #e5e7eb;\n}\n\n.gv-pm-edit,\n.gv-pm-del {\n  width: 28px;\n  height: 28px;\n  border-radius: 8px;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  padding: 0;\n  border: none;\n  transition: background 0.15s ease;\n}\n\n.gv-pm-edit {\n  background: transparent;\n  color: rgba(229, 231, 235, 0.5);\n}\n\n.gv-pm-edit:hover {\n  background: rgba(255, 255, 255, 0.08);\n  color: #e5e7eb;\n}\n\n.gv-pm-item + .gv-pm-item {\n  margin-top: 4px;\n}\n\n.gv-pm-bottom {\n  grid-row: 2;\n  grid-column: 1;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n}\n\n.gv-pm-item-meta {\n  display: flex;\n  gap: 6px;\n  flex-wrap: wrap;\n  align-items: center;\n  min-width: 0;\n}\n\n.gv-pm-chip {\n  font-size: 10px;\n  font-weight: 600;\n  letter-spacing: 0.02em;\n  border-radius: 999px;\n  padding: 2px 8px;\n  background: oklch(0.7 0.16 155 / 0.12);\n  color: oklch(0.8 0.08 155);\n  border: none;\n  cursor: pointer;\n  transition: all 0.15s ease;\n}\n\n.gv-pm-chip:hover {\n  background: oklch(0.7 0.16 155 / 0.22);\n}\n\n.theme-host.light-theme .gv-pm-chip,\nbody.light-theme .gv-pm-chip {\n  background: oklch(0.55 0.17 155 / 0.08);\n  color: oklch(0.4 0.12 155);\n  border: none;\n}\n\n.theme-host.light-theme .gv-pm-chip:hover,\nbody.light-theme .gv-pm-chip:hover {\n  background: oklch(0.55 0.17 155 / 0.15);\n}\n\n.theme-host.dark-theme .gv-pm-chip,\nbody.dark-theme .gv-pm-chip {\n  background: oklch(0.7 0.16 155 / 0.12);\n  color: oklch(0.8 0.08 155);\n  border: none;\n}\n\n.gv-pm-actions {\n  display: inline-flex;\n  gap: 8px;\n}\n\n.gv-pm-del {\n  background: transparent;\n  color: rgba(229, 231, 235, 0.5);\n  border: none;\n  position: relative;\n}\n\n.gv-pm-del.armed {\n  background: rgba(239, 68, 68, 0.2);\n  color: #fca5a5;\n}\n\n.gv-pm-del:hover {\n  background: rgba(239, 68, 68, 0.12);\n  color: #fca5a5;\n}\n\n.theme-host.light-theme .gv-pm-edit,\nbody.light-theme .gv-pm-edit {\n  background: transparent;\n  border: none;\n  color: rgba(31, 41, 55, 0.4);\n}\n\n.theme-host.light-theme .gv-pm-edit:hover,\nbody.light-theme .gv-pm-edit:hover {\n  background: rgba(0, 0, 0, 0.06);\n  color: #1f2937;\n}\n\n.theme-host.light-theme .gv-pm-del,\nbody.light-theme .gv-pm-del {\n  background: transparent;\n  color: rgba(31, 41, 55, 0.4);\n  border: none;\n}\n\n.theme-host.light-theme .gv-pm-del:hover,\nbody.light-theme .gv-pm-del:hover {\n  background: rgba(239, 68, 68, 0.08);\n  color: #dc2626;\n}\n\n.theme-host.light-theme .gv-pm-del.armed,\nbody.light-theme .gv-pm-del.armed {\n  background: rgba(239, 68, 68, 0.15);\n  color: #dc2626;\n}\n\n.theme-host.dark-theme .gv-pm-edit,\nbody.dark-theme .gv-pm-edit {\n  background: transparent;\n  border: none;\n  color: rgba(229, 231, 235, 0.5);\n}\n\n.theme-host.dark-theme .gv-pm-edit:hover,\nbody.dark-theme .gv-pm-edit:hover {\n  background: rgba(255, 255, 255, 0.08);\n  color: #e5e7eb;\n}\n\n.theme-host.dark-theme .gv-pm-del,\nbody.dark-theme .gv-pm-del {\n  background: transparent;\n  color: rgba(229, 231, 235, 0.5);\n  border: none;\n}\n\n.theme-host.dark-theme .gv-pm-del:hover,\nbody.dark-theme .gv-pm-del:hover {\n  background: rgba(239, 68, 68, 0.12);\n  color: #fca5a5;\n}\n\n.theme-host.dark-theme .gv-pm-del.armed,\nbody.dark-theme .gv-pm-del.armed {\n  background: rgba(239, 68, 68, 0.2);\n  color: #fca5a5;\n}\n\n.gv-pm-edit::before,\n.gv-pm-del::before {\n  content: '';\n  width: 16px;\n  height: 16px;\n  display: block;\n  background: currentColor;\n  -webkit-mask: var(--gv-icon) center / contain no-repeat;\n  mask: var(--gv-icon) center / contain no-repeat;\n}\n\n.gv-pm-edit::before {\n  --gv-icon: url('data:image/svg+xml,%3csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22%3e%3cpath fill=%22%23000%22 d=%22M10.9 1.6l3.5 3.5-8.8 8.8H2v-3.6l8.9-8.7zM2 14h12v1H2z%22/%3e%3c/svg%3e');\n}\n\n.gv-pm-del::before {\n  --gv-icon: url('data:image/svg+xml,%3csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22%3e%3cpath fill=%22%23000%22 d=%22M6 2h4l1 1h3v1H2V3h3l1-1zm-1 4h1v7H5V6zm3 0h1v7H8V6zm-2 0h1v7H6V6zm6 0l-1 8H5L4 6h8z%22/%3e%3c/svg%3e');\n}\n\n.gv-pm-footer {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n  padding: 12px 16px 14px 16px;\n  border-top: 1px solid rgba(255, 255, 255, 0.06);\n}\n\n.theme-host.light-theme .gv-pm-footer,\nbody.light-theme .gv-pm-footer {\n  border-top: 1px solid rgba(0, 0, 0, 0.06);\n}\n\n.theme-host.dark-theme .gv-pm-footer,\nbody.dark-theme .gv-pm-footer {\n  border-top: 1px solid rgba(255, 255, 255, 0.06);\n}\n\n/* Footer button groups */\n.gv-pm-footer-actions {\n  display: flex;\n  gap: 8px;\n  width: 100%;\n}\n\n.gv-pm-footer-secondary {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  width: 100%;\n}\n\n.gv-pm-import-input {\n  display: none;\n}\n\n/* Primary actions row (Backup & Docs) */\n.gv-pm-backup-btn,\n.gv-pm-website-btn {\n  height: 34px;\n  border-radius: 9px;\n  padding: 0 14px;\n  background: oklch(0.55 0.17 155 / 0.1);\n  border: none;\n  color: oklch(0.55 0.17 155);\n  flex: 1;\n  font-size: 12px;\n  font-weight: 600;\n  white-space: nowrap;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 6px;\n  box-sizing: border-box;\n  cursor: pointer;\n  transition: all 0.15s ease;\n  text-decoration: none;\n}\n\n.gv-pm-backup-btn:hover,\n.gv-pm-website-btn:hover {\n  background: oklch(0.55 0.17 155 / 0.18);\n}\n\n.gv-pm-backup-btn:active,\n.gv-pm-website-btn:active {\n  background: oklch(0.55 0.17 155 / 0.25);\n}\n\n.theme-host.dark-theme .gv-pm-backup-btn,\n.theme-host.dark-theme .gv-pm-website-btn,\nbody.dark-theme .gv-pm-backup-btn,\nbody.dark-theme .gv-pm-website-btn {\n  background: oklch(0.7 0.16 155 / 0.1);\n  color: oklch(0.7 0.16 155);\n}\n\n.theme-host.dark-theme .gv-pm-backup-btn:hover,\n.theme-host.dark-theme .gv-pm-website-btn:hover,\nbody.dark-theme .gv-pm-backup-btn:hover,\nbody.dark-theme .gv-pm-website-btn:hover {\n  background: oklch(0.7 0.16 155 / 0.18);\n}\n\n/* Secondary buttons (Import/Export) */\n.gv-pm-import-btn,\n.gv-pm-export-btn {\n  height: 30px;\n  border-radius: 8px;\n  padding: 0 10px;\n  background: transparent;\n  border: none;\n  color: rgba(229, 231, 235, 0.6);\n  flex: 1 1 auto;\n  min-width: 0;\n  font-size: 11px;\n  font-weight: 500;\n  white-space: nowrap;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  box-sizing: border-box;\n  cursor: pointer;\n  transition: all 0.15s ease;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.gv-pm-import-btn:hover,\n.gv-pm-export-btn:hover {\n  background: rgba(255, 255, 255, 0.06);\n  color: #e5e7eb;\n}\n\n.theme-host.light-theme .gv-pm-import-btn,\n.theme-host.light-theme .gv-pm-export-btn,\nbody.light-theme .gv-pm-import-btn,\nbody.light-theme .gv-pm-export-btn {\n  background: transparent;\n  border: none;\n  color: rgba(31, 41, 55, 0.5);\n}\n\n.theme-host.light-theme .gv-pm-import-btn:hover,\n.theme-host.light-theme .gv-pm-export-btn:hover,\nbody.light-theme .gv-pm-import-btn:hover,\nbody.light-theme .gv-pm-export-btn:hover {\n  background: rgba(0, 0, 0, 0.04);\n  color: #1f2937;\n}\n\n.theme-host.dark-theme .gv-pm-import-btn,\n.theme-host.dark-theme .gv-pm-export-btn,\nbody.dark-theme .gv-pm-import-btn,\nbody.dark-theme .gv-pm-export-btn {\n  background: transparent;\n  border: none;\n  color: rgba(229, 231, 235, 0.6);\n}\n\n.theme-host.dark-theme .gv-pm-import-btn:hover,\n.theme-host.dark-theme .gv-pm-export-btn:hover,\nbody.dark-theme .gv-pm-import-btn:hover,\nbody.dark-theme .gv-pm-export-btn:hover {\n  background: rgba(255, 255, 255, 0.06);\n  color: #e5e7eb;\n}\n\n/* Settings button */\n.gv-pm-settings {\n  height: 30px;\n  border-radius: 8px;\n  padding: 0 10px;\n  background: transparent;\n  border: none;\n  color: rgba(229, 231, 235, 0.6);\n  flex: 1 1 auto;\n  min-width: 0;\n  font-size: 11px;\n  font-weight: 500;\n  white-space: nowrap;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 4px;\n  box-sizing: border-box;\n  cursor: pointer;\n  transition: all 0.15s ease;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.gv-pm-settings::before {\n  content: '⚙';\n  font-size: 13px;\n}\n\n.gv-pm-settings:hover {\n  background: rgba(255, 255, 255, 0.06);\n  color: #e5e7eb;\n}\n\n.theme-host.light-theme .gv-pm-settings,\nbody.light-theme .gv-pm-settings {\n  background: transparent;\n  border: none;\n  color: rgba(31, 41, 55, 0.5);\n}\n\n.theme-host.light-theme .gv-pm-settings:hover,\nbody.light-theme .gv-pm-settings:hover {\n  background: rgba(0, 0, 0, 0.04);\n  color: #1f2937;\n}\n\n.theme-host.dark-theme .gv-pm-settings,\nbody.dark-theme .gv-pm-settings {\n  background: transparent;\n  border: none;\n  color: rgba(229, 231, 235, 0.6);\n}\n\n.theme-host.dark-theme .gv-pm-settings:hover,\nbody.dark-theme .gv-pm-settings:hover {\n  background: rgba(255, 255, 255, 0.06);\n  color: #e5e7eb;\n}\n\n/* GitHub link button */\n.gv-pm-gh {\n  height: 30px;\n  border-radius: 8px;\n  padding: 0 10px;\n  border: none;\n  background: transparent;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 4px;\n  cursor: pointer;\n  transition: all 0.15s ease;\n  text-decoration: none;\n  font-size: 11px;\n  font-weight: 500;\n  color: rgba(229, 231, 235, 0.6);\n  white-space: nowrap;\n  flex: 1 1 auto;\n  min-width: 0;\n  box-sizing: border-box;\n  overflow: hidden;\n}\n\n.gv-pm-gh:hover {\n  background: rgba(255, 255, 255, 0.06);\n  color: #e5e7eb;\n}\n\n.gv-pm-gh-icon {\n  width: 14px;\n  height: 14px;\n  display: block;\n  background: #e5e7eb;\n  flex-shrink: 0;\n  -webkit-mask: url('data:image/svg+xml,%3csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22%3e%3cpath fill=%22%23000%22 d=%22M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8 8 0 0016 8c0-4.42-3.58-8-8-8z%22/%3e%3c/svg%3e')\n    center / contain no-repeat;\n  mask: url('data:image/svg+xml,%3csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22%3e%3cpath fill=%22%23000%22 d=%22M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8 8 0 0016 8c0-4.42-3.58-8-8-8z%22/%3e%3c/svg%3e')\n    center / contain no-repeat;\n}\n\n.gv-pm-gh-text {\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  min-width: 0;\n  flex: 0 1 auto;\n  text-align: center;\n}\n\n.theme-host.light-theme .gv-pm-gh,\nbody.light-theme .gv-pm-gh {\n  background: transparent;\n  border: none;\n  color: rgba(31, 41, 55, 0.5);\n}\n\n.theme-host.light-theme .gv-pm-gh:hover,\nbody.light-theme .gv-pm-gh:hover {\n  background: rgba(0, 0, 0, 0.04);\n  color: #1f2937;\n}\n\n.theme-host.light-theme .gv-pm-gh-icon,\nbody.light-theme .gv-pm-gh-icon {\n  background: #1f2937;\n}\n\n.theme-host.dark-theme .gv-pm-gh,\nbody.dark-theme .gv-pm-gh {\n  background: transparent;\n  border: none;\n  color: rgba(229, 231, 235, 0.6);\n}\n\n.theme-host.dark-theme .gv-pm-gh:hover,\nbody.dark-theme .gv-pm-gh:hover {\n  background: rgba(255, 255, 255, 0.06);\n  color: #e5e7eb;\n}\n\n.theme-host.dark-theme .gv-pm-gh-icon,\nbody.dark-theme .gv-pm-gh-icon {\n  background: #e5e7eb;\n}\n\n/* Notice message - floating toast style */\n.gv-pm-notice {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n  padding: 10px 20px;\n  border-radius: 8px;\n  font-size: 13px;\n  font-weight: 500;\n  line-height: 1.4;\n  white-space: nowrap;\n  pointer-events: none;\n  z-index: 100;\n  opacity: 0;\n  transition:\n    opacity 0.2s ease,\n    transform 0.2s ease;\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n}\n\n.gv-pm-notice:not(:empty) {\n  opacity: 1;\n  transform: translate(-50%, -50%) scale(1);\n}\n\n.gv-pm-notice:empty {\n  transform: translate(-50%, -50%) scale(0.9);\n}\n\n.gv-pm-notice.ok {\n  background: rgba(34, 197, 94, 0.95);\n  color: #ffffff;\n}\n\n.gv-pm-notice.err {\n  background: rgba(239, 68, 68, 0.95);\n  color: #ffffff;\n}\n\n.theme-host.light-theme .gv-pm-notice.ok,\nbody.light-theme .gv-pm-notice.ok {\n  background: rgba(34, 197, 94, 0.95);\n  color: #ffffff;\n}\n\n.theme-host.light-theme .gv-pm-notice.err,\nbody.light-theme .gv-pm-notice.err {\n  background: rgba(239, 68, 68, 0.95);\n  color: #ffffff;\n}\n\n.theme-host.dark-theme .gv-pm-notice.ok,\nbody.dark-theme .gv-pm-notice.ok {\n  background: rgba(34, 197, 94, 0.95);\n  color: #ffffff;\n}\n\n.theme-host.dark-theme .gv-pm-notice.err,\nbody.dark-theme .gv-pm-notice.err {\n  background: rgba(239, 68, 68, 0.95);\n  color: #ffffff;\n}\n\n/* Inline confirm popover for delete (floating and responsive) */\n.gv-pm-confirm {\n  position: fixed;\n  background: rgba(2, 6, 23, 0.96);\n  color: #e5e7eb;\n  border: 1px solid rgba(255, 255, 255, 0.12);\n  border-radius: 10px;\n  padding: 6px 8px;\n  display: inline-flex;\n  gap: 8px;\n  align-items: center;\n  z-index: 2147483646;\n  max-width: 280px;\n  box-shadow: 0 8px 24px rgba(2, 6, 23, 0.4);\n}\n\n.gv-pm-confirm span {\n  font-size: 12px;\n  opacity: 0.95;\n  line-height: 1.3;\n  white-space: normal;\n  flex: 1;\n}\n\n.gv-pm-confirm button {\n  padding: 2px 8px;\n  border-radius: 6px;\n  border: 1px solid rgba(255, 255, 255, 0.18);\n  background: rgba(31, 41, 55, 0.6);\n  color: #e5e7eb;\n  white-space: nowrap;\n  font-size: 12px;\n}\n\n.gv-pm-confirm .gv-pm-confirm-yes {\n  background: rgba(239, 68, 68, 0.18);\n  border-color: rgba(239, 68, 68, 0.38);\n  color: #fecaca;\n}\n\n.gv-pm-confirm button:hover {\n  filter: brightness(1.05);\n}\n\n.gv-pm-confirm::after {\n  content: '';\n  position: absolute;\n  width: 10px;\n  height: 10px;\n  background: rgba(2, 6, 23, 0.96);\n  border: 1px solid rgba(255, 255, 255, 0.12);\n  transform: rotate(45deg);\n}\n\n.gv-pm-confirm[data-side='left']::after {\n  right: -6px;\n  top: 50%;\n  transform: translateY(-50%) rotate(45deg);\n  border-left: none;\n  border-bottom: none;\n}\n\n.gv-pm-confirm[data-side='right']::after {\n  left: -6px;\n  top: 50%;\n  transform: translateY(-50%) rotate(45deg);\n  border-right: none;\n  border-top: none;\n}\n\n.gv-pm-drag {\n  width: 10px;\n  height: 14px;\n  margin-left: -6px;\n  margin-right: 6px;\n  position: relative;\n  opacity: 0.7;\n  cursor: move;\n}\n\n.gv-pm-drag::before {\n  content: '⠿';\n  position: absolute;\n  inset: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 10px;\n}\n\n.gv-locked .gv-pm-drag {\n  cursor: default;\n  opacity: 0.9;\n}\n\n/* ========================================\n   PM Theme Toggle\n   ======================================== */\n\n.gv-pm-theme-toggle {\n  width: 38px;\n  height: 20px;\n  border-radius: 999px;\n  position: relative;\n  cursor: pointer;\n  flex-shrink: 0;\n  border: none;\n  padding: 0;\n  background: linear-gradient(135deg, oklch(0.82 0.12 80), oklch(0.72 0.15 50));\n  box-shadow:\n    inset 0 1px 2px oklch(0 0 0 / 0.08),\n    0 1px 3px oklch(0 0 0 / 0.06);\n  transition:\n    background 0.4s cubic-bezier(0.4, 0, 0.2, 1),\n    box-shadow 0.3s ease;\n  outline: none;\n}\n\n/* Thumb (white circle) */\n.gv-pm-theme-toggle::before {\n  content: '';\n  position: absolute;\n  top: 2px;\n  left: 2px;\n  width: 16px;\n  height: 16px;\n  border-radius: 50%;\n  background: white;\n  box-shadow: 0 1px 4px oklch(0 0 0 / 0.18);\n  transition:\n    transform 0.4s cubic-bezier(0.4, 0, 0.2, 1),\n    background 0.4s ease,\n    box-shadow 0.4s ease;\n}\n\n/* Emoji on the thumb */\n.gv-pm-theme-toggle::after {\n  content: '☀️';\n  position: absolute;\n  top: 2px;\n  left: 2px;\n  width: 16px;\n  height: 16px;\n  display: grid;\n  place-items: center;\n  font-size: 10px;\n  line-height: 1;\n  pointer-events: none;\n  z-index: 1;\n  transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.gv-pm-theme-toggle:hover {\n  box-shadow:\n    inset 0 1px 2px oklch(0 0 0 / 0.1),\n    0 2px 8px oklch(0 0 0 / 0.12);\n}\n\n.gv-pm-theme-toggle:focus-visible {\n  box-shadow:\n    0 0 0 2px oklch(0.55 0.17 155 / 0.4),\n    inset 0 1px 2px oklch(0 0 0 / 0.08);\n}\n\n/* Dark state — vibrant purple gradient */\n.gv-pm-theme-toggle.gv-pm-theme-dark {\n  background: linear-gradient(135deg, oklch(0.45 0.18 285), oklch(0.35 0.22 300));\n}\n\n.gv-pm-theme-toggle.gv-pm-theme-dark::before {\n  transform: translateX(18px);\n  background: white;\n  box-shadow: 0 1px 4px oklch(0 0 0 / 0.25);\n}\n\n.gv-pm-theme-toggle.gv-pm-theme-dark::after {\n  content: '🌙';\n  transform: translateX(18px);\n}\n\n/* Smooth panel theme transition */\n.gv-pm-panel.gv-pm-transitioning,\n.gv-pm-panel.gv-pm-transitioning * {\n  transition:\n    background 0.4s ease,\n    color 0.4s ease,\n    border-color 0.4s ease,\n    box-shadow 0.4s ease !important;\n}\n\n/* ========================================\n   PM Theme Override: Dark\n   (user forces dark on any page)\n   ======================================== */\n\n.gv-pm-panel.gv-pm-panel[data-gv-theme='dark'] {\n  --gv-pm-surface: oklch(0.2 0.008 285);\n  background: oklch(0.2 0.008 285);\n  color: oklch(0.92 0.004 250);\n  border-color: oklch(0.3 0.008 285 / 0.7);\n  box-shadow:\n    0 16px 48px oklch(0 0 0 / 0.4),\n    0 6px 20px oklch(0 0 0 / 0.25);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-header {\n  border-bottom-color: oklch(0.3 0.008 285 / 0.5);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-title {\n  color: oklch(0.92 0.004 250);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-version {\n  background: oklch(0.26 0.008 285 / 0.7);\n  color: oklch(0.72 0.006 250);\n  border-color: oklch(0.3 0.008 285 / 0.6);\n  box-shadow: 0 1px 4px oklch(0 0 0 / 0.2);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-version:hover {\n  border-color: oklch(0.7 0.16 155 / 0.5);\n  background: oklch(0.28 0.008 285 / 0.8);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-lang {\n  background: oklch(0.24 0.008 285);\n  color: oklch(0.72 0.006 250);\n  border-color: oklch(0.3 0.008 285);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-lang:hover {\n  border-color: oklch(0.7 0.16 155 / 0.4);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-add {\n  background: oklch(0.7 0.16 155);\n  color: oklch(0.15 0.025 160);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-add:hover {\n  background: oklch(0.7 0.2 155);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-lock {\n  background: oklch(0.24 0.008 285);\n  border-color: oklch(0.3 0.008 285);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-lock:hover {\n  background: oklch(0.28 0.008 285);\n  border-color: oklch(0.7 0.16 155 / 0.3);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-lock.active {\n  background: oklch(0.28 0.008 285);\n  border-color: oklch(0.7 0.16 155);\n  box-shadow: 0 0 0 3px oklch(0.7 0.16 155 / 0.1);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-search input[type='search'] {\n  background: rgba(31, 41, 55, 0.5);\n  color: #e5e7eb;\n  border: 1.5px solid rgba(255, 255, 255, 0.08);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-search input[type='search']:focus {\n  border-color: oklch(0.7 0.16 155 / 0.5);\n  background: rgba(31, 41, 55, 0.7);\n  box-shadow: 0 0 0 3px oklch(0.7 0.16 155 / 0.1);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-search input[type='search']::placeholder {\n  color: #9ca3af;\n  opacity: 0.7;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-tag {\n  background: rgba(55, 65, 81, 0.4);\n  color: rgba(229, 231, 235, 0.8);\n  border: none;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-tag:hover {\n  background: rgba(55, 65, 81, 0.6);\n  color: #e5e7eb;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-tag.active {\n  background: oklch(0.7 0.16 155 / 0.18);\n  color: oklch(0.85 0.1 155);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-input-text,\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-input-tags {\n  background: rgba(31, 41, 55, 0.5);\n  color: #e5e7eb;\n  border: 1.5px solid rgba(255, 255, 255, 0.08);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-input-text:focus,\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-input-tags:focus {\n  border-color: oklch(0.7 0.16 155 / 0.5);\n  box-shadow: 0 0 0 3px oklch(0.7 0.16 155 / 0.1);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-input-text::placeholder,\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-input-tags::placeholder {\n  color: #9ca3af;\n  opacity: 0.7;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-save {\n  background: oklch(0.7 0.16 155);\n  color: oklch(0.15 0.025 160);\n  box-shadow: 0 2px 8px oklch(0.7 0.16 155 / 0.3);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-save:hover {\n  background: oklch(0.7 0.2 155);\n  box-shadow: 0 4px 14px oklch(0.7 0.16 155 / 0.4);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-cancel {\n  color: #e5e7eb;\n  border: 1px solid rgba(255, 255, 255, 0.12);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-cancel:hover {\n  background: rgba(255, 255, 255, 0.06);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-md-collapsed::after {\n  background: linear-gradient(to right, transparent, oklch(0.22 0.006 285) 50%);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-expand-btn {\n  border: none;\n  color: rgba(156, 163, 175, 0.6);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-expand-btn:hover {\n  background: rgba(255, 255, 255, 0.06);\n  color: #d1d5db;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-item {\n  background: rgba(255, 255, 255, 0.05);\n  border: none;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-item:hover {\n  background: rgba(255, 255, 255, 0.09);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-item-text {\n  color: #e5e7eb;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-chip {\n  background: oklch(0.7 0.16 155 / 0.12);\n  color: oklch(0.8 0.08 155);\n  border: none;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-edit {\n  background: transparent;\n  border: none;\n  color: rgba(229, 231, 235, 0.5);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-edit:hover {\n  background: rgba(255, 255, 255, 0.08);\n  color: #e5e7eb;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-del {\n  background: transparent;\n  color: rgba(229, 231, 235, 0.5);\n  border: none;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-del:hover {\n  background: rgba(239, 68, 68, 0.12);\n  color: #fca5a5;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-del.armed {\n  background: rgba(239, 68, 68, 0.2);\n  color: #fca5a5;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-footer {\n  border-top: 1px solid rgba(255, 255, 255, 0.06);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-backup-btn,\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-website-btn {\n  background: oklch(0.7 0.16 155 / 0.1);\n  color: oklch(0.7 0.16 155);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-backup-btn:hover,\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-website-btn:hover {\n  background: oklch(0.7 0.16 155 / 0.18);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-import-btn,\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-export-btn {\n  background: transparent;\n  border: none;\n  color: rgba(229, 231, 235, 0.6);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-import-btn:hover,\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-export-btn:hover {\n  background: rgba(255, 255, 255, 0.06);\n  color: #e5e7eb;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-settings {\n  background: transparent;\n  border: none;\n  color: rgba(229, 231, 235, 0.6);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-settings:hover {\n  background: rgba(255, 255, 255, 0.06);\n  color: #e5e7eb;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-gh {\n  background: transparent;\n  border: none;\n  color: rgba(229, 231, 235, 0.6);\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-gh:hover {\n  background: rgba(255, 255, 255, 0.06);\n  color: #e5e7eb;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-gh-icon {\n  background: #e5e7eb;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-notice.ok {\n  background: rgba(34, 197, 94, 0.95);\n  color: #ffffff;\n}\n\n.gv-pm-panel[data-gv-theme='dark'] .gv-pm-notice.err {\n  background: rgba(239, 68, 68, 0.95);\n  color: #ffffff;\n}\n\n/* ========================================\n   PM Theme Override: Light\n   (user forces light on any page)\n   ======================================== */\n\n.gv-pm-panel.gv-pm-panel[data-gv-theme='light'] {\n  --gv-pm-surface: oklch(0.995 0.002 250);\n  background: oklch(0.995 0.002 250);\n  color: oklch(0.14 0.004 285);\n  border-color: oklch(0.92 0.004 250 / 0.7);\n  box-shadow:\n    0 16px 48px oklch(0 0 0 / 0.12),\n    0 6px 20px oklch(0 0 0 / 0.06);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-header {\n  border-bottom-color: oklch(0.92 0.004 250 / 0.5);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-title {\n  color: oklch(0.14 0.004 285);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-version {\n  background: oklch(0.95 0.004 250 / 0.6);\n  color: oklch(0.4 0.006 250);\n  border-color: oklch(0.92 0.004 250 / 0.5);\n  box-shadow: 0 1px 4px oklch(0 0 0 / 0.04);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-version:hover {\n  border-color: oklch(0.55 0.17 155 / 0.4);\n  background: oklch(0.95 0.004 250 / 0.8);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-lang {\n  background: oklch(0.995 0.002 250);\n  color: oklch(0.38 0.006 250);\n  border-color: oklch(0.92 0.004 250);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-lang:hover {\n  border-color: oklch(0.55 0.17 155 / 0.4);\n  box-shadow: 0 2px 6px oklch(0 0 0 / 0.06);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-add {\n  background: oklch(0.55 0.17 155);\n  color: oklch(0.99 0.01 155);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-add:hover {\n  background: oklch(0.6 0.2 158);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-lock {\n  background: oklch(0.995 0.002 250);\n  border-color: oklch(0.92 0.004 250);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-lock:hover {\n  background: oklch(0.96 0.003 250);\n  border-color: oklch(0.55 0.17 155 / 0.3);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-lock.active {\n  background: oklch(0.94 0.015 155);\n  border-color: oklch(0.55 0.17 155);\n  box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.1);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-search input[type='search'] {\n  background: rgba(0, 0, 0, 0.04);\n  color: #1f2937;\n  border: 1.5px solid rgba(0, 0, 0, 0.08);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-search input[type='search']:focus {\n  border-color: oklch(0.55 0.17 155 / 0.5);\n  background: rgba(0, 0, 0, 0.02);\n  box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.08);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-search input[type='search']::placeholder {\n  color: #6b7280;\n  opacity: 0.7;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-tag {\n  background: rgba(0, 0, 0, 0.05);\n  color: rgba(31, 41, 55, 0.7);\n  border: none;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-tag:hover {\n  background: rgba(0, 0, 0, 0.08);\n  color: #1f2937;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-tag.active {\n  background: oklch(0.55 0.17 155 / 0.12);\n  color: oklch(0.4 0.15 155);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-input-text,\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-input-tags {\n  background: rgba(0, 0, 0, 0.04);\n  color: #1f2937;\n  border: 1.5px solid rgba(0, 0, 0, 0.08);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-input-text:focus,\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-input-tags:focus {\n  border-color: oklch(0.55 0.17 155 / 0.5);\n  box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.08);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-input-text::placeholder,\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-input-tags::placeholder {\n  color: #6b7280;\n  opacity: 0.7;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-save {\n  background: oklch(0.55 0.17 155);\n  color: oklch(0.99 0.01 155);\n  box-shadow: 0 2px 8px oklch(0.55 0.17 155 / 0.3);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-save:hover {\n  background: oklch(0.6 0.2 158);\n  box-shadow: 0 4px 14px oklch(0.55 0.17 155 / 0.4);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-cancel {\n  color: #1f2937;\n  border: 1px solid rgba(0, 0, 0, 0.12);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-cancel:hover {\n  background: rgba(0, 0, 0, 0.06);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-md-collapsed::after {\n  background: linear-gradient(to right, transparent, oklch(0.955 0.002 250) 50%);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-md pre {\n  background: rgba(0, 0, 0, 0.05);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-expand-btn {\n  border: none;\n  color: rgba(107, 114, 128, 0.5);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-expand-btn:hover {\n  background: rgba(0, 0, 0, 0.04);\n  color: #374151;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-item {\n  background: rgba(0, 0, 0, 0.04);\n  border: none;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-item:hover {\n  background: rgba(0, 0, 0, 0.07);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-item-text {\n  color: #1f2937;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-chip {\n  background: oklch(0.55 0.17 155 / 0.08);\n  color: oklch(0.4 0.12 155);\n  border: none;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-chip:hover {\n  background: oklch(0.55 0.17 155 / 0.15);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-edit {\n  background: transparent;\n  border: none;\n  color: rgba(31, 41, 55, 0.4);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-edit:hover {\n  background: rgba(0, 0, 0, 0.06);\n  color: #1f2937;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-del {\n  background: transparent;\n  color: rgba(31, 41, 55, 0.4);\n  border: none;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-del:hover {\n  background: rgba(239, 68, 68, 0.08);\n  color: #dc2626;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-del.armed {\n  background: rgba(239, 68, 68, 0.15);\n  color: #dc2626;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-footer {\n  border-top: 1px solid rgba(0, 0, 0, 0.06);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-backup-btn,\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-website-btn {\n  background: oklch(0.55 0.17 155 / 0.1);\n  color: oklch(0.55 0.17 155);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-backup-btn:hover,\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-website-btn:hover {\n  background: oklch(0.55 0.17 155 / 0.18);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-import-btn,\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-export-btn {\n  background: transparent;\n  border: none;\n  color: rgba(31, 41, 55, 0.5);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-import-btn:hover,\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-export-btn:hover {\n  background: rgba(0, 0, 0, 0.04);\n  color: #1f2937;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-settings {\n  background: transparent;\n  border: none;\n  color: rgba(31, 41, 55, 0.5);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-settings:hover {\n  background: rgba(0, 0, 0, 0.04);\n  color: #1f2937;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-gh {\n  background: transparent;\n  border: none;\n  color: rgba(31, 41, 55, 0.5);\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-gh:hover {\n  background: rgba(0, 0, 0, 0.04);\n  color: #1f2937;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-gh-icon {\n  background: #1f2937;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-notice.ok {\n  background: rgba(34, 197, 94, 0.95);\n  color: #ffffff;\n}\n\n.gv-pm-panel[data-gv-theme='light'] .gv-pm-notice.err {\n  background: rgba(239, 68, 68, 0.95);\n  color: #ffffff;\n}\n\n/* ========================================\n   Folder Manager Styles\n   ======================================== */\n\n/* CSS Variables for folder theming */\n:root {\n  --folder-bg: #ffffff;\n  --folder-text: #1f2937;\n  --folder-border: #e5e7eb;\n  --folder-hover-bg: #f3f4f6;\n  --folder-active-bg: #e0e7ff;\n  --folder-icon-color: #6b7280;\n  --folder-menu-bg: #ffffff;\n  --folder-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n  --folder-dragover-bg: #dbeafe;\n  --folder-dragover-border: #3b82f6;\n  /* Selection colors adapted to shadcn/ui primary (emerald/green) theme */\n  --folder-selected-bg-start: #f0fdf4;\n  /* green-50 - subtle background */\n  --folder-selected-bg-end: #dcfce7;\n  /* green-100 - slightly deeper */\n  --folder-selected-border: #10b981;\n  /* emerald-500 - matches primary tone */\n  --folder-selected-accent: #34d399;\n  /* emerald-400 - vibrant accent */\n  --folder-selected-glow: rgba(16, 185, 129, 0.12);\n  /* emerald-500 with low opacity */\n}\n\n/* ========================================\n   Dark Theme Variable Definitions\n\n   NOTE: Dark theme variables are defined in TWO places for different scenarios:\n   1. @media (prefers-color-scheme: dark) - For system-level dark mode preference\n   2. .theme-host.dark-theme - For Gemini's theme system (higher specificity)\n\n   ⚠️ IMPORTANT: Keep these values synchronized when updating dark theme colors.\n   Changes to one should be reflected in the other to ensure consistent appearance.\n\n   SSOT (Single Source of Truth) - Subtle Selection Colors (shadcn/ui emerald):\n   Light Mode:\n   - Accent Bar: #34d399 (emerald-400)\n   - Background Glow: rgba(16, 185, 129, 0.12) - emerald-500 at 12% opacity\n\n   Dark Mode:\n   - Accent Bar: #6ee7b7 (emerald-300)\n   - Background Glow: rgba(16, 185, 129, 0.10) - emerald-500 at 10% opacity\n   ======================================== */\n\n@media (prefers-color-scheme: dark) {\n  :root {\n    --folder-bg: #1f2937;\n    --folder-text: #e5e7eb;\n    --folder-border: #374151;\n    --folder-hover-bg: #374151;\n    --folder-active-bg: #1e40af;\n    --folder-icon-color: #9ca3af;\n    --folder-menu-bg: #1f2937;\n    --folder-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);\n    --folder-dragover-bg: #1e3a8a;\n    --folder-dragover-border: #60a5fa;\n    /* Subtle selection colors for dark mode (shadcn/ui emerald theme) */\n    --folder-selected-accent: #6ee7b7;\n    /* emerald-300 - bright for dark bg */\n    --folder-selected-glow: rgba(16, 185, 129, 0.1);\n    /* emerald-500 at 10% opacity */\n    --folder-selected-bg-start: #065f46;\n    /* emerald-800 */\n    --folder-selected-bg-end: #064e3b;\n    /* emerald-900 */\n  }\n}\n\n/* Theme support - Overrides system preferences on Gemini/AI Studio */\n.theme-host.dark-theme,\nbody.dark-theme {\n  --folder-bg: #1f2937;\n  --folder-text: #e5e7eb;\n  --folder-border: #374151;\n  --folder-hover-bg: #374151;\n  --folder-active-bg: #1e40af;\n  --folder-icon-color: #9ca3af;\n  --folder-menu-bg: #1f2937;\n  --folder-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);\n  --folder-dragover-bg: #1e3a8a;\n  --folder-dragover-border: #60a5fa;\n  /* Subtle selection colors for dark theme (shadcn/ui emerald theme) */\n  --folder-selected-accent: #6ee7b7;\n  /* emerald-300 - bright for dark bg */\n  --folder-selected-glow: rgba(16, 185, 129, 0.1);\n  /* emerald-500 at 10% opacity */\n  --folder-selected-bg-start: #065f46;\n  /* emerald-800 */\n  --folder-selected-bg-end: #064e3b;\n  /* emerald-900 */\n}\n\n.theme-host.light-theme,\nbody.light-theme {\n  --folder-bg: #ffffff;\n  --folder-text: #1f2937;\n  --folder-border: #e5e7eb;\n  --folder-hover-bg: #f3f4f6;\n  --folder-active-bg: #e0e7ff;\n  --folder-icon-color: #6b7280;\n  --folder-menu-bg: #ffffff;\n  --folder-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n  --folder-dragover-bg: #dbeafe;\n  --folder-dragover-border: #3b82f6;\n  /* Subtle selection colors for light theme (shadcn/ui emerald theme) */\n  --folder-selected-accent: #34d399;\n  /* emerald-400 - vibrant accent */\n  --folder-selected-glow: rgba(16, 185, 129, 0.12);\n  /* emerald-500 at 12% opacity */\n}\n\n/* Folder Container */\n.gv-folder-container {\n  margin-bottom: 16px;\n  padding: 8px 8px 8px 12px;\n  /* Ensure alignment with Recent section */\n  margin-left: 0;\n  font-family: 'Google Sans', Roboto, Arial, sans-serif;\n}\n\n/* Folder Header */\n.gv-folder-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 8px 16px 8px 0;\n  margin-bottom: 8px;\n  border-radius: 8px;\n  transition:\n    background-color 0.2s,\n    border 0.2s;\n}\n\n.gv-folder-header.gv-folder-list-dragover {\n  background-color: var(--folder-dragover-bg);\n  border: 2px dashed var(--folder-dragover-border);\n}\n\n.gv-folder-header .title-container {\n  flex: 1;\n}\n\n/* Let native Gemini title styles take precedence - don't override */\n.gv-folder-header .title {\n  margin: 0;\n  padding-left: 16px;\n  /* Make the title darker/more subtle */\n  opacity: 0.7;\n  color: var(--bard-color-on-surface-variant);\n}\n\n.gv-folder-add-btn {\n  width: 32px;\n  height: 32px;\n  border-radius: 50%;\n  border: none;\n  background: transparent;\n  color: var(--folder-icon-color);\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  transition:\n    background-color 0.2s,\n    color 0.2s;\n}\n\n.gv-folder-add-btn:hover {\n  background-color: var(--folder-hover-bg);\n  color: var(--folder-text);\n}\n\n/* Folder List */\n.gv-folder-list {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n  min-height: 24px;\n  /* Reduced min-height for better visual balance when empty */\n  padding: 4px;\n  border-radius: 8px;\n  transition:\n    background-color 0.2s,\n    border 0.2s;\n}\n\n.gv-folder-list-dragover {\n  background-color: var(--folder-dragover-bg);\n  border: 2px dashed var(--folder-dragover-border);\n}\n\n/* Empty state placeholder */\n.gv-folder-empty {\n  padding: 4px 16px;\n  font-size: 12px;\n  color: var(--folder-icon-color);\n  opacity: 0.7;\n  user-select: none;\n}\n\n/* Folder Item */\n.gv-folder-item {\n  display: flex;\n  flex-direction: column;\n}\n\n.gv-folder-item-header {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 8px 12px;\n  border-radius: 8px;\n  cursor: pointer;\n  transition: background-color 0.2s;\n  position: relative;\n}\n\n.gv-folder-item-header:hover {\n  background-color: var(--folder-hover-bg);\n}\n\n.gv-folder-item-header.gv-folder-dragover {\n  background-color: var(--folder-dragover-bg);\n  border: 2px dashed var(--folder-dragover-border);\n}\n\n.gv-folder-item-header[draggable='true'] {\n  cursor: grab;\n}\n\n.gv-folder-item-header[draggable='true']:active {\n  cursor: grabbing;\n}\n\n/* Reorder indicator on conversation elements (top/bottom half detection) */\n.gv-reorder-above {\n  box-shadow: 0 -2px 0 0 #4285f4;\n}\n\n.gv-reorder-below {\n  box-shadow: 0 2px 0 0 #4285f4;\n}\n\n/* Reorder gap between folder items for drag-and-drop sorting */\n.gv-reorder-gap {\n  height: 0;\n  margin: 0 8px;\n  border-radius: 1px;\n  padding: 3px 0;\n  transition: background-color 0.15s ease;\n}\n\n.gv-reorder-gap-active {\n  background-color: #4285f4;\n}\n\n.gv-folder-expand-btn {\n  width: 20px;\n  height: 20px;\n  border: none;\n  background: transparent;\n  color: var(--folder-icon-color);\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 0;\n  flex-shrink: 0;\n}\n\n.gv-folder-expand-btn .google-symbols {\n  font-size: 20px;\n}\n\n.gv-folder-icon {\n  font-size: 20px;\n  color: var(--folder-icon-color);\n  flex-shrink: 0;\n  transition: color 0.2s ease;\n}\n\n.gv-folder-name {\n  flex: 1;\n  font-size: 14px;\n  color: var(--folder-text);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  user-select: none;\n}\n\n.gv-folder-pin-btn {\n  width: 24px;\n  height: 24px;\n  border: none;\n  background: transparent;\n  color: var(--folder-icon-color);\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 4px;\n  opacity: 0;\n  transition:\n    opacity 0.2s,\n    background-color 0.2s;\n  flex-shrink: 0;\n  margin-left: auto;\n  margin-right: 4px;\n}\n\n.gv-folder-item-header:hover .gv-folder-pin-btn {\n  opacity: 1;\n}\n\n.gv-folder-pin-btn:hover {\n  background-color: var(--folder-hover-bg);\n}\n\n.gv-folder-pin-btn .google-symbols {\n  font-size: 18px;\n}\n\n.gv-folder-actions-btn {\n  width: 24px;\n  height: 24px;\n  border: none;\n  background: transparent;\n  color: var(--folder-icon-color);\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 4px;\n  opacity: 0;\n  transition:\n    opacity 0.2s,\n    background-color 0.2s;\n  flex-shrink: 0;\n}\n\n.gv-folder-item-header:hover .gv-folder-actions-btn {\n  opacity: 1;\n}\n\n.gv-folder-actions-btn:hover {\n  background-color: var(--folder-hover-bg);\n}\n\n.gv-folder-actions-btn .google-symbols {\n  font-size: 18px;\n}\n\n/* Folder Content (conversations and subfolders) */\n.gv-folder-content {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n  margin-top: 2px;\n  margin-left: 12px;\n  padding-left: 8px;\n  border-left: 1px solid var(--folder-border);\n}\n\n/* Conversation Item in Folder */\n.gv-folder-conversation {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  padding: 8px 6px;\n  border-radius: 8px;\n  cursor: pointer;\n  transition:\n    background-color 0.15s ease,\n    opacity 0.2s;\n  position: relative;\n}\n\n.gv-folder-conversation:hover {\n  background-color: var(--folder-hover-bg);\n}\n\n/* Starred conversation - subtle gold accent on the left */\n.gv-folder-conversation.gv-starred {\n  background: linear-gradient(to right, rgba(251, 191, 36, 0.08) 0%, transparent 100%);\n}\n\n.gv-folder-conversation.gv-starred::before {\n  content: '';\n  position: absolute;\n  left: 0;\n  top: 50%;\n  transform: translateY(-50%);\n  width: 3px;\n  height: 20px;\n  background: linear-gradient(to bottom, #fbbf24, #f59e0b);\n  border-radius: 0 2px 2px 0;\n  opacity: 0.9;\n}\n\n/* ========================================\n   Multi-select Selection Styles\n   Subtle design inspired by starred conversations\n   ======================================== */\n\n/* Shared selection base styles - subtle like starred items */\n.gv-folder-conversation-selected,\n[data-test-id='conversation'].gv-conversation-selected {\n  position: relative;\n  /* Very subtle gradient background (similar opacity to starred: ~10%) */\n  background: linear-gradient(\n    to right,\n    var(--folder-selected-glow) 0%,\n    transparent 100%\n  ) !important;\n  transition: background 200ms ease;\n}\n\n/* Folder conversation specific adjustments */\n.gv-folder-conversation-selected {\n  border-radius: 10px;\n}\n\n/* Native conversation specific adjustments */\n[data-test-id='conversation'].gv-conversation-selected {\n  border-radius: 12px !important;\n}\n\n/* Subtle left accent indicator (similar to starred dot) */\n.gv-folder-conversation-selected::before,\n[data-test-id='conversation'].gv-conversation-selected::before {\n  content: '';\n  position: absolute;\n  left: 0;\n  top: 50%;\n  transform: translateY(-50%);\n  width: 3px;\n  height: 60%;\n  border-radius: 0 2px 2px 0;\n  background-color: var(--folder-selected-accent);\n  opacity: 0.9;\n  pointer-events: none;\n}\n\n/* Ensure content is above the gradient overlay */\n.gv-folder-conversation-selected > *,\n[data-test-id='conversation'].gv-conversation-selected > * {\n  position: relative;\n  z-index: 1;\n}\n\n/* Invalid selection feedback animation */\n.gv-invalid-selection {\n  animation: invalidShake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97);\n}\n\n@keyframes invalidShake {\n  0%,\n  100% {\n    transform: translateX(0);\n  }\n\n  10%,\n  30%,\n  50%,\n  70%,\n  90% {\n    transform: translateX(-4px);\n  }\n\n  20%,\n  40%,\n  60%,\n  80% {\n    transform: translateX(4px);\n  }\n}\n\n/* Hide archived conversations (conversations that are in folders) */\n[data-test-id='conversation'].gv-conversation-archived {\n  display: none !important;\n}\n\n/* Multi-select mode indicator */\n.gv-multi-select-indicator {\n  display: none;\n  align-items: center;\n  justify-content: space-between;\n  padding: 12px 16px;\n  margin: 8px 8px 0 12px;\n  /* Elegant twilight gradient background matching selection */\n  background: linear-gradient(\n    135deg,\n    var(--folder-selected-bg-start) 0%,\n    var(--folder-selected-bg-end) 100%\n  );\n  border: 1px solid var(--folder-selected-border);\n  border-radius: 10px;\n  box-shadow:\n    0 4px 12px var(--folder-selected-glow),\n    0 2px 4px rgba(0, 0, 0, 0.08),\n    0 0 0 1px var(--folder-selected-border);\n  animation: slideDown 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n@keyframes slideDown {\n  from {\n    opacity: 0;\n    transform: translateY(-10px);\n  }\n\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n.gv-multi-select-mode .gv-multi-select-indicator {\n  display: flex;\n}\n\n.gv-multi-select-indicator-content {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.gv-multi-select-indicator-content mat-icon {\n  font-size: 20px !important;\n  width: 20px !important;\n  height: 20px !important;\n  color: var(--folder-selected-border);\n}\n\n.gv-multi-select-indicator-text {\n  font-size: 14px;\n  font-weight: 500;\n  color: var(--folder-text);\n}\n\n/* Multi-select actions container */\n.gv-multi-select-actions {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n}\n\n/* Common styling for action buttons */\n.gv-multi-select-action-btn {\n  min-width: 32px;\n  width: 32px;\n  height: 32px;\n  border: none;\n  background: transparent;\n  color: var(--folder-icon-color);\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 6px;\n  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.gv-multi-select-action-btn mat-icon {\n  font-size: 20px !important;\n  width: 20px !important;\n  height: 20px !important;\n}\n\n/* Delete button specific styling */\n.gv-multi-select-delete-btn {\n  color: #ef4444;\n}\n\n.gv-multi-select-delete-btn:hover {\n  background-color: rgba(239, 68, 68, 0.12);\n  color: #dc2626;\n  transform: scale(1.05);\n}\n\n.gv-multi-select-delete-btn:active {\n  transform: scale(0.95);\n}\n\n/* Exit button specific styling */\n.gv-multi-select-exit-btn:hover {\n  background-color: rgba(0, 0, 0, 0.08);\n  color: var(--folder-text);\n  transform: scale(1.05);\n}\n\n.gv-multi-select-exit-btn:active {\n  transform: scale(0.95);\n}\n\n/* Draggable conversations in folders */\n.gv-folder-conversation[draggable='true'] {\n  cursor: grab;\n}\n\n.gv-folder-conversation[draggable='true']:active {\n  cursor: grabbing;\n}\n\n.gv-conversation-icon {\n  font-size: 12px !important;\n  width: 16px !important;\n  height: 16px !important;\n  margin-left: -2px;\n  margin-right: -2px;\n  margin-top: 3px;\n  color: var(--folder-icon-color);\n  flex-shrink: 0;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  line-height: 1;\n  overflow: hidden !important;\n  font-variation-settings:\n    'FILL' 0,\n    'wght' 400,\n    'GRAD' 0,\n    'opsz' 20;\n}\n\n.gv-conversation-icon::before,\n.gv-conversation-icon::after {\n  display: none !important;\n  content: none !important;\n}\n\n/* Firefox specific alignment */\n@supports (-moz-appearance: none) {\n  .gv-conversation-icon {\n    /* 1px aligns better in Firefox */\n    margin-top: 1px !important;\n  }\n}\n\n.gv-conversation-title {\n  flex: 1;\n  font-size: 14px;\n  color: var(--folder-text);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n/* Action buttons container for proper layout */\n.gv-conversation-actions {\n  display: flex;\n  align-items: center;\n  gap: 0;\n  margin-left: auto;\n  flex-shrink: 0;\n}\n\n/* Star button - hidden by default, show on hover to save space */\n.gv-conversation-star-btn {\n  min-width: 24px;\n  width: 24px;\n  height: 24px;\n  border: none;\n  background: transparent;\n  color: #9ca3af;\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 6px;\n  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n  flex-shrink: 0;\n  padding: 0;\n  opacity: 0;\n}\n\n.gv-folder-conversation:hover .gv-conversation-star-btn {\n  opacity: 1;\n}\n\n/* Starred state */\n.gv-conversation-star-btn.starred {\n  opacity: 1 !important;\n  color: #fbbf24;\n}\n\n.gv-conversation-star-btn:hover {\n  background-color: rgba(251, 191, 36, 0.12);\n  color: #fbbf24;\n  transform: scale(1.05);\n}\n\n.gv-conversation-star-btn.starred:hover {\n  background-color: rgba(251, 191, 36, 0.15);\n}\n\n.gv-conversation-star-btn mat-icon {\n  font-size: 18px !important;\n  width: 18px !important;\n  height: 18px !important;\n  transition: transform 0.2s;\n}\n\n.gv-conversation-star-btn:active mat-icon {\n  transform: scale(0.9);\n}\n\n/* Remove button - only visible on hover */\n.gv-conversation-remove-btn {\n  min-width: 24px;\n  width: 24px;\n  height: 24px;\n  border: none;\n  background: transparent;\n  color: #9ca3af;\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 6px;\n  opacity: 0;\n  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n  flex-shrink: 0;\n  padding: 0;\n}\n\n.gv-folder-conversation:hover .gv-conversation-remove-btn {\n  opacity: 1;\n}\n\n.gv-conversation-remove-btn:hover {\n  background-color: rgba(239, 68, 68, 0.12);\n  color: #ef4444;\n  transform: scale(1.05);\n}\n\n.gv-conversation-remove-btn mat-icon {\n  font-size: 18px !important;\n  width: 18px !important;\n  height: 18px !important;\n  display: flex !important;\n  align-items: center !important;\n  justify-content: center !important;\n  line-height: 1 !important;\n  transition: transform 0.2s;\n}\n\n.gv-conversation-remove-btn:active mat-icon {\n  transform: scale(0.9);\n}\n\n/* Folder Context Menu */\n/* ── Folder Context Menu ── */\n.gv-folder-menu {\n  background: oklch(0.995 0.002 250);\n  border: 1px solid oklch(0.92 0.004 250 / 0.7);\n  border-radius: 14px;\n  box-shadow:\n    0 12px 40px oklch(0 0 0 / 0.1),\n    0 4px 16px oklch(0 0 0 / 0.05);\n  padding: 6px;\n  z-index: 2147483647;\n  min-width: 170px;\n  font-family:\n    ui-sans-serif,\n    system-ui,\n    -apple-system,\n    Segoe UI,\n    Roboto,\n    Helvetica,\n    Arial,\n    'Apple Color Emoji',\n    'Segoe UI Emoji';\n  animation: gv-folder-fadeIn 0.15s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-menu {\n    background: oklch(0.2 0.008 285);\n    border-color: oklch(0.3 0.008 285 / 0.7);\n    box-shadow:\n      0 12px 40px oklch(0 0 0 / 0.35),\n      0 4px 16px oklch(0 0 0 / 0.2);\n  }\n}\n\n.theme-host.dark-theme .gv-folder-menu,\nbody.dark-theme .gv-folder-menu {\n  background: oklch(0.2 0.008 285);\n  border-color: oklch(0.3 0.008 285 / 0.7);\n  box-shadow:\n    0 12px 40px oklch(0 0 0 / 0.35),\n    0 4px 16px oklch(0 0 0 / 0.2);\n}\n\n.theme-host.light-theme .gv-folder-menu,\nbody.light-theme .gv-folder-menu {\n  background: oklch(0.995 0.002 250);\n  border-color: oklch(0.92 0.004 250 / 0.7);\n  box-shadow:\n    0 12px 40px oklch(0 0 0 / 0.1),\n    0 4px 16px oklch(0 0 0 / 0.05);\n}\n\n.gv-folder-menu-item {\n  display: flex;\n  align-items: center;\n  width: 100%;\n  padding: 9px 14px;\n  border: none;\n  background: transparent;\n  color: oklch(0.14 0.004 285);\n  text-align: left;\n  cursor: pointer;\n  border-radius: 9px;\n  font-size: 13px;\n  font-weight: 500;\n  transition: background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.gv-folder-menu-item:hover {\n  background: oklch(0.14 0.004 285 / 0.06);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-menu-item {\n    color: oklch(0.92 0.004 250);\n  }\n  .gv-folder-menu-item:hover {\n    background: oklch(0.92 0.004 250 / 0.08);\n  }\n}\n\n.theme-host.dark-theme .gv-folder-menu-item,\nbody.dark-theme .gv-folder-menu-item {\n  color: oklch(0.92 0.004 250);\n}\n\n.theme-host.dark-theme .gv-folder-menu-item:hover,\nbody.dark-theme .gv-folder-menu-item:hover {\n  background: oklch(0.92 0.004 250 / 0.08);\n}\n\n.theme-host.light-theme .gv-folder-menu-item,\nbody.light-theme .gv-folder-menu-item {\n  color: oklch(0.14 0.004 285);\n}\n\n.theme-host.light-theme .gv-folder-menu-item:hover,\nbody.light-theme .gv-folder-menu-item:hover {\n  background: oklch(0.14 0.004 285 / 0.06);\n}\n\n/* ── Color Picker Dialog ── */\n.gv-color-picker-dialog {\n  position: fixed;\n  background: oklch(0.995 0.002 250);\n  border: 1px solid oklch(0.92 0.004 250 / 0.7);\n  border-radius: 14px;\n  padding: 12px;\n  box-shadow:\n    0 12px 40px oklch(0 0 0 / 0.1),\n    0 4px 16px oklch(0 0 0 / 0.05);\n  z-index: 10001;\n  display: grid;\n  grid-template-columns: repeat(4, 36px);\n  gap: 8px;\n  animation: gv-folder-fadeIn 0.15s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n@keyframes gv-folder-fadeIn {\n  from {\n    opacity: 0;\n    transform: scale(0.96) translateY(4px);\n  }\n  to {\n    opacity: 1;\n    transform: scale(1) translateY(0);\n  }\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-color-picker-dialog {\n    background: oklch(0.2 0.008 285);\n    border-color: oklch(0.3 0.008 285 / 0.7);\n    box-shadow:\n      0 12px 40px oklch(0 0 0 / 0.35),\n      0 4px 16px oklch(0 0 0 / 0.2);\n  }\n}\n\n.theme-host.dark-theme .gv-color-picker-dialog,\nbody.dark-theme .gv-color-picker-dialog {\n  background: oklch(0.2 0.008 285);\n  border-color: oklch(0.3 0.008 285 / 0.7);\n  box-shadow:\n    0 12px 40px oklch(0 0 0 / 0.35),\n    0 4px 16px oklch(0 0 0 / 0.2);\n}\n\n.theme-host.light-theme .gv-color-picker-dialog,\nbody.light-theme .gv-color-picker-dialog {\n  background: oklch(0.995 0.002 250);\n  border-color: oklch(0.92 0.004 250 / 0.7);\n}\n\n.gv-color-picker-item {\n  width: 36px;\n  height: 36px;\n  border: 2px solid transparent;\n  border-radius: 10px;\n  cursor: pointer;\n  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n  position: relative;\n  padding: 0;\n  box-shadow: 0 1px 4px oklch(0 0 0 / 0.08);\n}\n\n.gv-color-picker-item:hover {\n  transform: scale(1.12);\n  border-color: oklch(0.14 0.004 285 / 0.3);\n  box-shadow: 0 3px 10px oklch(0 0 0 / 0.15);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-color-picker-item:hover {\n    border-color: oklch(0.92 0.004 250 / 0.4);\n  }\n}\n\n.gv-color-picker-item.selected {\n  border-color: oklch(0.55 0.17 155);\n  box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.25);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-color-picker-item.selected {\n    border-color: oklch(0.7 0.16 155);\n    box-shadow: 0 0 0 3px oklch(0.7 0.16 155 / 0.25);\n  }\n}\n\n.gv-color-picker-item.selected::after {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n  color: white;\n  font-size: 18px;\n  font-weight: bold;\n  text-shadow: 0 1px 2px oklch(0 0 0 / 0.3);\n}\n\n/* Drag cursor for original conversation elements */\n[data-test-id='conversation'][draggable='true'] {\n  cursor: grab;\n}\n\n[data-test-id='conversation'][draggable='true']:active {\n  cursor: grabbing;\n}\n\n/* Inline Input/Rename Styles */\n.gv-folder-inline-input {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 8px 12px;\n  background-color: var(--folder-hover-bg);\n  border-radius: 8px;\n  margin: 4px 0;\n}\n\n.gv-folder-name-input,\n.gv-folder-rename-input {\n  flex: 1;\n  padding: 6px 8px;\n  border: 1px solid var(--folder-border);\n  border-radius: 4px;\n  background-color: var(--folder-bg);\n  color: var(--folder-text);\n  font-size: 14px;\n  outline: none;\n}\n\n.gv-folder-name-input:focus,\n.gv-folder-rename-input:focus {\n  border-color: #3b82f6;\n  box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);\n}\n\n.gv-folder-rename-inline {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  flex: 1;\n}\n\n.gv-folder-inline-btn {\n  width: 24px;\n  height: 24px;\n  border: none;\n  background: transparent;\n  color: var(--folder-icon-color);\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 4px;\n  padding: 0;\n  transition:\n    background-color 0.2s,\n    color 0.2s;\n  flex-shrink: 0;\n}\n\n.gv-folder-inline-btn:hover {\n  background-color: var(--folder-hover-bg);\n}\n\n.gv-folder-inline-save:hover {\n  color: #10b981;\n  background-color: rgba(16, 185, 129, 0.1);\n}\n\n.gv-folder-inline-cancel:hover {\n  color: #ef4444;\n  background-color: rgba(239, 68, 68, 0.1);\n}\n\n.gv-folder-inline-btn mat-icon {\n  font-size: 18px;\n  width: 18px;\n  height: 18px;\n}\n\n.gv-hidden {\n  display: none !important;\n}\n\n/* Confirm Dialog */\n/* ── Confirm Dialog (Delete Conversation / Delete Folder) ── */\n.gv-folder-confirm-dialog {\n  background: oklch(0.995 0.002 250);\n  border: 1px solid oklch(0.92 0.004 250 / 0.7);\n  border-radius: 16px;\n  box-shadow:\n    0 12px 40px oklch(0 0 0 / 0.1),\n    0 4px 16px oklch(0 0 0 / 0.05);\n  padding: 16px;\n  z-index: 2147483647;\n  min-width: 250px;\n  max-width: 340px;\n  font-family:\n    ui-sans-serif,\n    system-ui,\n    -apple-system,\n    Segoe UI,\n    Roboto,\n    Helvetica,\n    Arial,\n    'Apple Color Emoji',\n    'Segoe UI Emoji';\n  animation: gv-folder-fadeIn 0.15s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-confirm-dialog {\n    background: oklch(0.2 0.008 285);\n    border-color: oklch(0.3 0.008 285 / 0.7);\n    box-shadow:\n      0 12px 40px oklch(0 0 0 / 0.35),\n      0 4px 16px oklch(0 0 0 / 0.2);\n  }\n}\n\n.theme-host.dark-theme .gv-folder-confirm-dialog,\nbody.dark-theme .gv-folder-confirm-dialog {\n  background: oklch(0.2 0.008 285);\n  border-color: oklch(0.3 0.008 285 / 0.7);\n  box-shadow:\n    0 12px 40px oklch(0 0 0 / 0.35),\n    0 4px 16px oklch(0 0 0 / 0.2);\n}\n\n.theme-host.light-theme .gv-folder-confirm-dialog,\nbody.light-theme .gv-folder-confirm-dialog {\n  background: oklch(0.995 0.002 250);\n  border-color: oklch(0.92 0.004 250 / 0.7);\n}\n\n.gv-folder-confirm-message {\n  color: oklch(0.14 0.004 285);\n  font-size: 13px;\n  font-weight: 500;\n  line-height: 1.55;\n  margin-bottom: 14px;\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-confirm-message {\n    color: oklch(0.92 0.004 250);\n  }\n}\n\n.theme-host.dark-theme .gv-folder-confirm-message,\nbody.dark-theme .gv-folder-confirm-message {\n  color: oklch(0.92 0.004 250);\n}\n\n.theme-host.light-theme .gv-folder-confirm-message,\nbody.light-theme .gv-folder-confirm-message {\n  color: oklch(0.14 0.004 285);\n}\n\n.gv-folder-confirm-actions {\n  display: flex;\n  gap: 8px;\n  justify-content: flex-end;\n}\n\n.gv-folder-confirm-btn {\n  padding: 6px 14px;\n  border-radius: 9px;\n  border: 1px solid oklch(0.14 0.004 285 / 0.12);\n  background: transparent;\n  color: oklch(0.14 0.004 285);\n  font-size: 13px;\n  font-weight: 600;\n  cursor: pointer;\n  transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.gv-folder-confirm-btn:hover {\n  background: oklch(0.14 0.004 285 / 0.06);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-confirm-btn {\n    border-color: oklch(0.92 0.004 250 / 0.12);\n    color: oklch(0.92 0.004 250);\n  }\n  .gv-folder-confirm-btn:hover {\n    background: oklch(0.92 0.004 250 / 0.06);\n  }\n}\n\n.theme-host.dark-theme .gv-folder-confirm-btn,\nbody.dark-theme .gv-folder-confirm-btn {\n  border-color: oklch(0.92 0.004 250 / 0.12);\n  color: oklch(0.92 0.004 250);\n}\n\n.theme-host.dark-theme .gv-folder-confirm-btn:hover,\nbody.dark-theme .gv-folder-confirm-btn:hover {\n  background: oklch(0.92 0.004 250 / 0.06);\n}\n\n.theme-host.light-theme .gv-folder-confirm-btn,\nbody.light-theme .gv-folder-confirm-btn {\n  border-color: oklch(0.14 0.004 285 / 0.12);\n  color: oklch(0.14 0.004 285);\n}\n\n.theme-host.light-theme .gv-folder-confirm-btn:hover,\nbody.light-theme .gv-folder-confirm-btn:hover {\n  background: oklch(0.14 0.004 285 / 0.06);\n}\n\n.gv-folder-confirm-yes {\n  background: oklch(0.6 0.22 25 / 0.1);\n  border-color: oklch(0.6 0.22 25 / 0.3);\n  color: oklch(0.6 0.22 25);\n}\n\n.gv-folder-confirm-yes:hover {\n  background: oklch(0.6 0.22 25 / 0.18);\n  border-color: oklch(0.6 0.22 25 / 0.5);\n}\n\n/* ── Move to Folder Dialog ── */\n.gv-folder-dialog-overlay {\n  position: fixed;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  background: oklch(0 0 0 / 0.35);\n  z-index: 2147483647;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  animation: gv-folder-overlayIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n@keyframes gv-folder-overlayIn {\n  from {\n    opacity: 0;\n  }\n  to {\n    opacity: 1;\n  }\n}\n\n.gv-folder-dialog {\n  background: oklch(0.995 0.002 250);\n  border: 1px solid oklch(0.92 0.004 250 / 0.7);\n  border-radius: 18px;\n  box-shadow:\n    0 16px 48px oklch(0 0 0 / 0.12),\n    0 6px 20px oklch(0 0 0 / 0.06);\n  padding: 0;\n  min-width: 340px;\n  max-width: 420px;\n  max-height: 80vh;\n  display: flex;\n  flex-direction: column;\n  font-family:\n    ui-sans-serif,\n    system-ui,\n    -apple-system,\n    Segoe UI,\n    Roboto,\n    Helvetica,\n    Arial,\n    'Apple Color Emoji',\n    'Segoe UI Emoji';\n  animation: gv-folder-fadeIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-dialog {\n    background: oklch(0.2 0.008 285);\n    border-color: oklch(0.3 0.008 285 / 0.7);\n    box-shadow:\n      0 16px 48px oklch(0 0 0 / 0.4),\n      0 6px 20px oklch(0 0 0 / 0.25);\n  }\n}\n\n.theme-host.dark-theme .gv-folder-dialog,\nbody.dark-theme .gv-folder-dialog {\n  background: oklch(0.2 0.008 285);\n  border-color: oklch(0.3 0.008 285 / 0.7);\n  box-shadow:\n    0 16px 48px oklch(0 0 0 / 0.4),\n    0 6px 20px oklch(0 0 0 / 0.25);\n}\n\n.theme-host.light-theme .gv-folder-dialog,\nbody.light-theme .gv-folder-dialog {\n  background: oklch(0.995 0.002 250);\n  border-color: oklch(0.92 0.004 250 / 0.7);\n}\n\n.gv-folder-dialog-title {\n  color: oklch(0.14 0.004 285);\n  font-size: 17px;\n  font-weight: 800;\n  letter-spacing: -0.02em;\n  padding: 16px 18px 12px 18px;\n  border-bottom: 1px solid oklch(0.92 0.004 250 / 0.5);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-dialog-title {\n    color: oklch(0.92 0.004 250);\n    border-bottom-color: oklch(0.3 0.008 285 / 0.5);\n  }\n}\n\n.theme-host.dark-theme .gv-folder-dialog-title,\nbody.dark-theme .gv-folder-dialog-title {\n  color: oklch(0.92 0.004 250);\n  border-bottom-color: oklch(0.3 0.008 285 / 0.5);\n}\n\n.theme-host.light-theme .gv-folder-dialog-title,\nbody.light-theme .gv-folder-dialog-title {\n  color: oklch(0.14 0.004 285);\n  border-bottom-color: oklch(0.92 0.004 250 / 0.5);\n}\n\n.gv-folder-dialog-list {\n  flex: 1;\n  overflow-y: auto;\n  padding: 6px 8px;\n  min-height: 200px;\n  max-height: 400px;\n  scrollbar-gutter: stable;\n}\n\n.gv-folder-dialog-item {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  width: 100%;\n  padding: 10px 12px;\n  border: none;\n  background: transparent;\n  color: oklch(0.14 0.004 285);\n  font-size: 13px;\n  font-weight: 500;\n  cursor: pointer;\n  border-radius: 10px;\n  transition: background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1);\n  text-align: left;\n}\n\n.gv-folder-dialog-item:not(:disabled):hover {\n  background: oklch(0.14 0.004 285 / 0.06);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-dialog-item {\n    color: oklch(0.92 0.004 250);\n  }\n  .gv-folder-dialog-item:not(:disabled):hover {\n    background: oklch(0.92 0.004 250 / 0.08);\n  }\n}\n\n.theme-host.dark-theme .gv-folder-dialog-item,\nbody.dark-theme .gv-folder-dialog-item {\n  color: oklch(0.92 0.004 250);\n}\n\n.theme-host.dark-theme .gv-folder-dialog-item:not(:disabled):hover,\nbody.dark-theme .gv-folder-dialog-item:not(:disabled):hover {\n  background: oklch(0.92 0.004 250 / 0.08);\n}\n\n.theme-host.light-theme .gv-folder-dialog-item,\nbody.light-theme .gv-folder-dialog-item {\n  color: oklch(0.14 0.004 285);\n}\n\n.theme-host.light-theme .gv-folder-dialog-item:not(:disabled):hover,\nbody.light-theme .gv-folder-dialog-item:not(:disabled):hover {\n  background: oklch(0.14 0.004 285 / 0.06);\n}\n\n.gv-folder-dialog-item:disabled {\n  cursor: not-allowed;\n  opacity: 0.4;\n}\n\n.gv-folder-dialog-item mat-icon {\n  font-size: 16px !important;\n  width: 16px !important;\n  height: 16px !important;\n  color: oklch(0.55 0.17 155);\n  flex-shrink: 0;\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-dialog-item mat-icon {\n    color: oklch(0.7 0.16 155);\n  }\n}\n\n.gv-folder-dialog-item span {\n  flex: 1;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.gv-folder-dialog-cancel {\n  padding: 12px 18px;\n  border: none;\n  border-top: 1px solid oklch(0.92 0.004 250 / 0.5);\n  background: transparent;\n  color: oklch(0.14 0.004 285);\n  font-size: 13px;\n  font-weight: 600;\n  cursor: pointer;\n  transition: background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1);\n  border-radius: 0 0 18px 18px;\n}\n\n.gv-folder-dialog-cancel:hover {\n  background: oklch(0.14 0.004 285 / 0.06);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-dialog-cancel {\n    color: oklch(0.92 0.004 250);\n    border-top-color: oklch(0.3 0.008 285 / 0.5);\n  }\n  .gv-folder-dialog-cancel:hover {\n    background: oklch(0.92 0.004 250 / 0.06);\n  }\n}\n\n.theme-host.dark-theme .gv-folder-dialog-cancel,\nbody.dark-theme .gv-folder-dialog-cancel {\n  color: oklch(0.92 0.004 250);\n  border-top-color: oklch(0.3 0.008 285 / 0.5);\n}\n\n.theme-host.dark-theme .gv-folder-dialog-cancel:hover,\nbody.dark-theme .gv-folder-dialog-cancel:hover {\n  background: oklch(0.92 0.004 250 / 0.06);\n}\n\n.theme-host.light-theme .gv-folder-dialog-cancel,\nbody.light-theme .gv-folder-dialog-cancel {\n  color: oklch(0.14 0.004 285);\n  border-top-color: oklch(0.92 0.004 250 / 0.5);\n}\n\n.theme-host.light-theme .gv-folder-dialog-cancel:hover,\nbody.light-theme .gv-folder-dialog-cancel:hover {\n  background: oklch(0.14 0.004 285 / 0.06);\n}\n\n/* Custom Tooltip Styles */\n.gv-tooltip {\n  position: fixed;\n  background-color: rgba(0, 0, 0, 0.9);\n  color: #fff;\n  padding: 8px 12px;\n  border-radius: 6px;\n  font-size: 13px;\n  font-weight: 500;\n  max-width: 300px;\n  word-wrap: break-word;\n  z-index: 10000;\n  pointer-events: none;\n  opacity: 0;\n  transition: opacity 0.15s ease-in-out;\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);\n  font-family: 'Google Sans', Roboto, Arial, sans-serif;\n}\n\n.gv-tooltip.show {\n  opacity: 1;\n}\n\n/* Light theme tooltip */\nhtml[data-color-scheme='light'] .gv-tooltip {\n  background-color: rgba(0, 0, 0, 0.85);\n  color: #fff;\n}\n\n/* ==================== Formula Copy Feature ==================== */\n\n/* Math element hover effect - make formulas clickable */\n.math-inline,\n.math-display,\n[data-math] {\n  cursor: pointer !important;\n  transition: all 0.2s ease;\n  border-radius: 4px;\n  padding: 2px 4px;\n  margin: 0 2px;\n}\n\n.math-inline:hover,\n.math-display:hover,\n[data-math]:hover {\n  background-color: rgba(66, 133, 244, 0.1) !important;\n  box-shadow: 0 0 0 1px rgba(66, 133, 244, 0.3);\n}\n\n/* Light theme hover */\nhtml[data-color-scheme='light'] .math-inline:hover,\nhtml[data-color-scheme='light'] .math-display:hover,\nhtml[data-color-scheme='light'] [data-math]:hover {\n  background-color: rgba(66, 133, 244, 0.08) !important;\n}\n\n/* Copy toast notification */\n.gv-copy-toast {\n  position: fixed;\n  color: white;\n  padding: 10px 16px;\n  border-radius: 8px;\n  font-size: 14px;\n  font-weight: 500;\n  z-index: 100000;\n  pointer-events: none;\n  opacity: 0;\n  transform: translateY(-10px) scale(0.9);\n  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n  white-space: nowrap;\n  font-family: 'Google Sans', Roboto, Arial, sans-serif;\n}\n\n/* Success state */\n.gv-copy-toast-success {\n  background: linear-gradient(135deg, #34a853 0%, #1e8e3e 100%);\n  box-shadow:\n    0 4px 16px rgba(0, 0, 0, 0.2),\n    0 2px 8px rgba(52, 168, 83, 0.3);\n}\n\n/* Error state */\n.gv-copy-toast-error {\n  background: linear-gradient(135deg, #ea4335 0%, #c5221f 100%);\n  box-shadow:\n    0 4px 16px rgba(0, 0, 0, 0.2),\n    0 2px 8px rgba(234, 67, 53, 0.3);\n}\n\n.gv-copy-toast-show {\n  opacity: 1;\n  transform: translateY(0) scale(1);\n}\n\n/* Add slight pulse animation */\n@keyframes pulse {\n  0%,\n  100% {\n    transform: scale(1);\n  }\n\n  50% {\n    transform: scale(1.05);\n  }\n}\n\n.gv-copy-toast-show {\n  animation: pulse 0.3s ease-out;\n}\n\n/* Light theme toast */\nhtml[data-color-scheme='light'] .gv-copy-toast-success {\n  box-shadow:\n    0 4px 16px rgba(0, 0, 0, 0.15),\n    0 2px 8px rgba(52, 168, 83, 0.2);\n}\n\nhtml[data-color-scheme='light'] .gv-copy-toast-error {\n  box-shadow:\n    0 4px 16px rgba(0, 0, 0, 0.15),\n    0 2px 8px rgba(234, 67, 53, 0.2);\n}\n\n/* AI Studio formula hover effect - make ms-katex elements clickable */\nms-katex {\n  cursor: pointer !important;\n  transition: all 0.2s ease;\n  border-radius: 4px;\n  display: inline-block;\n}\n\nms-katex:hover {\n  background-color: rgba(66, 133, 244, 0.1) !important;\n  box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);\n}\n\n/* AI Studio: Also style .katex elements inside ms-katex for better hit area feedback */\nms-katex .katex {\n  transition: opacity 0.2s ease;\n}\n\nms-katex:hover .katex {\n  opacity: 0.9;\n}\n\n/* AI Studio light theme formula hover - detect via body background or explicit theme */\nhtml[data-color-scheme='light'] ms-katex:hover,\nbody.light-theme ms-katex:hover {\n  background-color: rgba(66, 133, 244, 0.08) !important;\n}\n\n/* ==========================================================================\n   Folder Import/Export Styles\n   ========================================================================== */\n\n/* Header actions container */\n.gv-folder-header-actions {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n}\n\n/* Action buttons (import/export) */\n.gv-folder-action-btn {\n  background: none;\n  border: none;\n  padding: 4px;\n  cursor: pointer;\n  border-radius: 50%;\n  color: var(--folder-icon-color);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  transition: background-color 0.2s ease;\n  opacity: 0.9;\n}\n\n.gv-folder-action-btn:hover {\n  background-color: rgba(0, 0, 0, 0.05);\n  opacity: 1;\n}\n\nhtml.dark .gv-folder-action-btn:hover,\nbody.dark-theme .gv-folder-action-btn:hover,\n[data-theme='dark'] .gv-folder-action-btn:hover,\n[data-color-scheme='dark'] .gv-folder-action-btn:hover {\n  background-color: rgba(255, 255, 255, 0.08);\n}\n\n.gv-folder-action-btn mat-icon {\n  font-size: 18px;\n  width: 18px;\n  height: 18px;\n}\n\n/* Filter active state */\n.gv-folder-action-btn.gv-filter-active {\n  background-color: rgba(66, 133, 244, 0.15);\n  opacity: 1;\n}\n\n.gv-folder-action-btn.gv-filter-active mat-icon {\n  color: #4285f4;\n}\n\nhtml.dark .gv-folder-action-btn.gv-filter-active,\nbody.dark-theme .gv-folder-action-btn.gv-filter-active,\n[data-theme='dark'] .gv-folder-action-btn.gv-filter-active,\n[data-color-scheme='dark'] .gv-folder-action-btn.gv-filter-active {\n  background-color: rgba(138, 180, 248, 0.2);\n}\n\nhtml.dark .gv-folder-action-btn.gv-filter-active mat-icon,\nbody.dark-theme .gv-folder-action-btn.gv-filter-active mat-icon,\n[data-theme='dark'] .gv-folder-action-btn.gv-filter-active mat-icon,\n[data-color-scheme='dark'] .gv-folder-action-btn.gv-filter-active mat-icon {\n  color: #8ab4f8;\n}\n\n/* ==========================\n   AI Studio specific tweaks\n   - Compact spacing + icon fallbacks\n   - Do NOT affect gemini.google UI\n   - Let AI Studio manage sidebar width natively\n   ========================== */\n\n/* Allow folder container to adapt to native sidebar width */\n.gv-aistudio-root ms-prompt-history-v3 {\n  /* Ensure folder content doesn't overflow */\n  overflow-x: hidden;\n}\n\n.gv-aistudio-root .nav-content.v3-left-nav {\n  /* Just add padding for folder icons, don't force width */\n  padding-right: 4px;\n}\n\n.gv-aistudio .gv-folder-header {\n  padding: 6px 8px 6px 0;\n  margin-bottom: 6px;\n}\n\n.gv-aistudio .gv-folder-title {\n  font-size: 12px;\n  opacity: 0.8;\n}\n\n.gv-aistudio .gv-folder-item-header {\n  gap: 6px;\n  padding: 6px 10px;\n  /* Reserve space for more button only (pin is hidden by default) */\n  padding-right: 10px;\n  overflow: visible;\n  min-height: 28px;\n}\n\n.gv-aistudio .gv-folder-name {\n  font-size: 12px;\n  /* Allow a bit more width visually */\n  letter-spacing: 0.1px;\n  /* Ensure folder name has minimum width to display at least a few characters */\n  min-width: 40px;\n  flex: 1;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.gv-aistudio .gv-folder-expand-btn {\n  width: 18px;\n  height: 18px;\n}\n\n.gv-aistudio .gv-folder-icon {\n  font-size: 16px;\n}\n\n/* Reduce container/list side paddings to utilize width */\n.gv-aistudio.gv-folder-container {\n  padding-left: 2px;\n  padding-right: 2px;\n}\n\n.gv-aistudio .gv-folder-list {\n  padding-left: 0;\n  padding-right: 0;\n}\n\n/* Make action buttons absolute so they don’t steal inline space */\n.gv-aistudio .gv-folder-pin-btn,\n.gv-aistudio .gv-folder-actions-btn {\n  width: 24px;\n  height: 24px;\n  position: absolute;\n  top: 50%;\n  transform: translateY(-50%);\n  margin: 0;\n  overflow: visible;\n  z-index: 1;\n}\n\n/* Pin button: hidden by default, show on hover to save space */\n.gv-aistudio .gv-folder-pin-btn {\n  opacity: 0;\n  transition: opacity 0.15s ease;\n}\n\n/* More button: hidden by default, show on hover to save space */\n.gv-aistudio .gv-folder-actions-btn {\n  opacity: 0;\n  transition: opacity 0.15s ease;\n}\n\n.gv-aistudio .gv-folder-item-header:hover .gv-folder-pin-btn,\n.gv-aistudio .gv-folder-item-header:hover .gv-folder-actions-btn {\n  opacity: 1;\n}\n\n.gv-aistudio .gv-folder-actions-btn {\n  right: 6px;\n}\n\n.gv-aistudio .gv-folder-pin-btn {\n  right: 28px;\n}\n\n.gv-aistudio .gv-folder-header-actions .gv-folder-action-btn {\n  padding: 2px;\n}\n\n/* Ensure pin/more icons are perfectly centered and not clipped */\n.gv-aistudio .gv-folder-pin-btn .google-symbols,\n.gv-aistudio .gv-folder-actions-btn .google-symbols {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 24px;\n  /* match button box */\n  height: 24px;\n  line-height: 24px;\n  overflow: visible !important;\n  /* never clip emoji */\n}\n\n.gv-aistudio .gv-folder-pin-btn .google-symbols::before,\n.gv-aistudio .gv-folder-actions-btn .google-symbols::before {\n  width: 24px;\n  height: 24px;\n  line-height: 24px;\n  text-align: center;\n  font-size: 20px;\n  /* slightly larger for better visual balance */\n}\n\n/* Icon fallbacks: hide ligature text and use simple glyphs */\n.gv-aistudio .gv-folder-pin-btn .google-symbols,\n.gv-aistudio .gv-folder-add-btn .google-symbols,\n.gv-aistudio .gv-folder-actions-btn .google-symbols,\n.gv-aistudio .gv-folder-expand-btn .google-symbols,\n.gv-aistudio .gv-folder-icon.google-symbols,\n.gv-aistudio .gv-conversation-icon.google-symbols,\n.gv-aistudio .gv-folder-action-btn .google-symbols,\n.gv-aistudio .gv-conversation-star-btn .google-symbols,\n.gv-aistudio .gv-conversation-remove-btn .google-symbols,\n.gv-aistudio .gv-folder-uncategorized-header .google-symbols {\n  font-family: inherit !important;\n  /* Don’t rely on Material Symbols in aistudio */\n  font-weight: 400;\n  font-size: 0;\n  /* Hide text like 'push_pin' */\n  line-height: 20px;\n  display: inline-block;\n  width: 20px;\n  /* bigger box to avoid clipping */\n  height: 20px;\n  overflow: hidden;\n  vertical-align: middle;\n}\n\n.gv-aistudio .google-symbols::before {\n  display: block;\n  width: 20px;\n  height: 20px;\n  line-height: 20px;\n  text-align: center;\n  font-size: 18px;\n}\n\n/* Per-icon mappings */\n.gv-aistudio .google-symbols[data-icon='expand_more']::before {\n  content: '▾';\n}\n\n.gv-aistudio .google-symbols[data-icon='chevron_right']::before {\n  content: '▸';\n}\n\n.gv-aistudio .google-symbols[data-icon='folder']::before {\n  content: '📁';\n  font-size: 16px;\n}\n\n.gv-aistudio .google-symbols[data-icon='chat']::before {\n  content: '💬';\n  font-size: 16px;\n}\n\n.gv-aistudio .google-symbols[data-icon='push_pin']::before {\n  content: '📌';\n  font-size: 16px;\n}\n\n.gv-aistudio .gv-folder-pin-btn[data-state='unpinned'] .google-symbols::before {\n  content: '📍';\n  font-size: 14px;\n}\n\n.gv-aistudio\n  .gv-folder-item[data-pinned='true']\n  > .gv-folder-item-header\n  .gv-folder-pin-btn\n  .google-symbols::before {\n  content: '📌';\n  color: #f59e0b;\n}\n\n/* Subtle highlight for pinned folders */\n.gv-aistudio .gv-folder-item[data-pinned='true'] > .gv-folder-item-header {\n  background-color: rgba(245, 158, 11, 0.08);\n}\n\n/* Aistudio conversation layout tweaks */\n.gv-aistudio .gv-folder-conversation {\n  overflow: visible;\n}\n\n/* AI Studio selected conversation style */\n.gv-aistudio .gv-folder-conversation-selected {\n  position: relative;\n  background: linear-gradient(\n    to right,\n    var(--folder-selected-glow) 0%,\n    transparent 100%\n  ) !important;\n  border-radius: 10px;\n  transition: background 200ms ease;\n}\n\n.gv-aistudio .gv-folder-conversation-selected::before {\n  content: '';\n  position: absolute;\n  left: 0;\n  top: 50%;\n  transform: translateY(-50%);\n  width: 3px;\n  height: 60%;\n  border-radius: 0 2px 2px 0;\n  background-color: var(--folder-selected-accent);\n  opacity: 0.9;\n  pointer-events: none;\n}\n\n.gv-aistudio .gv-folder-conversation-selected > * {\n  position: relative;\n  z-index: 1;\n}\n\n.gv-aistudio .gv-conversation-title {\n  min-width: 0;\n}\n\n.gv-aistudio .gv-conversation-star-btn {\n  margin-left: auto;\n}\n\n/* AI Studio: keep conversations readable with clear hierarchy */\n.gv-aistudio .gv-folder-content {\n  margin-left: 16px;\n  padding-left: 10px;\n  border-left: 1px solid var(--folder-border);\n}\n\n/* Root drop zone visuals */\n.gv-aistudio .gv-folder-root-drop {\n  margin: 4px 8px 0 8px;\n  min-height: 28px;\n  border-radius: 6px;\n  border: 1px dashed var(--folder-border);\n  color: var(--folder-icon-color);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 11px;\n  opacity: 0.7;\n}\n\n.gv-aistudio .gv-folder-root-drop.gv-folder-dragover {\n  background-color: var(--folder-dragover-bg);\n  border-color: var(--folder-dragover-border);\n  opacity: 1;\n}\n\n/* Uncategorized section styles */\n.gv-aistudio .gv-folder-uncategorized {\n  margin-top: 8px;\n  padding-top: 8px;\n  border-top: 1px solid var(--folder-border);\n}\n\n.gv-aistudio .gv-folder-uncategorized-header {\n  display: flex;\n  align-items: center;\n  padding: 6px 10px;\n  font-size: 12px;\n  color: var(--folder-icon-color);\n  opacity: 0.7;\n}\n\n.gv-aistudio .gv-folder-uncategorized-content {\n  padding-left: 8px;\n}\n\n.gv-aistudio .google-symbols[data-icon='inbox']::before {\n  content: '📥';\n  font-size: 14px;\n}\n\n.gv-aistudio .google-symbols[data-icon='more_vert']::before {\n  content: '⋮';\n  font-size: 16px;\n}\n\n.gv-aistudio .google-symbols[data-icon='add']::before {\n  content: '+';\n  font-size: 16px;\n}\n\n.gv-aistudio .google-symbols[data-icon='download']::before {\n  content: '⬇';\n  font-size: 16px;\n}\n\n.gv-aistudio .google-symbols[data-icon='upload']::before {\n  content: '⬆';\n  font-size: 16px;\n}\n\n.gv-aistudio .google-symbols[data-icon='star']::before {\n  content: '★';\n  font-size: 14px;\n}\n\n.gv-aistudio .google-symbols[data-icon='star_outline']::before {\n  content: '☆';\n  font-size: 14px;\n}\n\n.gv-aistudio .google-symbols[data-icon='close']::before {\n  content: '×';\n  font-size: 14px;\n}\n\n/* Import dialog */\n/* ── Folder Import Dialog ── */\n.gv-folder-import-dialog {\n  background: oklch(0.995 0.002 250);\n  border: 1px solid oklch(0.92 0.004 250 / 0.7);\n  border-radius: 18px;\n  padding: 24px;\n  min-width: 400px;\n  max-width: 500px;\n  box-shadow:\n    0 16px 48px oklch(0 0 0 / 0.12),\n    0 6px 20px oklch(0 0 0 / 0.06);\n  font-family:\n    ui-sans-serif,\n    system-ui,\n    -apple-system,\n    Segoe UI,\n    Roboto,\n    Helvetica,\n    Arial,\n    'Apple Color Emoji',\n    'Segoe UI Emoji';\n  animation: gv-folder-fadeIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-import-dialog {\n    background: oklch(0.2 0.008 285);\n    border-color: oklch(0.3 0.008 285 / 0.7);\n    box-shadow:\n      0 16px 48px oklch(0 0 0 / 0.4),\n      0 6px 20px oklch(0 0 0 / 0.25);\n  }\n}\n\n.theme-host.dark-theme .gv-folder-import-dialog,\nbody.dark-theme .gv-folder-import-dialog {\n  background: oklch(0.2 0.008 285);\n  border-color: oklch(0.3 0.008 285 / 0.7);\n  box-shadow:\n    0 16px 48px oklch(0 0 0 / 0.4),\n    0 6px 20px oklch(0 0 0 / 0.25);\n}\n\n.theme-host.light-theme .gv-folder-import-dialog,\nbody.light-theme .gv-folder-import-dialog {\n  background: oklch(0.995 0.002 250);\n  border-color: oklch(0.92 0.004 250 / 0.7);\n}\n\nhtml.dark .gv-folder-import-dialog,\n[data-theme='dark'] .gv-folder-import-dialog,\n[data-color-scheme='dark'] .gv-folder-import-dialog {\n  background: oklch(0.2 0.008 285);\n  border-color: oklch(0.3 0.008 285 / 0.7);\n  box-shadow:\n    0 16px 48px oklch(0 0 0 / 0.4),\n    0 6px 20px oklch(0 0 0 / 0.25);\n}\n\n.gv-folder-import-dialog .gv-folder-dialog-title {\n  font-size: 17px;\n  font-weight: 800;\n  letter-spacing: -0.02em;\n  margin-bottom: 20px;\n  padding: 0;\n  border-bottom: none;\n  color: oklch(0.14 0.004 285);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-import-dialog .gv-folder-dialog-title {\n    color: oklch(0.92 0.004 250);\n  }\n}\n\nhtml.dark .gv-folder-import-dialog .gv-folder-dialog-title,\n[data-theme='dark'] .gv-folder-import-dialog .gv-folder-dialog-title,\n[data-color-scheme='dark'] .gv-folder-import-dialog .gv-folder-dialog-title {\n  color: oklch(0.92 0.004 250);\n}\n\n.theme-host.dark-theme .gv-folder-import-dialog .gv-folder-dialog-title,\nbody.dark-theme .gv-folder-import-dialog .gv-folder-dialog-title {\n  color: oklch(0.92 0.004 250);\n}\n\n.theme-host.light-theme .gv-folder-import-dialog .gv-folder-dialog-title,\nbody.light-theme .gv-folder-import-dialog .gv-folder-dialog-title {\n  color: oklch(0.14 0.004 285);\n}\n\n/* Strategy selection */\n.gv-folder-import-strategy {\n  margin-bottom: 20px;\n}\n\n.gv-folder-import-strategy-label {\n  font-size: 12px;\n  font-weight: 700;\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n  margin-bottom: 12px;\n  color: oklch(0.14 0.004 285 / 0.5);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-import-strategy-label {\n    color: oklch(0.92 0.004 250 / 0.5);\n  }\n}\n\nhtml.dark .gv-folder-import-strategy-label,\n[data-theme='dark'] .gv-folder-import-strategy-label,\n[data-color-scheme='dark'] .gv-folder-import-strategy-label {\n  color: oklch(0.92 0.004 250 / 0.5);\n}\n\n.theme-host.dark-theme .gv-folder-import-strategy-label,\nbody.dark-theme .gv-folder-import-strategy-label {\n  color: oklch(0.92 0.004 250 / 0.5);\n}\n\n.theme-host.light-theme .gv-folder-import-strategy-label,\nbody.light-theme .gv-folder-import-strategy-label {\n  color: oklch(0.14 0.004 285 / 0.5);\n}\n\n.gv-folder-import-strategy-options {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.gv-folder-import-radio-option {\n  display: flex;\n  align-items: center;\n  padding: 12px 14px;\n  border: 1.5px solid oklch(0.92 0.004 250 / 0.7);\n  border-radius: 10px;\n  cursor: pointer;\n  transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.gv-folder-import-radio-option:hover {\n  background: oklch(0.55 0.17 155 / 0.06);\n  border-color: oklch(0.55 0.17 155 / 0.4);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-import-radio-option {\n    border-color: oklch(0.3 0.008 285 / 0.7);\n  }\n  .gv-folder-import-radio-option:hover {\n    background: oklch(0.7 0.16 155 / 0.08);\n    border-color: oklch(0.7 0.16 155 / 0.4);\n  }\n}\n\nhtml.dark .gv-folder-import-radio-option,\n[data-theme='dark'] .gv-folder-import-radio-option,\n[data-color-scheme='dark'] .gv-folder-import-radio-option {\n  border-color: oklch(0.3 0.008 285 / 0.7);\n}\n\nhtml.dark .gv-folder-import-radio-option:hover,\n[data-theme='dark'] .gv-folder-import-radio-option:hover,\n[data-color-scheme='dark'] .gv-folder-import-radio-option:hover {\n  background: oklch(0.7 0.16 155 / 0.08);\n  border-color: oklch(0.7 0.16 155 / 0.4);\n}\n\n.theme-host.dark-theme .gv-folder-import-radio-option,\nbody.dark-theme .gv-folder-import-radio-option {\n  border-color: oklch(0.3 0.008 285 / 0.7);\n}\n\n.theme-host.dark-theme .gv-folder-import-radio-option:hover,\nbody.dark-theme .gv-folder-import-radio-option:hover {\n  background: oklch(0.7 0.16 155 / 0.08);\n  border-color: oklch(0.7 0.16 155 / 0.4);\n}\n\n.theme-host.light-theme .gv-folder-import-radio-option,\nbody.light-theme .gv-folder-import-radio-option {\n  border-color: oklch(0.92 0.004 250 / 0.7);\n}\n\n.theme-host.light-theme .gv-folder-import-radio-option:hover,\nbody.light-theme .gv-folder-import-radio-option:hover {\n  background: oklch(0.55 0.17 155 / 0.06);\n  border-color: oklch(0.55 0.17 155 / 0.4);\n}\n\n.gv-folder-import-radio-option input[type='radio'] {\n  margin-right: 12px;\n  cursor: pointer;\n  accent-color: oklch(0.55 0.17 155);\n}\n\n.gv-folder-import-radio-option span {\n  font-size: 13px;\n  font-weight: 500;\n  color: oklch(0.14 0.004 285);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-import-radio-option span {\n    color: oklch(0.92 0.004 250);\n  }\n}\n\nhtml.dark .gv-folder-import-radio-option span,\n[data-theme='dark'] .gv-folder-import-radio-option span,\n[data-color-scheme='dark'] .gv-folder-import-radio-option span {\n  color: oklch(0.92 0.004 250);\n}\n\n.theme-host.dark-theme .gv-folder-import-radio-option span,\nbody.dark-theme .gv-folder-import-radio-option span {\n  color: oklch(0.92 0.004 250);\n}\n\n.theme-host.light-theme .gv-folder-import-radio-option span,\nbody.light-theme .gv-folder-import-radio-option span {\n  color: oklch(0.14 0.004 285);\n}\n\n/* File input */\n.gv-folder-import-file-input {\n  margin-bottom: 20px;\n}\n\n.gv-folder-import-file-button {\n  background: oklch(0.55 0.17 155);\n  color: oklch(0.99 0.01 155);\n  border: none;\n  padding: 10px 20px;\n  border-radius: 9px;\n  cursor: pointer;\n  font-size: 13px;\n  font-weight: 700;\n  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n  width: 100%;\n  box-shadow: 0 2px 8px oklch(0.55 0.17 155 / 0.3);\n}\n\n.gv-folder-import-file-button:hover {\n  background: oklch(0.6 0.2 158);\n  box-shadow: 0 4px 14px oklch(0.55 0.17 155 / 0.4);\n  transform: translateY(-1px);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-import-file-button {\n    background: oklch(0.7 0.16 155);\n    color: oklch(0.15 0.025 160);\n    box-shadow: 0 2px 8px oklch(0.7 0.16 155 / 0.3);\n  }\n  .gv-folder-import-file-button:hover {\n    background: oklch(0.7 0.2 155);\n    box-shadow: 0 4px 14px oklch(0.7 0.16 155 / 0.4);\n  }\n}\n\n.theme-host.light-theme .gv-folder-import-file-button,\nbody.light-theme .gv-folder-import-file-button {\n  background: oklch(0.55 0.17 155);\n  color: oklch(0.99 0.01 155);\n  box-shadow: 0 2px 8px oklch(0.55 0.17 155 / 0.3);\n}\n\n.theme-host.dark-theme .gv-folder-import-file-button,\nbody.dark-theme .gv-folder-import-file-button {\n  background: oklch(0.7 0.16 155);\n  color: oklch(0.15 0.025 160);\n  box-shadow: 0 2px 8px oklch(0.7 0.16 155 / 0.3);\n}\n\n.gv-folder-import-file-name {\n  margin-top: 12px;\n  font-size: 12px;\n  font-weight: 500;\n  color: oklch(0.14 0.004 285 / 0.5);\n  padding: 9px 12px;\n  background: oklch(0.14 0.004 285 / 0.04);\n  border-radius: 10px;\n  text-align: center;\n  min-height: 32px;\n  display: none;\n  align-items: center;\n  justify-content: center;\n}\n\n.gv-folder-import-file-name:not(:empty) {\n  display: flex;\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-import-file-name {\n    background: oklch(0.92 0.004 250 / 0.06);\n    color: oklch(0.92 0.004 250 / 0.5);\n  }\n}\n\nhtml.dark .gv-folder-import-file-name,\n[data-theme='dark'] .gv-folder-import-file-name,\n[data-color-scheme='dark'] .gv-folder-import-file-name {\n  background: oklch(0.92 0.004 250 / 0.06);\n  color: oklch(0.92 0.004 250 / 0.5);\n}\n\n.theme-host.dark-theme .gv-folder-import-file-name,\nbody.dark-theme .gv-folder-import-file-name {\n  background: oklch(0.92 0.004 250 / 0.06);\n  color: oklch(0.92 0.004 250 / 0.5);\n}\n\n.theme-host.light-theme .gv-folder-import-file-name,\nbody.light-theme .gv-folder-import-file-name {\n  background: oklch(0.14 0.004 285 / 0.04);\n  color: oklch(0.14 0.004 285 / 0.5);\n}\n\n/* Dialog buttons */\n.gv-folder-dialog-buttons {\n  display: flex;\n  gap: 8px;\n  justify-content: flex-end;\n}\n\n.gv-folder-dialog-btn {\n  padding: 8px 18px;\n  border-radius: 9px;\n  border: none;\n  font-size: 13px;\n  font-weight: 700;\n  cursor: pointer;\n  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.gv-folder-dialog-btn-primary {\n  background: oklch(0.55 0.17 155);\n  color: oklch(0.99 0.01 155);\n  box-shadow: 0 2px 8px oklch(0.55 0.17 155 / 0.3);\n}\n\n.gv-folder-dialog-btn-primary:hover {\n  background: oklch(0.6 0.2 158);\n  box-shadow: 0 4px 14px oklch(0.55 0.17 155 / 0.4);\n  transform: translateY(-1px);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-dialog-btn-primary {\n    background: oklch(0.7 0.16 155);\n    color: oklch(0.15 0.025 160);\n    box-shadow: 0 2px 8px oklch(0.7 0.16 155 / 0.3);\n  }\n  .gv-folder-dialog-btn-primary:hover {\n    background: oklch(0.7 0.2 155);\n    box-shadow: 0 4px 14px oklch(0.7 0.16 155 / 0.4);\n  }\n}\n\n.theme-host.light-theme .gv-folder-dialog-btn-primary,\nbody.light-theme .gv-folder-dialog-btn-primary {\n  background: oklch(0.55 0.17 155);\n  color: oklch(0.99 0.01 155);\n  box-shadow: 0 2px 8px oklch(0.55 0.17 155 / 0.3);\n}\n\n.theme-host.dark-theme .gv-folder-dialog-btn-primary,\nbody.dark-theme .gv-folder-dialog-btn-primary {\n  background: oklch(0.7 0.16 155);\n  color: oklch(0.15 0.025 160);\n  box-shadow: 0 2px 8px oklch(0.7 0.16 155 / 0.3);\n}\n\n.gv-folder-dialog-btn-secondary {\n  background: transparent;\n  color: oklch(0.55 0.17 155);\n  border: 1px solid oklch(0.14 0.004 285 / 0.12);\n}\n\n.gv-folder-dialog-btn-secondary:hover {\n  background: oklch(0.14 0.004 285 / 0.06);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-dialog-btn-secondary {\n    color: oklch(0.7 0.16 155);\n    border-color: oklch(0.92 0.004 250 / 0.12);\n  }\n  .gv-folder-dialog-btn-secondary:hover {\n    background: oklch(0.92 0.004 250 / 0.06);\n  }\n}\n\nhtml.dark .gv-folder-dialog-btn-secondary,\n[data-theme='dark'] .gv-folder-dialog-btn-secondary,\n[data-color-scheme='dark'] .gv-folder-dialog-btn-secondary {\n  color: oklch(0.7 0.16 155);\n  border-color: oklch(0.92 0.004 250 / 0.12);\n}\n\nhtml.dark .gv-folder-dialog-btn-secondary:hover,\n[data-theme='dark'] .gv-folder-dialog-btn-secondary:hover,\n[data-color-scheme='dark'] .gv-folder-dialog-btn-secondary:hover {\n  background: oklch(0.92 0.004 250 / 0.06);\n}\n\n.theme-host.dark-theme .gv-folder-dialog-btn-secondary,\nbody.dark-theme .gv-folder-dialog-btn-secondary {\n  color: oklch(0.7 0.16 155);\n  border-color: oklch(0.92 0.004 250 / 0.12);\n}\n\n.theme-host.dark-theme .gv-folder-dialog-btn-secondary:hover,\nbody.dark-theme .gv-folder-dialog-btn-secondary:hover {\n  background: oklch(0.92 0.004 250 / 0.06);\n}\n\n.theme-host.light-theme .gv-folder-dialog-btn-secondary,\nbody.light-theme .gv-folder-dialog-btn-secondary {\n  color: oklch(0.55 0.17 155);\n  border-color: oklch(0.14 0.004 285 / 0.12);\n}\n\n.theme-host.light-theme .gv-folder-dialog-btn-secondary:hover,\nbody.light-theme .gv-folder-dialog-btn-secondary:hover {\n  background: oklch(0.14 0.004 285 / 0.06);\n}\n\n/* Paste JSON toggle & textarea */\n.gv-folder-import-paste-container {\n  margin-bottom: 20px;\n}\n\n.gv-folder-import-paste-toggle {\n  background: transparent;\n  color: oklch(0.55 0.17 155);\n  border: 1px dashed oklch(0.92 0.004 250 / 0.7);\n  padding: 9px 16px;\n  border-radius: 10px;\n  cursor: pointer;\n  font-size: 13px;\n  font-weight: 600;\n  width: 100%;\n  transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.gv-folder-import-paste-toggle:hover {\n  background: oklch(0.55 0.17 155 / 0.06);\n  border-color: oklch(0.55 0.17 155 / 0.4);\n}\n\n.gv-folder-import-paste-toggle-active {\n  background: oklch(0.55 0.17 155 / 0.1);\n  border-color: oklch(0.55 0.17 155 / 0.5);\n  border-style: solid;\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-import-paste-toggle {\n    color: oklch(0.7 0.16 155);\n    border-color: oklch(0.3 0.008 285 / 0.7);\n  }\n  .gv-folder-import-paste-toggle:hover {\n    background: oklch(0.7 0.16 155 / 0.08);\n    border-color: oklch(0.7 0.16 155 / 0.4);\n  }\n  .gv-folder-import-paste-toggle-active {\n    background: oklch(0.7 0.16 155 / 0.12);\n    border-color: oklch(0.7 0.16 155 / 0.5);\n  }\n}\n\nhtml.dark .gv-folder-import-paste-toggle,\n[data-theme='dark'] .gv-folder-import-paste-toggle,\n[data-color-scheme='dark'] .gv-folder-import-paste-toggle {\n  color: oklch(0.7 0.16 155);\n  border-color: oklch(0.3 0.008 285 / 0.7);\n}\n\nhtml.dark .gv-folder-import-paste-toggle:hover,\n[data-theme='dark'] .gv-folder-import-paste-toggle:hover,\n[data-color-scheme='dark'] .gv-folder-import-paste-toggle:hover {\n  background: oklch(0.7 0.16 155 / 0.08);\n  border-color: oklch(0.7 0.16 155 / 0.4);\n}\n\nhtml.dark .gv-folder-import-paste-toggle-active,\n[data-theme='dark'] .gv-folder-import-paste-toggle-active,\n[data-color-scheme='dark'] .gv-folder-import-paste-toggle-active {\n  background: oklch(0.7 0.16 155 / 0.12);\n  border-color: oklch(0.7 0.16 155 / 0.5);\n}\n\n.gv-folder-import-paste-area {\n  width: 100%;\n  min-height: 120px;\n  max-height: 300px;\n  margin-top: 10px;\n  padding: 9px 12px;\n  border: 1.5px solid oklch(0.92 0.004 250 / 0.7);\n  border-radius: 10px;\n  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n  font-size: 12px;\n  line-height: 1.5;\n  resize: vertical;\n  background: oklch(0.14 0.004 285 / 0.04);\n  color: oklch(0.14 0.004 285);\n  box-sizing: border-box;\n  transition:\n    border-color 0.2s ease,\n    box-shadow 0.2s ease;\n}\n\n.gv-folder-import-paste-area:focus {\n  outline: none;\n  border-color: oklch(0.55 0.17 155 / 0.5);\n  box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.1);\n}\n\n@media (prefers-color-scheme: dark) {\n  .gv-folder-import-paste-area {\n    background: oklch(0.92 0.004 250 / 0.06);\n    color: oklch(0.92 0.004 250);\n    border-color: oklch(0.3 0.008 285 / 0.7);\n  }\n  .gv-folder-import-paste-area:focus {\n    border-color: oklch(0.7 0.16 155 / 0.5);\n    box-shadow: 0 0 0 3px oklch(0.7 0.16 155 / 0.1);\n  }\n}\n\nhtml.dark .gv-folder-import-paste-area,\n[data-theme='dark'] .gv-folder-import-paste-area,\n[data-color-scheme='dark'] .gv-folder-import-paste-area {\n  background: oklch(0.92 0.004 250 / 0.06);\n  color: oklch(0.92 0.004 250);\n  border-color: oklch(0.3 0.008 285 / 0.7);\n}\n\nhtml.dark .gv-folder-import-paste-area:focus,\n[data-theme='dark'] .gv-folder-import-paste-area:focus,\n[data-color-scheme='dark'] .gv-folder-import-paste-area:focus {\n  border-color: oklch(0.7 0.16 155 / 0.5);\n  box-shadow: 0 0 0 3px oklch(0.7 0.16 155 / 0.1);\n}\n\n/* Notification toast */\n.gv-notification {\n  position: fixed;\n  bottom: 24px;\n  right: 24px;\n  padding: 12px 20px;\n  border-radius: 8px;\n  font-size: 14px;\n  font-weight: 500;\n  color: white;\n  z-index: 2147483647;\n  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);\n  opacity: 0;\n  transform: translateY(20px);\n  transition: all 0.3s ease;\n  max-width: 400px;\n  font-family: 'Google Sans', Roboto, Arial, sans-serif;\n}\n\n.gv-notification.show {\n  opacity: 1;\n  transform: translateY(0);\n}\n\n.gv-notification-success {\n  background: linear-gradient(135deg, #34a853 0%, #0d652d 100%);\n}\n\n.gv-notification-error {\n  background: linear-gradient(135deg, #ea4335 0%, #c5221f 100%);\n}\n\n.gv-notification-info {\n  background: linear-gradient(135deg, #1a73e8 0%, #1765cc 100%);\n}\n\n/* ==========================================================================\n   Export Dialog Styles\n   ========================================================================== */\n\n/* Dialog overlay */\n.gv-export-dialog-overlay {\n  position: fixed;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  background: rgba(0, 0, 0, 0.5);\n  backdrop-filter: blur(4px);\n  z-index: 2147483647;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  animation: gv-fade-in 0.2s ease-out;\n}\n\n@keyframes gv-fade-in {\n  from {\n    opacity: 0;\n  }\n\n  to {\n    opacity: 1;\n  }\n}\n\n/* Dialog container */\n.gv-export-dialog {\n  background: white;\n  border-radius: 16px;\n  padding: 24px;\n  min-width: 420px;\n  max-width: 500px;\n  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);\n  font-family: 'Google Sans', Roboto, Arial, sans-serif;\n  animation: gv-slide-up 0.3s ease-out;\n}\n\n@keyframes gv-slide-up {\n  from {\n    opacity: 0;\n    transform: translateY(20px);\n  }\n\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\nhtml.dark .gv-export-dialog,\n[data-theme='dark'] .gv-export-dialog,\n[data-color-scheme='dark'] .gv-export-dialog {\n  background: #1e1e1e;\n  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);\n}\n\n/* Dialog title */\n.gv-export-dialog-title {\n  font-size: 20px;\n  font-weight: 500;\n  margin-bottom: 8px;\n  color: #202124;\n}\n\nhtml.dark .gv-export-dialog-title,\n[data-theme='dark'] .gv-export-dialog-title,\n[data-color-scheme='dark'] .gv-export-dialog-title {\n  color: #e8eaed;\n}\n\n/* Dialog subtitle */\n.gv-export-dialog-subtitle {\n  font-size: 14px;\n  color: #5f6368;\n  margin-bottom: 20px;\n}\n\nhtml.dark .gv-export-dialog-subtitle,\n[data-theme='dark'] .gv-export-dialog-subtitle,\n[data-color-scheme='dark'] .gv-export-dialog-subtitle {\n  color: #9aa0a6;\n}\n\n/* Warning message */\n.gv-export-dialog-warning {\n  font-size: 13px;\n  color: #d93025;\n  background-color: #fef7e0;\n  border: 1px solid #f9ab00;\n  border-radius: 8px;\n  padding: 12px 16px;\n  margin-bottom: 20px;\n  line-height: 1.5;\n}\n\nhtml.dark .gv-export-dialog-warning,\n[data-theme='dark'] .gv-export-dialog-warning,\n[data-color-scheme='dark'] .gv-export-dialog-warning {\n  color: #fdd663;\n  background-color: rgba(249, 171, 0, 0.1);\n  border-color: #f9ab00;\n}\n\n/* Format list */\n.gv-export-format-list {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  margin-bottom: 24px;\n}\n\n/* Format option */\n.gv-export-format-option {\n  display: flex;\n  align-items: flex-start;\n  padding: 16px;\n  border: 2px solid #dadce0;\n  border-radius: 8px;\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.gv-export-format-option:hover {\n  background-color: #f8f9fa;\n  border-color: #1a73e8;\n}\n\n.gv-export-format-option:has(input:checked) {\n  background-color: #e8f0fe;\n  border-color: #1a73e8;\n}\n\nhtml.dark .gv-export-format-option,\n[data-theme='dark'] .gv-export-format-option,\n[data-color-scheme='dark'] .gv-export-format-option {\n  border-color: #3c4043;\n}\n\nhtml.dark .gv-export-format-option:hover,\n[data-theme='dark'] .gv-export-format-option:hover,\n[data-color-scheme='dark'] .gv-export-format-option:hover {\n  background-color: #2d2e30;\n  border-color: #8ab4f8;\n}\n\nhtml.dark .gv-export-format-option:has(input:checked),\n[data-theme='dark'] .gv-export-format-option:has(input:checked),\n[data-color-scheme='dark'] .gv-export-format-option:has(input:checked) {\n  background-color: #1e3a5f;\n  border-color: #8ab4f8;\n}\n\n/* Radio input */\n.gv-export-format-option input[type='radio'] {\n  margin-right: 12px;\n  margin-top: 2px;\n  cursor: pointer;\n  flex-shrink: 0;\n  width: 18px;\n  height: 18px;\n  accent-color: #1a73e8;\n}\n\n/* Format content */\n.gv-export-format-content {\n  flex: 1;\n}\n\n.gv-export-format-label {\n  font-size: 15px;\n  font-weight: 500;\n  color: #202124;\n  margin-bottom: 4px;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\nhtml.dark .gv-export-format-label,\n[data-theme='dark'] .gv-export-format-label,\n[data-color-scheme='dark'] .gv-export-format-label {\n  color: #e8eaed;\n}\n\n.gv-export-format-badge {\n  display: inline-block;\n  padding: 2px 8px;\n  background: #34a853;\n  color: white;\n  font-size: 11px;\n  font-weight: 500;\n  border-radius: 4px;\n  text-transform: uppercase;\n  letter-spacing: 0.5px;\n}\n\n.gv-export-format-description {\n  font-size: 13px;\n  color: #5f6368;\n  line-height: 1.4;\n}\n\nhtml.dark .gv-export-format-description,\n[data-theme='dark'] .gv-export-format-description,\n[data-color-scheme='dark'] .gv-export-format-description {\n  color: #9aa0a6;\n}\n\n/* Dialog buttons */\n.gv-export-dialog-buttons {\n  display: flex;\n  gap: 12px;\n  justify-content: flex-end;\n}\n\n.gv-export-dialog-btn {\n  padding: 10px 24px;\n  border-radius: 8px;\n  border: none;\n  font-size: 14px;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.gv-export-dialog-btn-primary {\n  background: #1a73e8;\n  color: white;\n}\n\n.gv-export-dialog-btn-primary:hover {\n  background: #1765cc;\n  box-shadow: 0 2px 8px rgba(26, 115, 232, 0.3);\n}\n\n.gv-export-dialog-btn-secondary {\n  background: transparent;\n  color: #1a73e8;\n  border: 1px solid #dadce0;\n}\n\n.gv-export-dialog-btn-secondary:hover {\n  background: #f8f9fa;\n}\n\nhtml.dark .gv-export-dialog-btn-secondary,\n[data-theme='dark'] .gv-export-dialog-btn-secondary,\n[data-color-scheme='dark'] .gv-export-dialog-btn-secondary {\n  color: #8ab4f8;\n  border-color: #3c4043;\n}\n\nhtml.dark .gv-export-dialog-btn-secondary:hover,\n[data-theme='dark'] .gv-export-dialog-btn-secondary:hover,\n[data-color-scheme='dark'] .gv-export-dialog-btn-secondary:hover {\n  background: #2d2e30;\n}\n\n/* Dark theme fallback selectors used by some Gemini variants */\nhtml.dark-theme .gv-export-dialog,\nbody.dark-theme .gv-export-dialog,\nbody[data-theme='dark'] .gv-export-dialog,\nbody[data-color-scheme='dark'] .gv-export-dialog {\n  background: #1e1e1e;\n  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);\n}\n\nhtml.dark-theme .gv-export-dialog-title,\nbody.dark-theme .gv-export-dialog-title,\nbody[data-theme='dark'] .gv-export-dialog-title,\nbody[data-color-scheme='dark'] .gv-export-dialog-title {\n  color: #e8eaed;\n}\n\nhtml.dark-theme .gv-export-dialog-subtitle,\nbody.dark-theme .gv-export-dialog-subtitle,\nbody[data-theme='dark'] .gv-export-dialog-subtitle,\nbody[data-color-scheme='dark'] .gv-export-dialog-subtitle {\n  color: #9aa0a6;\n}\n\nhtml.dark-theme .gv-export-dialog-warning,\nbody.dark-theme .gv-export-dialog-warning,\nbody[data-theme='dark'] .gv-export-dialog-warning,\nbody[data-color-scheme='dark'] .gv-export-dialog-warning {\n  color: #fdd663;\n  background-color: rgba(249, 171, 0, 0.1);\n  border-color: #f9ab00;\n}\n\nhtml.dark-theme .gv-export-format-option,\nbody.dark-theme .gv-export-format-option,\nbody[data-theme='dark'] .gv-export-format-option,\nbody[data-color-scheme='dark'] .gv-export-format-option {\n  border-color: #3c4043;\n}\n\nhtml.dark-theme .gv-export-format-option:hover,\nbody.dark-theme .gv-export-format-option:hover,\nbody[data-theme='dark'] .gv-export-format-option:hover,\nbody[data-color-scheme='dark'] .gv-export-format-option:hover {\n  background-color: #2d2e30;\n  border-color: #8ab4f8;\n}\n\nhtml.dark-theme .gv-export-format-option:has(input:checked),\nbody.dark-theme .gv-export-format-option:has(input:checked),\nbody[data-theme='dark'] .gv-export-format-option:has(input:checked),\nbody[data-color-scheme='dark'] .gv-export-format-option:has(input:checked) {\n  background-color: #1e3a5f;\n  border-color: #8ab4f8;\n}\n\nhtml.dark-theme .gv-export-format-label,\nbody.dark-theme .gv-export-format-label,\nbody[data-theme='dark'] .gv-export-format-label,\nbody[data-color-scheme='dark'] .gv-export-format-label {\n  color: #e8eaed;\n}\n\nhtml.dark-theme .gv-export-format-description,\nbody.dark-theme .gv-export-format-description,\nbody[data-theme='dark'] .gv-export-format-description,\nbody[data-color-scheme='dark'] .gv-export-format-description {\n  color: #9aa0a6;\n}\n\nhtml.dark-theme .gv-export-dialog-btn-secondary,\nbody.dark-theme .gv-export-dialog-btn-secondary,\nbody[data-theme='dark'] .gv-export-dialog-btn-secondary,\nbody[data-color-scheme='dark'] .gv-export-dialog-btn-secondary {\n  color: #8ab4f8;\n  border-color: #3c4043;\n}\n\nhtml.dark-theme .gv-export-dialog-btn-secondary:hover,\nbody.dark-theme .gv-export-dialog-btn-secondary:hover,\nbody[data-theme='dark'] .gv-export-dialog-btn-secondary:hover,\nbody[data-color-scheme='dark'] .gv-export-dialog-btn-secondary:hover {\n  background: #2d2e30;\n}\n\n/* Font size control section in export dialog */\n.gv-export-fontsize-section {\n  margin-bottom: 20px;\n  padding: 16px;\n  border: 1px solid #dadce0;\n  border-radius: 8px;\n  background: #f8f9fa;\n}\n\nhtml.dark .gv-export-fontsize-section,\n[data-theme='dark'] .gv-export-fontsize-section,\n[data-color-scheme='dark'] .gv-export-fontsize-section,\nhtml.dark-theme .gv-export-fontsize-section,\nbody.dark-theme .gv-export-fontsize-section,\nbody[data-theme='dark'] .gv-export-fontsize-section,\nbody[data-color-scheme='dark'] .gv-export-fontsize-section {\n  border-color: #3c4043;\n  background: #2d2e30;\n}\n\n.gv-export-fontsize-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 10px;\n}\n\n.gv-export-fontsize-label {\n  font-size: 14px;\n  font-weight: 500;\n  color: #202124;\n}\n\nhtml.dark .gv-export-fontsize-label,\n[data-theme='dark'] .gv-export-fontsize-label,\n[data-color-scheme='dark'] .gv-export-fontsize-label,\nhtml.dark-theme .gv-export-fontsize-label,\nbody.dark-theme .gv-export-fontsize-label,\nbody[data-theme='dark'] .gv-export-fontsize-label,\nbody[data-color-scheme='dark'] .gv-export-fontsize-label {\n  color: #e8eaed;\n}\n\n.gv-export-fontsize-value {\n  font-size: 14px;\n  font-weight: 600;\n  color: #1a73e8;\n  min-width: 40px;\n  text-align: right;\n}\n\nhtml.dark .gv-export-fontsize-value,\n[data-theme='dark'] .gv-export-fontsize-value,\n[data-color-scheme='dark'] .gv-export-fontsize-value,\nhtml.dark-theme .gv-export-fontsize-value,\nbody.dark-theme .gv-export-fontsize-value,\nbody[data-theme='dark'] .gv-export-fontsize-value,\nbody[data-color-scheme='dark'] .gv-export-fontsize-value {\n  color: #8ab4f8;\n}\n\n.gv-export-fontsize-slider {\n  width: 100%;\n  height: 4px;\n  -webkit-appearance: none;\n  appearance: none;\n  outline: none;\n  border-radius: 2px;\n  background: #dadce0;\n  cursor: pointer;\n  margin-bottom: 12px;\n}\n\n.gv-export-fontsize-slider::-webkit-slider-thumb {\n  -webkit-appearance: none;\n  appearance: none;\n  width: 18px;\n  height: 18px;\n  border-radius: 50%;\n  background: #1a73e8;\n  cursor: pointer;\n  border: 2px solid #fff;\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);\n}\n\n.gv-export-fontsize-slider::-moz-range-thumb {\n  width: 18px;\n  height: 18px;\n  border-radius: 50%;\n  background: #1a73e8;\n  cursor: pointer;\n  border: 2px solid #fff;\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);\n}\n\nhtml.dark .gv-export-fontsize-slider,\n[data-theme='dark'] .gv-export-fontsize-slider,\n[data-color-scheme='dark'] .gv-export-fontsize-slider,\nhtml.dark-theme .gv-export-fontsize-slider,\nbody.dark-theme .gv-export-fontsize-slider,\nbody[data-theme='dark'] .gv-export-fontsize-slider,\nbody[data-color-scheme='dark'] .gv-export-fontsize-slider {\n  background: #3c4043;\n}\n\n.gv-export-fontsize-preview {\n  padding: 10px 14px;\n  border: 1px solid #dadce0;\n  border-radius: 6px;\n  background: white;\n  color: #202124;\n  font-family: Georgia, 'Times New Roman', serif;\n  line-height: 1.6;\n  max-height: 60px;\n  overflow: hidden;\n}\n\nhtml.dark .gv-export-fontsize-preview,\n[data-theme='dark'] .gv-export-fontsize-preview,\n[data-color-scheme='dark'] .gv-export-fontsize-preview,\nhtml.dark-theme .gv-export-fontsize-preview,\nbody.dark-theme .gv-export-fontsize-preview,\nbody[data-theme='dark'] .gv-export-fontsize-preview,\nbody[data-color-scheme='dark'] .gv-export-fontsize-preview {\n  border-color: #3c4043;\n  background: #1e1e1e;\n  color: #e8eaed;\n}\n\n.gv-aistudio .gv-conversation-title {\n  min-width: 0;\n  font-size: 12px;\n}\n\n/* Hide conversation icon to save space */\n.gv-aistudio .gv-conversation-icon.google-symbols {\n  display: none;\n}\n\n/* Message selection mode for export */\n.gv-export-select-mode .gv-export-msg-host {\n  position: relative;\n  cursor: pointer;\n}\n\n.gv-export-msg-selector {\n  display: none;\n  position: absolute;\n  left: 8px;\n  top: 50%;\n  transform: translateY(-50%);\n  z-index: 30;\n}\n\n.gv-export-select-mode .gv-export-msg-selector {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.gv-export-msg-checkbox {\n  width: 22px;\n  height: 22px;\n  border-radius: 6px;\n  border: 1px solid rgba(0, 0, 0, 0.15);\n  background: rgba(255, 255, 255, 0.9);\n  color: #64748b;\n  cursor: pointer;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  transition: all 0.12s ease;\n}\n\n.gv-export-msg-checkbox:hover {\n  border-color: rgba(22, 163, 74, 0.6);\n  box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.12);\n}\n\n.gv-export-msg-checkbox-mark {\n  width: 8px;\n  height: 4px;\n  border-left: 2px solid transparent;\n  border-bottom: 2px solid transparent;\n  transform: rotate(-45deg) translate(0, -1px);\n}\n\n.gv-export-msg-checkbox[data-selected='true'] {\n  background: #16a34a;\n  border-color: #16a34a;\n}\n\n.gv-export-msg-checkbox[data-selected='true'] .gv-export-msg-checkbox-mark {\n  border-left-color: #fff;\n  border-bottom-color: #fff;\n}\n\n/* Dark theme for checkbox */\nhtml.dark .gv-export-msg-checkbox,\nhtml.dark-theme .gv-export-msg-checkbox,\n.theme-host.dark-theme .gv-export-msg-checkbox {\n  border-color: rgba(255, 255, 255, 0.2);\n  background: rgba(19, 22, 28, 0.9);\n  color: #8b98aa;\n}\n\nhtml.dark .gv-export-msg-checkbox:hover,\nhtml.dark-theme .gv-export-msg-checkbox:hover,\n.theme-host.dark-theme .gv-export-msg-checkbox:hover {\n  border-color: rgba(128, 255, 176, 0.7);\n  box-shadow: 0 0 0 2px rgba(128, 255, 176, 0.15);\n}\n\n.gv-export-msg-selected {\n  box-shadow:\n    0 0 0 1px rgba(34, 197, 94, 0.55),\n    0 8px 18px rgba(34, 197, 94, 0.1);\n}\n\n.gv-export-select-bar {\n  position: fixed;\n  left: 50%;\n  top: 12px;\n  width: max-content;\n  max-width: calc(100vw - 32px);\n  transform: translateX(-50%);\n  z-index: 2147483600;\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 10px 14px;\n  border-radius: 14px;\n  background: rgba(13, 17, 23, 0.92);\n  border: 1px solid rgba(255, 255, 255, 0.12);\n  backdrop-filter: blur(10px);\n  color: #e5ecf5;\n  box-shadow:\n    0 12px 30px rgba(0, 0, 0, 0.38),\n    0 2px 8px rgba(0, 0, 0, 0.22);\n  overflow-x: auto;\n  scrollbar-width: none;\n  /* Firefox */\n}\n\n.gv-export-select-bar::-webkit-scrollbar {\n  display: none;\n  /* Chrome, Safari, Opera */\n}\n\n.gv-export-select-all-toggle,\n.gv-export-select-role-btn,\n.gv-export-select-export-btn,\n.gv-export-select-cancel-btn {\n  border: 0;\n  cursor: pointer;\n  transition: all 0.12s ease;\n}\n\n.gv-export-select-all-toggle,\n.gv-export-select-role-btn {\n  min-width: 68px;\n  flex-shrink: 0;\n  height: 34px;\n  padding: 0 12px;\n  border-radius: 10px;\n  background: rgba(255, 255, 255, 0.08);\n  color: #e5ecf5;\n  font-size: 13px;\n  font-weight: 600;\n  white-space: nowrap;\n}\n\n.gv-export-select-all-toggle[data-checked='true'],\n.gv-export-select-role-btn[data-checked='true'],\n.gv-export-select-role-btn:active {\n  background: rgba(22, 163, 74, 0.24);\n  color: #bbf7d0;\n}\n\n.gv-export-select-all-toggle:hover:not(:active),\n.gv-export-select-role-btn:hover:not(:active) {\n  background: rgba(255, 255, 255, 0.14);\n}\n\n.gv-export-select-count {\n  min-width: 92px;\n  flex-shrink: 0;\n  text-align: center;\n  color: #cfd8e3;\n  font-size: 13px;\n  font-weight: 600;\n  white-space: nowrap;\n}\n\n.gv-export-select-export-btn {\n  min-width: 74px;\n  flex-shrink: 0;\n  height: 34px;\n  padding: 0 14px;\n  border-radius: 10px;\n  background: #1d4ed8;\n  color: #fff;\n  font-size: 13px;\n  font-weight: 700;\n  white-space: nowrap;\n}\n\n.gv-export-select-export-btn:hover:not(:disabled) {\n  background: #2563eb;\n}\n\n.gv-export-select-export-btn:disabled {\n  opacity: 0.45;\n  cursor: not-allowed;\n}\n\n.gv-export-select-cancel-btn {\n  width: 34px;\n  flex-shrink: 0;\n  height: 34px;\n  border-radius: 10px;\n  background: transparent;\n  color: #a8b6c8;\n  font-size: 20px;\n  line-height: 1;\n}\n\n.gv-export-select-cancel-btn:hover {\n  background: rgba(255, 255, 255, 0.1);\n  color: #eef4ff;\n}\n\n.gv-export-progress-overlay {\n  position: fixed;\n  left: 50%;\n  transform: translateX(-50%);\n  top: 12px;\n  z-index: 2147483645;\n  pointer-events: none;\n}\n\n.gv-export-progress-card {\n  min-width: 220px;\n  max-width: min(360px, calc(100vw - 24px));\n  padding: 9px 12px;\n  border-radius: 999px;\n  border: 1px solid rgba(15, 23, 42, 0.12);\n  background: rgba(255, 255, 255, 0.92);\n  color: #0f172a;\n  box-shadow:\n    0 10px 24px rgba(15, 23, 42, 0.16),\n    0 2px 8px rgba(15, 23, 42, 0.08);\n  backdrop-filter: blur(10px);\n  display: grid;\n  grid-template-columns: auto 1fr;\n  grid-template-areas:\n    'spinner title'\n    'spinner desc';\n  align-items: center;\n  column-gap: 10px;\n  row-gap: 1px;\n}\n\n.gv-export-progress-spinner {\n  grid-area: spinner;\n  width: 22px;\n  height: 22px;\n  border-radius: 999px;\n  border: 2px solid rgba(100, 116, 139, 0.32);\n  border-top-color: #2563eb;\n  animation: gv-export-progress-spin 0.8s linear infinite;\n}\n\n.gv-export-progress-title {\n  grid-area: title;\n  font-size: 16px;\n  font-weight: 700;\n  color: #0f172a;\n  line-height: 1.2;\n}\n\n.gv-export-progress-desc {\n  grid-area: desc;\n  font-size: 12px;\n  color: #475569;\n  line-height: 1.2;\n}\n\nhtml.dark .gv-export-progress-card,\nhtml.dark-theme .gv-export-progress-card,\nbody.dark-theme .gv-export-progress-card,\n[data-theme='dark'] .gv-export-progress-card,\n[data-color-scheme='dark'] .gv-export-progress-card,\nbody[data-theme='dark'] .gv-export-progress-card,\nbody[data-color-scheme='dark'] .gv-export-progress-card {\n  border: 1px solid rgba(148, 163, 184, 0.3);\n  background: rgba(10, 16, 24, 0.86);\n  color: #e5ecf5;\n  box-shadow:\n    0 10px 24px rgba(0, 0, 0, 0.32),\n    0 2px 8px rgba(0, 0, 0, 0.2);\n}\n\nhtml.dark .gv-export-progress-spinner,\nhtml.dark-theme .gv-export-progress-spinner,\nbody.dark-theme .gv-export-progress-spinner,\n[data-theme='dark'] .gv-export-progress-spinner,\n[data-color-scheme='dark'] .gv-export-progress-spinner,\nbody[data-theme='dark'] .gv-export-progress-spinner,\nbody[data-color-scheme='dark'] .gv-export-progress-spinner {\n  border: 2px solid rgba(148, 163, 184, 0.35);\n  border-top-color: #3b82f6;\n}\n\nhtml.dark .gv-export-progress-title,\nhtml.dark-theme .gv-export-progress-title,\nbody.dark-theme .gv-export-progress-title,\n[data-theme='dark'] .gv-export-progress-title,\n[data-color-scheme='dark'] .gv-export-progress-title,\nbody[data-theme='dark'] .gv-export-progress-title,\nbody[data-color-scheme='dark'] .gv-export-progress-title {\n  color: #f8fbff;\n}\n\nhtml.dark .gv-export-progress-desc,\nhtml.dark-theme .gv-export-progress-desc,\nbody.dark-theme .gv-export-progress-desc,\n[data-theme='dark'] .gv-export-progress-desc,\n[data-color-scheme='dark'] .gv-export-progress-desc,\nbody[data-theme='dark'] .gv-export-progress-desc,\nbody[data-color-scheme='dark'] .gv-export-progress-desc {\n  color: #aebfd2;\n}\n\n@keyframes gv-export-progress-spin {\n  from {\n    transform: rotate(0);\n  }\n\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n@media (max-width: 640px) {\n  .gv-export-select-bar {\n    left: 10px;\n    right: 10px;\n    top: 10px;\n    transform: none;\n    justify-content: space-between;\n    gap: 8px;\n  }\n\n  .gv-export-select-all-toggle {\n    min-width: 62px;\n    padding: 0 10px;\n  }\n\n  .gv-export-select-count {\n    min-width: 82px;\n    font-size: 12px;\n  }\n\n  .gv-export-select-export-btn {\n    min-width: 66px;\n    padding: 0 10px;\n  }\n\n  .gv-export-progress-overlay {\n    top: 10px;\n  }\n\n  .gv-export-progress-card {\n    max-width: calc(100vw - 20px);\n  }\n}\n\n/* ===== Changelog Modal ===== */\n\n.gv-changelog-overlay {\n  position: fixed;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  background: rgba(0, 0, 0, 0.5);\n  backdrop-filter: blur(4px);\n  z-index: 2147483647;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  animation: gv-fade-in 0.2s ease-out;\n}\n\n.gv-changelog-dialog {\n  background: white;\n  border-radius: 16px;\n  min-width: 360px;\n  max-width: 520px;\n  width: 90vw;\n  max-height: 80vh;\n  display: flex;\n  flex-direction: column;\n  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);\n  font-family: 'Google Sans', Roboto, Arial, sans-serif;\n  animation: gv-slide-up 0.3s ease-out;\n}\n\nhtml.dark .gv-changelog-dialog,\n[data-theme='dark'] .gv-changelog-dialog,\n[data-color-scheme='dark'] .gv-changelog-dialog {\n  background: #1e1e1e;\n  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);\n}\n\n.gv-changelog-header {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 20px 24px 16px;\n  border-bottom: 1px solid #e0e0e0;\n}\n\nhtml.dark .gv-changelog-header,\n[data-theme='dark'] .gv-changelog-header,\n[data-color-scheme='dark'] .gv-changelog-header {\n  border-bottom-color: #3c4043;\n}\n\n.gv-changelog-title {\n  font-size: 18px;\n  font-weight: 600;\n  color: #202124;\n}\n\nhtml.dark .gv-changelog-title,\n[data-theme='dark'] .gv-changelog-title,\n[data-color-scheme='dark'] .gv-changelog-title {\n  color: #e8eaed;\n}\n\n.gv-changelog-version {\n  font-size: 12px;\n  font-weight: 500;\n  color: #1a73e8;\n  background: #e8f0fe;\n  padding: 2px 8px;\n  border-radius: 12px;\n}\n\nhtml.dark .gv-changelog-version,\n[data-theme='dark'] .gv-changelog-version,\n[data-color-scheme='dark'] .gv-changelog-version {\n  color: #8ab4f8;\n  background: rgba(138, 180, 248, 0.15);\n}\n\n.gv-changelog-close {\n  margin-left: auto;\n  background: none;\n  border: none;\n  font-size: 18px;\n  color: #5f6368;\n  cursor: pointer;\n  padding: 4px 8px;\n  border-radius: 50%;\n  line-height: 1;\n  transition: background-color 0.2s;\n}\n\n.gv-changelog-close:hover {\n  background-color: rgba(0, 0, 0, 0.08);\n}\n\nhtml.dark .gv-changelog-close,\n[data-theme='dark'] .gv-changelog-close,\n[data-color-scheme='dark'] .gv-changelog-close {\n  color: #9aa0a6;\n}\n\nhtml.dark .gv-changelog-close:hover,\n[data-theme='dark'] .gv-changelog-close:hover,\n[data-color-scheme='dark'] .gv-changelog-close:hover {\n  background-color: rgba(255, 255, 255, 0.08);\n}\n\n.gv-changelog-body {\n  flex: 1;\n  overflow-y: auto;\n  padding: 16px 24px;\n  font-size: 14px;\n  line-height: 1.6;\n  color: #3c4043;\n}\n\nhtml.dark .gv-changelog-body,\n[data-theme='dark'] .gv-changelog-body,\n[data-color-scheme='dark'] .gv-changelog-body {\n  color: #bdc1c6;\n}\n\n.gv-changelog-body h3 {\n  font-size: 16px;\n  font-weight: 600;\n  margin: 0 0 12px;\n  color: #202124;\n}\n\nhtml.dark .gv-changelog-body h3,\n[data-theme='dark'] .gv-changelog-body h3,\n[data-color-scheme='dark'] .gv-changelog-body h3 {\n  color: #e8eaed;\n}\n\n.gv-changelog-body ul {\n  margin: 0 0 12px;\n  padding-left: 20px;\n}\n\n.gv-changelog-body li {\n  margin-bottom: 6px;\n}\n\n.gv-changelog-body code {\n  font-size: 13px;\n  background: #f1f3f4;\n  padding: 1px 6px;\n  border-radius: 4px;\n}\n\nhtml.dark .gv-changelog-body code,\n[data-theme='dark'] .gv-changelog-body code,\n[data-color-scheme='dark'] .gv-changelog-body code {\n  background: #3c4043;\n}\n\n.gv-changelog-body p {\n  margin: 0 0 12px;\n}\n\n.gv-changelog-body img {\n  max-width: 100%;\n  border-radius: 8px;\n  margin: 8px 0;\n  cursor: zoom-in;\n  transition: opacity 0.15s;\n}\n\n.gv-changelog-body img:hover {\n  opacity: 0.9;\n}\n\n.gv-changelog-body a {\n  color: #1a73e8;\n  text-decoration: none;\n}\n\n.gv-changelog-body a:hover {\n  text-decoration: underline;\n}\n\nhtml.dark .gv-changelog-body a,\n[data-theme='dark'] .gv-changelog-body a,\n[data-color-scheme='dark'] .gv-changelog-body a {\n  color: #8ab4f8;\n}\n\n.gv-changelog-footer {\n  display: flex;\n  flex-direction: column;\n  padding: 16px 24px;\n  border-top: 1px solid #e0e0e0;\n  gap: 12px;\n}\n\nhtml.dark .gv-changelog-footer,\n[data-theme='dark'] .gv-changelog-footer,\n[data-color-scheme='dark'] .gv-changelog-footer {\n  border-top-color: #3c4043;\n}\n\n.gv-changelog-recommendation {\n  font-size: 13px;\n  color: #5f6368;\n  margin: 0;\n  line-height: 1.5;\n}\n\nhtml.dark .gv-changelog-recommendation,\n[data-theme='dark'] .gv-changelog-recommendation,\n[data-color-scheme='dark'] .gv-changelog-recommendation {\n  color: #9aa0a6;\n}\n\n.gv-changelog-social-row {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 6px 12px;\n  margin: 8px 0 0;\n  font-size: 12px;\n  color: #5f6368;\n  line-height: 1.5;\n}\n\n.gv-changelog-social-item {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  text-decoration: none;\n  color: inherit;\n  transition: opacity 0.15s;\n}\n\na.gv-changelog-social-item:hover {\n  opacity: 0.7;\n}\n\n.gv-changelog-social-icon {\n  display: inline-flex;\n  align-items: center;\n  flex-shrink: 0;\n}\n\nhtml.dark .gv-changelog-social-row,\n[data-theme='dark'] .gv-changelog-social-row,\n[data-color-scheme='dark'] .gv-changelog-social-row {\n  color: #9aa0a6;\n}\n\n.gv-changelog-action-row {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n}\n\n.gv-changelog-icon-group {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n}\n\n.gv-changelog-icon-link {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 32px;\n  height: 32px;\n  border-radius: 50%;\n  color: #5f6368;\n  transition:\n    background-color 0.2s,\n    color 0.2s;\n  text-decoration: none;\n}\n\n.gv-changelog-icon-link:hover {\n  background-color: rgba(0, 0, 0, 0.06);\n}\n\n.gv-changelog-icon-sponsor:hover {\n  color: #db61a2;\n}\n\n.gv-changelog-icon-github:hover {\n  color: #24292f;\n}\n\n.gv-changelog-icon-docs:hover {\n  color: #1a73e8;\n}\n\n.gv-changelog-icon-x:hover {\n  color: #000000;\n}\n\nhtml.dark .gv-changelog-icon-link,\n[data-theme='dark'] .gv-changelog-icon-link,\n[data-color-scheme='dark'] .gv-changelog-icon-link {\n  color: #9aa0a6;\n}\n\nhtml.dark .gv-changelog-icon-link:hover,\n[data-theme='dark'] .gv-changelog-icon-link:hover,\n[data-color-scheme='dark'] .gv-changelog-icon-link:hover {\n  background-color: rgba(255, 255, 255, 0.08);\n}\n\nhtml.dark .gv-changelog-icon-sponsor:hover,\n[data-theme='dark'] .gv-changelog-icon-sponsor:hover,\n[data-color-scheme='dark'] .gv-changelog-icon-sponsor:hover {\n  color: #f778ba;\n}\n\nhtml.dark .gv-changelog-icon-github:hover,\n[data-theme='dark'] .gv-changelog-icon-github:hover,\n[data-color-scheme='dark'] .gv-changelog-icon-github:hover {\n  color: #e8eaed;\n}\n\nhtml.dark .gv-changelog-icon-docs:hover,\n[data-theme='dark'] .gv-changelog-icon-docs:hover,\n[data-color-scheme='dark'] .gv-changelog-icon-docs:hover {\n  color: #8ab4f8;\n}\n\nhtml.dark .gv-changelog-icon-x:hover,\n[data-theme='dark'] .gv-changelog-icon-x:hover,\n[data-color-scheme='dark'] .gv-changelog-icon-x:hover {\n  color: #e8eaed;\n}\n\n.gv-changelog-docs-wrapper {\n  position: relative;\n  display: flex;\n  align-items: center;\n}\n\n.gv-changelog-docs-annotation {\n  position: absolute;\n  left: calc(100% + 6px);\n  white-space: nowrap;\n  font-size: 11px;\n  color: #80868b;\n  pointer-events: none;\n  display: flex;\n  align-items: center;\n  gap: 2px;\n}\n\n.gv-changelog-docs-annotation::before {\n  content: '\\2190';\n  font-size: 12px;\n}\n\nhtml.dark .gv-changelog-docs-annotation,\n[data-theme='dark'] .gv-changelog-docs-annotation,\n[data-color-scheme='dark'] .gv-changelog-docs-annotation {\n  color: #80868b;\n}\n\n.gv-changelog-got-it {\n  background: #1a73e8;\n  color: white;\n  border: none;\n  border-radius: 20px;\n  padding: 8px 24px;\n  font-size: 14px;\n  font-weight: 500;\n  cursor: pointer;\n  transition: background-color 0.2s;\n  white-space: nowrap;\n}\n\n.gv-changelog-got-it:hover {\n  background: #1557b0;\n}\n\nhtml.dark .gv-changelog-got-it,\n[data-theme='dark'] .gv-changelog-got-it,\n[data-color-scheme='dark'] .gv-changelog-got-it {\n  background: #8ab4f8;\n  color: #202124;\n}\n\nhtml.dark .gv-changelog-got-it:hover,\n[data-theme='dark'] .gv-changelog-got-it:hover,\n[data-color-scheme='dark'] .gv-changelog-got-it:hover {\n  background: #aecbfa;\n}\n\n.gv-changelog-chrome-rating {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 10px 14px;\n  margin-bottom: 12px;\n  border-radius: 10px;\n  background: linear-gradient(135deg, #eff6ff 0%, #f5f3ff 100%);\n  border: 1px solid rgba(99, 102, 241, 0.12);\n}\n\n.gv-changelog-chrome-rating-text {\n  flex: 1;\n  font-size: 12.5px;\n  color: #374151;\n  line-height: 1.4;\n}\n\n.gv-changelog-chrome-rating-link {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  padding: 6px 14px;\n  background: #1a73e8;\n  color: white !important;\n  border-radius: 20px;\n  font-size: 12px;\n  font-weight: 500;\n  text-decoration: none !important;\n  white-space: nowrap;\n  flex-shrink: 0;\n  transition: background-color 0.2s;\n}\n\n.gv-changelog-chrome-rating-link:hover {\n  background: #1557b0;\n  text-decoration: none !important;\n}\n\nhtml.dark .gv-changelog-chrome-rating,\n[data-theme='dark'] .gv-changelog-chrome-rating,\n[data-color-scheme='dark'] .gv-changelog-chrome-rating {\n  background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);\n  border-color: rgba(139, 92, 246, 0.2);\n}\n\nhtml.dark .gv-changelog-chrome-rating-text,\n[data-theme='dark'] .gv-changelog-chrome-rating-text,\n[data-color-scheme='dark'] .gv-changelog-chrome-rating-text {\n  color: #bdc1c6;\n}\n\nhtml.dark .gv-changelog-chrome-rating-link,\n[data-theme='dark'] .gv-changelog-chrome-rating-link,\n[data-color-scheme='dark'] .gv-changelog-chrome-rating-link {\n  background: #8ab4f8;\n  color: #202124 !important;\n}\n\nhtml.dark .gv-changelog-chrome-rating-link:hover,\n[data-theme='dark'] .gv-changelog-chrome-rating-link:hover,\n[data-color-scheme='dark'] .gv-changelog-chrome-rating-link:hover {\n  background: #aecbfa;\n}\n\n.gv-changelog-lightbox {\n  position: fixed;\n  inset: 0;\n  z-index: 2147483647;\n  background: rgba(0, 0, 0, 0.88);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 24px;\n  cursor: zoom-out;\n  animation: gv-lightbox-in 0.18s ease;\n}\n\n@keyframes gv-lightbox-in {\n  from {\n    opacity: 0;\n    transform: scale(0.96);\n  }\n\n  to {\n    opacity: 1;\n    transform: scale(1);\n  }\n}\n\n.gv-changelog-lightbox-img {\n  max-width: 90vw;\n  max-height: 88vh;\n  border-radius: 10px;\n  object-fit: contain;\n  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);\n  pointer-events: none;\n}\n\n/* Changelog notification mode toggle */\n.gv-changelog-notify-toggle {\n  margin-bottom: 12px;\n  padding: 8px 12px;\n  border-radius: 8px;\n  background: rgba(0, 0, 0, 0.03);\n  border: 1px solid rgba(0, 0, 0, 0.06);\n}\n\n.gv-changelog-notify-label {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  cursor: pointer;\n  font-size: 12.5px;\n  color: #555;\n  line-height: 1.4;\n  user-select: none;\n}\n\n.gv-changelog-notify-checkbox {\n  width: 15px;\n  height: 15px;\n  margin: 0;\n  cursor: pointer;\n  accent-color: #1a73e8;\n  flex-shrink: 0;\n}\n\nhtml.dark .gv-changelog-notify-toggle,\n[data-theme='dark'] .gv-changelog-notify-toggle,\n[data-color-scheme='dark'] .gv-changelog-notify-toggle {\n  background: rgba(255, 255, 255, 0.04);\n  border-color: rgba(255, 255, 255, 0.08);\n}\n\nhtml.dark .gv-changelog-notify-label,\n[data-theme='dark'] .gv-changelog-notify-label,\n[data-color-scheme='dark'] .gv-changelog-notify-label {\n  color: #9aa0a6;\n}\n\n/* ── RTL Support ── */\n/* Applied via the `gv-rtl` class on document.body when an RTL language is active. */\n\n/* Timeline bar: move from the right edge to the left edge */\nbody.gv-rtl .gemini-timeline-bar {\n  right: auto;\n  left: 15px;\n}\n\n/* Prompt manager trigger: mirror to the left side */\nbody.gv-rtl .gv-pm-trigger {\n  right: auto;\n  left: 18px;\n}\n\n/* Preview panel: reverse the slide-in direction (from right instead of left) */\nbody.gv-rtl .timeline-preview-panel {\n  transform: scale(0.96) translateX(-8px);\n  direction: rtl;\n}\n\nbody.gv-rtl .timeline-preview-panel.visible {\n  transform: scale(1) translateX(0);\n}\n\n/* Preview list items: active border on the right side */\nbody.gv-rtl .timeline-preview-item {\n  border-left: none;\n  border-right: 3px solid transparent;\n  padding: 6px 6px 6px 10px;\n}\n\nbody.gv-rtl .timeline-preview-item.active {\n  border-right-color: var(--timeline-dot-active-color);\n}\n\n/* Preview index: right-align numbers in RTL */\nbody.gv-rtl .timeline-preview-index {\n  text-align: right;\n  justify-content: flex-end;\n}\n\n/* ── Message Timestamps ── */\n.gv-timestamp {\n  display: flex;\n  font-size: 0.9em;\n  color: var(--gm-neutral-variant-50, #999);\n  opacity: 0.7;\n  margin: 12px 0 6px 0;\n  font-weight: 400;\n  user-select: none;\n  line-height: 1.2;\n  font-family:\n    -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n}\n\n.gv-timestamp-user {\n  justify-content: flex-end;\n  text-align: right;\n}\n\n.gv-timestamp-assistant {\n  justify-content: flex-start;\n  text-align: left;\n}\n\n/* Dark mode timestamp color */\nhtml.dark .gv-timestamp,\n[data-theme='dark'] .gv-timestamp,\n[data-color-scheme='dark'] .gv-timestamp {\n  color: var(--gm-neutral-variant-70, #aaa);\n}\n\n/* RTL support for timestamps */\nbody.gv-rtl .gv-timestamp {\n  text-align: right;\n}\n"
  },
  {
    "path": "public/fetchInterceptor.js",
    "content": "/**\n * Fetch Interceptor - Injected into MAIN world\n *\n * This script runs in the page context (MAIN world) to intercept native fetch calls.\n * It catches Gemini download requests and modifies them to fetch the original resolution image\n * without watermark parameters.\n *\n * The script respects the user's watermark remover setting and communicates with the\n * content script via DOM-based bridge for watermark removal processing.\n * (CustomEvents don't cross world boundaries in Firefox, so we use a hidden DOM element)\n */\n\n(function () {\n  'use strict';\n\n  /** Timeout for watermark processing in milliseconds */\n  const WATERMARK_PROCESSING_TIMEOUT_MS = 30000;\n\n  // Prevent double injection\n  if (window.__gvFetchInterceptorInstalled) {\n    console.log('[Gemini Voyager] Fetch interceptor already installed, skipping');\n    return;\n  }\n  window.__gvFetchInterceptorInstalled = true;\n\n  console.log('[Gemini Voyager] Fetch interceptor loading (MAIN world)...');\n\n  /**\n   * Pattern to match Gemini download URLs\n   * Only matches rd-gg-dl paths (dl = download) to avoid intercepting normal image display\n   * Matches both googleusercontent.com and ggpht.com domains\n   */\n  const GEMINI_DOWNLOAD_PATTERN =\n    /https:\\/\\/[^/]+(\\.googleusercontent\\.com|\\.ggpht\\.com)\\/rd-gg-dl\\//;\n  const CSP_BLOCKED_TELEMETRY_PATTERNS = [/^https:\\/\\/www\\.googletagmanager\\.com\\/td\\?/i];\n  const GOOGLE_SIZE_PATTERN = /=[swh]\\d+[^?#]*/;\n\n  /**\n   * Replace size parameter with =s0 for original size\n   * Gemini uses =sNNN format for resized images, =s0 means original\n   */\n  const replaceWithOriginalSize = (src) => {\n    // Match common Google size patterns and replace with =s0 (but keep the rest of the URL)\n    if (GOOGLE_SIZE_PATTERN.test(src)) {\n      return src.replace(GOOGLE_SIZE_PATTERN, '=s0');\n    }\n    // Fallback: if no size param but it's a google image, append =s0\n    return src.includes('=') ? src + '-s0' : src + '=s0';\n  };\n\n  const isKnownCspBlockedTelemetryRequest = (requestUrl) =>\n    CSP_BLOCKED_TELEMETRY_PATTERNS.some((pattern) => pattern.test(requestUrl));\n\n  /**\n   * DOM-based communication bridge\n   * CustomEvents don't cross world boundaries in Firefox, so we use a hidden DOM element\n   */\n  const GV_BRIDGE_ID = 'gv-watermark-bridge';\n\n  const getBridgeElement = () => {\n    let bridge = document.getElementById(GV_BRIDGE_ID);\n    if (!bridge) {\n      bridge = document.createElement('div');\n      bridge.id = GV_BRIDGE_ID;\n      bridge.style.display = 'none';\n      document.documentElement.appendChild(bridge);\n    }\n    return bridge;\n  };\n\n  /**\n   * Update status on the bridge for the content script to pick up (and show Toasts)\n   */\n  const updateStatus = (status, details = {}) => {\n    const bridge = getBridgeElement();\n    if (bridge) {\n      bridge.dataset.status = JSON.stringify({\n        type: status, // 'START', 'PROGRESS', 'SUCCESS', 'ERROR', 'WARNING'\n        timestamp: Date.now(),\n        ...details,\n      });\n    }\n  };\n\n  /**\n   * Check if watermark remover is enabled by reading from bridge element\n   */\n  const isWatermarkRemoverEnabled = () => {\n    const bridge = getBridgeElement();\n    return bridge.dataset.enabled === 'true';\n  };\n\n  // Store original fetch\n  const originalFetch = window.fetch;\n\n  // Intercept fetch\n  // IMPORTANT: This must be a regular function (NOT async) to preserve the original Promise\n  // chain for passthrough requests. An async function always wraps the return value in a new\n  // Promise, which breaks Angular's zone.js change detection and causes link-block elements\n  // to render with empty href attributes.\n  window.fetch = function (...args) {\n    const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;\n\n    // Gemini page regularly triggers GTM telemetry requests that are blocked by page CSP.\n    // Since this interceptor wraps window.fetch in MAIN world, those blocked requests get\n    // attributed to this extension in chrome://extensions. Short-circuit known blocked\n    // telemetry endpoints to avoid noisy extension error reports.\n    if (url && typeof url === 'string' && isKnownCspBlockedTelemetryRequest(url)) {\n      return Promise.resolve(new Response(null, { status: 204, statusText: 'No Content' }));\n    }\n\n    // Check if this is a Gemini download request (specifically rd-gg-dl for downloads)\n    if (url && typeof url === 'string' && GEMINI_DOWNLOAD_PATTERN.test(url)) {\n      // Replace with original size URL\n      const origSizeUrl = replaceWithOriginalSize(url);\n\n      // Modify the request to use original size\n      if (typeof args[0] === 'string') {\n        args[0] = origSizeUrl;\n      } else if (args[0]?.url) {\n        // For Request objects, we need to create a new one with the modified URL\n        const init = args[1] || {};\n        args[0] = new Request(origSizeUrl, {\n          ...init,\n          method: args[0].method,\n          headers: args[0].headers,\n          body: args[0].body,\n          mode: args[0].mode,\n          credentials: args[0].credentials,\n          cache: args[0].cache,\n          redirect: args[0].redirect,\n          referrer: args[0].referrer,\n          integrity: args[0].integrity,\n        });\n      }\n\n      // Only process watermark removal if enabled — use async IIFE only for this path\n      if (isWatermarkRemoverEnabled()) {\n        return (async () => {\n          console.log('[Gemini Voyager] Intercepting download for watermark removal');\n\n          // Declare response and blob outside try block so they're accessible in catch\n          let response, blob;\n\n          try {\n            // Check content length first (via HEAD request) to show appropriate message\n            // But we'll just show \"downloading\" first and update if large\n            updateStatus('DOWNLOADING');\n\n            // Fetch the original size image\n            response = await originalFetch.apply(this, args);\n\n            if (!response.ok) {\n              updateStatus('ERROR', { message: `HTTP Error: ${response.status}` });\n              return response;\n            }\n\n            // Check content length for large files (5MB) - update status\n            const contentLength = response.headers.get('content-length');\n            if (contentLength && parseInt(contentLength, 10) > 5 * 1024 * 1024) {\n              updateStatus('DOWNLOADING_LARGE');\n            }\n\n            // Clone response to read blob\n            blob = await response.blob();\n\n            // Step 2: Processing\n            updateStatus('PROCESSING');\n\n            // Send blob to content script for watermark removal via DOM bridge\n            const processedBlob = await new Promise((resolve, reject) => {\n              const requestId = 'gv_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);\n              const bridge = getBridgeElement();\n\n              // Watch for response via MutationObserver (works across worlds in Firefox)\n              const observer = new MutationObserver(() => {\n                const response = bridge.dataset.response;\n                if (response) {\n                  try {\n                    const data = JSON.parse(response);\n                    if (data.requestId === requestId) {\n                      observer.disconnect();\n                      bridge.removeAttribute('data-response');\n\n                      if (data.error) reject(new Error(data.error));\n                      else\n                        fetch(data.base64)\n                          .then((r) => r.blob())\n                          .then(resolve)\n                          .catch(reject);\n                    }\n                  } catch (e) {\n                    console.warn('[Gemini Voyager] Failed to parse bridge response:', e);\n                  }\n                }\n              });\n              observer.observe(bridge, { attributes: true, attributeFilter: ['data-response'] });\n\n              // Send request via DOM bridge\n              const reader = new FileReader();\n              reader.onloadend = () => {\n                bridge.dataset.request = JSON.stringify({ requestId, base64: reader.result });\n              };\n              reader.onerror = () => reject(new Error('Failed to read blob'));\n              reader.readAsDataURL(blob);\n\n              // Timeout for watermark processing\n              setTimeout(() => {\n                observer.disconnect();\n                reject(new Error('Processing timeout'));\n              }, WATERMARK_PROCESSING_TIMEOUT_MS);\n            });\n\n            updateStatus('SUCCESS');\n\n            // Return processed response\n            return new Response(processedBlob, {\n              status: response.status,\n              statusText: response.statusText,\n              headers: response.headers,\n            });\n          } catch (error) {\n            console.warn('[Gemini Voyager] Watermark processing failed, using original:', error);\n            updateStatus('ERROR', { message: error.message || 'Unknown error' });\n            // Return the original blob if available, otherwise fall through to originalFetch\n            if (blob && response) {\n              return new Response(blob, {\n                status: response.status,\n                statusText: response.statusText,\n                headers: response.headers,\n              });\n            }\n            // If blob/response not available (error before fetch completed), fall through\n            return originalFetch.apply(this, args);\n          }\n        })();\n      }\n    }\n\n    // Pass through: return the ORIGINAL Promise directly (no async wrapping)\n    return originalFetch.apply(this, args);\n  };\n\n  console.log('[Gemini Voyager] Fetch interceptor active');\n})();\n"
  },
  {
    "path": "public/katex-config.js",
    "content": "/**\n * KaTeX Configuration Override Script\n * This script runs in the page context (not content script context)\n * to suppress KaTeX strict mode warnings for Unicode text in math mode\n */\n\n(function () {\n  'use strict';\n\n  // Monkey patch console.warn to suppress specific KaTeX warnings\n  const originalWarn = console.warn;\n  console.warn = function (...args) {\n    const message = args[0];\n\n    // Suppress KaTeX Unicode warnings\n    if (\n      typeof message === 'string' &&\n      (message.includes('unicodeTextInMathMode') ||\n        message.includes('LaTeX-incompatible input and strict mode') ||\n        message.includes(\"KaTeX doesn't work in quirks mode\") ||\n        message.includes('No ID or name found in config'))\n    ) {\n      // Silently ignore these warnings\n      return;\n    }\n\n    // Pass through all other warnings\n    return originalWarn.apply(console, args);\n  };\n\n  console.log('[Gemini Voyager] KaTeX configuration applied - Unicode warnings suppressed');\n})();\n"
  },
  {
    "path": "public/prevent-auto-scroll.js",
    "content": "(function () {\n  'use strict';\n  if (window.__gvPreventAutoScrollInstalled) return;\n  window.__gvPreventAutoScrollInstalled = true;\n\n  console.log('[Gemini Voyager] Prevent auto scroll script loaded');\n\n  const BRIDGE_ID = 'gv-prevent-auto-scroll-bridge';\n  function isEnabled() {\n    const bridge = document.getElementById(BRIDGE_ID);\n    return bridge && bridge.dataset.enabled === 'true';\n  }\n\n  function getScrollTop(el) {\n    if (el === window) return document.documentElement.scrollTop || document.body.scrollTop;\n    return el.scrollTop;\n  }\n\n  function getScrollHeight(el) {\n    if (el === window) return document.documentElement.scrollHeight || document.body.scrollHeight;\n    return el.scrollHeight;\n  }\n\n  function getClientHeight(el) {\n    if (el === window) return document.documentElement.clientHeight || window.innerHeight;\n    return el.clientHeight;\n  }\n\n  function isScrolledUp(el) {\n    const st = getScrollTop(el);\n    const sh = getScrollHeight(el);\n    const ch = getClientHeight(el);\n    // If not scrollable or very small\n    if (sh <= ch + 10) return false;\n    return sh - st - ch > 150;\n  }\n\n  function isScrollingDownTo(el, args) {\n    if (args.length === 0) return false;\n    let targetY = undefined;\n    if (args.length === 1 && args[0] && typeof args[0] === 'object') {\n      if ('top' in args[0]) targetY = args[0].top;\n    } else if (args.length >= 2) {\n      targetY = args[1];\n    }\n\n    if (targetY === undefined) return false;\n    const currentScrollTop = getScrollTop(el);\n    return targetY > currentScrollTop;\n  }\n\n  function isScrollingDownBy(args) {\n    if (args.length === 0) return false;\n    if (args.length === 1 && args[0] && typeof args[0] === 'object') {\n      return args[0].top > 0;\n    } else if (args.length >= 2) {\n      return args[1] > 0;\n    }\n    return false;\n  }\n\n  function shouldBlockScrollTo(el, args) {\n    if (!isEnabled()) return false;\n    if (isScrolledUp(el) && isScrollingDownTo(el, args)) {\n      return true;\n    }\n    return false;\n  }\n\n  function shouldBlockScrollBy(el, args) {\n    if (!isEnabled()) return false;\n    if (isScrolledUp(el) && isScrollingDownBy(args)) {\n      return true;\n    }\n    return false;\n  }\n\n  const originalWindowScrollTo = window.scrollTo;\n  window.scrollTo = function (...args) {\n    if (shouldBlockScrollTo(window, args)) return;\n    return originalWindowScrollTo.apply(this, args);\n  };\n\n  const originalWindowScrollBy = window.scrollBy;\n  window.scrollBy = function (...args) {\n    if (shouldBlockScrollBy(window, args)) return;\n    return originalWindowScrollBy.apply(this, args);\n  };\n\n  const originalElementScrollTo = Element.prototype.scrollTo;\n  Element.prototype.scrollTo = function (...args) {\n    if (shouldBlockScrollTo(this, args)) return;\n    return originalElementScrollTo.apply(this, args);\n  };\n\n  const originalElementScrollBy = Element.prototype.scrollBy;\n  Element.prototype.scrollBy = function (...args) {\n    if (shouldBlockScrollBy(this, args)) return;\n    return originalElementScrollBy.apply(this, args);\n  };\n\n  const originalScrollIntoView = Element.prototype.scrollIntoView;\n  Element.prototype.scrollIntoView = function (...args) {\n    if (isEnabled()) {\n      let ancestor = this.parentElement;\n      let blocked = false;\n      while (ancestor) {\n        if (ancestor.scrollHeight > ancestor.clientHeight) {\n          if (isScrolledUp(ancestor)) {\n            const rect = this.getBoundingClientRect();\n            if (rect.top > (window.innerHeight || document.documentElement.clientHeight)) {\n              blocked = true;\n            } else if (rect.bottom > ancestor.getBoundingClientRect().bottom) {\n              blocked = true;\n            }\n            break;\n          }\n        }\n        ancestor = ancestor.parentElement;\n      }\n      if (!ancestor && isScrolledUp(window)) {\n        const rect = this.getBoundingClientRect();\n        if (rect.top > (window.innerHeight || document.documentElement.clientHeight)) {\n          blocked = true;\n        }\n      }\n\n      if (blocked) return;\n    }\n    return originalScrollIntoView.apply(this, args);\n  };\n\n  const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor(\n    Element.prototype,\n    'scrollTop',\n  );\n  if (originalScrollTopDescriptor) {\n    Object.defineProperty(Element.prototype, 'scrollTop', {\n      get: originalScrollTopDescriptor.get,\n      set: function (value) {\n        if (isEnabled() && isScrolledUp(this)) {\n          const currentVal = originalScrollTopDescriptor.get.call(this);\n          if (value > currentVal) {\n            return;\n          }\n        }\n        return originalScrollTopDescriptor.set.call(this, value);\n      },\n    });\n  }\n})();\n"
  },
  {
    "path": "safari/App/SafariWebExtensionHandler.swift",
    "content": "//\n//  SafariWebExtensionHandler.swift\n//  Gemini Voyager Safari Extension\n//\n//  Created for Gemini Voyager\n//  https://github.com/Nagi-ovo/gemini-voyager\n//\n\nimport SafariServices\nimport os.log\n\nlet logger = OSLog(subsystem: \"com.gemini-voyager.safari\", category: \"extension\")\n\nclass SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {\n\n    /// Handles messages from the extension's JavaScript code\n    /// - Parameters:\n    ///   - userInfo: Message payload from JavaScript\n    ///   - context: Extension context for responding\n    func beginRequest(with context: NSExtensionContext) {\n        let request = context.inputItems.first as? NSExtensionItem\n\n        guard let message = request?.userInfo?[SFExtensionMessageKey] as? [String: Any],\n              let action = message[\"action\"] as? String else {\n            os_log(.error, log: logger, \"Invalid message format\")\n            context.completeRequest(returningItems: nil)\n            return\n        }\n\n        os_log(.info, log: logger, \"Received action: %{public}@\", action)\n\n        // Handle different message types\n        switch action {\n        case \"ping\":\n            handlePing(context: context)\n\n        case \"getVersion\":\n            handleGetVersion(context: context)\n\n        case \"syncStorage\":\n            handleSyncStorage(message: message, context: context)\n\n        default:\n            os_log(.info, log: logger, \"Unknown action: %{public}@\", action)\n            respondWithError(context: context, message: \"Unknown action\")\n        }\n    }\n\n    // MARK: - Message Handlers\n\n    /// Simple health check\n    private func handlePing(context: NSExtensionContext) {\n        respondWithSuccess(context: context, data: [\"status\": \"ok\", \"message\": \"pong\"])\n    }\n\n    /// Returns extension version info\n    private func handleGetVersion(context: NSExtensionContext) {\n        let version = Bundle.main.object(forInfoDictionaryKey: \"CFBundleShortVersionString\") as? String ?? \"unknown\"\n        let build = Bundle.main.object(forInfoDictionaryKey: \"CFBundleVersion\") as? String ?? \"unknown\"\n\n        respondWithSuccess(context: context, data: [\n            \"version\": version,\n            \"build\": build,\n            \"platform\": \"safari-macos\"\n        ])\n    }\n\n    /// Handle storage synchronization (placeholder for future feature)\n    private func handleSyncStorage(message: [String: Any], context: NSExtensionContext) {\n        // Future: Implement native storage sync with UserDefaults or Keychain\n        os_log(.info, log: logger, \"Storage sync requested (not yet implemented)\")\n        respondWithSuccess(context: context, data: [\"synced\": false])\n    }\n\n    // MARK: - Response Helpers\n\n    private func respondWithSuccess(context: NSExtensionContext, data: [String: Any]) {\n        let response = NSExtensionItem()\n        response.userInfo = [\n            SFExtensionMessageKey: [\n                \"success\": true,\n                \"data\": data\n            ]\n        ]\n        context.completeRequest(returningItems: [response])\n    }\n\n    private func respondWithError(context: NSExtensionContext, message: String) {\n        let response = NSExtensionItem()\n        response.userInfo = [\n            SFExtensionMessageKey: [\n                \"success\": false,\n                \"error\": message\n            ]\n        ]\n        context.completeRequest(returningItems: [response])\n    }\n}\n"
  },
  {
    "path": "safari/Models/SafariMessage.swift",
    "content": "//\n//  SafariMessage.swift\n//  Gemini Voyager Safari Extension\n//\n//  Created for Gemini Voyager\n//  https://github.com/Nagi-ovo/gemini-voyager\n//\n\nimport Foundation\n\n/// Message types exchanged between JavaScript and native Swift code\nenum SafariMessageAction: String, Codable {\n    case ping\n    case getVersion\n    case syncStorage\n\n    // Future actions can be added here:\n    // case exportToFiles\n    // case importFromFiles\n    // case showNotification\n}\n\n/// Standard message structure from JavaScript\nstruct SafariMessage: Codable {\n    let action: SafariMessageAction\n    let payload: [String: AnyCodable]?\n\n    enum CodingKeys: String, CodingKey {\n        case action\n        case payload\n    }\n}\n\n/// Standard response structure to JavaScript\nstruct SafariResponse: Codable {\n    let success: Bool\n    let data: [String: AnyCodable]?\n    let error: String?\n\n    static func success(data: [String: Any]) -> SafariResponse {\n        SafariResponse(\n            success: true,\n            data: data.mapValues { AnyCodable($0) },\n            error: nil\n        )\n    }\n\n    static func error(message: String) -> SafariResponse {\n        SafariResponse(\n            success: false,\n            data: nil,\n            error: message\n        )\n    }\n}\n\n/// Type-erased wrapper for Codable values\nstruct AnyCodable: Codable {\n    let value: Any\n\n    init(_ value: Any) {\n        self.value = value\n    }\n\n    init(from decoder: Decoder) throws {\n        let container = try decoder.singleValueContainer()\n\n        if let bool = try? container.decode(Bool.self) {\n            value = bool\n        } else if let int = try? container.decode(Int.self) {\n            value = int\n        } else if let double = try? container.decode(Double.self) {\n            value = double\n        } else if let string = try? container.decode(String.self) {\n            value = string\n        } else if let array = try? container.decode([AnyCodable].self) {\n            value = array.map { $0.value }\n        } else if let dictionary = try? container.decode([String: AnyCodable].self) {\n            value = dictionary.mapValues { $0.value }\n        } else {\n            throw DecodingError.dataCorruptedError(\n                in: container,\n                debugDescription: \"Unsupported type\"\n            )\n        }\n    }\n\n    func encode(to encoder: Encoder) throws {\n        var container = encoder.singleValueContainer()\n\n        switch value {\n        case let bool as Bool:\n            try container.encode(bool)\n        case let int as Int:\n            try container.encode(int)\n        case let double as Double:\n            try container.encode(double)\n        case let string as String:\n            try container.encode(string)\n        case let array as [Any]:\n            try container.encode(array.map { AnyCodable($0) })\n        case let dictionary as [String: Any]:\n            try container.encode(dictionary.mapValues { AnyCodable($0) })\n        default:\n            throw EncodingError.invalidValue(\n                value,\n                EncodingError.Context(\n                    codingPath: container.codingPath,\n                    debugDescription: \"Unsupported type\"\n                )\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "safari/README.md",
    "content": "# Safari Development Guide\n\nEnglish | [简体中文](README_ZH.md)\n\nDeveloper guide for building and extending Voyager for Safari.\n\n> [!TIP]\n> **Looking to install?** You can now download the pre-signed app directly from the [latest release](https://github.com/Nagi-ovo/gemini-voyager/releases/latest). Simply download the `.dmg` and follow the prompts to install.\n\n## Quick Start\n\n### Build from Source\n\n```bash\n# Install dependencies\nbun install\n\n# Build for Safari\nbun run build:safari\n```\n\nThis creates a `dist_safari/` folder with the extension files.\n\n### Convert and Run\n\n```bash\n# Convert to Safari format\nxcrun safari-web-extension-converter dist_safari --macos-only --app-name \"Gemini Voyager\"\n\n# Open in Xcode\nopen \"Gemini Voyager/Gemini Voyager.xcodeproj\"\n```\n\nIn Xcode:\n\n1. Select **Signing & Capabilities** → Choose your Team\n2. Set target to **My Mac**\n3. Press **⌘R** to build and run\n\n## Development Workflow\n\n### Auto-reload on Changes\n\n```bash\nbun run dev:safari\n```\n\nThis watches for file changes and rebuilds automatically. After each rebuild:\n\n1. Press **⌘R** in Xcode to reload\n2. Safari will refresh the extension\n\n### Manual Build\n\n```bash\n# After code changes\nbun run build:safari\n\n# Then rebuild in Xcode (⌘R)\n```\n\n## Adding Swift Native Code (Optional)\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\nThis project includes Swift code for native macOS features. Adding it is **optional** but recommended.\n\n### Files Included\n\n```\nsafari/\n├── App/\n│   └── SafariWebExtensionHandler.swift  # Native message handler\n└── Models/\n    └── SafariMessage.swift              # Message definitions\n```\n\n### How to Add\n\n1. Open the Xcode project\n2. Right-click **\"Gemini Voyager Extension\"** target\n3. Select **Add Files to \"Gemini Voyager Extension\"...**\n4. Navigate to `safari/App/` and `safari/Models/`\n5. Check **\"Copy items if needed\"**\n6. Ensure target is **\"Gemini Voyager Extension\"**\n\n### Native Features\n\nOnce added, you can:\n\n- Access macOS Keychain (future)\n- Use native notifications\n- Access file system with native pickers\n- Sync via iCloud (future)\n- Enhanced debugging logs\n\n### Native Messaging API\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\n**From JavaScript:**\n\n```javascript\n// Health check\nbrowser.runtime.sendNativeMessage({ action: 'ping' }, (response) => {\n  console.log(response); // { success: true, data: { status: \"ok\", message: \"pong\" } }\n});\n\n// Get version\nbrowser.runtime.sendNativeMessage({ action: 'getVersion' }, (response) => {\n  console.log(response.data); // { version: \"1.0.0\", platform: \"macOS\" }\n});\n```\n\n**Available Actions:**\n\n- `ping` - Health check\n- `getVersion` - Get extension version info\n- `syncStorage` - Sync storage (placeholder for future)\n\n## Debugging\n\n### View Extension Logs\n\n**Web Console:**\n\n- Safari → Develop → Web Extension Background Pages → Gemini Voyager\n\n**Native Logs:**\n\n```bash\nlog stream --predicate 'subsystem == \"com.gemini-voyager.safari\"' --level debug\n```\n\n### Common Issues\n\n**\"Module 'SafariServices' not found\"**\n\n- Ensure Swift files are added to \"Gemini Voyager Extension\" target, not the main app\n\n**Native messaging not working**\n\n- Check `Info.plist` has `SafariWebExtensionHandler` as principal class\n\n**Swift files not compiling**\n\n- Verify Target Membership in Xcode file inspector\n\n## Building for Distribution\n\n### Create Archive\n\n1. Product → Archive in Xcode\n2. Window → Organizer\n3. Select archive → Distribute App\n4. Follow prompts to export\n\n### For App Store\n\nRequires:\n\n- Apple Developer account ($99/year)\n- App Store Connect setup\n- App review submission\n\nSee [Apple's official guide](https://developer.apple.com/documentation/safariservices/safari_web_extensions/distributing_your_safari_web_extension) for details.\n\n## Project Structure\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\n```\n├── dist_safari/              # Built extension (gitignored)\n├── safari/                   # Native Swift code\n│   ├── App/                 # Extension handlers\n│   ├── Models/              # Data models\n│   └── Resources/           # Example code\n├── src/                     # Main extension source\n└── vite.config.safari.ts    # Safari build config\n```\n\n## Build Commands\n\n```bash\nbun run build:safari   # Production build\nbun run dev:safari     # Development with auto-reload\nbun run build:all      # Build for all browsers\n```\n\n## Update Reminder Configuration\n\nBy default, update reminders are **disabled** for Safari builds to avoid conflicts with App Store auto-updates.\n\nTo enable update reminders (for manual distribution):\n\n```bash\nENABLE_SAFARI_UPDATE_CHECK=true bun run build:safari\n```\n\n**Note**: Only enable this if you're distributing the extension manually (not via App Store). App Store versions should use the default (disabled) to rely on automatic updates.\n\n## Known Limitations\n\nDue to Safari's technical architecture and security restrictions, the following features are currently unavailable in the Safari version:\n\n- **(a) Nano Banana Watermark Removal**: Watermark detection and removal for Gemini™-generated images is not supported.\n- **(b) Image Export**: Direct export to image format is not supported (including in Chat Export). **Recommendation**: Use **PDF Export** instead.\n\n## Resources\n\n- [Safari Web Extensions Docs](https://developer.apple.com/documentation/safariservices/safari_web_extensions)\n- [Native Messaging Guide](https://developer.apple.com/documentation/safariservices/safari_web_extensions/messaging_between_the_app_and_javascript_in_a_safari_web_extension)\n- [Converting Extensions for Safari](https://developer.apple.com/documentation/safariservices/safari_web_extensions/converting_a_web_extension_for_safari)\n\n## Contributing\n\nSee [CONTRIBUTING.md](../.github/CONTRIBUTING.md) for contribution guidelines.\n\nWhen adding native features:\n\n1. Define action in `SafariMessage.swift`\n2. Implement handler in `SafariWebExtensionHandler.swift`\n3. Add JavaScript API in web extension\n4. Document in this README\n"
  },
  {
    "path": "safari/README_ZH.md",
    "content": "# Safari 开发指南\n\n[English](README.md) | 简体中文\n\n为 Safari 构建和扩展 Voyager 的开发者指南。\n\n> [!TIP]\n> **想要进行安装？** 你现在可以直接从 [最新发布页](https://github.com/Nagi-ovo/gemini-voyager/releases/latest) 下载预签名的应用。只需下载 `.dmg` 并按提示安装即可。\n\n## 快速开始\n\n### 从源代码构建\n\n```bash\n# 安装依赖\nbun install\n\n# 为 Safari 构建\nbun run build:safari\n```\n\n这会创建一个包含扩展文件的 `dist_safari/` 文件夹。\n\n### 转换并运行\n\n```bash\n# 转换为 Safari 格式\nxcrun safari-web-extension-converter dist_safari --macos-only --app-name \"Gemini Voyager\"\n\n# 在 Xcode 中打开\nopen \"Gemini Voyager/Gemini Voyager.xcodeproj\"\n```\n\n在 Xcode 中：\n\n1. 选择 **Signing & Capabilities** → 选择你的 Team\n2. 设置目标为 **My Mac**\n3. 按 **⌘R** 构建并运行\n\n## 开发工作流\n\n### 文件变更自动重载\n\n```bash\nbun run dev:safari\n```\n\n这会监听文件变更并自动重新构建。每次重新构建后：\n\n1. 在 Xcode 中按 **⌘R** 重新加载\n2. Safari 会刷新扩展\n\n### 手动构建\n\n```bash\n# 修改代码后\nbun run build:safari\n\n# 然后在 Xcode 中重新构建（⌘R）\n```\n\n## 添加 Swift 原生代码（可选）\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\n本项目包含用于原生 macOS 功能的 Swift 代码。添加它是**可选的**，但推荐使用。\n\n### 包含的文件\n\n```\nsafari/\n├── App/\n│   └── SafariWebExtensionHandler.swift  # 原生消息处理器\n└── Models/\n    └── SafariMessage.swift              # 消息定义\n```\n\n### 如何添加\n\n1. 打开 Xcode 项目\n2. 右键点击 **\"Gemini Voyager Extension\"** 目标\n3. 选择 **Add Files to \"Gemini Voyager Extension\"...**\n4. 导航到 `safari/App/` 和 `safari/Models/`\n5. 勾选 **\"Copy items if needed\"**\n6. 确保目标是 **\"Gemini Voyager Extension\"**\n\n### 原生功能\n\n添加后，你可以：\n\n- 访问 macOS 钥匙串（未来）\n- 使用原生通知\n- 通过原生选择器访问文件系统\n- 通过 iCloud 同步（未来）\n- 增强的调试日志\n\n### 原生消息 API\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\n**从 JavaScript 调用：**\n\n```javascript\n// 健康检查\nbrowser.runtime.sendNativeMessage({ action: 'ping' }, (response) => {\n  console.log(response); // { success: true, data: { status: \"ok\", message: \"pong\" } }\n});\n\n// 获取版本\nbrowser.runtime.sendNativeMessage({ action: 'getVersion' }, (response) => {\n  console.log(response.data); // { version: \"1.0.0\", platform: \"macOS\" }\n});\n```\n\n**可用操作：**\n\n- `ping` - 健康检查\n- `getVersion` - 获取扩展版本信息\n- `syncStorage` - 同步存储（未来功能的占位符）\n\n## 调试\n\n### 查看扩展日志\n\n**Web 控制台：**\n\n- Safari → 开发 → Web Extension Background Pages → Gemini Voyager\n\n**原生日志：**\n\n```bash\nlog stream --predicate 'subsystem == \"com.gemini-voyager.safari\"' --level debug\n```\n\n### 常见问题\n\n**\"Module 'SafariServices' not found\"**\n\n- 确保 Swift 文件添加到 \"Gemini Voyager Extension\" 目标，而不是主应用\n\n**原生消息不工作**\n\n- 检查 `Info.plist` 是否将 `SafariWebExtensionHandler` 设置为主类\n\n**Swift 文件未编译**\n\n- 在 Xcode 文件检查器中验证目标成员资格\n\n## 构建分发版本\n\n### 创建存档\n\n1. 在 Xcode 中选择 Product → Archive\n2. Window → Organizer\n3. 选择存档 → Distribute App\n4. 按提示导出\n\n### 发布到 App Store\n\n需要：\n\n- Apple Developer 账号（$99/年）\n- App Store Connect 设置\n- 应用审核提交\n\n详见 [Apple 官方指南](https://developer.apple.com/documentation/safariservices/safari_web_extensions/distributing_your_safari_web_extension)。\n\n## 项目结构\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\n```\n├── dist_safari/              # 构建的扩展（已忽略）\n├── safari/                   # 原生 Swift 代码\n│   ├── App/                 # 扩展处理器\n│   ├── Models/              # 数据模型\n│   └── Resources/           # 示例代码\n├── src/                     # 主扩展源代码\n└── vite.config.safari.ts    # Safari 构建配置\n```\n\n## 构建命令\n\n```bash\nbun run build:safari   # 生产构建\nbun run dev:safari     # 开发模式（自动重载）\nbun run build:all      # 为所有浏览器构建\n```\n\n## 更新提醒配置\n\n默认情况下，Safari 版本的更新提醒是**禁用的**，以避免与 App Store 自动更新冲突。\n\n如需启用更新提醒（用于手动分发）：\n\n```bash\nENABLE_SAFARI_UPDATE_CHECK=true bun run build:safari\n```\n\n**注意**：仅在手动分发扩展时启用此功能（非 App Store 分发）。App Store 版本应使用默认设置（禁用），以依赖自动更新。\n\n## 已知限制\n\n由于 Safari 的技术架构和安全限制，以下功能目前在 Safari 版本中不可用：\n\n- **(a) Nano Banana 水印去除**：暂不支持对 Gemini™ 生成进行图片水印识别与去除。\n- **(b) 图片导出**：暂不支持直接导出为图片（包括在对话导出功能中）。**建议**：改用 **PDF 导出**。\n\n## 资源\n\n- [Safari Web Extensions 文档](https://developer.apple.com/documentation/safariservices/safari_web_extensions)\n- [原生消息指南](https://developer.apple.com/documentation/safariservices/safari_web_extensions/messaging_between_the_app_and_javascript_in_a_safari_web_extension)\n- [为 Safari 转换扩展](https://developer.apple.com/documentation/safariservices/safari_web_extensions/converting_a_web_extension_for_safari)\n\n## 贡献\n\n查看 [CONTRIBUTING.md](../.github/CONTRIBUTING.md) 了解贡献指南。\n\n添加原生功能时：\n\n1. 在 `SafariMessage.swift` 中定义操作\n2. 在 `SafariWebExtensionHandler.swift` 中实现处理器\n3. 在 web 扩展中添加 JavaScript API\n4. 在本 README 中记录\n"
  },
  {
    "path": "safari/Resources/example-native-messaging.js",
    "content": "/**\n * Example: How to use native messaging in Safari\n *\n * This file demonstrates how to communicate with the Swift native code\n * from your JavaScript extension code.\n */\n\n// Note: Safari uses browser.runtime.sendNativeMessage() differently than Chrome\n// In Safari, it's handled through the SafariWebExtensionHandler\n\n/**\n * Send a message to native Swift code\n * @param {string} action - The action to perform\n * @param {object} payload - Data to send\n * @returns {Promise<object>} Response from native code\n */\nasync function sendNativeMessage(action, payload = {}) {\n  return new Promise((resolve, reject) => {\n    // Safari's native messaging API expects a single message object.\n    browser.runtime.sendNativeMessage({ action, payload }, (response) => {\n      if (browser.runtime.lastError) {\n        return reject(browser.runtime.lastError);\n      }\n      if (response && response.success) {\n        resolve(response.data);\n      } else {\n        reject(new Error(response?.error || 'Native messaging failed'));\n      }\n    });\n  });\n}\n\n// Example 1: Health check\nasync function checkNativeHealth() {\n  try {\n    const response = await sendNativeMessage('ping');\n    console.log('Native messaging is working:', response);\n    // Output: { status: \"ok\", message: \"pong\" }\n  } catch (error) {\n    console.error('Native messaging not available:', error);\n  }\n}\n\n// Example 2: Get version info\nasync function getExtensionVersion() {\n  try {\n    const info = await sendNativeMessage('getVersion');\n    console.log('Extension version:', info.version);\n    console.log('Build:', info.build);\n    console.log('Platform:', info.platform);\n  } catch (error) {\n    console.error('Failed to get version:', error);\n  }\n}\n\n// Example 3: Sync storage (placeholder for future feature)\nasync function syncStorage(data) {\n  try {\n    const result = await sendNativeMessage('syncStorage', { data });\n    console.log('Storage sync result:', result);\n  } catch (error) {\n    console.error('Storage sync failed:', error);\n  }\n}\n\n// Export for use in other modules\nexport { sendNativeMessage, checkNativeHealth, getExtensionVersion, syncStorage };\n"
  },
  {
    "path": "scripts/build-edge.js",
    "content": "#!/usr/bin/env node\n/**\n * Build script for Edge store submission.\n * Edge doesn't accept the 'key' field in manifest.json\n * This script builds the Chrome extension and removes incompatible fields.\n */\nimport { execSync } from 'child_process';\nimport fs from 'fs';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\nconst rootDir = path.resolve(__dirname, '..');\nconst distDir = path.join(rootDir, 'dist_chrome');\nconst manifestPath = path.join(distDir, 'manifest.json');\n\n// Fields that Edge doesn't accept\nconst EDGE_INCOMPATIBLE_FIELDS = ['key'];\n\nasync function buildForEdge() {\n  console.log('🔨 Building Chrome extension...');\n\n  try {\n    execSync('bun run build:chrome', {\n      cwd: rootDir,\n      stdio: 'inherit',\n    });\n  } catch (error) {\n    console.error('❌ Build failed:', error.message);\n    process.exit(1);\n  }\n\n  console.log('\\n🔧 Preparing for Edge submission...');\n\n  // Read and parse manifest\n  if (!fs.existsSync(manifestPath)) {\n    console.error('❌ manifest.json not found in dist_chrome/');\n    process.exit(1);\n  }\n\n  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));\n\n  // Remove incompatible fields\n  let removedFields = [];\n  for (const field of EDGE_INCOMPATIBLE_FIELDS) {\n    if (field in manifest) {\n      delete manifest[field];\n      removedFields.push(field);\n    }\n  }\n\n  if (removedFields.length > 0) {\n    console.log(`   Removed fields: ${removedFields.join(', ')}`);\n  }\n\n  // Write back the cleaned manifest\n  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\\n');\n\n  console.log('✅ Edge build ready!');\n  console.log(`   Output: ${distDir}/`);\n\n  // Zip the output\n  const packageJsonPath = path.join(rootDir, 'package.json');\n  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));\n  const version = packageJson.version;\n  const zipName = `voyager-edge-v${version}.zip`;\n  const zipPath = path.join(rootDir, zipName);\n\n  console.log(`\\n📦 Zipping into ${zipName}...`);\n\n  try {\n    // Remove existing zip if it exists\n    if (fs.existsSync(zipPath)) {\n      fs.unlinkSync(zipPath);\n    }\n\n    // Zip the *contents* of dist_chrome\n    execSync(`zip -r \"${zipPath}\" .`, {\n      cwd: distDir,\n      stdio: 'inherit',\n    });\n\n    console.log(`✨ Successfully created: ${zipName}`);\n  } catch (error) {\n    console.error('❌ Zipping failed:', error.message);\n    process.exit(1);\n  }\n}\n\nbuildForEdge();\n"
  },
  {
    "path": "scripts/build-safari.sh",
    "content": "#!/bin/bash\n\n# Build Safari Extension\n# This script builds the extension for Safari using xcrun safari-web-extension-converter\n\nset -e\n\necho \"🔨 Building extension for Safari...\"\n\n# Step 1: Build the extension using Vite\necho \"📦 Building with Vite...\"\nnpm run build:safari\n\n# Step 2: Check if dist_safari exists\nif [ ! -d \"dist_safari\" ]; then\n  echo \"❌ Error: dist_safari directory not found\"\n  exit 1\nfi\n\necho \"✅ Build completed: dist_safari/\"\n\n# Step 3: Convert to Safari App Extension (requires macOS)\nif [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n  echo \"\"\n  echo \"🍎 Safari Extension Converter Information:\"\n  echo \"\"\n  echo \"To convert this extension for Safari, run:\"\n  echo \"\"\n  echo \"  xcrun safari-web-extension-converter dist_safari --app-name 'Gemini Voyager' --bundle-identifier com.nagi-ovo.Gemini-Voyager\"\n  echo \"\"\n  echo \"This will create a Safari App Extension project that you can:\"\n  echo \"  1. Open in Xcode\"\n  echo \"  2. Sign with your Apple Developer ID\"\n  echo \"  3. Build and run on Safari\"\n  echo \"\"\n  echo \"Note: You need:\"\n  echo \"  - macOS 11 (Big Sur) or later\"\n  echo \"  - Xcode 12 or later\"\n  echo \"  - Safari 14 or later\"\n  echo \"\"\n  echo \"For development testing without Xcode:\"\n  echo \"  xcrun safari-web-extension-converter dist_safari --macos-only\"\n  echo \"\"\nelse\n  echo \"\"\n  echo \"⚠️  Safari extension conversion requires macOS with Xcode\"\n  echo \"The built extension is available in: dist_safari/\"\n  echo \"\"\nfi\n\necho \"✨ Done!\"\n\n"
  },
  {
    "path": "scripts/bump-version.js",
    "content": "import { execSync } from 'child_process';\nimport fs from 'fs';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n// Paths to the files containing the version\nconst packageJsonPath = path.resolve(__dirname, '../package.json');\nconst manifestJsonPath = path.resolve(__dirname, '../manifest.json');\nconst manifestDevJsonPath = path.resolve(__dirname, '../manifest.dev.json');\n\n// Helper to read JSON file\nfunction readJson(filePath) {\n  return JSON.parse(fs.readFileSync(filePath, 'utf8'));\n}\n\n// Helper to write JSON file\nfunction writeJson(filePath, data) {\n  fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\\n');\n}\n\n// Logic to bump version with rollover at 10\nfunction bumpVersion(version) {\n  let [major, minor, patch] = version.split('.').map(Number);\n\n  patch += 1;\n\n  if (patch > 9) {\n    patch = 0;\n    minor += 1;\n  }\n\n  if (minor > 9) {\n    minor = 0;\n    major += 1;\n  }\n\n  return `${major}.${minor}.${patch}`;\n}\n\nasync function main() {\n  try {\n    console.log('Reading current version...');\n    const packageJson = readJson(packageJsonPath);\n    const currentVersion = packageJson.version;\n\n    console.log(`Current version: ${currentVersion}`);\n\n    const newVersion = bumpVersion(currentVersion);\n    console.log(`New version:     ${newVersion}`);\n\n    // Update package.json\n    packageJson.version = newVersion;\n    writeJson(packageJsonPath, packageJson);\n    console.log('Updated package.json');\n\n    // Update manifest.json\n    if (fs.existsSync(manifestJsonPath)) {\n      const manifestJson = readJson(manifestJsonPath);\n      manifestJson.version = newVersion;\n      writeJson(manifestJsonPath, manifestJson);\n      console.log('Updated manifest.json');\n    } else {\n      console.warn('manifest.json not found, skipping...');\n    }\n\n    // Update manifest.dev.json\n    if (fs.existsSync(manifestDevJsonPath)) {\n      const manifestDevJson = readJson(manifestDevJsonPath);\n      manifestDevJson.version = newVersion;\n      writeJson(manifestDevJsonPath, manifestDevJson);\n      console.log('Updated manifest.dev.json');\n    }\n\n    console.log('Version bump complete! 🚀');\n\n    console.log('Running format...');\n    execSync('bun run format', { stdio: 'inherit' });\n    console.log('Format complete! ✨');\n  } catch (error) {\n    console.error('Error bumping version:', error);\n    process.exit(1);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "scripts/generate-sponsors.cjs",
    "content": "#!/usr/bin/env node\n/**\n * Generate the sponsor board SVG with GitHub Sponsors, Afdian, and Tipping Friends.\n *\n * Usage:\n *   node sponsorkit/generate-sponsors.js\n *\n * Environment variables:\n *   GITHUB_TOKEN - GitHub token for fetching sponsors\n *   AFDIAN_USER_ID - Afdian user ID\n *   AFDIAN_TOKEN - Afdian API token\n */\nconst fsp = require('fs/promises');\nconst path = require('path');\nconst crypto = require('crypto');\n\nconst ROOT = path.resolve(__dirname, '..');\nconst FRIENDS_PATH = path.join(ROOT, 'sponsorkit', 'sponsors.json');\nconst OUTPUT_DIR = path.join(ROOT, 'docs', 'public', 'assets');\nconst OUTPUT_PATH = path.join(OUTPUT_DIR, 'sponsors.svg');\nconst OUTPUT_PNG_PATH = path.join(OUTPUT_DIR, 'sponsors.png');\nconst SPONSORS_URL = 'https://github.com/sponsors/Nagi-ovo';\nconst OWNER_LOGIN = 'Nagi-ovo';\nconst GRAPHQL_ENDPOINT = 'https://api.github.com/graphql';\nconst AFDIAN_API = 'https://afdian.com/api/open/query-sponsor';\nconst FONT_FAMILY =\n  \"-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'\";\n\nasync function main() {\n  const friends = await loadFriends();\n  await ensureDir(OUTPUT_DIR);\n\n  const githubToken = process.env.GITHUB_TOKEN;\n  const afdianUserId = process.env.AFDIAN_USER_ID;\n  const afdianToken = process.env.AFDIAN_TOKEN;\n\n  const [githubSponsors, afdianSponsors] = await Promise.all([\n    fetchGitHubSponsors(githubToken),\n    fetchAfdianSponsors(afdianUserId, afdianToken),\n  ]);\n\n  const svgContent = await buildSvg({ githubSponsors, afdianSponsors, friends });\n  await fsp.writeFile(OUTPUT_PATH, svgContent, 'utf8');\n  console.log(\n    `Generated ${path.relative(ROOT, OUTPUT_PATH)} with ${githubSponsors.length} GitHub sponsors, ${afdianSponsors.length} Afdian sponsors, and ${friends.length} tipping friends.`,\n  );\n}\n\nasync function loadFriends() {\n  try {\n    const raw = await fsp.readFile(FRIENDS_PATH, 'utf8');\n    const list = JSON.parse(raw);\n    if (!Array.isArray(list)) {\n      throw new Error(`Friend data must be an array in ${FRIENDS_PATH}`);\n    }\n    return list\n      .map((item) => {\n        if (typeof item === 'string') return item.trim();\n        return String(item.name || '').trim();\n      })\n      .filter(Boolean);\n  } catch (e) {\n    console.warn('⚠️  Failed to load friends list:', e.message);\n    return [];\n  }\n}\n\nasync function ensureDir(target) {\n  await fsp.mkdir(target, { recursive: true });\n}\n\nasync function fetchGitHubSponsors(token) {\n  if (!token) {\n    console.warn('⚠️  No GitHub token available, GitHub sponsors will be skipped.');\n    return [];\n  }\n\n  const sponsors = [];\n  let cursor = null;\n  const headers = {\n    'Content-Type': 'application/json',\n    Authorization: `Bearer ${token}`,\n    'User-Agent': 'gemini-voyager-sponsor-bot',\n  };\n\n  while (true) {\n    const body = {\n      query: `\n        query($login: String!, $cursor: String) {\n          user(login: $login) {\n            sponsorshipsAsMaintainer(first: 100, after: $cursor, includePrivate: true, activeOnly: false, orderBy: {field: CREATED_AT, direction: DESC}) {\n              nodes {\n                sponsorEntity {\n                  ... on User {\n                    login\n                    name\n                    avatarUrl\n                    url\n                  }\n                  ... on Organization {\n                    login\n                    name\n                    avatarUrl\n                    url\n                  }\n                }\n                tier {\n                  monthlyPriceInDollars\n                }\n                createdAt\n              }\n              pageInfo {\n                hasNextPage\n                endCursor\n              }\n            }\n          }\n        }\n      `,\n      variables: {\n        login: OWNER_LOGIN,\n        cursor,\n      },\n    };\n\n    const res = await fetch(GRAPHQL_ENDPOINT, {\n      method: 'POST',\n      headers,\n      body: JSON.stringify(body),\n    });\n\n    if (!res.ok) {\n      const text = await res.text();\n      console.error(`Failed to fetch GitHub sponsors (${res.status}): ${text}`);\n      return sponsors;\n    }\n\n    const payload = await res.json();\n    const data = payload?.data?.user?.sponsorshipsAsMaintainer;\n    if (!data) {\n      console.error('Unexpected response from GitHub Sponsors API.');\n      return sponsors;\n    }\n\n    for (const node of data.nodes || []) {\n      const sponsor = node?.sponsorEntity;\n      if (!sponsor?.login || !sponsor?.avatarUrl) continue;\n      sponsors.push({\n        login: sponsor.login,\n        name: sponsor.name || sponsor.login,\n        avatarUrl: sponsor.avatarUrl,\n        url: sponsor.url || `https://github.com/${sponsor.login}`,\n        amount: node?.tier?.monthlyPriceInDollars || 0,\n        createdAt: node?.createdAt,\n        provider: 'github',\n      });\n    }\n\n    if (!data.pageInfo?.hasNextPage) {\n      break;\n    }\n    cursor = data.pageInfo.endCursor;\n  }\n\n  sponsors.sort((a, b) => {\n    if (b.amount !== a.amount) return b.amount - a.amount;\n    if (a.createdAt && b.createdAt) {\n      return new Date(a.createdAt) - new Date(b.createdAt);\n    }\n    return a.login.localeCompare(b.login);\n  });\n\n  return sponsors;\n}\n\nasync function fetchAfdianSponsors(userId, token) {\n  if (!userId || !token) {\n    console.warn('⚠️  No Afdian credentials available, Afdian sponsors will be skipped.');\n    return [];\n  }\n\n  const sponsors = [];\n  let page = 1;\n\n  while (true) {\n    const ts = Math.floor(Date.now() / 1000);\n    const params = JSON.stringify({ page });\n    const sign = crypto\n      .createHash('md5')\n      .update(`${token}params${params}ts${ts}user_id${userId}`)\n      .digest('hex');\n\n    const res = await fetch(AFDIAN_API, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({\n        user_id: userId,\n        params,\n        ts,\n        sign,\n      }),\n    });\n\n    if (!res.ok) {\n      console.error(`Failed to fetch Afdian sponsors (${res.status})`);\n      break;\n    }\n\n    const payload = await res.json();\n    if (payload.ec !== 200) {\n      console.error('Afdian API error:', payload.em);\n      break;\n    }\n\n    const list = payload.data?.list || [];\n    if (list.length === 0) break;\n\n    for (const item of list) {\n      const user = item.user;\n      if (!user) continue;\n      sponsors.push({\n        login: user.user_id,\n        name: user.name || '匿名用户',\n        avatarUrl: user.avatar || 'https://pic1.afdiancdn.com/default/avatar/avatar-purple.png',\n        url: `https://afdian.com/u/${user.user_id}`,\n        amount: parseFloat(item.all_sum_amount) || 0,\n        provider: 'afdian',\n      });\n    }\n\n    if (list.length < 20) break;\n    page++;\n  }\n\n  sponsors.sort((a, b) => b.amount - a.amount);\n  return sponsors;\n}\n\nasync function buildSvg({ githubSponsors, afdianSponsors, friends }) {\n  const width = 800;\n  const outerPadding = 40;\n  const innerPadding = 32;\n  const contentWidth = width - outerPadding * 2;\n\n  // Embed avatar data\n  await embedAvatarData(githubSponsors);\n  await embedAvatarData(afdianSponsors);\n\n  let cursorY = innerPadding;\n  const defs = [];\n\n  // GitHub Sponsors section\n  const githubGrid = renderSponsorGrid({\n    sponsors: githubSponsors,\n    title: 'GitHub Sponsors',\n    x: 0,\n    y: cursorY,\n    width: contentWidth,\n    sponsorUrl: SPONSORS_URL,\n  });\n  defs.push(...githubGrid.defs);\n  cursorY += githubGrid.height + 60;\n\n  // Afdian Sponsors section\n  const afdianGrid = renderSponsorGrid({\n    sponsors: afdianSponsors,\n    title: 'Afdian Sponsors',\n    x: 0,\n    y: cursorY,\n    width: contentWidth,\n    sponsorUrl: 'https://afdian.com/a/nagi',\n  });\n  defs.push(...afdianGrid.defs);\n  cursorY += afdianGrid.height + 60;\n\n  // Tipping Friends section\n  const friendTable = renderFriendTable({\n    friends,\n    x: 0,\n    y: cursorY,\n    width: contentWidth,\n  });\n  cursorY += friendTable.height + innerPadding;\n\n  const boardHeight = cursorY;\n  const height = boardHeight + outerPadding * 2;\n\n  const svg = `<svg width=\"${width}\" height=\"${height}\" viewBox=\"0 0 ${width} ${height}\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" role=\"img\">\n    <title>Gemini Voyager Sponsors</title>\n    <defs>\n      ${defs.join('\\n')}\n    </defs>\n    <g transform=\"translate(${outerPadding}, ${outerPadding})\" font-family=\"${FONT_FAMILY}\">\n      ${githubGrid.markup}\n      ${afdianGrid.markup}\n      ${friendTable.markup}\n    </g>\n  </svg>`;\n\n  return svg;\n}\n\nasync function avatarDataUri(url) {\n  try {\n    const sizedUrl = url.includes('?') ? `${url}&s=160` : `${url}?s=160`;\n    const res = await fetch(sizedUrl);\n    if (!res.ok) return null;\n    const contentType = res.headers.get('content-type') || 'image/png';\n    const buffer = Buffer.from(await res.arrayBuffer());\n    return `data:${contentType};base64,${buffer.toString('base64')}`;\n  } catch {\n    return null;\n  }\n}\n\nasync function embedAvatarData(sponsors) {\n  const results = await Promise.allSettled(\n    sponsors.map(async (sponsor) => {\n      if (!sponsor.avatarUrl) return;\n      sponsor.avatar = await avatarDataUri(sponsor.avatarUrl);\n    }),\n  );\n}\n\nfunction renderSponsorGrid({ sponsors, title, x, y, width, sponsorUrl }) {\n  const avatarSize = 36;\n  const baseGap = 36;\n  const gapY = 24;\n  let cols = Math.floor(width / (avatarSize + baseGap));\n  const rows = Math.max(1, Math.ceil(sponsors.length / cols));\n  let spacing = baseGap;\n  if (cols > 1) {\n    spacing = (width - cols * avatarSize) / (cols - 1);\n    if (spacing < baseGap) {\n      spacing = baseGap;\n      cols = Math.max(1, Math.floor((width + baseGap) / (avatarSize + baseGap)));\n    }\n  }\n  const gridWidth = sponsors.length ? cols * avatarSize + Math.max(0, cols - 1) * spacing : width;\n  const gridHeight = sponsors.length ? rows * avatarSize + Math.max(0, rows - 1) * gapY : 80;\n  const titleHeight = 36;\n  const sectionHeight = titleHeight + 36 + gridHeight + 32;\n  const centerX = width / 2;\n  const offsetX = Math.max(0, (width - gridWidth) / 2);\n\n  let markup = `\n    <g transform=\"translate(${x}, ${y})\">\n      <text x=\"${centerX}\" y=\"0\" text-anchor=\"middle\" font-size=\"24\" font-weight=\"600\" fill=\"#222222\">${escapeText(title)} · ${sponsors.length}</text>\n  `;\n  const clipDefs = [];\n\n  if (!sponsors.length) {\n    markup += `\n      <g transform=\"translate(0, 50)\">\n        <a xlink:href=\"${sponsorUrl}\" target=\"_blank\">\n          <text x=\"${width / 2}\" y=\"20\" text-anchor=\"middle\" font-size=\"16\" fill=\"#666666\">Become a Sponsor ❤️</text>\n        </a>\n      </g>\n    </g>`;\n    return { markup, height: sectionHeight, defs: [] };\n  }\n\n  markup += `<g transform=\"translate(0, 50)\">`;\n\n  sponsors.forEach((sponsor, index) => {\n    const col = index % cols;\n    const row = Math.floor(index / cols);\n    const avatarX = offsetX + col * (avatarSize + spacing);\n    const avatarY = row * (avatarSize + gapY);\n    const clipId = `cp-${sponsor.provider}-${index}`;\n    clipDefs.push(\n      `<clipPath id=\"${clipId}\">\n        <circle cx=\"${avatarSize / 2}\" cy=\"${avatarSize / 2}\" r=\"${avatarSize / 2}\" />\n      </clipPath>`,\n    );\n    markup += `\n      <g transform=\"translate(${avatarX}, ${avatarY})\">\n        <a xlink:href=\"${escapeAttr(sponsor.url)}\" target=\"_blank\">\n          ${\n            sponsor.avatar\n              ? `<image href=\"${sponsor.avatar}\" x=\"0\" y=\"0\" width=\"${avatarSize}\" height=\"${avatarSize}\" clip-path=\"url(#${clipId})\" />`\n              : `<circle cx=\"${avatarSize / 2}\" cy=\"${avatarSize / 2}\" r=\"${avatarSize / 2}\" fill=\"rgba(0,0,0,0.08)\" />`\n          }\n          <text x=\"${avatarSize / 2}\" y=\"${avatarSize + 18}\" text-anchor=\"middle\" font-size=\"11\" fill=\"#555555\">${escapeText(sponsor.name).slice(0, 12)}</text>\n        </a>\n      </g>\n    `;\n  });\n\n  markup += '</g></g>';\n\n  return {\n    markup,\n    height: sectionHeight,\n    defs: clipDefs,\n  };\n}\n\nfunction renderFriendTable({ friends, x, y, width }) {\n  const tableTop = 44;\n  const columns = 6;\n  const colWidth = width / columns;\n  const rowHeight = 28;\n  const rows = Math.max(1, Math.ceil(friends.length / columns));\n  const tableHeight = rows * rowHeight;\n  const centerX = width / 2;\n\n  let markup = `\n    <g transform=\"translate(${x}, ${y})\">\n      <text x=\"${centerX}\" y=\"0\" text-anchor=\"middle\" font-size=\"24\" font-weight=\"600\" fill=\"#222222\">Tipping Friends · ${friends.length}</text>\n      <g transform=\"translate(0, 44)\">\n  `;\n\n  const orderedFriends = [...friends].reverse();\n  orderedFriends.forEach((name, index) => {\n    const row = Math.floor(index / columns);\n    const col = index % columns;\n    const textX = col * colWidth + colWidth / 2;\n    const textY = row * rowHeight + rowHeight / 2;\n    markup += `<text x=\"${textX}\" y=\"${textY}\" text-anchor=\"middle\" alignment-baseline=\"middle\" font-size=\"13\" fill=\"#555555\">${escapeText(name)}</text>`;\n  });\n\n  markup += '</g></g>';\n\n  return {\n    markup,\n    height: tableTop + tableHeight,\n  };\n}\n\nfunction escapeText(value) {\n  return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n}\n\nfunction escapeAttr(value) {\n  return escapeText(value).replace(/\"/g, '&quot;');\n}\n\nmain().catch((err) => {\n  console.error(err);\n  process.exitCode = 1;\n});\n"
  },
  {
    "path": "scripts/launch-chrome.cjs",
    "content": "'use strict';\n\nconst fs = require('fs');\nconst net = require('net');\nconst os = require('os');\nconst path = require('path');\nconst { spawn } = require('child_process');\n\nconst TARGET_URL = process.env.CHROME_OPEN_URL || 'https://gemini.google.com/';\nconst BUILD_TIMEOUT_MS = Number(process.env.CHROME_OPEN_BUILD_TIMEOUT_MS) || 120000; // ms\nconst BUILD_POLL_INTERVAL_MS = Number(process.env.CHROME_OPEN_POLL_INTERVAL_MS) || 500; // ms\n\nconst repoRoot = path.resolve(__dirname, '..');\nconst distDir = path.join(repoRoot, 'dist_chrome');\nconst manifestPath = path.join(distDir, 'manifest.json');\nconst profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-voyager-chrome-'));\n\nlet devProcess,\n  chromeRunner,\n  debugPort,\n  reloadTimer,\n  lastBuildTime = 0,\n  shuttingDown = false;\nlet devtoolsCommandId = 0;\n\nmain().catch((error) => {\n  log(`Fatal error: ${error.message}`);\n  process.exit(1);\n});\n\nasync function main() {\n  debugPort = await getAvailablePort();\n\n  devProcess = startDevBuild();\n  await waitForManifest(Date.now());\n  lastBuildTime = manifestMtime();\n\n  chromeRunner = await launchChrome();\n  attachProcessHandlers();\n  log('Chrome launched with the latest build.');\n\n  if (debugPort) startReloadWatcher();\n}\n\nfunction startDevBuild() {\n  const args = ['run', 'dev:chrome'];\n  log(`Starting dev build: bun ${args.join(' ')}`);\n  const child = spawn('bun', args, { cwd: repoRoot, stdio: 'inherit', env: process.env });\n  child.on('exit', (code, signal) => {\n    if (shuttingDown) return;\n    log(`Dev build process exited (${signal ? `signal ${signal}` : `code ${code}`}).`);\n    process.exit(code ?? 0);\n  });\n  return child;\n}\n\nasync function launchChrome() {\n  const webExt = await import('web-ext-run').then((mod) => mod.default ?? mod);\n  const args = ['--no-first-run', '--no-default-browser-check'];\n  if (debugPort)\n    args.push('--remote-debugging-address=127.0.0.1', `--remote-debugging-port=${debugPort}`);\n  const config = {\n    target: 'chromium',\n    sourceDir: distDir,\n    startUrl: [TARGET_URL],\n    keepProfileChanges: true,\n    chromiumProfile: profileDir,\n    args,\n    noInput: true,\n  };\n  if (process.env.CHROME_BIN) config.chromiumBinary = process.env.CHROME_BIN;\n  log('Launching Chrome via web-ext-run.');\n  return webExt.cmd.run(config, { shouldExitProgram: false });\n}\n\nfunction attachProcessHandlers() {\n  const cleanup = () => {\n    if (shuttingDown) return;\n    shuttingDown = true;\n    devProcess?.kill('SIGTERM');\n    chromeRunner?.exit?.();\n    if (reloadTimer) clearInterval(reloadTimer);\n    fs.rmSync(profileDir, { recursive: true, force: true });\n  };\n  process.on('SIGINT', () => {\n    cleanup();\n    process.exit(0);\n  });\n  process.on('SIGTERM', () => {\n    cleanup();\n    process.exit(0);\n  });\n  process.on('exit', cleanup);\n}\n\nasync function waitForManifest(startTime) {\n  const deadline = Date.now() + BUILD_TIMEOUT_MS;\n  while (Date.now() < deadline) {\n    try {\n      const stat = fs.statSync(manifestPath);\n      if (stat.mtimeMs >= startTime && stat.size > 0) return;\n    } catch {}\n    await new Promise((r) => setTimeout(r, BUILD_POLL_INTERVAL_MS));\n  }\n  throw new Error('Timed out waiting for dist_chrome/manifest.json');\n}\n\nfunction startReloadWatcher() {\n  if (reloadTimer) return;\n  reloadTimer = setInterval(async () => {\n    if (shuttingDown) return;\n    const mtime = manifestMtime();\n    if (mtime <= lastBuildTime) return;\n    lastBuildTime = mtime;\n    try {\n      await reloadTargetTabs();\n    } catch (error) {\n      log(`Tab reload failed: ${error.message}`);\n    }\n  }, BUILD_POLL_INTERVAL_MS);\n}\n\nfunction manifestMtime() {\n  try {\n    return fs.statSync(manifestPath).mtimeMs;\n  } catch {\n    return 0;\n  }\n}\n\nasync function reloadTargetTabs() {\n  const targets = await fetchJson(`http://127.0.0.1:${debugPort}/json`);\n  const matches = Array.isArray(targets)\n    ? targets.filter(\n        (t) => t?.type === 'page' && typeof t?.url === 'string' && t.url.startsWith(TARGET_URL),\n      )\n    : [];\n  if (matches.length === 0) return;\n  await Promise.all(\n    matches.map((t) =>\n      t?.webSocketDebuggerUrl\n        ? sendDevtoolsCommand(t.webSocketDebuggerUrl, 'Page.reload', { ignoreCache: true })\n        : Promise.resolve(),\n    ),\n  );\n}\n\nasync function fetchJson(url) {\n  const res = await fetch(url);\n  if (!res.ok) throw new Error(`Devtools endpoint returned ${res.status}`);\n  return res.json();\n}\n\nfunction sendDevtoolsCommand(url, method, params) {\n  return new Promise((resolve, reject) => {\n    const ws = new WebSocket(url);\n    const id = (devtoolsCommandId += 1);\n    let settled = false;\n    const settle = (error) => {\n      if (settled) return;\n      settled = true;\n      clearTimeout(timeoutId);\n      if (error) reject(error);\n      else resolve();\n    };\n    const timeoutId = setTimeout(() => {\n      settle(new Error(`Devtools command timeout: ${method}`));\n      ws.close();\n    }, 5000);\n\n    ws.onopen = () => ws.send(JSON.stringify({ id, method, params }));\n    ws.onmessage = (event) => {\n      const payload =\n        typeof event.data === 'string' ? event.data : (event.data?.toString?.() ?? '');\n      if (!payload) return;\n      try {\n        if (JSON.parse(payload).id === id) {\n          ws.close();\n          settle();\n        }\n      } catch (error) {\n        log(`Devtools message parse error: ${error.message}`);\n      }\n    };\n    ws.onerror = (error) => settle(error);\n    ws.onclose = () => settle(new Error('Devtools connection closed before response.'));\n  });\n}\n\nfunction getAvailablePort() {\n  return new Promise((resolve, reject) => {\n    const server = net.createServer();\n    server.once('error', reject);\n    server.listen(0, '127.0.0.1', () => {\n      const addr = server.address();\n      const port = typeof addr === 'object' && addr ? addr.port : null;\n      server.close(() =>\n        port ? resolve(port) : reject(new Error('Unable to determine open port.')),\n      );\n    });\n  });\n}\n\nfunction log(message) {\n  console.log(`[chrome-open] ${message}`);\n}\n"
  },
  {
    "path": "sponsorkit/sponsors.json",
    "content": "[\n  \"第一位无名赞助者\",\n  \"王萱\",\n  \"NGC2237\",\n  \"白洺泽\",\n  \"无名赞助者 2 号\",\n  \"粤北\",\n  \"**颖\",\n  \"liubok\",\n  \"lty\",\n  \"Yohjishong\",\n  \"猫之汉化\",\n  \"益达\",\n  \"tttt\",\n  \"Alex\",\n  \"BlancX\",\n  \"i马克图布\",\n  \"松子\",\n  \"Zy\",\n  \"胡杨\",\n  \"*源\",\n  \"脆脆的\",\n  \"**朗\",\n  \"无邪。\",\n  \"Jakobs\",\n  \"小星星4818\",\n  \"聪明文思\",\n  \"菜头\",\n  \"ForeverJv\",\n  \"Atopos\",\n  \"dragoon\",\n  \"徐云霆\",\n  \"Ewwwzh\",\n  \"Grace\",\n  \"tnyh0330\",\n  \"iK兎Ki\",\n  \"¥999\",\n  \"不要这雪\",\n  \"混淆\",\n  \"夜雨思晗\",\n  \"顗逺\",\n  \"Justin\",\n  \"好名字被用光了\",\n  \"Y.\",\n  \".\",\n  \"理想\",\n  \"逆地无天\",\n  \"龙\",\n  \"轩辕\",\n  \"四方上下为宇\",\n  \"Wuliyasi\",\n  \"Chemisme\",\n  \"蔡蔡\",\n  \"Zhang\",\n  \"鸿\",\n  \"Carho\",\n  \"風を食む\",\n  \"小栗\",\n  \"。。。。\",\n  \"Red\",\n  \"抬头见喜\",\n  \"空海\",\n  \"浦睿\",\n  \"Danial\",\n  \"我要午睡\",\n  \"魚尾鱼尾鱼\",\n  \"Rosépineus\",\n  \"洞穴里的太阳\",\n  \"逍尧子\",\n  \"边际不寄\",\n  \"桓\",\n  \"Y\",\n  \"青\",\n  \"谷彻然\",\n  \"LiuB\",\n  \"摆\",\n  \"Meursault\",\n  \"Ruby\",\n  \"**豪\",\n  \"**朗\",\n  \"**彬\",\n  \"**彬\",\n  \"**俊\",\n  \"**彬\",\n  \"**霖\",\n  \"**涛\",\n  \"**东\",\n  \"**源\",\n  \"**元\",\n  \"Howland\",\n  \"Kamil\",\n  \"CS\",\n  \"HD0NG\",\n  \"dannyquayle\",\n  \"Lowis Wong\",\n  \"Stacey🌋\",\n  \"又堵\",\n  \"国家一级可乐品鉴师\",\n  \"Cyanis\",\n  \"99\",\n  \"李火旺\",\n  \"晴空叁玖里\",\n  \"金童\",\n  \"Y\",\n  \"?\",\n  \"chellyzzz\",\n  \"一定要跟自己和解\",\n  \"yankee_qi\",\n  \"浮小尘\",\n  \"*迅\",\n  \"*浩\",\n  \"**柠\",\n  \"**文\",\n  \"**滔\",\n  \"**宇\",\n  \"**佐\",\n  \"*强\",\n  \"**泰\",\n  \"**星\",\n  \"**飞\",\n  \"*鑫\",\n  \"Commie.苍\",\n  \"Mezzo Forte\",\n  \"爱染城\",\n  \"Maco\",\n  \"G2TH3S\",\n  \"Kezd-\",\n  \"Hsu\",\n  \"红薯\",\n  \"公无渡河\",\n  \"晃\",\n  \"6 z 1\",\n  \"炒米线\",\n  \"前熬夜运动员\",\n  \"青丘\",\n  \"一日看尽长安花\",\n  \"Bee.\",\n  \"Inject\",\n  \"Leslie Mo\",\n  \"Fm106.8\",\n  \"柯山梦\",\n  \"柴荨-🌙🐎\",\n  \"夜间飞鸟\",\n  \"六虎凶鸣\",\n  \"博协\",\n  \"梦中的梦\",\n  \"wnlei\",\n  \"孤守半岛\",\n  \"Siri\",\n  \"MEEE\",\n  \"十分人性\",\n  \"西米 SU\"\n]\n"
  },
  {
    "path": "src/assets/styles/tailwind.css",
    "content": "@import 'tailwindcss';\n\n@layer base {\n  :root {\n    --background: oklch(0.98 0.002 250);\n    --foreground: oklch(0.14 0.004 285);\n    --card: oklch(0.995 0.001 250);\n    --card-foreground: oklch(0.14 0.004 285);\n    --popover: oklch(0.995 0.001 250);\n    --popover-foreground: oklch(0.14 0.004 285);\n    --primary: oklch(0.55 0.17 155);\n    --primary-foreground: oklch(0.99 0 0);\n    --secondary: oklch(0.95 0.004 250);\n    --secondary-foreground: oklch(0.38 0.006 250);\n    --muted: oklch(0.96 0.003 250);\n    --muted-foreground: oklch(0.5 0.01 250);\n    --accent: oklch(0.94 0.015 155);\n    --accent-foreground: oklch(0.14 0.004 285);\n    --destructive: oklch(0.6 0.22 25);\n    --destructive-foreground: oklch(0.99 0 0);\n    --border: oklch(0.92 0.004 250);\n    --input: oklch(0.92 0.004 250);\n    --ring: oklch(0.55 0.17 155);\n    --radius: 0.625rem;\n  }\n\n  .dark {\n    --background: oklch(0.16 0.006 285);\n    --foreground: oklch(0.92 0.004 250);\n    --card: oklch(0.2 0.008 285);\n    --card-foreground: oklch(0.92 0.004 250);\n    --popover: oklch(0.2 0.008 285);\n    --popover-foreground: oklch(0.92 0.004 250);\n    --primary: oklch(0.7 0.16 155);\n    --primary-foreground: oklch(0.12 0.01 285);\n    --secondary: oklch(0.24 0.008 285);\n    --secondary-foreground: oklch(0.72 0.006 250);\n    --muted: oklch(0.2 0.006 285);\n    --muted-foreground: oklch(0.56 0.008 250);\n    --accent: oklch(0.26 0.015 155);\n    --accent-foreground: oklch(0.72 0.006 250);\n    --destructive: oklch(0.6 0.22 25);\n    --destructive-foreground: oklch(0.12 0.01 285);\n    --border: oklch(0.3 0.008 285);\n    --input: oklch(0.3 0.008 285);\n    --ring: oklch(0.7 0.16 155);\n  }\n\n  * {\n    border-color: var(--border);\n  }\n\n  /* Disable default View Transition crossfade — we use a custom clip-path circle expand */\n  ::view-transition-old(root),\n  ::view-transition-new(root) {\n    animation: none;\n    mix-blend-mode: normal;\n  }\n  ::view-transition-new(root) {\n    z-index: 1;\n  }\n\n  body {\n    background-color: var(--background);\n    color: var(--foreground);\n    font-feature-settings:\n      'rlig' 1,\n      'calt' 1;\n  }\n}\n\n@theme {\n  --animate-spin-slow: spin 20s linear infinite;\n\n  @keyframes spin {\n    to {\n      transform: rotate(360deg);\n    }\n  }\n\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-destructive-foreground: var(--destructive-foreground);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n}\n"
  },
  {
    "path": "src/components/DarkModeToggle.tsx",
    "content": "import React from 'react';\n\nimport { Moon, Sun } from 'lucide-react';\n\nimport { useDarkMode } from '../hooks/useDarkMode';\nimport { Button } from './ui/button';\n\nexport const DarkModeToggle: React.FC = () => {\n  const { isDark, toggleDarkMode } = useDarkMode();\n\n  return (\n    <Button\n      variant=\"ghost\"\n      size=\"icon\"\n      onClick={(e) => toggleDarkMode(e)}\n      title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}\n      className=\"h-9 w-9\"\n    >\n      {isDark ? <Sun className=\"h-4 w-4\" /> : <Moon className=\"h-4 w-4\" />}\n      <span className=\"sr-only\">Toggle dark mode</span>\n    </Button>\n  );\n};\n"
  },
  {
    "path": "src/components/LanguageSwitcher.tsx",
    "content": "import React from 'react';\n\nimport { Globe } from 'lucide-react';\n\nimport { useLanguage } from '../contexts/LanguageContext';\nimport { APP_LANGUAGE_LABELS, getNextLanguage } from '../utils/language';\nimport { Button } from './ui/button';\n\nexport const LanguageSwitcher: React.FC = () => {\n  const { language, setLanguage } = useLanguage();\n  const nextLanguage = getNextLanguage(language);\n\n  const toggleLanguage = () => {\n    setLanguage(nextLanguage);\n  };\n\n  return (\n    <Button\n      variant=\"ghost\"\n      size=\"icon\"\n      onClick={toggleLanguage}\n      title={`Switch to ${APP_LANGUAGE_LABELS[nextLanguage]}`}\n      className=\"h-9 w-9\"\n    >\n      <Globe className=\"h-4 w-4\" />\n      <span className=\"sr-only\">Toggle language</span>\n    </Button>\n  );\n};\n"
  },
  {
    "path": "src/components/ui/button.tsx",
    "content": "import * as React from 'react';\n\nimport { type VariantProps, cva } from 'class-variance-authority';\n\nimport { cn } from '../../lib/utils';\n\nconst buttonVariants = cva(\n  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 relative overflow-hidden',\n  {\n    variants: {\n      variant: {\n        default:\n          'bg-primary text-primary-foreground shadow hover:bg-primary/90 before:absolute before:inset-1/2 before:h-0 before:w-0 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-white/30 before:transition-all before:duration-500 active:before:h-full active:before:w-full active:before:duration-0',\n        destructive:\n          'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 before:absolute before:inset-1/2 before:h-0 before:w-0 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-white/30 before:transition-all before:duration-500 active:before:h-full active:before:w-full active:before:duration-0',\n        outline:\n          'border border-input bg-card hover:bg-accent hover:text-accent-foreground shadow-sm before:absolute before:inset-1/2 before:h-0 before:w-0 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-primary/10 before:transition-all before:duration-500 active:before:h-full active:before:w-full active:before:duration-0',\n        secondary:\n          'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 before:absolute before:inset-1/2 before:h-0 before:w-0 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-primary/20 before:transition-all before:duration-500 active:before:h-full active:before:w-full active:before:duration-0',\n        ghost:\n          'hover:bg-accent hover:text-accent-foreground before:absolute before:inset-1/2 before:h-0 before:w-0 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-primary/10 before:transition-all before:duration-500 active:before:h-full active:before:w-full active:before:duration-0',\n        link: 'text-primary underline-offset-4 hover:underline',\n      },\n      size: {\n        default: 'h-10 px-4 py-2',\n        sm: 'h-9 rounded-md px-3',\n        lg: 'h-11 rounded-md px-8',\n        icon: 'h-10 w-10',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  },\n);\n\nexport interface ButtonProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean;\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, size, children, ...props }, ref) => {\n    return (\n      <button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props}>\n        <span className=\"relative z-10\">{children}</span>\n      </button>\n    );\n  },\n);\nButton.displayName = 'Button';\n\nexport { Button, buttonVariants };\n"
  },
  {
    "path": "src/components/ui/card.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '../../lib/utils';\n\nconst Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div\n      ref={ref}\n      className={cn(\n        'bg-card text-card-foreground border-border/60 rounded-xl border shadow-sm',\n        className,\n      )}\n      {...props}\n    />\n  ),\n);\nCard.displayName = 'Card';\n\nconst CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div ref={ref} className={cn('flex flex-col space-y-1.5 p-4', className)} {...props} />\n  ),\n);\nCardHeader.displayName = 'CardHeader';\n\nconst CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(\n  ({ className, ...props }, ref) => (\n    <h3\n      ref={ref}\n      className={cn('text-foreground/60 text-xs font-bold tracking-widest uppercase', className)}\n      {...props}\n    />\n  ),\n);\nCardTitle.displayName = 'CardTitle';\n\nconst CardDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <p ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />\n));\nCardDescription.displayName = 'CardDescription';\n\nconst CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div ref={ref} className={cn('px-4 pt-0 pb-4', className)} {...props} />\n  ),\n);\nCardContent.displayName = 'CardContent';\n\nconst CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div ref={ref} className={cn('flex items-center p-4 pt-0', className)} {...props} />\n  ),\n);\nCardFooter.displayName = 'CardFooter';\n\nexport { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };\n"
  },
  {
    "path": "src/components/ui/label.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '../../lib/utils';\n\nconst Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(\n  ({ className, ...props }, ref) => (\n    <label\n      ref={ref}\n      className={cn(\n        'text-foreground text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70',\n        className,\n      )}\n      {...props}\n    />\n  ),\n);\nLabel.displayName = 'Label';\n\nexport { Label };\n"
  },
  {
    "path": "src/components/ui/select.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '../../lib/utils';\n\nexport interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}\n\nconst Select = React.forwardRef<HTMLSelectElement, SelectProps>(\n  ({ className, children, ...props }, ref) => {\n    return (\n      <select\n        className={cn(\n          'border-input bg-card flex h-10 w-full items-center justify-between rounded-lg border px-3 py-2 text-sm shadow-sm transition-colors',\n          'hover:border-ring/50 focus:ring-ring focus:ring-2 focus:ring-offset-2 focus:outline-none',\n          'disabled:cursor-not-allowed disabled:opacity-50',\n          className,\n        )}\n        ref={ref}\n        {...props}\n      >\n        {children}\n      </select>\n    );\n  },\n);\nSelect.displayName = 'Select';\n\nexport { Select };\n"
  },
  {
    "path": "src/components/ui/slider.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '../../lib/utils';\n\nexport interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {\n  onValueChange?: (value: number) => void;\n  onValueCommit?: (value: number) => void;\n}\n\nconst Slider = React.forwardRef<HTMLInputElement, SliderProps>(\n  ({ className, onValueChange, onValueCommit, ...props }, ref) => {\n    const handleInput = (\n      e: React.ChangeEvent<HTMLInputElement> | React.FormEvent<HTMLInputElement>,\n    ) => {\n      const value = Number((e.target as HTMLInputElement).value);\n      onValueChange?.(value);\n    };\n\n    const handleCommit = (\n      e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>,\n    ) => {\n      const value = Number((e.target as HTMLInputElement).value);\n      onValueCommit?.(value);\n    };\n\n    return (\n      <input\n        ref={ref}\n        type=\"range\"\n        className={cn(\n          'bg-secondary h-2 w-full cursor-pointer appearance-none rounded-full',\n          '[&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:h-4.5 [&::-webkit-slider-thumb]:w-4.5 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:transition-all [&::-webkit-slider-thumb]:duration-200 hover:[&::-webkit-slider-thumb]:scale-110',\n          '[&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:h-4.5 [&::-moz-range-thumb]:w-4.5 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:shadow-md',\n          className,\n        )}\n        onInput={handleInput}\n        onChange={handleInput}\n        onMouseUp={handleCommit}\n        onTouchEnd={handleCommit}\n        {...props}\n      />\n    );\n  },\n);\nSlider.displayName = 'Slider';\n\nexport { Slider };\n"
  },
  {
    "path": "src/components/ui/switch.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '../../lib/utils';\n\nexport interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {}\n\nconst Switch = React.forwardRef<HTMLInputElement, SwitchProps>(({ className, ...props }, ref) => {\n  return (\n    <label className=\"relative inline-flex cursor-pointer items-center\">\n      <input ref={ref} type=\"checkbox\" className=\"peer sr-only\" {...props} />\n      <div\n        className={cn(\n          'bg-input peer-focus:ring-ring peer h-6 w-11 rounded-full shadow-inner transition-colors duration-200 peer-focus:ring-2 peer-focus:ring-offset-2 peer-focus:outline-none',\n          \"after:absolute after:top-[2px] after:left-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:shadow-md after:transition-all after:duration-200 after:content-[''] peer-checked:after:translate-x-full\",\n          'peer-checked:bg-primary',\n          className,\n        )}\n      ></div>\n    </label>\n  );\n});\nSwitch.displayName = 'Switch';\n\nexport { Switch };\n"
  },
  {
    "path": "src/contexts/LanguageContext.tsx",
    "content": "import React, { ReactNode, createContext, useContext, useEffect, useState } from 'react';\n\nimport browser from 'webextension-polyfill';\n\nimport { StorageKeys } from '@/core/types/common';\nimport { getCurrentLanguage, setCachedLanguage } from '@/utils/i18n';\nimport { type AppLanguage, normalizeLanguage } from '@/utils/language';\nimport { TRANSLATIONS, type TranslationKey } from '@/utils/translations';\n\ninterface LanguageContextType {\n  language: AppLanguage;\n  setLanguage: (lang: AppLanguage) => void;\n  t: (key: TranslationKey) => string;\n}\n\nconst LanguageContext = createContext<LanguageContextType | undefined>(undefined);\n\nexport const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children }) => {\n  // Get initial language from browser UI language\n  const getInitialLanguage = (): AppLanguage => {\n    try {\n      const browserLang = browser.i18n.getUILanguage();\n      return normalizeLanguage(browserLang);\n    } catch {\n      return 'en';\n    }\n  };\n\n  const [language, setLanguageState] = useState<AppLanguage>(getInitialLanguage());\n\n  // Load saved language preference on mount\n  useEffect(() => {\n    const loadLanguage = async () => {\n      try {\n        const current = await getCurrentLanguage();\n        setLanguageState(current);\n      } catch (error) {\n        console.error('Failed to load language preference:', error);\n      }\n    };\n    loadLanguage();\n  }, []);\n\n  // Listen for language changes from other tabs/contexts\n  useEffect(() => {\n    const handleStorageChange = (\n      changes: { [key: string]: browser.Storage.StorageChange },\n      areaName: string,\n    ) => {\n      const next = changes[StorageKeys.LANGUAGE]?.newValue;\n      if ((areaName === 'sync' || areaName === 'local') && typeof next === 'string') {\n        setLanguageState(normalizeLanguage(next));\n      }\n    };\n\n    browser.storage.onChanged.addListener(handleStorageChange);\n    return () => {\n      browser.storage.onChanged.removeListener(handleStorageChange);\n    };\n  }, []);\n\n  const setLanguage = async (lang: AppLanguage) => {\n    setLanguageState(lang);\n    setCachedLanguage(lang);\n    try {\n      await browser.storage.sync.set({ [StorageKeys.LANGUAGE]: lang });\n      return;\n    } catch (error) {\n      try {\n        await browser.storage.local.set({ [StorageKeys.LANGUAGE]: lang });\n        return;\n      } catch (localError) {\n        console.error('Failed to save language preference:', error, localError);\n      }\n    }\n  };\n\n  const t = (key: TranslationKey): string => {\n    return TRANSLATIONS[language][key] ?? TRANSLATIONS.en[key] ?? key;\n  };\n\n  return (\n    <LanguageContext.Provider value={{ language, setLanguage, t }}>\n      {children}\n    </LanguageContext.Provider>\n  );\n};\n\nexport const useLanguage = (): LanguageContextType => {\n  const context = useContext(LanguageContext);\n  if (!context) {\n    throw new Error('useLanguage must be used within a LanguageProvider');\n  }\n  return context;\n};\n"
  },
  {
    "path": "src/core/errors/AppError.ts",
    "content": "/**\n * Custom error classes following best practices\n * Provides structured error handling with context\n */\n\nexport enum ErrorCode {\n  // Storage errors\n  STORAGE_READ_FAILED = 'STORAGE_READ_FAILED',\n  STORAGE_WRITE_FAILED = 'STORAGE_WRITE_FAILED',\n  STORAGE_PARSE_FAILED = 'STORAGE_PARSE_FAILED',\n\n  // DOM errors\n  ELEMENT_NOT_FOUND = 'ELEMENT_NOT_FOUND',\n  ELEMENT_QUERY_FAILED = 'ELEMENT_QUERY_FAILED',\n\n  // Timeline errors\n  TIMELINE_INIT_FAILED = 'TIMELINE_INIT_FAILED',\n  TIMELINE_RENDER_FAILED = 'TIMELINE_RENDER_FAILED',\n\n  // Folder errors\n  FOLDER_CREATE_FAILED = 'FOLDER_CREATE_FAILED',\n  FOLDER_UPDATE_FAILED = 'FOLDER_UPDATE_FAILED',\n  FOLDER_DELETE_FAILED = 'FOLDER_DELETE_FAILED',\n\n  // Conversation errors\n  CONVERSATION_ADD_FAILED = 'CONVERSATION_ADD_FAILED',\n  CONVERSATION_REMOVE_FAILED = 'CONVERSATION_REMOVE_FAILED',\n  CONVERSATION_NAVIGATE_FAILED = 'CONVERSATION_NAVIGATE_FAILED',\n\n  // Generic errors\n  UNKNOWN_ERROR = 'UNKNOWN_ERROR',\n  VALIDATION_ERROR = 'VALIDATION_ERROR',\n}\n\nexport interface ErrorContext {\n  [key: string]: unknown;\n}\n\nexport class AppError extends Error {\n  constructor(\n    public readonly code: ErrorCode,\n    message: string,\n    public readonly context?: ErrorContext,\n    public readonly originalError?: Error,\n  ) {\n    super(message);\n    this.name = 'AppError';\n\n    // Maintains proper stack trace for where error was thrown\n    if (Error.captureStackTrace) {\n      Error.captureStackTrace(this, AppError);\n    }\n  }\n\n  toJSON(): Record<string, unknown> {\n    return {\n      name: this.name,\n      code: this.code,\n      message: this.message,\n      context: this.context,\n      stack: this.stack,\n      originalError: this.originalError?.message,\n    };\n  }\n}\n\nexport class StorageError extends AppError {\n  constructor(code: ErrorCode, message: string, context?: ErrorContext, originalError?: Error) {\n    super(code, message, context, originalError);\n    this.name = 'StorageError';\n  }\n}\n\nexport class DOMError extends AppError {\n  constructor(code: ErrorCode, message: string, context?: ErrorContext, originalError?: Error) {\n    super(code, message, context, originalError);\n    this.name = 'DOMError';\n  }\n}\n\nexport class ValidationError extends AppError {\n  constructor(message: string, context?: ErrorContext) {\n    super(ErrorCode.VALIDATION_ERROR, message, context);\n    this.name = 'ValidationError';\n  }\n}\n\n/**\n * Error handler utility\n */\nexport class ErrorHandler {\n  static handle(error: unknown, context?: ErrorContext): AppError {\n    if (error instanceof AppError) {\n      return error;\n    }\n\n    if (error instanceof Error) {\n      return new AppError(ErrorCode.UNKNOWN_ERROR, error.message, context, error);\n    }\n\n    return new AppError(ErrorCode.UNKNOWN_ERROR, String(error), context);\n  }\n\n  static isRecoverable(error: AppError): boolean {\n    const recoverableCodes = [ErrorCode.ELEMENT_NOT_FOUND, ErrorCode.STORAGE_READ_FAILED];\n\n    return recoverableCodes.includes(error.code);\n  }\n}\n"
  },
  {
    "path": "src/core/index.ts",
    "content": "/**\n * Core module exports\n * Central barrel file for all core functionality\n */\n\n// Types\nexport * from './types/common';\nexport * from './types/timeline';\nexport * from './types/folder';\n\n// Errors\nexport * from './errors/AppError';\n\n// Services\nexport * from './services/LoggerService';\nexport * from './services/StorageService';\nexport * from './services/DOMService';\n\n// Utils\nexport * from './utils/hash';\nexport * from './utils/text';\nexport * from './utils/selectors';\nexport * from './utils/array';\nexport * from './utils/async';\nexport * from './utils/version';\nexport * from './utils/concurrency';\n"
  },
  {
    "path": "src/core/services/AccountIsolationService.ts",
    "content": "import { StorageKeys } from '@/core/types/common';\nimport { hashString } from '@/core/utils/hash';\n\nconst EMAIL_PATTERN = /\\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}\\b/i;\nconst PROFILE_MAP_VERSION = 1;\nconst ACCOUNT_ISOLATION_KEY_BY_PLATFORM = {\n  gemini: StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED_GEMINI,\n  aistudio: StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED_AISTUDIO,\n} as const;\n\ninterface AccountProfileRecord {\n  id: number;\n  createdAt: number;\n  updatedAt: number;\n  routeUserId?: string;\n  emailHash?: string;\n}\n\ninterface AccountProfileMap {\n  version: number;\n  nextId: number;\n  profiles: Record<string, AccountProfileRecord>;\n  routeAliases: Record<string, string>;\n  emailAliases: Record<string, string>;\n}\n\nexport interface AccountScope {\n  accountKey: string;\n  accountId: number;\n  routeUserId: string | null;\n  emailHash: string | null;\n}\n\nexport interface AccountScopeHints {\n  pageUrl?: string | null;\n  routeUserId?: string | null;\n  email?: string | null;\n}\n\nexport interface AccountContext {\n  routeUserId: string | null;\n  email: string | null;\n}\n\nexport type AccountPlatform = 'gemini' | 'aistudio';\n\nfunction parseHostname(url: string): string | null {\n  try {\n    return new URL(url).hostname.toLowerCase();\n  } catch {\n    return null;\n  }\n}\n\nfunction isAIStudioHost(hostname: string | null): boolean {\n  return hostname === 'aistudio.google.com' || hostname === 'aistudio.google.cn';\n}\n\nfunction isGeminiHost(hostname: string | null): boolean {\n  return hostname === 'gemini.google.com' || hostname === 'business.gemini.google';\n}\n\nexport function detectAccountPlatformFromUrl(pageUrl: string | null | undefined): AccountPlatform {\n  const hostname = parseHostname(pageUrl || '');\n  if (isAIStudioHost(hostname)) return 'aistudio';\n  return 'gemini';\n}\n\nexport function getAccountIsolationStorageKey(platform: AccountPlatform): string {\n  return ACCOUNT_ISOLATION_KEY_BY_PLATFORM[platform];\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return typeof value === 'object' && value !== null;\n}\n\nfunction toStringRecord(value: unknown): Record<string, string> {\n  if (!isRecord(value)) return {};\n  return Object.fromEntries(\n    Object.entries(value).filter(\n      (entry): entry is [string, string] =>\n        typeof entry[0] === 'string' && typeof entry[1] === 'string',\n    ),\n  );\n}\n\nfunction toProfileRecord(value: unknown): AccountProfileRecord | null {\n  if (!isRecord(value)) return null;\n  const id = value.id;\n  const createdAt = value.createdAt;\n  const updatedAt = value.updatedAt;\n  if (typeof id !== 'number' || !Number.isFinite(id)) return null;\n  if (typeof createdAt !== 'number' || !Number.isFinite(createdAt)) return null;\n  if (typeof updatedAt !== 'number' || !Number.isFinite(updatedAt)) return null;\n\n  const routeUserId = typeof value.routeUserId === 'string' ? value.routeUserId : undefined;\n  const emailHash = typeof value.emailHash === 'string' ? value.emailHash : undefined;\n  return {\n    id,\n    createdAt,\n    updatedAt,\n    routeUserId,\n    emailHash,\n  };\n}\n\nfunction createDefaultProfileMap(): AccountProfileMap {\n  return {\n    version: PROFILE_MAP_VERSION,\n    nextId: 1,\n    profiles: {},\n    routeAliases: {},\n    emailAliases: {},\n  };\n}\n\nfunction parseProfileMap(value: unknown): AccountProfileMap {\n  if (!isRecord(value)) return createDefaultProfileMap();\n\n  const nextId =\n    typeof value.nextId === 'number' && Number.isFinite(value.nextId) ? value.nextId : 1;\n  const profilesRaw = isRecord(value.profiles) ? value.profiles : {};\n\n  const profiles: Record<string, AccountProfileRecord> = {};\n  for (const [key, recordRaw] of Object.entries(profilesRaw)) {\n    if (typeof key !== 'string') continue;\n    const record = toProfileRecord(recordRaw);\n    if (!record) continue;\n    profiles[key] = record;\n  }\n\n  return {\n    version: PROFILE_MAP_VERSION,\n    nextId: Math.max(1, Math.floor(nextId)),\n    profiles,\n    routeAliases: toStringRecord(value.routeAliases),\n    emailAliases: toStringRecord(value.emailAliases),\n  };\n}\n\nexport function extractRouteUserIdFromPath(pathname: string): string | null {\n  const match = pathname.match(/^\\/u\\/(\\d+)\\//);\n  return match?.[1] ?? null;\n}\n\nexport function extractRouteUserIdFromUrl(url: string): string | null {\n  try {\n    const parsed = new URL(url);\n    return extractRouteUserIdFromPath(parsed.pathname);\n  } catch {\n    return null;\n  }\n}\n\nexport function normalizeEmailAddress(email: string | null | undefined): string | null {\n  if (!email) return null;\n  const normalized = email.trim().toLowerCase();\n  if (!normalized || !EMAIL_PATTERN.test(normalized)) return null;\n  return normalized;\n}\n\nexport function extractEmailFromText(text: string | null | undefined): string | null {\n  if (!text) return null;\n  const match = text.match(EMAIL_PATTERN);\n  if (!match) return null;\n  return normalizeEmailAddress(match[0]);\n}\n\nfunction extractEmailFromElement(element: Element): string | null {\n  const candidates = [\n    element.getAttribute('data-email'),\n    element.getAttribute('aria-label'),\n    element.getAttribute('title'),\n    element.textContent,\n  ];\n\n  if (element instanceof HTMLAnchorElement) {\n    candidates.push(element.href);\n  }\n\n  for (const candidate of candidates) {\n    const email = extractEmailFromText(candidate);\n    if (email) return email;\n  }\n\n  return null;\n}\n\nfunction findEmailBySelectors(\n  doc: Document,\n  selectors: string[],\n  limitPerSelector: number = 40,\n): string | null {\n  for (const selector of selectors) {\n    const elements = Array.from(doc.querySelectorAll(selector)).slice(0, limitPerSelector);\n    for (const element of elements) {\n      const email = extractEmailFromElement(element);\n      if (email) return email;\n    }\n  }\n\n  return null;\n}\n\nexport function detectAccountContextFromDocument(pageUrl: string, doc: Document): AccountContext {\n  const routeUserId = extractRouteUserIdFromUrl(pageUrl);\n  const hostname = parseHostname(pageUrl);\n\n  if (isAIStudioHost(hostname)) {\n    const aiStudioEmail = findEmailBySelectors(doc, [\n      '.account-switcher-text',\n      '#account-switcher-button .button-container[aria-label]',\n      'alkali-accountswitcher [aria-label*=\"@\"]',\n      '.account-switcher-container [aria-label*=\"@\"]',\n    ]);\n    if (aiStudioEmail) {\n      return { routeUserId, email: aiStudioEmail };\n    }\n  }\n\n  if (isGeminiHost(hostname)) {\n    const geminiEmail = findEmailBySelectors(doc, [\n      '[aria-label*=\"@\"]',\n      '[data-email]',\n      '[title*=\"@\"]',\n      'a[href^=\"mailto:\"]',\n    ]);\n    if (geminiEmail) {\n      return { routeUserId, email: geminiEmail };\n    }\n  }\n\n  const fallbackEmail = findEmailBySelectors(doc, [\n    '[data-email]',\n    '[aria-label*=\"@\"]',\n    '[title*=\"@\"]',\n    'a[href^=\"mailto:\"]',\n    'img[alt*=\"@\"]',\n  ]);\n  if (fallbackEmail) {\n    return { routeUserId, email: fallbackEmail };\n  }\n\n  return { routeUserId, email: null };\n}\n\nexport function buildScopedStorageKey(baseKey: string, accountKey: string): string {\n  return `${baseKey}:acct:${hashString(accountKey)}`;\n}\n\nexport function buildScopedFolderStorageKey(accountKey: string): string {\n  return buildScopedStorageKey(StorageKeys.FOLDER_DATA, accountKey);\n}\n\nexport class AccountIsolationService {\n  private operationQueue: Promise<unknown> = Promise.resolve();\n\n  private serialize<T>(operation: () => Promise<T>): Promise<T> {\n    const next = this.operationQueue.then(operation, operation);\n    this.operationQueue = next.catch(() => undefined);\n    return next;\n  }\n\n  async isIsolationEnabled(options?: {\n    platform?: AccountPlatform;\n    pageUrl?: string | null;\n  }): Promise<boolean> {\n    try {\n      const platform = options?.platform ?? detectAccountPlatformFromUrl(options?.pageUrl ?? null);\n      const platformKey = getAccountIsolationStorageKey(platform);\n      const result = await chrome.storage.sync.get([\n        platformKey,\n        StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED,\n      ]);\n\n      if (typeof result[platformKey] === 'boolean') {\n        return result[platformKey] === true;\n      }\n\n      // Backward compatibility: fall back to legacy single flag.\n      if (typeof result[StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED] === 'boolean') {\n        return result[StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED] === true;\n      }\n\n      return false;\n    } catch {\n      return false;\n    }\n  }\n\n  async resolveAccountScope(hints: AccountScopeHints = {}): Promise<AccountScope> {\n    return this.serialize(async () => {\n      const routeUserId =\n        hints.routeUserId ?? (hints.pageUrl ? extractRouteUserIdFromUrl(hints.pageUrl) : null);\n      const normalizedEmail = normalizeEmailAddress(hints.email ?? null);\n      const emailHash = normalizedEmail ? hashString(normalizedEmail) : null;\n      const now = Date.now();\n\n      const map = await this.readProfileMap();\n\n      const keyFromEmail = emailHash ? map.emailAliases[emailHash] : null;\n      const keyFromRoute = routeUserId ? map.routeAliases[routeUserId] : null;\n\n      const accountKey =\n        keyFromEmail ??\n        keyFromRoute ??\n        (emailHash ? `email:${emailHash}` : routeUserId ? `route:${routeUserId}` : 'default');\n\n      let profile = map.profiles[accountKey];\n      if (!profile) {\n        profile = {\n          id: map.nextId,\n          createdAt: now,\n          updatedAt: now,\n          routeUserId: routeUserId ?? undefined,\n          emailHash: emailHash ?? undefined,\n        };\n        map.nextId += 1;\n      } else {\n        profile = {\n          ...profile,\n          updatedAt: now,\n          routeUserId: routeUserId ?? profile.routeUserId,\n          emailHash: emailHash ?? profile.emailHash,\n        };\n      }\n\n      if (routeUserId) {\n        map.routeAliases[routeUserId] = accountKey;\n      }\n      if (emailHash) {\n        map.emailAliases[emailHash] = accountKey;\n      }\n\n      map.profiles[accountKey] = profile;\n      await this.writeProfileMap(map);\n\n      return {\n        accountKey,\n        accountId: profile.id,\n        routeUserId: routeUserId ?? null,\n        emailHash,\n      };\n    });\n  }\n\n  private async readProfileMap(): Promise<AccountProfileMap> {\n    try {\n      const result = await chrome.storage.local.get([StorageKeys.GV_ACCOUNT_PROFILE_MAP]);\n      return parseProfileMap(result[StorageKeys.GV_ACCOUNT_PROFILE_MAP]);\n    } catch {\n      return createDefaultProfileMap();\n    }\n  }\n\n  private async writeProfileMap(map: AccountProfileMap): Promise<void> {\n    await chrome.storage.local.set({\n      [StorageKeys.GV_ACCOUNT_PROFILE_MAP]: map,\n    });\n  }\n}\n\nexport const accountIsolationService = new AccountIsolationService();\n"
  },
  {
    "path": "src/core/services/DOMService.ts",
    "content": "/**\n * DOM manipulation service\n * Centralizes DOM queries and mutations with error handling\n */\nimport { DOMError, ErrorCode } from '../errors/AppError';\nimport type { Nullable, Result } from '../types/common';\nimport { logger } from './LoggerService';\n\nexport interface WaitForElementOptions {\n  timeout?: number;\n  checkInterval?: number;\n  rootElement?: Element;\n}\n\nexport class DOMService {\n  private readonly logger = logger.createChild('DOM');\n  private observers: Map<string, MutationObserver> = new Map();\n\n  /**\n   * Wait for an element to appear in the DOM\n   */\n  async waitForElement(\n    selector: string,\n    options: WaitForElementOptions = {},\n  ): Promise<Result<HTMLElement>> {\n    const { timeout = 5000, rootElement = document.body } = options;\n\n    this.logger.debug(`Waiting for element: ${selector}`);\n\n    return new Promise((resolve) => {\n      // Check if element already exists\n      const existing = rootElement.querySelector(selector);\n      if (existing) {\n        this.logger.debug(`Element found immediately: ${selector}`);\n        resolve({ success: true, data: existing as HTMLElement });\n        return;\n      }\n\n      let timeoutId: number | null = null;\n      const observer = new MutationObserver(() => {\n        const element = rootElement.querySelector(selector);\n        if (element) {\n          this.logger.debug(`Element found: ${selector}`);\n\n          observer.disconnect();\n          if (timeoutId !== null) {\n            clearTimeout(timeoutId);\n          }\n\n          resolve({ success: true, data: element as HTMLElement });\n        }\n      });\n\n      observer.observe(rootElement, {\n        childList: true,\n        subtree: true,\n      });\n\n      // Set timeout\n      if (timeout > 0) {\n        timeoutId = window.setTimeout(() => {\n          this.logger.warn(`Element not found within timeout: ${selector}`);\n\n          observer.disconnect();\n          resolve({\n            success: false,\n            error: new DOMError(ErrorCode.ELEMENT_NOT_FOUND, `Element not found: ${selector}`, {\n              selector,\n              timeout,\n            }),\n          });\n        }, timeout);\n      }\n    });\n  }\n\n  /**\n   * Safe query selector\n   */\n  querySelector<T extends HTMLElement = HTMLElement>(\n    selector: string,\n    root: Element | Document = document,\n  ): Result<T> {\n    try {\n      const element = root.querySelector(selector);\n\n      if (!element) {\n        this.logger.debug(`Element not found: ${selector}`);\n        return {\n          success: false,\n          error: new DOMError(ErrorCode.ELEMENT_NOT_FOUND, `Element not found: ${selector}`, {\n            selector,\n          }),\n        };\n      }\n\n      return { success: true, data: element as T };\n    } catch (error) {\n      this.logger.error(`Query failed: ${selector}`, { error });\n      return {\n        success: false,\n        error: new DOMError(\n          ErrorCode.ELEMENT_QUERY_FAILED,\n          `Query failed: ${selector}`,\n          { selector },\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n\n  /**\n   * Safe query selector all\n   */\n  querySelectorAll<T extends HTMLElement = HTMLElement>(\n    selector: string,\n    root: Element | Document = document,\n  ): Result<T[]> {\n    try {\n      const elements = Array.from(root.querySelectorAll(selector)) as T[];\n\n      this.logger.debug(`Found ${elements.length} elements for: ${selector}`);\n\n      return { success: true, data: elements };\n    } catch (error) {\n      this.logger.error(`Query failed: ${selector}`, { error });\n      return {\n        success: false,\n        error: new DOMError(\n          ErrorCode.ELEMENT_QUERY_FAILED,\n          `Query failed: ${selector}`,\n          { selector },\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n\n  /**\n   * Create element with attributes\n   */\n  createElement<K extends keyof HTMLElementTagNameMap>(\n    tagName: K,\n    attributes?: Partial<Record<string, string>>,\n    children?: (HTMLElement | string)[],\n  ): HTMLElementTagNameMap[K] {\n    const element = document.createElement(tagName);\n\n    if (attributes) {\n      Object.entries(attributes).forEach(([key, value]) => {\n        if (value !== undefined) {\n          element.setAttribute(key, value);\n        }\n      });\n    }\n\n    if (children) {\n      children.forEach((child) => {\n        if (typeof child === 'string') {\n          element.appendChild(document.createTextNode(child));\n        } else {\n          element.appendChild(child);\n        }\n      });\n    }\n\n    return element;\n  }\n\n  /**\n   * Create observer for element changes\n   */\n  observeElement(\n    element: Element,\n    callback: MutationCallback,\n    options: MutationObserverInit = { childList: true, subtree: true },\n  ): string {\n    const observerId = `observer-${Date.now()}-${Math.random()}`;\n\n    const observer = new MutationObserver(callback);\n    observer.observe(element, options);\n\n    this.observers.set(observerId, observer);\n\n    this.logger.debug(`Created observer: ${observerId}`);\n\n    return observerId;\n  }\n\n  /**\n   * Disconnect an observer\n   */\n  disconnectObserver(observerId: string): void {\n    const observer = this.observers.get(observerId);\n\n    if (observer) {\n      observer.disconnect();\n      this.observers.delete(observerId);\n      this.logger.debug(`Disconnected observer: ${observerId}`);\n    }\n  }\n\n  /**\n   * Disconnect all observers\n   */\n  disconnectAllObservers(): void {\n    this.logger.debug(`Disconnecting ${this.observers.size} observers`);\n\n    this.observers.forEach((observer) => observer.disconnect());\n    this.observers.clear();\n  }\n\n  /**\n   * Get computed style value\n   */\n  getComputedStyleValue(element: Element, property: string): string {\n    return getComputedStyle(element).getPropertyValue(property);\n  }\n\n  /**\n   * Check if element matches selector\n   */\n  matches(element: Element, selector: string): boolean {\n    return element.matches(selector);\n  }\n\n  /**\n   * Find closest ancestor matching selector\n   */\n  closest<T extends HTMLElement = HTMLElement>(element: Element, selector: string): Nullable<T> {\n    return element.closest(selector) as Nullable<T>;\n  }\n}\n\n// Export singleton instance\nexport const domService = new DOMService();\n"
  },
  {
    "path": "src/core/services/DataBackupService.ts",
    "content": "/**\n * DataBackupService - Robust multi-layer backup system for preventing data loss\n *\n * This service provides a reliable backup mechanism using localStorage instead of sessionStorage.\n * It implements multiple backup layers with timestamp validation to prevent data loss in scenarios\n * like network disconnections, page refreshes, and browser crashes.\n *\n * Backup Strategy:\n * 1. Primary Backup - Updated on every successful save\n * 2. Emergency Backup - Snapshot before each save operation\n * 3. BeforeUnload Backup - Created when user leaves the page\n *\n * Recovery Priority:\n * 1. Primary backup (most recent)\n * 2. Emergency backup (pre-save snapshot)\n * 3. BeforeUnload backup (page exit snapshot)\n * 4. In-memory data (current session)\n */\n\nexport interface BackupMetadata {\n  timestamp: string;\n  version: string;\n  dataSize: number;\n  itemCount: number;\n}\n\nexport interface BackupData<T> {\n  data: T;\n  metadata: BackupMetadata;\n}\n\nexport class DataBackupService<T = unknown> {\n  private readonly primaryKey: string;\n  private readonly emergencyKey: string;\n  private readonly beforeUnloadKey: string;\n  private readonly metadataKey: string;\n  private readonly maxBackupAge: number = 7 * 24 * 60 * 60 * 1000; // 7 days\n  private beforeUnloadHandler: (() => void) | null = null;\n\n  constructor(\n    private readonly namespace: string,\n    private readonly validateData: (data: T) => boolean = () => true,\n  ) {\n    this.primaryKey = `gvBackup_${namespace}_primary`;\n    this.emergencyKey = `gvBackup_${namespace}_emergency`;\n    this.beforeUnloadKey = `gvBackup_${namespace}_beforeUnload`;\n    this.metadataKey = `gvBackup_${namespace}_metadata`;\n  }\n\n  /**\n   * Create a primary backup (called after successful save)\n   */\n  createPrimaryBackup(data: T): boolean {\n    try {\n      const backup = this.createBackupData(data);\n      localStorage.setItem(this.primaryKey, JSON.stringify(backup));\n      this.updateMetadata('primary', backup.metadata);\n      console.log(`[BackupService:${this.namespace}] Primary backup created`);\n      return true;\n    } catch (error) {\n      console.error(`[BackupService:${this.namespace}] Failed to create primary backup:`, error);\n      return false;\n    }\n  }\n\n  /**\n   * Create an emergency backup (called before save operation)\n   */\n  createEmergencyBackup(data: T): boolean {\n    try {\n      const backup = this.createBackupData(data);\n      localStorage.setItem(this.emergencyKey, JSON.stringify(backup));\n      console.log(`[BackupService:${this.namespace}] Emergency backup created`);\n      return true;\n    } catch (error) {\n      console.error(`[BackupService:${this.namespace}] Failed to create emergency backup:`, error);\n      return false;\n    }\n  }\n\n  /**\n   * Create a beforeUnload backup (called when page is about to close)\n   */\n  private createBeforeUnloadBackup(data: T): boolean {\n    try {\n      const backup = this.createBackupData(data);\n      localStorage.setItem(this.beforeUnloadKey, JSON.stringify(backup));\n      console.log(`[BackupService:${this.namespace}] BeforeUnload backup created`);\n      return true;\n    } catch (error) {\n      console.error(\n        `[BackupService:${this.namespace}] Failed to create beforeUnload backup:`,\n        error,\n      );\n      return false;\n    }\n  }\n\n  /**\n   * Setup automatic beforeUnload backup\n   */\n  setupBeforeUnloadBackup(getDataFn: () => T): void {\n    if (this.beforeUnloadHandler) {\n      window.removeEventListener('beforeunload', this.beforeUnloadHandler);\n    }\n\n    this.beforeUnloadHandler = () => {\n      try {\n        const data = getDataFn();\n        this.createBeforeUnloadBackup(data);\n      } catch (error) {\n        console.error(`[BackupService:${this.namespace}] BeforeUnload handler error:`, error);\n      }\n    };\n\n    window.addEventListener('beforeunload', this.beforeUnloadHandler);\n  }\n\n  /**\n   * Attempt to recover data from backups\n   * Priority: primary > emergency > beforeUnload\n   */\n  recoverFromBackup(): T | null {\n    console.warn(`[BackupService:${this.namespace}] Attempting data recovery...`);\n\n    // Try primary backup first\n    const primary = this.loadBackup(this.primaryKey, 'primary');\n    if (primary) return primary;\n\n    // Try emergency backup\n    const emergency = this.loadBackup(this.emergencyKey, 'emergency');\n    if (emergency) return emergency;\n\n    // Try beforeUnload backup\n    const beforeUnload = this.loadBackup(this.beforeUnloadKey, 'beforeUnload');\n    if (beforeUnload) return beforeUnload;\n\n    console.error(`[BackupService:${this.namespace}] All backup recovery attempts failed`);\n    return null;\n  }\n\n  /**\n   * Load a specific backup\n   */\n  private loadBackup(key: string, type: string): T | null {\n    try {\n      const backupStr = localStorage.getItem(key);\n      if (!backupStr) {\n        console.log(`[BackupService:${this.namespace}] No ${type} backup found`);\n        return null;\n      }\n\n      const backup: BackupData<T> = JSON.parse(backupStr);\n\n      // Validate timestamp\n      if (!this.isBackupValid(backup)) {\n        console.warn(`[BackupService:${this.namespace}] ${type} backup is too old or invalid`);\n        return null;\n      }\n\n      // Validate data structure\n      if (!this.validateData(backup.data)) {\n        console.warn(`[BackupService:${this.namespace}] ${type} backup data validation failed`);\n        return null;\n      }\n\n      console.log(\n        `[BackupService:${this.namespace}] Successfully loaded ${type} backup from ${backup.metadata.timestamp}`,\n      );\n      return backup.data;\n    } catch (error) {\n      console.error(`[BackupService:${this.namespace}] Failed to load ${type} backup:`, error);\n      return null;\n    }\n  }\n\n  /**\n   * Create backup data with metadata\n   */\n  private createBackupData(data: T): BackupData<T> {\n    const dataStr = JSON.stringify(data);\n    const itemCount = this.getItemCount(data);\n\n    return {\n      data,\n      metadata: {\n        timestamp: new Date().toISOString(),\n        version: '1.0',\n        dataSize: dataStr.length,\n        itemCount,\n      },\n    };\n  }\n\n  /**\n   * Check if backup is within valid time range\n   */\n  private isBackupValid(backup: BackupData<T>): boolean {\n    try {\n      const backupTime = new Date(backup.metadata.timestamp).getTime();\n      const age = Date.now() - backupTime;\n\n      if (age < 0) {\n        console.warn(`[BackupService:${this.namespace}] Backup has future timestamp`);\n        return false;\n      }\n\n      if (age > this.maxBackupAge) {\n        console.warn(\n          `[BackupService:${this.namespace}] Backup is too old: ${Math.floor(age / (24 * 60 * 60 * 1000))} days`,\n        );\n        return false;\n      }\n\n      return true;\n    } catch {\n      return false;\n    }\n  }\n\n  /**\n   * Get item count from data (for logging)\n   */\n  private getItemCount(data: T): number {\n    try {\n      if (typeof data === 'object' && data !== null) {\n        if ('folders' in data && Array.isArray((data as Record<string, unknown>).folders)) {\n          return ((data as Record<string, unknown>).folders as unknown[]).length;\n        }\n        if (Array.isArray(data)) {\n          return data.length;\n        }\n        return Object.keys(data).length;\n      }\n      return 0;\n    } catch {\n      return 0;\n    }\n  }\n\n  /**\n   * Update metadata tracking\n   */\n  private updateMetadata(type: string, metadata: BackupMetadata): void {\n    try {\n      const allMetadata = this.getAllMetadata();\n      allMetadata[type] = metadata;\n      localStorage.setItem(this.metadataKey, JSON.stringify(allMetadata));\n    } catch (error) {\n      console.warn(`[BackupService:${this.namespace}] Failed to update metadata:`, error);\n    }\n  }\n\n  /**\n   * Get all backup metadata\n   */\n  getAllMetadata(): Record<string, BackupMetadata> {\n    try {\n      const metadataStr = localStorage.getItem(this.metadataKey);\n      return metadataStr ? JSON.parse(metadataStr) : {};\n    } catch {\n      return {};\n    }\n  }\n\n  /**\n   * Clear all backups (for testing or cleanup)\n   */\n  clearAllBackups(): void {\n    try {\n      localStorage.removeItem(this.primaryKey);\n      localStorage.removeItem(this.emergencyKey);\n      localStorage.removeItem(this.beforeUnloadKey);\n      localStorage.removeItem(this.metadataKey);\n      console.log(`[BackupService:${this.namespace}] All backups cleared`);\n    } catch (error) {\n      console.error(`[BackupService:${this.namespace}] Failed to clear backups:`, error);\n    }\n  }\n\n  /**\n   * Cleanup - remove event listeners\n   */\n  destroy(): void {\n    if (this.beforeUnloadHandler) {\n      window.removeEventListener('beforeunload', this.beforeUnloadHandler);\n      this.beforeUnloadHandler = null;\n    }\n  }\n}\n"
  },
  {
    "path": "src/core/services/GoogleDriveSyncService.ts",
    "content": "/**\n * Google Drive Sync Service\n *\n * Enterprise-grade service for syncing extension data to Google Drive\n * Uses Chrome Identity API for OAuth2 and Drive REST API v3 for storage\n *\n * Stores folders, prompts, and starred messages as separate files:\n * - gemini-voyager-folders.json\n * - gemini-voyager-prompts.json\n * - gemini-voyager-starred.json\n */\nimport type { FolderData } from '@/core/types/folder';\nimport type {\n  FolderExportPayload,\n  ForkExportPayload,\n  ForkNodesDataSync,\n  PromptExportPayload,\n  PromptItem,\n  StarredExportPayload,\n  StarredMessagesDataSync,\n  SyncAccountScope,\n  SyncMode,\n  SyncPlatform,\n  SyncState,\n} from '@/core/types/sync';\nimport { DEFAULT_SYNC_STATE } from '@/core/types/sync';\nimport { hashString } from '@/core/utils/hash';\nimport { EXTENSION_VERSION } from '@/core/utils/version';\n\nconst FOLDERS_FILE_NAME = 'gemini-voyager-folders.json';\nconst AISTUDIO_FOLDERS_FILE_NAME = 'gemini-voyager-aistudio-folders.json';\nconst PROMPTS_FILE_NAME = 'gemini-voyager-prompts.json';\nconst STARRED_FILE_NAME = 'gemini-voyager-starred.json';\nconst FORKS_FILE_NAME = 'gemini-voyager-forks.json';\nconst BACKUP_FOLDER_NAME = 'Gemini Voyager Data';\nconst DRIVE_API_BASE = 'https://www.googleapis.com/drive/v3';\nconst DRIVE_UPLOAD_BASE = 'https://www.googleapis.com/upload/drive/v3';\n\n// Retry configuration\nconst MAX_RETRIES = 3;\nconst INITIAL_RETRY_DELAY_MS = 1000;\nconst IDENTITY_TOKEN_TTL_SECONDS = 55 * 60;\n\n/**\n * Google Drive Sync Service\n * Handles authentication, upload, and download of sync data as separate files\n */\nexport class GoogleDriveSyncService {\n  private state: SyncState = { ...DEFAULT_SYNC_STATE };\n  private foldersFileId: string | null = null;\n  private aistudioFoldersFileId: string | null = null;\n  private promptsFileId: string | null = null;\n  private starredFileId: string | null = null;\n  private forksFileId: string | null = null;\n  private backupFolderId: string | null = null;\n  private fileIdByName: Record<string, string> = {};\n  private stateChangeCallback: ((state: SyncState) => void) | null = null;\n  private accessToken: string | null = null;\n  private tokenExpiry: number = 0;\n  private stateLoadPromise: Promise<void> | null = null;\n\n  constructor() {\n    this.stateLoadPromise = this.loadState();\n  }\n\n  onStateChange(callback: (state: SyncState) => void): void {\n    this.stateChangeCallback = callback;\n  }\n\n  /**\n   * Ensure state is loaded before returning\n   */\n  async getState(): Promise<SyncState> {\n    if (this.stateLoadPromise) {\n      await this.stateLoadPromise;\n    }\n    return { ...this.state };\n  }\n\n  async setMode(mode: SyncMode): Promise<void> {\n    this.state.mode = mode;\n    await this.saveState();\n    this.notifyStateChange();\n  }\n\n  async authenticate(interactive: boolean = true): Promise<boolean> {\n    try {\n      this.updateState({ isSyncing: true, error: null });\n      const token = await this.getAuthToken(interactive);\n      if (!token) {\n        // If not interactive and no token, just return false silently\n        if (!interactive) {\n          this.updateState({ isAuthenticated: false, isSyncing: false });\n          return false;\n        }\n        throw new Error('Failed to obtain auth token');\n      }\n      this.updateState({ isAuthenticated: true, isSyncing: false });\n      return true;\n    } catch (error) {\n      const errorMessage = error instanceof Error ? error.message : 'Authentication failed';\n      console.error('[GoogleDriveSyncService] Authentication failed:', error);\n      this.updateState({ isAuthenticated: false, isSyncing: false, error: errorMessage });\n      return false;\n    }\n  }\n\n  async signOut(): Promise<void> {\n    try {\n      if (this.accessToken) {\n        await this.removeCachedAuthToken(this.accessToken);\n        await fetch(`https://accounts.google.com/o/oauth2/revoke?token=${this.accessToken}`);\n      }\n    } catch (error) {\n      console.warn('[GoogleDriveSyncService] Sign out warning:', error);\n    }\n    await this.clearToken();\n    this.foldersFileId = null;\n    this.promptsFileId = null;\n    this.starredFileId = null;\n    this.forksFileId = null;\n    this.backupFolderId = null;\n    this.fileIdByName = {};\n    this.updateState({ isAuthenticated: false, lastSyncTime: null, error: null });\n    await this.saveState();\n  }\n\n  /**\n   * Upload folders, prompts, and starred messages as separate files to Google Drive\n   * @param folders Folder data to upload\n   * @param prompts Prompt items (only for Gemini platform)\n   * @param starred Starred messages (only for Gemini platform)\n   * @param interactive Whether to show auth prompt if needed\n   * @param platform Platform to upload for ('gemini' | 'aistudio')\n   */\n  async upload(\n    folders: FolderData,\n    prompts: PromptItem[],\n    starred: StarredMessagesDataSync | null = null,\n    interactive: boolean = true,\n    platform: SyncPlatform = 'gemini',\n    forks: ForkNodesDataSync | null = null,\n    accountScope: SyncAccountScope | null = null,\n  ): Promise<boolean> {\n    try {\n      this.updateState({ isSyncing: true, error: null });\n\n      const token = await this.getAuthToken(interactive);\n      if (!token) {\n        if (!interactive) {\n          console.log(\n            '[GoogleDriveSyncService] Upload skipped: Not authenticated (non-interactive)',\n          );\n          this.updateState({ isSyncing: false, isAuthenticated: false });\n          return false;\n        }\n        throw new Error('Not authenticated');\n      }\n\n      const now = new Date();\n\n      // Create folder payload\n      const folderPayload: FolderExportPayload = {\n        format: 'gemini-voyager.folders.v1',\n        exportedAt: now.toISOString(),\n        version: EXTENSION_VERSION,\n        data: folders,\n      };\n\n      // Create prompt payload\n      const promptPayload: PromptExportPayload = {\n        format: 'gemini-voyager.prompts.v1',\n        exportedAt: now.toISOString(),\n        version: EXTENSION_VERSION,\n        items: prompts,\n      };\n\n      // Upload folders file (platform-specific)\n      const foldersBaseFileName =\n        platform === 'aistudio' ? AISTUDIO_FOLDERS_FILE_NAME : FOLDERS_FILE_NAME;\n      const foldersFileName = this.getFileNameForScope(foldersBaseFileName, accountScope);\n      const foldersType = platform === 'aistudio' ? 'aistudio-folders' : 'folders';\n      const foldersFileIdToUse = await this.ensureFileId(token, foldersFileName, foldersType);\n      await this.uploadFileWithRetry(token, foldersFileIdToUse, folderPayload);\n      console.log(`[GoogleDriveSyncService] ${platform} folders uploaded successfully`);\n\n      // Upload prompts file (shared between Gemini and AI Studio)\n      if (prompts.length > 0) {\n        const promptsFileName = this.getFileNameForScope(PROMPTS_FILE_NAME, accountScope);\n        const promptsFileId = await this.ensureFileId(token, promptsFileName, 'prompts');\n        await this.uploadFileWithRetry(token, promptsFileId, promptPayload);\n        console.log('[GoogleDriveSyncService] Prompts uploaded successfully');\n      }\n\n      // Upload starred messages file (only for Gemini platform)\n      if (platform === 'gemini' && starred) {\n        // Truncate content in starred messages to save storage space\n        const MAX_CONTENT_LENGTH = 60;\n        const truncatedStarred: StarredMessagesDataSync = {\n          messages: Object.fromEntries(\n            Object.entries(starred.messages).map(([convId, messages]) => [\n              convId,\n              messages.map((msg) => ({\n                ...msg,\n                content:\n                  msg.content.length > MAX_CONTENT_LENGTH\n                    ? msg.content.slice(0, MAX_CONTENT_LENGTH) + '...'\n                    : msg.content,\n              })),\n            ]),\n          ),\n        };\n\n        const starredPayload: StarredExportPayload = {\n          format: 'gemini-voyager.starred.v1',\n          exportedAt: now.toISOString(),\n          version: EXTENSION_VERSION,\n          data: truncatedStarred,\n        };\n        const starredFileName = this.getFileNameForScope(STARRED_FILE_NAME, accountScope);\n        const starredFileId = await this.ensureFileId(token, starredFileName, 'starred');\n        await this.uploadFileWithRetry(token, starredFileId, starredPayload);\n        console.log('[GoogleDriveSyncService] Starred messages uploaded successfully');\n      }\n\n      // Upload fork nodes file (only for Gemini platform)\n      if (platform === 'gemini' && forks) {\n        const forksPayload: ForkExportPayload = {\n          format: 'gemini-voyager.forks.v1',\n          exportedAt: now.toISOString(),\n          version: EXTENSION_VERSION,\n          data: forks,\n        };\n        const forksFileName = this.getFileNameForScope(FORKS_FILE_NAME, accountScope);\n        const forksFileId = await this.ensureFileId(token, forksFileName, 'forks');\n        await this.uploadFileWithRetry(token, forksFileId, forksPayload);\n        console.log('[GoogleDriveSyncService] Fork nodes uploaded successfully');\n      }\n\n      const uploadTime = Date.now();\n      // Update platform-specific upload time\n      if (platform === 'aistudio') {\n        this.updateState({ isSyncing: false, lastUploadTimeAIStudio: uploadTime, error: null });\n      } else {\n        this.updateState({ isSyncing: false, lastUploadTime: uploadTime, error: null });\n      }\n      await this.saveState();\n\n      const fileCount = platform === 'gemini' ? (starred ? (forks ? 4 : 3) : 2) : 1;\n      console.log(\n        `[GoogleDriveSyncService] Upload successful - ${fileCount} file(s) for ${platform}`,\n      );\n      return true;\n    } catch (error) {\n      const errorMessage = error instanceof Error ? error.message : 'Upload failed';\n      console.error('[GoogleDriveSyncService] Upload failed:', error);\n      this.updateState({ isSyncing: false, error: errorMessage });\n      return false;\n    }\n  }\n\n  /**\n   * Download folders, prompts, and starred messages from separate files in Google Drive\n   * Returns { folders, prompts, starred } or null if no files exist\n   * @param interactive Whether to show auth prompt if needed\n   * @param platform Platform to download for ('gemini' | 'aistudio')\n   */\n  async download(\n    interactive: boolean = true,\n    platform: SyncPlatform = 'gemini',\n    accountScope: SyncAccountScope | null = null,\n  ): Promise<{\n    folders: FolderExportPayload | null;\n    prompts: PromptExportPayload | null;\n    starred: StarredExportPayload | null;\n    forks: ForkExportPayload | null;\n  } | null> {\n    try {\n      this.updateState({ isSyncing: true, error: null });\n\n      const token = await this.getAuthToken(interactive);\n      if (!token) {\n        if (!interactive) {\n          console.log(\n            '[GoogleDriveSyncService] Download skipped: Not authenticated (non-interactive)',\n          );\n          this.updateState({ isSyncing: false, isAuthenticated: false });\n          return null;\n        }\n        throw new Error('Not authenticated');\n      }\n\n      // Download folders file (platform-specific)\n      const foldersBaseFileName =\n        platform === 'aistudio' ? AISTUDIO_FOLDERS_FILE_NAME : FOLDERS_FILE_NAME;\n      const foldersFileId = await this.findFileForScope(token, foldersBaseFileName, accountScope);\n      let folders: FolderExportPayload | null = null;\n      if (foldersFileId) {\n        folders = await this.downloadFileWithRetry(token, foldersFileId);\n        console.log(`[GoogleDriveSyncService] ${platform} folders downloaded`);\n      }\n\n      // Download prompts file (shared between Gemini and AI Studio)\n      let prompts: PromptExportPayload | null = null;\n      const promptsFileId = await this.findFileForScope(token, PROMPTS_FILE_NAME, accountScope);\n      if (promptsFileId) {\n        prompts = await this.downloadFileWithRetry(token, promptsFileId);\n        console.log('[GoogleDriveSyncService] Prompts downloaded');\n      }\n\n      // Download starred messages file (only for Gemini platform)\n      let starred: StarredExportPayload | null = null;\n      if (platform === 'gemini') {\n        const starredFileId = await this.findFileForScope(token, STARRED_FILE_NAME, accountScope);\n        if (starredFileId) {\n          starred = await this.downloadFileWithRetry(token, starredFileId);\n          console.log('[GoogleDriveSyncService] Starred messages downloaded');\n        }\n      }\n\n      // Download fork nodes file (only for Gemini platform)\n      let forks: ForkExportPayload | null = null;\n      if (platform === 'gemini') {\n        const forksFileId = await this.findFileForScope(token, FORKS_FILE_NAME, accountScope);\n        if (forksFileId) {\n          forks = await this.downloadFileWithRetry(token, forksFileId);\n          console.log('[GoogleDriveSyncService] Fork nodes downloaded');\n        }\n      }\n\n      if (!folders && !prompts && !starred && !forks) {\n        console.log(`[GoogleDriveSyncService] No sync files found for ${platform}`);\n        this.updateState({ isSyncing: false });\n        return null;\n      }\n\n      const syncTime = Date.now();\n      // Update platform-specific sync time\n      if (platform === 'aistudio') {\n        this.updateState({ isSyncing: false, lastSyncTimeAIStudio: syncTime, error: null });\n      } else {\n        this.updateState({ isSyncing: false, lastSyncTime: syncTime, error: null });\n      }\n      await this.saveState();\n\n      return { folders, prompts, starred, forks };\n    } catch (error) {\n      const errorMessage = error instanceof Error ? error.message : 'Download failed';\n      console.error('[GoogleDriveSyncService] Download failed:', error);\n      this.updateState({ isSyncing: false, error: errorMessage });\n      return null;\n    }\n  }\n\n  // ============== Private Methods ==============\n\n  private async loadCachedToken(): Promise<void> {\n    try {\n      const result = await chrome.storage.local.get(['gvAccessToken', 'gvTokenExpiry']);\n      if (result.gvAccessToken && result.gvTokenExpiry && result.gvTokenExpiry > Date.now()) {\n        this.accessToken = result.gvAccessToken;\n        this.tokenExpiry = result.gvTokenExpiry;\n        console.log('[GoogleDriveSyncService] Loaded cached token');\n      }\n    } catch (error) {\n      console.error('[GoogleDriveSyncService] Failed to load cached token:', error);\n    }\n  }\n\n  private async saveToken(token: string, expiresIn: number): Promise<void> {\n    this.accessToken = token;\n    this.tokenExpiry = Date.now() + expiresIn * 1000 - 60000;\n    try {\n      await chrome.storage.local.set({ gvAccessToken: token, gvTokenExpiry: this.tokenExpiry });\n    } catch (error) {\n      console.error('[GoogleDriveSyncService] Failed to save token:', error);\n    }\n  }\n\n  private async clearToken(): Promise<void> {\n    this.accessToken = null;\n    this.tokenExpiry = 0;\n    try {\n      await chrome.storage.local.remove(['gvAccessToken', 'gvTokenExpiry']);\n    } catch (error) {\n      console.error('[GoogleDriveSyncService] Failed to clear token:', error);\n    }\n  }\n\n  private isUserDeniedAuthError(message: string): boolean {\n    const normalized = message.toLowerCase();\n    return (\n      normalized.includes('did not approve access') ||\n      normalized.includes('user denied') ||\n      normalized.includes('access_denied')\n    );\n  }\n\n  private extractIdentityToken(result: unknown): string | null {\n    if (typeof result === 'string' && result.trim()) {\n      return result;\n    }\n\n    if (typeof result === 'object' && result !== null) {\n      const token = (result as { token?: unknown }).token;\n      if (typeof token === 'string' && token.trim()) {\n        return token;\n      }\n    }\n\n    return null;\n  }\n\n  private async requestIdentityAuthToken(\n    interactive: boolean,\n  ): Promise<{ token: string | null; userDenied: boolean }> {\n    const identity = chrome.identity;\n    if (!identity?.getAuthToken) {\n      return { token: null, userDenied: false };\n    }\n\n    try {\n      const tokenResult = await new Promise<unknown>((resolve, reject) => {\n        identity.getAuthToken({ interactive }, (token) => {\n          if (chrome.runtime.lastError) {\n            reject(new Error(chrome.runtime.lastError.message));\n          } else {\n            resolve(token);\n          }\n        });\n      });\n\n      const token = this.extractIdentityToken(tokenResult);\n      if (!token) {\n        return { token: null, userDenied: false };\n      }\n\n      // getAuthToken does not provide expiry; keep a short TTL and persist for worker restarts.\n      await this.saveToken(token, IDENTITY_TOKEN_TTL_SECONDS);\n      return { token, userDenied: false };\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      const userDenied = this.isUserDeniedAuthError(message);\n      if (!userDenied) {\n        console.warn('[GoogleDriveSyncService] identity.getAuthToken failed:', error);\n      }\n      return { token: null, userDenied };\n    }\n  }\n\n  private async getTokenFromIdentity(\n    interactive: boolean,\n  ): Promise<{ token: string | null; userDenied: boolean }> {\n    if (!chrome.identity?.getAuthToken) {\n      return { token: null, userDenied: false };\n    }\n\n    const nonInteractiveResult = await this.requestIdentityAuthToken(false);\n    if (nonInteractiveResult.token) {\n      return nonInteractiveResult;\n    }\n\n    if (!interactive) {\n      return { token: null, userDenied: false };\n    }\n\n    return this.requestIdentityAuthToken(true);\n  }\n\n  private async removeCachedAuthToken(token: string): Promise<void> {\n    const identity = chrome.identity;\n    if (!identity?.removeCachedAuthToken) {\n      return;\n    }\n\n    await new Promise<void>((resolve) => {\n      identity.removeCachedAuthToken({ token }, () => resolve());\n    });\n  }\n\n  private async getTokenFromLegacyWebAuthFlow(): Promise<string | null> {\n    const manifest = chrome.runtime.getManifest();\n    const clientId = manifest.oauth2?.client_id;\n    const scopes = manifest.oauth2?.scopes?.join(' ');\n\n    if (!clientId || !scopes) {\n      console.error('[GoogleDriveSyncService] Missing oauth2 config');\n      return null;\n    }\n\n    const redirectUrl = chrome.identity.getRedirectURL();\n    console.log('[GoogleDriveSyncService] Auth flow starting with redirectUrl:', redirectUrl);\n    const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');\n    authUrl.searchParams.set('client_id', clientId);\n    authUrl.searchParams.set('redirect_uri', redirectUrl);\n    authUrl.searchParams.set('response_type', 'token');\n    authUrl.searchParams.set('scope', scopes);\n\n    try {\n      const responseUrl = await new Promise<string>((resolve, reject) => {\n        chrome.identity.launchWebAuthFlow(\n          { url: authUrl.toString(), interactive: true },\n          (response) => {\n            if (chrome.runtime.lastError) {\n              reject(new Error(chrome.runtime.lastError.message));\n            } else if (response) {\n              resolve(response);\n            } else {\n              reject(new Error('No response from auth flow'));\n            }\n          },\n        );\n      });\n\n      const url = new URL(responseUrl);\n      const hashParams = new URLSearchParams(url.hash.substring(1));\n      const accessToken = hashParams.get('access_token');\n      const expiresIn = parseInt(hashParams.get('expires_in') || '3600', 10);\n\n      if (accessToken) {\n        await this.saveToken(accessToken, expiresIn);\n        return accessToken;\n      }\n      return null;\n    } catch (error) {\n      console.error('[GoogleDriveSyncService] Auth flow failed:', error);\n      return null;\n    }\n  }\n\n  private async getAuthToken(interactive: boolean): Promise<string | null> {\n    if (this.accessToken && this.tokenExpiry > Date.now()) {\n      return this.accessToken;\n    }\n\n    if (this.accessToken && this.tokenExpiry <= Date.now()) {\n      this.accessToken = null;\n      this.tokenExpiry = 0;\n    }\n\n    await this.loadCachedToken();\n    if (this.accessToken && this.tokenExpiry > Date.now()) {\n      return this.accessToken;\n    }\n\n    const supportsIdentityApi = !!chrome.identity?.getAuthToken;\n    if (supportsIdentityApi) {\n      const identityResult = await this.getTokenFromIdentity(interactive);\n      if (identityResult.token) {\n        return identityResult.token;\n      }\n\n      if (!interactive) {\n        return null;\n      }\n\n      // Fallback: always try launchWebAuthFlow when getAuthToken fails.\n      // Some browsers (Arc) or Chrome versions may show an OAuth error page\n      // during getAuthToken, which looks like \"user denied\" when dismissed,\n      // but launchWebAuthFlow with a registered redirect URI can still succeed.\n      return this.getTokenFromLegacyWebAuthFlow();\n    }\n\n    if (!interactive) {\n      return null;\n    }\n\n    return this.getTokenFromLegacyWebAuthFlow();\n  }\n\n  private async findFile(token: string, fileName: string): Promise<string | null> {\n    const query = encodeURIComponent(`name='${fileName}' and trashed=false`);\n    const url = `${DRIVE_API_BASE}/files?q=${query}&fields=files(id,name)`;\n    const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });\n    if (!response.ok) {\n      throw new Error(`Failed to search files: ${response.status}`);\n    }\n    const result = await response.json();\n    return result.files?.[0]?.id || null;\n  }\n\n  private getFileNameForScope(baseFileName: string, accountScope: SyncAccountScope | null): string {\n    if (!accountScope) return baseFileName;\n\n    const suffix = `acct-${hashString(accountScope.accountKey)}`;\n    const dotIndex = baseFileName.lastIndexOf('.');\n    if (dotIndex <= 0) {\n      return `${baseFileName}.${suffix}`;\n    }\n    return `${baseFileName.slice(0, dotIndex)}.${suffix}${baseFileName.slice(dotIndex)}`;\n  }\n\n  private async findFileForScope(\n    token: string,\n    baseFileName: string,\n    accountScope: SyncAccountScope | null,\n  ): Promise<string | null> {\n    if (!accountScope) {\n      return this.findFile(token, baseFileName);\n    }\n\n    const scopedFileName = this.getFileNameForScope(baseFileName, accountScope);\n    const scopedFileId = await this.findFile(token, scopedFileName);\n    if (scopedFileId) return scopedFileId;\n\n    // Backward compatibility: allow reading legacy shared file before user uploads scoped data.\n    return this.findFile(token, baseFileName);\n  }\n\n  private async ensureFileId(\n    token: string,\n    fileName: string,\n    type: 'folders' | 'aistudio-folders' | 'prompts' | 'starred' | 'forks',\n  ): Promise<string> {\n    // 1. Ensure backup folder exists\n    const folderId = await this.ensureBackupFolder(token);\n\n    // 2. Check if we have a valid cached file ID\n    const currentId = this.fileIdByName[fileName] ?? null;\n\n    if (currentId) {\n      const parents = await this.getFileParents(token, currentId);\n      if (parents) {\n        // File exists\n        if (!parents.includes(folderId)) {\n          // File exists but not in the backup folder, move it\n          console.log(`[GoogleDriveSyncService] Moving ${fileName} to backup folder`);\n          await this.moveFile(token, currentId, folderId, parents);\n        }\n        return currentId;\n      }\n      // If checkFileParents returns null, the file doesn't exist (e.g. deleted externally), proceed to find/create\n    }\n\n    // 3. Search for the file globally (in case it was created before but we lost the ID reference)\n    const existingId = await this.findFile(token, fileName);\n    if (existingId) {\n      // Found existing file\n      this.setFileIdForType(type, existingId);\n      this.fileIdByName[fileName] = existingId;\n\n      // Check if it needs moving\n      const parents = await this.getFileParents(token, existingId);\n      if (parents && !parents.includes(folderId)) {\n        console.log(`[GoogleDriveSyncService] Moving existing ${fileName} to backup folder`);\n        await this.moveFile(token, existingId, folderId, parents);\n      }\n      return existingId;\n    }\n\n    // 4. Create new file in the backup folder\n    console.log(`[GoogleDriveSyncService] Creating new file ${fileName} in backup folder`);\n    const newId = await this.createFile(token, fileName, folderId);\n    this.setFileIdForType(type, newId);\n    this.fileIdByName[fileName] = newId;\n    return newId;\n  }\n\n  private setFileIdForType(\n    type: 'folders' | 'aistudio-folders' | 'prompts' | 'starred' | 'forks',\n    fileId: string,\n  ): void {\n    switch (type) {\n      case 'folders':\n        this.foldersFileId = fileId;\n        break;\n      case 'aistudio-folders':\n        this.aistudioFoldersFileId = fileId;\n        break;\n      case 'prompts':\n        this.promptsFileId = fileId;\n        break;\n      case 'starred':\n        this.starredFileId = fileId;\n        break;\n      case 'forks':\n        this.forksFileId = fileId;\n        break;\n    }\n  }\n\n  private async ensureBackupFolder(token: string): Promise<string> {\n    if (this.backupFolderId) {\n      // Verify it still exists\n      const exists = await this.checkFileExists(token, this.backupFolderId);\n      if (exists) return this.backupFolderId;\n    }\n\n    // Search for folder\n    const query = encodeURIComponent(\n      `name='${BACKUP_FOLDER_NAME}' and mimeType='application/vnd.google-apps.folder' and trashed=false`,\n    );\n    const url = `${DRIVE_API_BASE}/files?q=${query}&fields=files(id)`;\n    const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });\n    if (!response.ok) throw new Error('Failed to search for backup folder');\n\n    const data = await response.json();\n    const existingId = data.files?.[0]?.id;\n\n    if (existingId) {\n      this.backupFolderId = existingId;\n      return existingId;\n    }\n\n    // Create folder\n    const metadata = {\n      name: BACKUP_FOLDER_NAME,\n      mimeType: 'application/vnd.google-apps.folder',\n    };\n    const createResponse = await fetch(`${DRIVE_API_BASE}/files`, {\n      method: 'POST',\n      headers: {\n        Authorization: `Bearer ${token}`,\n        'Content-Type': 'application/json',\n      },\n      body: JSON.stringify(metadata),\n    });\n\n    if (!createResponse.ok) throw new Error('Failed to create backup folder');\n    const folderData = await createResponse.json();\n    this.backupFolderId = folderData.id;\n    console.log('[GoogleDriveSyncService] Created backup folder:', this.backupFolderId);\n    return folderData.id;\n  }\n\n  private async getFileParents(token: string, fileId: string): Promise<string[] | null> {\n    try {\n      // Also check if file is trashed - if so, treat as non-existent\n      const response = await fetch(`${DRIVE_API_BASE}/files/${fileId}?fields=parents,trashed`, {\n        headers: { Authorization: `Bearer ${token}` },\n      });\n      if (response.status === 404) return null;\n      if (!response.ok) return null;\n      const data = await response.json();\n      // If file is in trash, treat as non-existent so we create a new one\n      if (data.trashed) {\n        console.log(`[GoogleDriveSyncService] File ${fileId} is in trash, will create new one`);\n        return null;\n      }\n      return data.parents || [];\n    } catch {\n      return null;\n    }\n  }\n\n  private async moveFile(\n    token: string,\n    fileId: string,\n    targetFolderId: string,\n    currentParents: string[],\n  ): Promise<void> {\n    const previousParents = currentParents.join(',');\n    const url = `${DRIVE_API_BASE}/files/${fileId}?addParents=${targetFolderId}&removeParents=${previousParents}&fields=id,parents`;\n    const response = await fetch(url, {\n      method: 'PATCH',\n      headers: { Authorization: `Bearer ${token}` },\n    });\n    if (!response.ok) {\n      console.error('[GoogleDriveSyncService] Failed to move file:', await response.text());\n      // Don't throw, just log. It's not critical if move fails, as long as we can access the file.\n    }\n  }\n\n  private async checkFileExists(token: string, fileId: string): Promise<boolean> {\n    try {\n      const response = await fetch(`${DRIVE_API_BASE}/files/${fileId}?fields=id`, {\n        headers: { Authorization: `Bearer ${token}` },\n      });\n      return response.ok;\n    } catch {\n      return false;\n    }\n  }\n\n  private async createFile(token: string, fileName: string, parentId?: string): Promise<string> {\n    const metadata: { name: string; mimeType: string; parents?: string[] } = {\n      name: fileName,\n      mimeType: 'application/json',\n    };\n    if (parentId) {\n      metadata.parents = [parentId];\n    }\n\n    const response = await fetch(`${DRIVE_API_BASE}/files`, {\n      method: 'POST',\n      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },\n      body: JSON.stringify(metadata),\n    });\n    if (!response.ok) {\n      throw new Error(`Failed to create file: ${response.status}`);\n    }\n    const result = await response.json();\n    return result.id;\n  }\n\n  private async uploadFileWithRetry(token: string, fileId: string, data: unknown): Promise<void> {\n    let delay = INITIAL_RETRY_DELAY_MS;\n    for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {\n      try {\n        const url = `${DRIVE_UPLOAD_BASE}/files/${fileId}?uploadType=media`;\n        const response = await fetch(url, {\n          method: 'PATCH',\n          headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },\n          body: JSON.stringify(data),\n        });\n        if (!response.ok) {\n          throw new Error(`Upload failed: ${response.status}`);\n        }\n        return;\n      } catch (error) {\n        if (attempt === MAX_RETRIES) throw error;\n        await this.sleep(delay);\n        delay *= 2;\n      }\n    }\n  }\n\n  private async downloadFileWithRetry<T>(token: string, fileId: string): Promise<T | null> {\n    let delay = INITIAL_RETRY_DELAY_MS;\n    for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {\n      try {\n        const url = `${DRIVE_API_BASE}/files/${fileId}?alt=media`;\n        const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });\n        if (!response.ok) {\n          if (response.status === 404) return null;\n          throw new Error(`Download failed: ${response.status}`);\n        }\n        return await response.json();\n      } catch (error) {\n        if (attempt === MAX_RETRIES) throw error;\n        await this.sleep(delay);\n        delay *= 2;\n      }\n    }\n    return null;\n  }\n\n  private async loadState(): Promise<void> {\n    try {\n      const result = await chrome.storage.local.get([\n        'gvSyncMode',\n        'gvLastSyncTime',\n        'gvLastUploadTime',\n        'gvLastSyncTimeAIStudio',\n        'gvLastUploadTimeAIStudio',\n        'gvSyncError',\n      ]);\n      this.state = {\n        mode: (result.gvSyncMode as SyncMode) || 'disabled',\n        lastSyncTime: result.gvLastSyncTime || null,\n        lastUploadTime: result.gvLastUploadTime || null,\n        lastSyncTimeAIStudio: result.gvLastSyncTimeAIStudio || null,\n        lastUploadTimeAIStudio: result.gvLastUploadTimeAIStudio || null,\n        error: result.gvSyncError || null,\n        isSyncing: false,\n        isAuthenticated: false,\n      };\n      const token = await this.getAuthToken(false);\n      this.state.isAuthenticated = !!token;\n    } catch (error) {\n      console.error('[GoogleDriveSyncService] Failed to load state:', error);\n    }\n  }\n\n  private async saveState(): Promise<void> {\n    try {\n      await chrome.storage.local.set({\n        gvSyncMode: this.state.mode,\n        gvLastSyncTime: this.state.lastSyncTime,\n        gvLastUploadTime: this.state.lastUploadTime,\n        gvLastSyncTimeAIStudio: this.state.lastSyncTimeAIStudio,\n        gvLastUploadTimeAIStudio: this.state.lastUploadTimeAIStudio,\n        gvSyncError: this.state.error,\n      });\n    } catch (error) {\n      console.error('[GoogleDriveSyncService] Failed to save state:', error);\n    }\n  }\n\n  private updateState(partial: Partial<SyncState>): void {\n    this.state = { ...this.state, ...partial };\n    this.notifyStateChange();\n  }\n\n  private notifyStateChange(): void {\n    if (this.stateChangeCallback) {\n      this.stateChangeCallback({ ...this.state });\n    }\n  }\n\n  private sleep(ms: number): Promise<void> {\n    return new Promise((resolve) => setTimeout(resolve, ms));\n  }\n}\n\nexport const googleDriveSyncService = new GoogleDriveSyncService();\n"
  },
  {
    "path": "src/core/services/KeyboardShortcutService.ts",
    "content": "/**\n * KeyboardShortcutService - Manages keyboard shortcuts for timeline navigation\n *\n * Design Patterns:\n * - Singleton: Ensures single instance across application\n * - Strategy: Configurable shortcut matching strategies\n * - Observer: Event-based callback system\n *\n * Features:\n * - Configurable shortcuts with modifier keys\n * - Chrome storage integration for persistence\n * - Type-safe action handling\n * - Collision detection with browser shortcuts\n */\nimport { StorageKeys } from '@/core/types/common';\nimport type {\n  KeyboardShortcut,\n  KeyboardShortcutConfig,\n  KeyboardShortcutStorage,\n  ModifierKey,\n  ShortcutAction,\n  ShortcutMatch,\n} from '@/core/types/keyboardShortcut';\nimport { isMac } from '@/core/utils/browser';\n\n/**\n * Default keyboard shortcuts configuration\n * Using vim-style j/k (convenient, no modifiers needed)\n */\nconst DEFAULT_SHORTCUTS: KeyboardShortcutConfig = {\n  previous: {\n    action: 'timeline:previous',\n    modifiers: [],\n    key: 'k',\n  },\n  next: {\n    action: 'timeline:next',\n    modifiers: [],\n    key: 'j',\n  },\n};\n\n/**\n * Callback type for shortcut actions\n */\nexport type ShortcutCallback = (action: ShortcutAction, event: KeyboardEvent) => void;\n\n/**\n * KeyboardShortcutService class\n * Singleton service for managing keyboard shortcuts\n */\nexport class KeyboardShortcutService {\n  private static instance: KeyboardShortcutService | null = null;\n\n  private config: KeyboardShortcutConfig;\n  private enabled: boolean = true;\n  private listeners: Set<ShortcutCallback> = new Set();\n  private keydownHandler: ((e: KeyboardEvent) => void) | null = null;\n  private storageChangeHandler:\n    | ((changes: Record<string, chrome.storage.StorageChange>, areaName: string) => void)\n    | null = null;\n\n  private constructor() {\n    this.config = DEFAULT_SHORTCUTS;\n  }\n\n  /**\n   * Get singleton instance (Factory Pattern)\n   */\n  static getInstance(): KeyboardShortcutService {\n    if (!KeyboardShortcutService.instance) {\n      KeyboardShortcutService.instance = new KeyboardShortcutService();\n    }\n    return KeyboardShortcutService.instance;\n  }\n\n  /**\n   * Initialize service: load config and attach listeners\n   */\n  async init(): Promise<void> {\n    await this.loadConfig();\n    this.attachKeyboardListener();\n    this.attachStorageListener();\n  }\n\n  /**\n   * Load configuration from chrome storage\n   */\n  private async loadConfig(): Promise<void> {\n    try {\n      if (typeof chrome !== 'undefined' && chrome.storage?.sync) {\n        const result = await chrome.storage.sync.get(StorageKeys.TIMELINE_SHORTCUTS);\n        const stored = result[StorageKeys.TIMELINE_SHORTCUTS] as\n          | KeyboardShortcutStorage\n          | undefined;\n\n        if (stored?.shortcuts) {\n          this.config = this.validateConfig(stored.shortcuts)\n            ? stored.shortcuts\n            : DEFAULT_SHORTCUTS;\n          this.enabled = stored.enabled ?? true;\n        }\n      } else {\n        // Fallback to localStorage\n        const stored = localStorage.getItem(StorageKeys.TIMELINE_SHORTCUTS);\n        if (stored) {\n          const parsed = JSON.parse(stored) as KeyboardShortcutStorage;\n          this.config = this.validateConfig(parsed.shortcuts)\n            ? parsed.shortcuts\n            : DEFAULT_SHORTCUTS;\n          this.enabled = parsed.enabled ?? true;\n        }\n      }\n    } catch (error) {\n      console.warn('[KeyboardShortcut] Failed to load config, using defaults:', error);\n      this.config = DEFAULT_SHORTCUTS;\n      this.enabled = true;\n    }\n  }\n\n  /**\n   * Save configuration to chrome storage\n   */\n  async saveConfig(config: KeyboardShortcutConfig, enabled: boolean = this.enabled): Promise<void> {\n    if (!this.validateConfig(config)) {\n      throw new Error('Invalid shortcut configuration');\n    }\n\n    this.config = config;\n    this.enabled = enabled;\n\n    const storage: KeyboardShortcutStorage = {\n      shortcuts: config,\n      enabled,\n    };\n\n    try {\n      if (typeof chrome !== 'undefined' && chrome.storage?.sync) {\n        await chrome.storage.sync.set({ [StorageKeys.TIMELINE_SHORTCUTS]: storage });\n      } else {\n        localStorage.setItem(StorageKeys.TIMELINE_SHORTCUTS, JSON.stringify(storage));\n      }\n    } catch (error) {\n      console.error('[KeyboardShortcut] Failed to save config:', error);\n      throw error;\n    }\n  }\n\n  /**\n   * Validate shortcut configuration\n   */\n  private validateConfig(config: KeyboardShortcutConfig): boolean {\n    try {\n      return !!(\n        config.previous &&\n        config.next &&\n        this.isValidShortcut(config.previous) &&\n        this.isValidShortcut(config.next)\n      );\n    } catch {\n      return false;\n    }\n  }\n\n  /**\n   * Validate individual shortcut\n   */\n  private isValidShortcut(shortcut: KeyboardShortcut): boolean {\n    const validModifiers: ModifierKey[] = ['Alt', 'Ctrl', 'Shift', 'Meta'];\n\n    return (\n      Array.isArray(shortcut.modifiers) &&\n      shortcut.modifiers.every((m) => validModifiers.includes(m)) &&\n      typeof shortcut.key === 'string' &&\n      shortcut.key.length > 0\n    );\n  }\n\n  /**\n   * Attach keyboard event listener\n   */\n  private attachKeyboardListener(): void {\n    if (this.keydownHandler) return;\n\n    this.keydownHandler = (event: KeyboardEvent) => {\n      if (!this.enabled) return;\n\n      // Ignore shortcuts when user is typing in input fields\n      if (this.isTypingInInputField(event)) return;\n\n      const match = this.matchShortcut(event);\n      if (match) {\n        event.preventDefault();\n        event.stopPropagation();\n        this.notifyListeners(match.action, event);\n      }\n    };\n\n    window.addEventListener('keydown', this.keydownHandler, { capture: true });\n  }\n\n  /**\n   * Check if user is typing in an input field\n   * Prevents shortcuts from interfering with text input\n   */\n  private isTypingInInputField(event: KeyboardEvent): boolean {\n    const target = event.target as HTMLElement;\n    if (!target) return false;\n\n    const tagName = target.tagName.toLowerCase();\n    const isEditable = target.isContentEditable;\n    const isInput = tagName === 'input' || tagName === 'textarea' || tagName === 'select';\n\n    return isEditable || isInput;\n  }\n\n  /**\n   * Attach storage change listener for cross-tab sync\n   */\n  private attachStorageListener(): void {\n    if (typeof chrome !== 'undefined' && chrome.storage?.onChanged) {\n      this.storageChangeHandler = (changes, areaName) => {\n        if (areaName !== 'sync') return;\n        if (changes[StorageKeys.TIMELINE_SHORTCUTS]) {\n          const newValue = changes[StorageKeys.TIMELINE_SHORTCUTS].newValue as\n            | KeyboardShortcutStorage\n            | undefined;\n          if (newValue?.shortcuts) {\n            this.config = this.validateConfig(newValue.shortcuts)\n              ? newValue.shortcuts\n              : DEFAULT_SHORTCUTS;\n            this.enabled = newValue.enabled ?? true;\n          }\n        }\n      };\n\n      chrome.storage.onChanged.addListener(this.storageChangeHandler);\n    }\n  }\n\n  /**\n   * Match keyboard event to shortcut (Strategy Pattern)\n   */\n  private matchShortcut(event: KeyboardEvent): ShortcutMatch | null {\n    const shortcuts = [\n      { action: 'timeline:previous' as const, config: this.config.previous },\n      { action: 'timeline:next' as const, config: this.config.next },\n    ];\n\n    // Check if any shortcut matches\n    for (const { action, config } of shortcuts) {\n      if (this.isShortcutPressed(event, config)) {\n        return { action, event };\n      }\n    }\n\n    return null;\n  }\n\n  /**\n   * Check if specific shortcut is pressed\n   */\n  private isShortcutPressed(event: KeyboardEvent, shortcut: KeyboardShortcut): boolean {\n    // Check key match\n    if (event.key !== shortcut.key) return false;\n\n    // Check modifier matches\n    const hasAlt = shortcut.modifiers.includes('Alt');\n    const hasCtrl = shortcut.modifiers.includes('Ctrl');\n    const hasShift = shortcut.modifiers.includes('Shift');\n    const hasMeta = shortcut.modifiers.includes('Meta');\n\n    return (\n      event.altKey === hasAlt &&\n      event.ctrlKey === hasCtrl &&\n      event.shiftKey === hasShift &&\n      event.metaKey === hasMeta\n    );\n  }\n\n  /**\n   * Notify all registered listeners (Observer Pattern)\n   */\n  private notifyListeners(action: ShortcutAction, event: KeyboardEvent): void {\n    this.listeners.forEach((callback) => {\n      try {\n        callback(action, event);\n      } catch (error) {\n        console.error('[KeyboardShortcut] Error in listener callback:', error);\n      }\n    });\n  }\n\n  /**\n   * Register a shortcut callback\n   */\n  on(callback: ShortcutCallback): () => void {\n    this.listeners.add(callback);\n    // Return unsubscribe function\n    return () => this.off(callback);\n  }\n\n  /**\n   * Unregister a shortcut callback\n   */\n  off(callback: ShortcutCallback): void {\n    this.listeners.delete(callback);\n  }\n\n  /**\n   * Get current configuration\n   */\n  getConfig(): { config: KeyboardShortcutConfig; enabled: boolean } {\n    return {\n      config: { ...this.config },\n      enabled: this.enabled,\n    };\n  }\n\n  /**\n   * Reset to default shortcuts\n   */\n  async resetToDefaults(): Promise<void> {\n    await this.saveConfig(DEFAULT_SHORTCUTS, true);\n  }\n\n  /**\n   * Enable/disable shortcuts\n   */\n  async setEnabled(enabled: boolean): Promise<void> {\n    this.enabled = enabled;\n    await this.saveConfig(this.config, enabled);\n  }\n\n  /**\n   * Format shortcut for display (e.g., \"Alt + ↑\" or \"j\")\n   */\n  formatShortcut(shortcut: KeyboardShortcut): string {\n    // Map common keys to symbols for better display\n    const keySymbols: Record<string, string> = {\n      ArrowUp: '↑',\n      ArrowDown: '↓',\n      ArrowLeft: '←',\n      ArrowRight: '→',\n      ' ': 'Space',\n      Enter: '⏎',\n      Tab: '⇥',\n      Backspace: '⌫',\n      Delete: '⌦',\n      Escape: 'Esc',\n    };\n\n    const key = keySymbols[shortcut.key] || shortcut.key;\n\n    if (shortcut.modifiers.length === 0) {\n      return key;\n    }\n\n    // Map modifier keys based on platform\n    const mac = isMac();\n    const modifierSymbols: Record<string, string> = mac\n      ? { Meta: '⌘', Alt: '⌥', Ctrl: '⌃', Shift: '⇧' }\n      : { Meta: 'Win', Alt: 'Alt', Ctrl: 'Ctrl', Shift: 'Shift' };\n\n    const modifiers = shortcut.modifiers.map((m) => modifierSymbols[m] || m);\n    const parts = [...modifiers, key];\n    return parts.join(mac ? '' : ' + ');\n  }\n\n  /**\n   * Cleanup service\n   */\n  destroy(): void {\n    if (this.keydownHandler) {\n      window.removeEventListener('keydown', this.keydownHandler, { capture: true });\n      this.keydownHandler = null;\n    }\n\n    if (this.storageChangeHandler && typeof chrome !== 'undefined' && chrome.storage?.onChanged) {\n      chrome.storage.onChanged.removeListener(this.storageChangeHandler);\n      this.storageChangeHandler = null;\n    }\n\n    this.listeners.clear();\n  }\n}\n\n/**\n * Export singleton instance for convenience\n */\nexport const keyboardShortcutService = KeyboardShortcutService.getInstance();\n"
  },
  {
    "path": "src/core/services/LoggerService.ts",
    "content": "/**\n * Centralized logging service\n * Replaces scattered console.log statements\n */\nimport type { ILogger } from '../types/common';\n\nexport enum LogLevel {\n  DEBUG = 0,\n  INFO = 1,\n  WARN = 2,\n  ERROR = 3,\n  NONE = 4,\n}\n\nexport interface LoggerConfig {\n  level: LogLevel;\n  prefix: string;\n  enableTimestamp: boolean;\n  enableContext: boolean;\n}\n\nexport class LoggerService implements ILogger {\n  private static instance: LoggerService;\n  private config: LoggerConfig;\n\n  private constructor(config: Partial<LoggerConfig> = {}) {\n    this.config = {\n      level:\n        config.level ?? (process.env.NODE_ENV === 'production' ? LogLevel.WARN : LogLevel.DEBUG),\n      prefix: config.prefix ?? '[GeminiVoyager]',\n      enableTimestamp: config.enableTimestamp ?? true,\n      enableContext: config.enableContext ?? true,\n    };\n  }\n\n  static getInstance(config?: Partial<LoggerConfig>): LoggerService {\n    if (!LoggerService.instance) {\n      LoggerService.instance = new LoggerService(config);\n    }\n    return LoggerService.instance;\n  }\n\n  /**\n   * Create a child logger with a specific prefix\n   */\n  createChild(prefix: string): ILogger {\n    return new LoggerService({\n      ...this.config,\n      prefix: `${this.config.prefix}:${prefix}`,\n    });\n  }\n\n  debug(message: string, context?: Record<string, unknown>): void {\n    this.log(LogLevel.DEBUG, message, context);\n  }\n\n  info(message: string, context?: Record<string, unknown>): void {\n    this.log(LogLevel.INFO, message, context);\n  }\n\n  warn(message: string, context?: Record<string, unknown>): void {\n    this.log(LogLevel.WARN, message, context);\n  }\n\n  error(message: string, context?: Record<string, unknown>): void {\n    this.log(LogLevel.ERROR, message, context);\n  }\n\n  private log(level: LogLevel, message: string, context?: Record<string, unknown>): void {\n    if (level < this.config.level) {\n      return;\n    }\n\n    const timestamp = this.config.enableTimestamp ? new Date().toISOString() : '';\n\n    const prefix = this.config.prefix;\n    const levelStr = LogLevel[level];\n\n    const parts = [timestamp, prefix, `[${levelStr}]`, message].filter(Boolean);\n\n    const logMessage = parts.join(' ');\n\n    const logFn = this.getLogFunction(level);\n\n    if (this.config.enableContext && context) {\n      logFn(logMessage, context);\n    } else {\n      logFn(logMessage);\n    }\n  }\n\n  private getLogFunction(level: LogLevel): (...args: unknown[]) => void {\n    switch (level) {\n      case LogLevel.DEBUG:\n        return console.debug.bind(console);\n      case LogLevel.INFO:\n        return console.info.bind(console);\n      case LogLevel.WARN:\n        return console.warn.bind(console);\n      case LogLevel.ERROR:\n        return console.error.bind(console);\n      default:\n        return console.log.bind(console);\n    }\n  }\n\n  setLevel(level: LogLevel): void {\n    this.config.level = level;\n  }\n\n  getLevel(): LogLevel {\n    return this.config.level;\n  }\n}\n\n// Export singleton instance\nexport const logger = LoggerService.getInstance();\n"
  },
  {
    "path": "src/core/services/StorageMonitor.ts",
    "content": "/**\n * StorageMonitor - Monitor browser storage quota and warn users when nearing limits\n *\n * This service monitors storage usage using the Storage API and provides warnings\n * when storage is running low. This helps prevent data loss from quota exceeded errors.\n *\n * Features:\n * - Periodic quota checks (configurable interval)\n * - Warning thresholds (default: 80%, 90%, 95%)\n * - User-friendly notifications with usage details\n * - Manual quota checking\n * - Automatic monitoring with cleanup\n */\n\nexport interface StorageQuotaInfo {\n  usage: number; // Bytes used\n  quota: number; // Total bytes available\n  usagePercent: number; // Usage percentage (0-1)\n  usageMB: number; // Usage in megabytes\n  quotaMB: number; // Quota in megabytes\n}\n\nexport interface StorageMonitorConfig {\n  enabled: boolean;\n  checkIntervalMs: number;\n  warningThresholds: number[]; // Array of percentages (e.g., [0.8, 0.9, 0.95])\n  showNotifications: boolean;\n}\n\ntype NotificationCallback = (message: string, level: 'info' | 'warning' | 'error') => void;\n\nexport class StorageMonitor {\n  private static instance: StorageMonitor | null = null;\n  private config: StorageMonitorConfig;\n  private monitorIntervalId: number | null = null;\n  private lastWarningLevel: number = 0; // Track highest warning level shown\n  private notificationCallback: NotificationCallback | null = null;\n\n  // Default configuration\n  private static readonly DEFAULT_CONFIG: StorageMonitorConfig = {\n    enabled: true,\n    checkIntervalMs: 60000, // Check every minute\n    warningThresholds: [0.8, 0.9, 0.95], // 80%, 90%, 95%\n    showNotifications: true,\n  };\n\n  private constructor(config?: Partial<StorageMonitorConfig>) {\n    this.config = {\n      ...StorageMonitor.DEFAULT_CONFIG,\n      ...config,\n    };\n  }\n\n  /**\n   * Get singleton instance\n   */\n  static getInstance(config?: Partial<StorageMonitorConfig>): StorageMonitor {\n    if (!StorageMonitor.instance) {\n      StorageMonitor.instance = new StorageMonitor(config);\n    }\n    return StorageMonitor.instance;\n  }\n\n  /**\n   * Set custom notification callback\n   */\n  setNotificationCallback(callback: NotificationCallback): void {\n    this.notificationCallback = callback;\n  }\n\n  /**\n   * Check if Storage API is available\n   */\n  static isStorageApiAvailable(): boolean {\n    return (\n      typeof navigator !== 'undefined' &&\n      'storage' in navigator &&\n      typeof navigator.storage.estimate === 'function'\n    );\n  }\n\n  /**\n   * Get current storage quota information\n   */\n  async checkQuota(): Promise<StorageQuotaInfo | null> {\n    if (!StorageMonitor.isStorageApiAvailable()) {\n      console.warn('[StorageMonitor] Storage API not available');\n      return null;\n    }\n\n    try {\n      const estimate = await navigator.storage.estimate();\n      const usage = estimate.usage || 0;\n      const quota = estimate.quota || 0;\n\n      if (quota === 0) {\n        console.warn('[StorageMonitor] Storage quota is 0, API may not be fully supported');\n        return null;\n      }\n\n      const usagePercent = usage / quota;\n      const usageMB = usage / (1024 * 1024);\n      const quotaMB = quota / (1024 * 1024);\n\n      return {\n        usage,\n        quota,\n        usagePercent,\n        usageMB,\n        quotaMB,\n      };\n    } catch (error) {\n      console.error('[StorageMonitor] Failed to check quota:', error);\n      return null;\n    }\n  }\n\n  /**\n   * Check quota and show warnings if needed\n   */\n  async checkAndWarn(): Promise<StorageQuotaInfo | null> {\n    const info = await this.checkQuota();\n    if (!info) return null;\n\n    // Find the highest threshold exceeded\n    const exceededThresholds = this.config.warningThresholds.filter(\n      (threshold) => info.usagePercent >= threshold,\n    );\n\n    if (exceededThresholds.length === 0) {\n      // Usage is below all thresholds, reset warning level\n      this.lastWarningLevel = 0;\n      return info;\n    }\n\n    const highestThreshold = Math.max(...exceededThresholds);\n\n    // Only show notification if this is a new or higher warning level\n    if (highestThreshold > this.lastWarningLevel) {\n      this.lastWarningLevel = highestThreshold;\n\n      const message = this.formatWarningMessage(info);\n      const level = this.getWarningLevel(info.usagePercent);\n\n      console.warn(`[StorageMonitor] ${message}`);\n\n      if (this.config.showNotifications) {\n        this.showNotification(message, level);\n      }\n    }\n\n    return info;\n  }\n\n  /**\n   * Format warning message\n   */\n  private formatWarningMessage(info: StorageQuotaInfo): string {\n    const usagePercent = Math.round(info.usagePercent * 100);\n    const usageMB = info.usageMB.toFixed(2);\n    const quotaMB = info.quotaMB.toFixed(2);\n\n    return (\n      `Storage usage is ${usagePercent}% (${usageMB} MB / ${quotaMB} MB). ` +\n      `Consider exporting and cleaning old data to free up space.`\n    );\n  }\n\n  /**\n   * Get warning level based on usage percentage\n   */\n  private getWarningLevel(usagePercent: number): 'info' | 'warning' | 'error' {\n    if (usagePercent >= 0.95) return 'error';\n    if (usagePercent >= 0.9) return 'warning';\n    return 'info';\n  }\n\n  /**\n   * Show notification (uses callback if set, otherwise uses default)\n   */\n  private showNotification(message: string, level: 'info' | 'warning' | 'error'): void {\n    if (this.notificationCallback) {\n      this.notificationCallback(message, level);\n      return;\n    }\n\n    // Default notification implementation\n    this.showDefaultNotification(message, level);\n  }\n\n  /**\n   * Default notification implementation (DOM-based)\n   */\n  private showDefaultNotification(message: string, level: 'info' | 'warning' | 'error'): void {\n    try {\n      const notification = document.createElement('div');\n      notification.className = `gv-notification gv-notification-${level}`;\n      notification.textContent = `[Gemini Voyager] ${message}`;\n\n      // Color based on level\n      const colors = {\n        info: '#2196F3',\n        warning: '#FF9800',\n        error: '#F44336',\n      };\n\n      const style = notification.style;\n      style.position = 'fixed';\n      style.top = '20px';\n      style.right = '20px';\n      style.padding = '12px 20px';\n      style.background = colors[level];\n      style.color = 'white';\n      style.borderRadius = '4px';\n      style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';\n      style.zIndex = String(2147483647);\n      style.maxWidth = '400px';\n      style.fontSize = '14px';\n      style.fontFamily = 'system-ui, -apple-system, sans-serif';\n      style.lineHeight = '1.4';\n\n      document.body.appendChild(notification);\n\n      // Auto-remove after timeout (longer for errors)\n      const timeout = level === 'error' ? 10000 : 5000;\n      setTimeout(() => {\n        try {\n          document.body.removeChild(notification);\n        } catch {\n          // Element might already be removed\n        }\n      }, timeout);\n    } catch (error) {\n      console.error('[StorageMonitor] Failed to show notification:', error);\n    }\n  }\n\n  /**\n   * Start automatic monitoring\n   */\n  startMonitoring(): void {\n    if (!this.config.enabled) {\n      console.log('[StorageMonitor] Monitoring is disabled');\n      return;\n    }\n\n    if (!StorageMonitor.isStorageApiAvailable()) {\n      console.warn('[StorageMonitor] Storage API not available, cannot start monitoring');\n      return;\n    }\n\n    if (this.monitorIntervalId !== null) {\n      console.log('[StorageMonitor] Monitoring already started');\n      return;\n    }\n\n    console.log(\n      `[StorageMonitor] Starting automatic monitoring (interval: ${this.config.checkIntervalMs}ms)`,\n    );\n\n    // Check immediately\n    this.checkAndWarn().catch((error) => {\n      console.error('[StorageMonitor] Initial check failed:', error);\n    });\n\n    // Then check periodically\n    this.monitorIntervalId = window.setInterval(() => {\n      this.checkAndWarn().catch((error) => {\n        console.error('[StorageMonitor] Periodic check failed:', error);\n      });\n    }, this.config.checkIntervalMs);\n  }\n\n  /**\n   * Stop automatic monitoring\n   */\n  stopMonitoring(): void {\n    if (this.monitorIntervalId !== null) {\n      console.log('[StorageMonitor] Stopping automatic monitoring');\n      window.clearInterval(this.monitorIntervalId);\n      this.monitorIntervalId = null;\n      this.lastWarningLevel = 0; // Reset warning level\n    }\n  }\n\n  /**\n   * Update configuration\n   */\n  updateConfig(config: Partial<StorageMonitorConfig>): void {\n    const wasEnabled = this.config.enabled;\n    this.config = { ...this.config, ...config };\n\n    // Restart monitoring if enabled state changed\n    if (wasEnabled !== this.config.enabled) {\n      if (this.config.enabled) {\n        this.startMonitoring();\n      } else {\n        this.stopMonitoring();\n      }\n    }\n  }\n\n  /**\n   * Get current configuration\n   */\n  getConfig(): StorageMonitorConfig {\n    return { ...this.config };\n  }\n\n  /**\n   * Get formatted storage info for display\n   */\n  async getFormattedInfo(): Promise<string | null> {\n    const info = await this.checkQuota();\n    if (!info) return null;\n\n    const usagePercent = Math.round(info.usagePercent * 100);\n    const usageMB = info.usageMB.toFixed(2);\n    const quotaMB = info.quotaMB.toFixed(2);\n\n    return `Storage: ${usageMB} MB / ${quotaMB} MB (${usagePercent}%)`;\n  }\n\n  /**\n   * Cleanup - stop monitoring and reset\n   */\n  destroy(): void {\n    this.stopMonitoring();\n    this.notificationCallback = null;\n    this.lastWarningLevel = 0;\n  }\n\n  /**\n   * Reset singleton instance (mainly for testing)\n   */\n  static resetInstance(): void {\n    if (StorageMonitor.instance) {\n      StorageMonitor.instance.destroy();\n      StorageMonitor.instance = null;\n    }\n  }\n}\n\n/**\n * Convenience function to get storage monitor instance\n */\nexport function getStorageMonitor(config?: Partial<StorageMonitorConfig>): StorageMonitor {\n  return StorageMonitor.getInstance(config);\n}\n"
  },
  {
    "path": "src/core/services/StorageService.ts",
    "content": "/**\n * Centralized storage service\n * Replaces direct localStorage and chrome.storage calls\n * Implements Repository pattern with type safety\n */\nimport { ErrorCode, StorageError } from '../errors/AppError';\nimport type { Result, StorageKey } from '../types/common';\nimport {\n  hasValidExtensionContext,\n  isExtensionContextInvalidatedError,\n} from '../utils/extensionContext';\nimport { logger } from './LoggerService';\n\nexport interface IStorageService {\n  get<T>(key: StorageKey): Promise<Result<T>>;\n  set<T>(key: StorageKey, value: T): Promise<Result<void>>;\n  remove(key: StorageKey): Promise<Result<void>>;\n  clear(): Promise<Result<void>>;\n}\n\n/**\n * Base Chrome Storage implementation (DRY: shared logic)\n */\nabstract class BaseChromeStorageService implements IStorageService {\n  protected abstract readonly storageArea: chrome.storage.StorageArea;\n  protected abstract readonly loggerName: string;\n\n  protected get logger(): ReturnType<typeof logger.createChild> {\n    // Lazy getter to avoid abstract property access in constructor\n    return logger.createChild(this.loggerName);\n  }\n\n  private isContextInvalidated(error: unknown): boolean {\n    return isExtensionContextInvalidatedError(error) || !hasValidExtensionContext();\n  }\n\n  async get<T>(key: StorageKey): Promise<Result<T>> {\n    try {\n      this.logger.debug(`Reading key: ${key}`);\n\n      const result = await new Promise<Record<string, T>>((resolve, reject) => {\n        this.storageArea.get([key], (items) => {\n          if (chrome.runtime.lastError) {\n            reject(new Error(chrome.runtime.lastError.message));\n            return;\n          }\n          resolve(items as Record<string, T>);\n        });\n      });\n\n      const value = result[key];\n\n      if (value === undefined) {\n        this.logger.debug(`Key not found: ${key}`);\n        return {\n          success: false,\n          error: new StorageError(ErrorCode.STORAGE_READ_FAILED, `Key not found: ${key}`, { key }),\n        };\n      }\n\n      this.logger.debug(`Successfully read key: ${key}`);\n      return { success: true, data: value };\n    } catch (error) {\n      if (this.isContextInvalidated(error)) {\n        this.logger.debug(`Extension context invalidated while reading key: ${key}`);\n        return {\n          success: false,\n          error: new StorageError(\n            ErrorCode.STORAGE_READ_FAILED,\n            `Extension context invalidated while reading key: ${key}`,\n            { key },\n            error instanceof Error ? error : undefined,\n          ),\n        };\n      }\n      this.logger.error(`Failed to read key: ${key}`, { error });\n      return {\n        success: false,\n        error: new StorageError(\n          ErrorCode.STORAGE_READ_FAILED,\n          `Failed to read key: ${key}`,\n          { key },\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n\n  async set<T>(key: StorageKey, value: T): Promise<Result<void>> {\n    try {\n      this.logger.debug(`Writing key: ${key}`);\n\n      await new Promise<void>((resolve, reject) => {\n        this.storageArea.set({ [key]: value }, () => {\n          if (chrome.runtime.lastError) {\n            reject(new Error(chrome.runtime.lastError.message));\n          } else {\n            resolve();\n          }\n        });\n      });\n\n      this.logger.debug(`Successfully wrote key: ${key}`);\n      return { success: true, data: undefined };\n    } catch (error) {\n      if (this.isContextInvalidated(error)) {\n        this.logger.debug(`Extension context invalidated while writing key: ${key}`);\n        return {\n          success: false,\n          error: new StorageError(\n            ErrorCode.STORAGE_WRITE_FAILED,\n            `Extension context invalidated while writing key: ${key}`,\n            { key, value },\n            error instanceof Error ? error : undefined,\n          ),\n        };\n      }\n      this.logger.error(`Failed to write key: ${key}`, { error });\n      return {\n        success: false,\n        error: new StorageError(\n          ErrorCode.STORAGE_WRITE_FAILED,\n          `Failed to write key: ${key}`,\n          { key, value },\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n\n  async remove(key: StorageKey): Promise<Result<void>> {\n    try {\n      this.logger.debug(`Removing key: ${key}`);\n\n      await new Promise<void>((resolve, reject) => {\n        this.storageArea.remove(key, () => {\n          if (chrome.runtime.lastError) {\n            reject(new Error(chrome.runtime.lastError.message));\n          } else {\n            resolve();\n          }\n        });\n      });\n\n      this.logger.debug(`Successfully removed key: ${key}`);\n      return { success: true, data: undefined };\n    } catch (error) {\n      if (this.isContextInvalidated(error)) {\n        this.logger.debug(`Extension context invalidated while removing key: ${key}`);\n        return {\n          success: false,\n          error: new StorageError(\n            ErrorCode.STORAGE_WRITE_FAILED,\n            `Extension context invalidated while removing key: ${key}`,\n            { key },\n            error instanceof Error ? error : undefined,\n          ),\n        };\n      }\n      this.logger.error(`Failed to remove key: ${key}`, { error });\n      return {\n        success: false,\n        error: new StorageError(\n          ErrorCode.STORAGE_WRITE_FAILED,\n          `Failed to remove key: ${key}`,\n          { key },\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n\n  async clear(): Promise<Result<void>> {\n    try {\n      this.logger.debug('Clearing all storage');\n\n      await new Promise<void>((resolve, reject) => {\n        this.storageArea.clear(() => {\n          if (chrome.runtime.lastError) {\n            reject(new Error(chrome.runtime.lastError.message));\n          } else {\n            resolve();\n          }\n        });\n      });\n\n      this.logger.debug('Successfully cleared storage');\n      return { success: true, data: undefined };\n    } catch (error) {\n      if (this.isContextInvalidated(error)) {\n        this.logger.debug('Extension context invalidated while clearing storage');\n        return {\n          success: false,\n          error: new StorageError(\n            ErrorCode.STORAGE_WRITE_FAILED,\n            'Extension context invalidated while clearing storage',\n            {},\n            error instanceof Error ? error : undefined,\n          ),\n        };\n      }\n      this.logger.error('Failed to clear storage', { error });\n      return {\n        success: false,\n        error: new StorageError(\n          ErrorCode.STORAGE_WRITE_FAILED,\n          'Failed to clear storage',\n          {},\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n}\n\n/**\n * Chrome Storage Sync implementation (100KB quota, cross-device sync)\n * Use for small settings and preferences\n */\nexport class ChromeStorageService extends BaseChromeStorageService {\n  protected readonly storageArea = chrome.storage.sync;\n  protected readonly loggerName = 'ChromeStorage';\n}\n\n/**\n * Chrome Storage Local implementation (5-10MB quota, local only)\n * Use for large data like prompts\n */\nexport class ChromeLocalStorageService extends BaseChromeStorageService {\n  protected readonly storageArea = chrome.storage.local;\n  protected readonly loggerName = 'ChromeLocalStorage';\n}\n\n/**\n * LocalStorage implementation (fallback)\n */\nexport class LocalStorageService implements IStorageService {\n  private readonly logger = logger.createChild('LocalStorage');\n\n  async get<T>(key: StorageKey): Promise<Result<T>> {\n    try {\n      this.logger.debug(`Reading key: ${key}`);\n\n      const raw = localStorage.getItem(key);\n\n      if (raw === null) {\n        this.logger.debug(`Key not found: ${key}`);\n        return {\n          success: false,\n          error: new StorageError(ErrorCode.STORAGE_READ_FAILED, `Key not found: ${key}`, { key }),\n        };\n      }\n\n      const value = JSON.parse(raw) as T;\n\n      this.logger.debug(`Successfully read key: ${key}`);\n      return { success: true, data: value };\n    } catch (error) {\n      this.logger.error(`Failed to read/parse key: ${key}`, { error });\n      return {\n        success: false,\n        error: new StorageError(\n          ErrorCode.STORAGE_PARSE_FAILED,\n          `Failed to read/parse key: ${key}`,\n          { key },\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n\n  async set<T>(key: StorageKey, value: T): Promise<Result<void>> {\n    try {\n      this.logger.debug(`Writing key: ${key}`);\n\n      const raw = JSON.stringify(value);\n      localStorage.setItem(key, raw);\n\n      this.logger.debug(`Successfully wrote key: ${key}`);\n      return { success: true, data: undefined };\n    } catch (error) {\n      this.logger.error(`Failed to write key: ${key}`, { error });\n      return {\n        success: false,\n        error: new StorageError(\n          ErrorCode.STORAGE_WRITE_FAILED,\n          `Failed to write key: ${key}`,\n          { key, value },\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n\n  async remove(key: StorageKey): Promise<Result<void>> {\n    try {\n      this.logger.debug(`Removing key: ${key}`);\n\n      localStorage.removeItem(key);\n\n      this.logger.debug(`Successfully removed key: ${key}`);\n      return { success: true, data: undefined };\n    } catch (error) {\n      this.logger.error(`Failed to remove key: ${key}`, { error });\n      return {\n        success: false,\n        error: new StorageError(\n          ErrorCode.STORAGE_WRITE_FAILED,\n          `Failed to remove key: ${key}`,\n          { key },\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n\n  async clear(): Promise<Result<void>> {\n    try {\n      this.logger.debug('Clearing all storage');\n\n      localStorage.clear();\n\n      this.logger.debug('Successfully cleared storage');\n      return { success: true, data: undefined };\n    } catch (error) {\n      this.logger.error('Failed to clear storage', { error });\n      return {\n        success: false,\n        error: new StorageError(\n          ErrorCode.STORAGE_WRITE_FAILED,\n          'Failed to clear storage',\n          {},\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n}\n\n/**\n * Storage type selection\n */\nexport type StorageType = 'sync' | 'local';\n\n/**\n * Storage factory - automatically selects the best available storage\n */\nexport class StorageFactory {\n  /**\n   * Create a storage service instance\n   * @param type - 'sync' for chrome.storage.sync (100KB, cross-device), 'local' for chrome.storage.local (5-10MB, local only)\n   */\n  static create(type: StorageType = 'sync'): IStorageService {\n    if (typeof chrome !== 'undefined' && chrome.storage) {\n      if (type === 'local' && chrome.storage.local) {\n        logger.info('Using ChromeLocalStorageService');\n        return new ChromeLocalStorageService();\n      }\n\n      if (chrome.storage.sync) {\n        logger.info('Using ChromeStorageService');\n        return new ChromeStorageService();\n      }\n    }\n\n    logger.info('Using LocalStorageService (fallback)');\n    return new LocalStorageService();\n  }\n}\n\n// Export singleton instances\nexport const storageService = StorageFactory.create('sync'); // For folders (small data, cross-device sync)\nexport const promptStorageService = StorageFactory.create('local'); // For prompts (large data, local only)\n"
  },
  {
    "path": "src/core/services/__tests__/AccountIsolationService.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { StorageKeys } from '@/core/types/common';\n\nimport {\n  AccountIsolationService,\n  buildScopedFolderStorageKey,\n  detectAccountContextFromDocument,\n  detectAccountPlatformFromUrl,\n  extractRouteUserIdFromUrl,\n} from '../AccountIsolationService';\n\ntype MockedChrome = typeof chrome;\n\nfunction createChromeMock(syncSeed: Record<string, unknown> = {}): MockedChrome {\n  const localStore: Record<string, unknown> = {};\n  const syncStore: Record<string, unknown> = { ...syncSeed };\n\n  const getFromStore = (\n    store: Record<string, unknown>,\n    keys?: unknown,\n  ): Record<string, unknown> => {\n    if (Array.isArray(keys)) {\n      return Object.fromEntries(keys.map((key) => [String(key), store[String(key)]]));\n    }\n    if (typeof keys === 'string') {\n      return { [keys]: store[keys] };\n    }\n    if (keys && typeof keys === 'object') {\n      const defaults = keys as Record<string, unknown>;\n      return Object.fromEntries(\n        Object.keys(defaults).map((key) => [key, store[key] ?? defaults[key]]),\n      );\n    }\n    return { ...store };\n  };\n\n  return {\n    storage: {\n      local: {\n        get: vi.fn(async (keys?: unknown) => getFromStore(localStore, keys)),\n        set: vi.fn(async (items: Record<string, unknown>) => {\n          Object.assign(localStore, items);\n        }),\n        remove: vi.fn(async (keys: string | string[]) => {\n          const list = Array.isArray(keys) ? keys : [keys];\n          list.forEach((key) => delete localStore[key]);\n        }),\n      },\n      sync: {\n        get: vi.fn(async (keys?: unknown) => getFromStore(syncStore, keys)),\n        set: vi.fn(async (items: Record<string, unknown>) => {\n          Object.assign(syncStore, items);\n        }),\n        remove: vi.fn(async (keys: string | string[]) => {\n          const list = Array.isArray(keys) ? keys : [keys];\n          list.forEach((key) => delete syncStore[key]);\n        }),\n        clear: vi.fn(async () => {\n          Object.keys(syncStore).forEach((key) => delete syncStore[key]);\n        }),\n      },\n      onChanged: {\n        addListener: vi.fn(),\n        removeListener: vi.fn(),\n      },\n    },\n  } as unknown as MockedChrome;\n}\n\ndescribe('AccountIsolationService', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    (globalThis as { chrome: MockedChrome }).chrome = createChromeMock({\n      [StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED]: true,\n    });\n  });\n\n  it('keeps stable account id for the same email', async () => {\n    const service = new AccountIsolationService();\n    const first = await service.resolveAccountScope({\n      pageUrl: 'https://gemini.google.com/u/1/app',\n      routeUserId: '1',\n      email: 'User@example.com',\n    });\n    const second = await service.resolveAccountScope({\n      pageUrl: 'https://gemini.google.com/u/1/app/abc',\n      routeUserId: '1',\n      email: 'user@example.com',\n    });\n\n    expect(first.accountId).toBe(second.accountId);\n    expect(first.accountKey).toBe(second.accountKey);\n    expect(first.accountKey.startsWith('email:')).toBe(true);\n  });\n\n  it('reuses the same account scope when only route id is provided later', async () => {\n    const service = new AccountIsolationService();\n    const withEmail = await service.resolveAccountScope({\n      pageUrl: 'https://gemini.google.com/u/2/app',\n      routeUserId: '2',\n      email: 'route-owner@example.com',\n    });\n    const withRouteOnly = await service.resolveAccountScope({\n      pageUrl: 'https://gemini.google.com/u/2/app',\n      routeUserId: '2',\n    });\n\n    expect(withRouteOnly.accountId).toBe(withEmail.accountId);\n    expect(withRouteOnly.accountKey).toBe(withEmail.accountKey);\n  });\n\n  it('extracts route user id from gemini urls', () => {\n    expect(extractRouteUserIdFromUrl('https://gemini.google.com/u/3/app')).toBe('3');\n    expect(extractRouteUserIdFromUrl('https://gemini.google.com/app')).toBeNull();\n    expect(extractRouteUserIdFromUrl('not-a-url')).toBeNull();\n  });\n\n  it('builds deterministic scoped folder storage keys', () => {\n    const keyA = buildScopedFolderStorageKey('email:abc');\n    const keyB = buildScopedFolderStorageKey('email:abc');\n    const keyC = buildScopedFolderStorageKey('route:1');\n\n    expect(keyA).toBe(keyB);\n    expect(keyA).not.toBe(keyC);\n    expect(keyA.startsWith('gvFolderData:acct:')).toBe(true);\n  });\n\n  it('reads account context from document attributes', () => {\n    document.body.innerHTML = `\n      <button aria-label=\"Google Account: User Name (sample.user@example.com)\"></button>\n    `;\n    const context = detectAccountContextFromDocument('https://gemini.google.com/u/4/app', document);\n    expect(context.routeUserId).toBe('4');\n    expect(context.email).toBe('sample.user@example.com');\n  });\n\n  it('reads AI Studio account email from account switcher structure', () => {\n    document.body.innerHTML = `\n      <div class=\"account-switcher-container\">\n        <button class=\"account-switcher-button\">\n          <span class=\"account-switcher-text\"> z13264500190@gmail.com </span>\n        </button>\n        <alkali-accountswitcher>\n          <div id=\"account-switcher-button\">\n            <div class=\"button-container\" aria-label=\"Google 账号：Nag1 (z13264500190@gmail.com)\"></div>\n          </div>\n        </alkali-accountswitcher>\n      </div>\n    `;\n\n    const context = detectAccountContextFromDocument(\n      'https://aistudio.google.com/prompts',\n      document,\n    );\n    expect(context.routeUserId).toBeNull();\n    expect(context.email).toBe('z13264500190@gmail.com');\n  });\n\n  it('detects account platform from url', () => {\n    expect(detectAccountPlatformFromUrl('https://aistudio.google.com/prompts')).toBe('aistudio');\n    expect(detectAccountPlatformFromUrl('https://aistudio.google.cn/library')).toBe('aistudio');\n    expect(detectAccountPlatformFromUrl('https://gemini.google.com/app')).toBe('gemini');\n    expect(detectAccountPlatformFromUrl(null)).toBe('gemini');\n  });\n\n  it('prefers platform-specific isolation switches over legacy switch', async () => {\n    (globalThis as { chrome: MockedChrome }).chrome = createChromeMock({\n      [StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED]: true,\n      [StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED_GEMINI]: false,\n      [StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED_AISTUDIO]: true,\n    });\n\n    const service = new AccountIsolationService();\n    await expect(service.isIsolationEnabled({ platform: 'gemini' })).resolves.toBe(false);\n    await expect(service.isIsolationEnabled({ platform: 'aistudio' })).resolves.toBe(true);\n  });\n\n  it('falls back to legacy isolation switch when platform-specific switch is missing', async () => {\n    (globalThis as { chrome: MockedChrome }).chrome = createChromeMock({\n      [StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED]: true,\n    });\n\n    const service = new AccountIsolationService();\n    await expect(service.isIsolationEnabled({ platform: 'gemini' })).resolves.toBe(true);\n    await expect(service.isIsolationEnabled({ platform: 'aistudio' })).resolves.toBe(true);\n  });\n});\n"
  },
  {
    "path": "src/core/services/__tests__/GoogleDriveSyncService.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\ntype MockedChrome = typeof chrome;\n\nfunction createChromeMock(): MockedChrome {\n  const localStorageArea = {\n    get: vi.fn().mockResolvedValue({}),\n    set: vi.fn().mockResolvedValue(undefined),\n    remove: vi.fn().mockResolvedValue(undefined),\n  };\n\n  const syncStorageArea = {\n    get: vi.fn().mockResolvedValue({}),\n    set: vi.fn().mockResolvedValue(undefined),\n    remove: vi.fn().mockResolvedValue(undefined),\n    clear: vi.fn().mockResolvedValue(undefined),\n  };\n\n  const runtime = {\n    lastError: null as chrome.runtime.LastError | null,\n    id: 'test-extension-id',\n    getManifest: vi.fn(() => ({\n      oauth2: {\n        client_id: 'test-client-id',\n        scopes: ['https://www.googleapis.com/auth/drive.file'],\n      },\n    })),\n  };\n\n  const identity = {\n    getAuthToken: vi.fn(),\n    removeCachedAuthToken: vi.fn((_details: { token: string }, callback?: () => void) => {\n      callback?.();\n    }),\n    launchWebAuthFlow: vi.fn(),\n    getRedirectURL: vi.fn(() => 'https://test-extension.chromiumapp.org/'),\n  };\n\n  return {\n    storage: {\n      local: localStorageArea,\n      sync: syncStorageArea,\n      onChanged: {\n        addListener: vi.fn(),\n        removeListener: vi.fn(),\n      },\n    },\n    runtime,\n    identity,\n  } as unknown as MockedChrome;\n}\n\nasync function loadServiceClass() {\n  vi.resetModules();\n  const mod = await import('../GoogleDriveSyncService');\n  return mod.GoogleDriveSyncService;\n}\n\ndescribe('GoogleDriveSyncService authentication', () => {\n  const fetchMock = vi.fn();\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    fetchMock.mockResolvedValue({\n      ok: true,\n      json: async () => ({}),\n      text: async () => '',\n    });\n    vi.stubGlobal('fetch', fetchMock);\n  });\n\n  afterEach(() => {\n    vi.unstubAllGlobals();\n  });\n\n  it('uses identity.getAuthToken non-interactive first, then interactive fallback', async () => {\n    const chromeMock = createChromeMock();\n    (globalThis as { chrome: MockedChrome }).chrome = chromeMock;\n\n    const getAuthTokenMock = chromeMock.identity.getAuthToken as unknown as ReturnType<\n      typeof vi.fn\n    >;\n    getAuthTokenMock.mockImplementation(\n      (details: { interactive?: boolean }, callback: (token?: string) => void) => {\n        callback(details.interactive ? 'interactive-token' : undefined);\n      },\n    );\n\n    const GoogleDriveSyncService = await loadServiceClass();\n    const service = new GoogleDriveSyncService();\n    await service.getState();\n    getAuthTokenMock.mockClear();\n\n    const ok = await service.authenticate(true);\n\n    expect(ok).toBe(true);\n    expect(getAuthTokenMock).toHaveBeenCalledTimes(2);\n    expect(getAuthTokenMock).toHaveBeenNthCalledWith(\n      1,\n      { interactive: false },\n      expect.any(Function),\n    );\n    expect(getAuthTokenMock).toHaveBeenNthCalledWith(\n      2,\n      { interactive: true },\n      expect.any(Function),\n    );\n\n    const state = await service.getState();\n    expect(state.isAuthenticated).toBe(true);\n  });\n\n  it('persists identity tokens to local storage for worker restarts', async () => {\n    const chromeMock = createChromeMock();\n    (globalThis as { chrome: MockedChrome }).chrome = chromeMock;\n\n    const getAuthTokenMock = chromeMock.identity.getAuthToken as unknown as ReturnType<\n      typeof vi.fn\n    >;\n    getAuthTokenMock.mockImplementation(\n      (_details: { interactive?: boolean }, callback: (token?: string) => void) => {\n        callback('identity-token');\n      },\n    );\n\n    const GoogleDriveSyncService = await loadServiceClass();\n    const service = new GoogleDriveSyncService();\n    await service.getState();\n\n    const saveLocalTokenMock = chromeMock.storage.local.set as unknown as ReturnType<typeof vi.fn>;\n    expect(saveLocalTokenMock).toHaveBeenCalledWith(\n      expect.objectContaining({ gvAccessToken: 'identity-token' }),\n    );\n\n    const state = await service.getState();\n    expect(state.isAuthenticated).toBe(true);\n  });\n\n  it('removes cached identity token during sign out', async () => {\n    const chromeMock = createChromeMock();\n    (globalThis as { chrome: MockedChrome }).chrome = chromeMock;\n\n    const getAuthTokenMock = chromeMock.identity.getAuthToken as unknown as ReturnType<\n      typeof vi.fn\n    >;\n    getAuthTokenMock.mockImplementation(\n      (_details: { interactive?: boolean }, callback: (token?: string) => void) => {\n        callback('cached-token');\n      },\n    );\n\n    const GoogleDriveSyncService = await loadServiceClass();\n    const service = new GoogleDriveSyncService();\n    await service.getState();\n\n    await service.authenticate(true);\n    await service.signOut();\n\n    const removeCachedAuthTokenMock = chromeMock.identity\n      .removeCachedAuthToken as unknown as ReturnType<typeof vi.fn>;\n    expect(removeCachedAuthTokenMock).toHaveBeenCalledWith(\n      { token: 'cached-token' },\n      expect.any(Function),\n    );\n\n    const removeLocalTokenMock = chromeMock.storage.local.remove as unknown as ReturnType<\n      typeof vi.fn\n    >;\n    expect(removeLocalTokenMock).toHaveBeenCalledWith(['gvAccessToken', 'gvTokenExpiry']);\n\n    const state = await service.getState();\n    expect(state.isAuthenticated).toBe(false);\n  });\n\n  it('reuses cached token before falling back to interactive web auth again', async () => {\n    const chromeMock = createChromeMock();\n    (globalThis as { chrome: MockedChrome }).chrome = chromeMock;\n\n    const runtimeRef = chromeMock.runtime as { lastError: chrome.runtime.LastError | null };\n    const getAuthTokenMock = chromeMock.identity.getAuthToken as unknown as ReturnType<\n      typeof vi.fn\n    >;\n    getAuthTokenMock.mockImplementation(\n      (details: { interactive?: boolean }, callback: (token?: string) => void) => {\n        if (details.interactive) {\n          runtimeRef.lastError = { message: 'OAuth2 service failure' } as chrome.runtime.LastError;\n          callback(undefined);\n          runtimeRef.lastError = null;\n          return;\n        }\n\n        callback(undefined);\n      },\n    );\n\n    const launchWebAuthFlowMock = chromeMock.identity.launchWebAuthFlow as unknown as ReturnType<\n      typeof vi.fn\n    >;\n    launchWebAuthFlowMock.mockImplementationOnce(\n      (_details: { url: string; interactive: boolean }, callback: (response?: string) => void) => {\n        callback(\n          'https://test-extension.chromiumapp.org/#access_token=legacy-token&expires_in=3600',\n        );\n      },\n    );\n    launchWebAuthFlowMock.mockImplementation(\n      (_details: { url: string; interactive: boolean }, callback: (response?: string) => void) => {\n        runtimeRef.lastError = {\n          message: 'The user did not approve access',\n        } as chrome.runtime.LastError;\n        callback(undefined);\n        runtimeRef.lastError = null;\n      },\n    );\n\n    const GoogleDriveSyncService = await loadServiceClass();\n    const service = new GoogleDriveSyncService();\n    await service.getState();\n\n    const firstAuth = await service.authenticate(true);\n    const secondAuth = await service.authenticate(true);\n\n    expect(firstAuth).toBe(true);\n    expect(secondAuth).toBe(true);\n    expect(launchWebAuthFlowMock).toHaveBeenCalledTimes(1);\n  });\n\n  it('falls back to launchWebAuthFlow when identity.getAuthToken is unavailable', async () => {\n    const chromeMock = createChromeMock();\n    const launchWebAuthFlowMock = chromeMock.identity.launchWebAuthFlow as unknown as ReturnType<\n      typeof vi.fn\n    >;\n    launchWebAuthFlowMock.mockImplementation(\n      (_details: { url: string; interactive: boolean }, callback: (response?: string) => void) => {\n        callback(\n          'https://test-extension.chromiumapp.org/#access_token=legacy-token&expires_in=3600',\n        );\n      },\n    );\n\n    const identityWithoutGetAuthToken = {\n      ...chromeMock.identity,\n      getAuthToken: undefined,\n    };\n\n    (globalThis as { chrome: MockedChrome }).chrome = {\n      ...chromeMock,\n      identity: identityWithoutGetAuthToken,\n    } as unknown as MockedChrome;\n\n    const GoogleDriveSyncService = await loadServiceClass();\n    const service = new GoogleDriveSyncService();\n    await service.getState();\n\n    const ok = await service.authenticate(true);\n\n    expect(ok).toBe(true);\n    expect(launchWebAuthFlowMock).toHaveBeenCalledTimes(1);\n\n    const saveLocalTokenMock = chromeMock.storage.local.set as unknown as ReturnType<typeof vi.fn>;\n    expect(saveLocalTokenMock).toHaveBeenCalledWith(\n      expect.objectContaining({ gvAccessToken: 'legacy-token' }),\n    );\n  });\n\n  it('falls back to launchWebAuthFlow when identity.getAuthToken fails interactively', async () => {\n    const chromeMock = createChromeMock();\n    (globalThis as { chrome: MockedChrome }).chrome = chromeMock;\n\n    const getAuthTokenMock = chromeMock.identity.getAuthToken as unknown as ReturnType<\n      typeof vi.fn\n    >;\n    getAuthTokenMock.mockImplementation(\n      (details: { interactive?: boolean }, callback: (token?: string) => void) => {\n        if (details.interactive) {\n          (chromeMock.runtime as { lastError: chrome.runtime.LastError | null }).lastError = {\n            message: 'OAuth2 service failure',\n          } as chrome.runtime.LastError;\n          callback(undefined);\n          (chromeMock.runtime as { lastError: chrome.runtime.LastError | null }).lastError = null;\n          return;\n        }\n        callback(undefined);\n      },\n    );\n\n    const launchWebAuthFlowMock = chromeMock.identity.launchWebAuthFlow as unknown as ReturnType<\n      typeof vi.fn\n    >;\n    launchWebAuthFlowMock.mockImplementation(\n      (_details: { url: string; interactive: boolean }, callback: (response?: string) => void) => {\n        callback(\n          'https://test-extension.chromiumapp.org/#access_token=legacy-fallback-token&expires_in=3600',\n        );\n      },\n    );\n\n    const GoogleDriveSyncService = await loadServiceClass();\n    const service = new GoogleDriveSyncService();\n    await service.getState();\n\n    const ok = await service.authenticate(true);\n\n    expect(ok).toBe(true);\n    expect(launchWebAuthFlowMock).toHaveBeenCalledTimes(1);\n  });\n});\n"
  },
  {
    "path": "src/core/services/__tests__/StorageService.test.ts",
    "content": "/**\n * StorageService unit tests\n * Demonstrates testing best practices for the refactored code\n */\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { StorageKeys } from '../../types/common';\nimport { ChromeStorageService, LocalStorageService } from '../StorageService';\n\ndescribe('LocalStorageService', () => {\n  let service: LocalStorageService;\n\n  beforeEach(() => {\n    service = new LocalStorageService();\n    localStorage.clear();\n    vi.clearAllMocks();\n  });\n\n  describe('get', () => {\n    it('should return data when key exists', async () => {\n      const testData = { value: 'test' };\n      localStorage.setItem(StorageKeys.FOLDER_DATA, JSON.stringify(testData));\n\n      const result = await service.get(StorageKeys.FOLDER_DATA);\n\n      expect(result.success).toBe(true);\n      if (result.success) {\n        expect(result.data).toEqual(testData);\n      }\n    });\n\n    it('should return error when key does not exist', async () => {\n      const result = await service.get(StorageKeys.FOLDER_DATA);\n\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error).toBeInstanceOf(Error);\n        expect(result.error.message).toContain('not found');\n      }\n    });\n\n    it('should handle JSON parse errors', async () => {\n      localStorage.setItem(StorageKeys.FOLDER_DATA, 'invalid json');\n\n      const result = await service.get(StorageKeys.FOLDER_DATA);\n\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error).toBeInstanceOf(Error);\n        expect(result.error.message).toContain('parse');\n      }\n    });\n  });\n\n  describe('set', () => {\n    it('should store data successfully', async () => {\n      const testData = { value: 'test' };\n\n      const result = await service.set(StorageKeys.FOLDER_DATA, testData);\n\n      expect(result.success).toBe(true);\n\n      const stored = localStorage.getItem(StorageKeys.FOLDER_DATA);\n      expect(stored).toBe(JSON.stringify(testData));\n    });\n\n    it('should handle storage errors', async () => {\n      // Mock localStorage to throw error\n      const setItemSpy = vi.spyOn(localStorage, 'setItem').mockImplementation(() => {\n        throw new Error('Storage quota exceeded');\n      });\n\n      const result = await service.set(StorageKeys.FOLDER_DATA, { test: 'data' });\n\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error).toBeInstanceOf(Error);\n        expect(result.error.message).toContain('Failed to write');\n      }\n\n      // Restore the spy\n      setItemSpy.mockRestore();\n    });\n  });\n\n  describe('remove', () => {\n    it('should remove key successfully', async () => {\n      localStorage.setItem(StorageKeys.FOLDER_DATA, 'test');\n\n      const result = await service.remove(StorageKeys.FOLDER_DATA);\n\n      expect(result.success).toBe(true);\n      expect(localStorage.getItem(StorageKeys.FOLDER_DATA)).toBeNull();\n    });\n  });\n\n  describe('clear', () => {\n    it('should clear all storage', async () => {\n      localStorage.setItem(StorageKeys.FOLDER_DATA, 'test1');\n      localStorage.setItem(StorageKeys.CHAT_WIDTH, 'test2');\n\n      const result = await service.clear();\n\n      expect(result.success).toBe(true);\n      expect(localStorage.length).toBe(0);\n    });\n  });\n});\n\ndescribe('ChromeStorageService', () => {\n  let service: ChromeStorageService;\n  let originalRuntimeId: string | undefined;\n\n  const setRuntimeId = (id: string | undefined): void => {\n    Object.defineProperty(chrome.runtime, 'id', {\n      value: id,\n      configurable: true,\n    });\n  };\n\n  beforeEach(() => {\n    service = new ChromeStorageService();\n    originalRuntimeId = chrome.runtime.id;\n    vi.clearAllMocks();\n  });\n\n  afterEach(() => {\n    setRuntimeId(originalRuntimeId);\n  });\n\n  it('returns context-invalidated error details when runtime context is gone', async () => {\n    const getSpy = vi.spyOn(chrome.storage.sync, 'get').mockImplementation(() => {\n      throw new Error('Extension context invalidated.');\n    });\n\n    const result = await service.get(StorageKeys.PROMPT_ITEMS);\n\n    expect(getSpy).toHaveBeenCalledOnce();\n    expect(result.success).toBe(false);\n    if (!result.success) {\n      expect(result.error.message).toContain('Extension context invalidated');\n    }\n  });\n\n  it('treats generic storage failures as context-invalidated when runtime id is missing', async () => {\n    setRuntimeId(undefined);\n    const getSpy = vi.spyOn(chrome.storage.sync, 'get').mockImplementation(() => {\n      throw new TypeError(\"Cannot read properties of undefined (reading 'get')\");\n    });\n\n    const result = await service.get(StorageKeys.PROMPT_ITEMS);\n\n    expect(getSpy).toHaveBeenCalledOnce();\n    expect(result.success).toBe(false);\n    if (!result.success) {\n      expect(result.error.message).toContain('Extension context invalidated');\n    }\n  });\n});\n"
  },
  {
    "path": "src/core/types/common.ts",
    "content": "/**\n * Common types used throughout the application\n * Following strict type safety principles\n */\n\nexport type Result<T, E = Error> = { success: true; data: T } | { success: false; error: E };\n\nexport interface IDisposable {\n  dispose(): void;\n}\n\nexport interface ILogger {\n  debug(message: string, context?: Record<string, unknown>): void;\n  info(message: string, context?: Record<string, unknown>): void;\n  warn(message: string, context?: Record<string, unknown>): void;\n  error(message: string, context?: Record<string, unknown>): void;\n}\n\nexport type Nullable<T> = T | null;\nexport type Optional<T> = T | undefined;\nexport type Maybe<T> = T | null | undefined;\n\n/**\n * Brand type for type-safe IDs\n */\nexport type Brand<K, T> = K & { __brand: T };\n\nexport type ConversationId = Brand<string, 'ConversationId'>;\nexport type FolderId = Brand<string, 'FolderId'>;\nexport type TurnId = Brand<string, 'TurnId'>;\n\n/**\n * Storage keys - centralized for type safety\n */\nexport const StorageKeys = {\n  // Folder system\n  FOLDER_DATA: 'gvFolderData',\n  FOLDER_DATA_AISTUDIO: 'gvFolderDataAIStudio',\n\n  // Timeline\n  TIMELINE_SCROLL_MODE: 'geminiTimelineScrollMode',\n  TIMELINE_HIDE_CONTAINER: 'geminiTimelineHideContainer',\n  TIMELINE_BAR_WIDTH: 'geminiTimelineBarWidth',\n  TIMELINE_DRAGGABLE: 'geminiTimelineDraggable',\n  TIMELINE_POSITION: 'geminiTimelinePosition',\n  TIMELINE_STARRED_MESSAGES: 'geminiTimelineStarredMessages',\n  TIMELINE_SHORTCUTS: 'geminiTimelineShortcuts',\n\n  // UI customization\n  CHAT_WIDTH: 'geminiChatWidth',\n\n  // Prompt Manager\n  PROMPT_ITEMS: 'gvPromptItems',\n  PROMPT_PANEL_LOCKED: 'gvPromptPanelLocked',\n  PROMPT_PANEL_POSITION: 'gvPromptPanelPosition',\n  PROMPT_TRIGGER_POSITION: 'gvPromptTriggerPosition',\n  PROMPT_CUSTOM_WEBSITES: 'gvPromptCustomWebsites',\n  PROMPT_THEME: 'gvPromptTheme',\n\n  // Global settings\n  LANGUAGE: 'language',\n  FORMULA_COPY_FORMAT: 'gvFormulaCopyFormat',\n\n  // Input behavior\n  CTRL_ENTER_SEND: 'gvCtrlEnterSend',\n  INPUT_COLLAPSE_ENABLED: 'gvInputCollapseEnabled',\n  INPUT_COLLAPSE_WHEN_NOT_EMPTY: 'gvInputCollapseWhenNotEmpty',\n\n  // Default Model\n  DEFAULT_MODEL: 'gvDefaultModel',\n\n  // Folder filtering\n  GV_FOLDER_FILTER_USER_ONLY: 'gvFolderFilterUserOnly',\n  GV_ACCOUNT_ISOLATION_ENABLED: 'gvAccountIsolationEnabled',\n  GV_ACCOUNT_ISOLATION_ENABLED_GEMINI: 'gvAccountIsolationEnabledGemini',\n  GV_ACCOUNT_ISOLATION_ENABLED_AISTUDIO: 'gvAccountIsolationEnabledAIStudio',\n  GV_ACCOUNT_PROFILE_MAP: 'gvAccountProfileMap',\n\n  // Sidebar behavior\n  GV_SIDEBAR_AUTO_HIDE: 'gvSidebarAutoHide',\n  GV_SIDEBAR_FULL_HIDE: 'gvSidebarFullHide',\n\n  // Folder spacing\n  GV_FOLDER_SPACING: 'gvFolderSpacing',\n  GV_AISTUDIO_FOLDER_SPACING: 'gvAIStudioFolderSpacing',\n  GV_FOLDER_TREE_INDENT: 'gvFolderTreeIndent',\n\n  // Snow effect (legacy, kept for backward compat migration)\n  GV_SNOW_EFFECT: 'gvSnowEffect',\n\n  // Visual effect (replaces GV_SNOW_EFFECT): 'off' | 'snow' | 'sakura'\n  GV_VISUAL_EFFECT: 'gvVisualEffect',\n\n  // Changelog\n  CHANGELOG_DISMISSED_VERSION: 'gvChangelogDismissedVersion',\n  CHANGELOG_NOTIFY_MODE: 'gvChangelogNotifyMode',\n\n  // Fork nodes\n  FORK_NODES: 'gvForkNodes',\n  FORK_ENABLED: 'gvForkEnabled',\n\n  // AI Studio master toggle\n  GV_AISTUDIO_ENABLED: 'gvAIStudioEnabled',\n\n  // Message timestamps\n  GV_SHOW_MESSAGE_TIMESTAMPS: 'gvShowMessageTimestamps',\n  GV_MESSAGE_TIMESTAMPS: 'gvMessageTimestamps',\n\n  // Popup section order\n  GV_POPUP_SECTION_ORDER: 'gvPopupSectionOrder',\n} as const;\n\nexport type StorageKey = (typeof StorageKeys)[keyof typeof StorageKeys];\n"
  },
  {
    "path": "src/core/types/folder.ts",
    "content": "/**\n * Folder-specific types\n * Extracted from the monolithic FolderManager\n */\nimport type { ConversationId, FolderId } from './common';\n\nexport interface Folder {\n  readonly id: FolderId;\n  name: string;\n  parentId: FolderId | null;\n  isExpanded: boolean;\n  pinned?: boolean;\n  color?: string;\n  sortIndex?: number;\n  createdAt: number;\n  updatedAt: number;\n}\n\nexport interface ConversationReference {\n  readonly conversationId: ConversationId;\n  title: string;\n  url: string;\n  addedAt: number;\n  lastOpenedAt?: number; // Timestamp when the conversation was last opened\n  updatedAt?: number; // Timestamp when the reference was last updated (e.g., renamed)\n  isGem?: boolean;\n  gemId?: string;\n  starred?: boolean; // Whether this conversation is starred in the folder\n  customTitle?: boolean; // Whether title was manually renamed in folder (don't auto-sync from native)\n  sortIndex?: number;\n}\n\nexport interface FolderData {\n  folders: Folder[];\n  folderContents: Record<string, ConversationReference[]>;\n}\n\nexport type DragDataType = 'conversation' | 'folder';\n\nexport interface BaseDragData {\n  type: DragDataType;\n  title: string;\n}\n\nexport interface ConversationDragData extends BaseDragData {\n  type: 'conversation';\n  conversationId: ConversationId;\n  url: string;\n  isGem?: boolean;\n  gemId?: string;\n  sourceFolderId?: FolderId;\n}\n\nexport interface FolderDragData extends BaseDragData {\n  type: 'folder';\n  folderId: FolderId;\n}\n\nexport type DragData = ConversationDragData | FolderDragData;\n\nexport interface GemConfig {\n  readonly id: string;\n  readonly name: string;\n  readonly icon: string;\n}\n"
  },
  {
    "path": "src/core/types/keyboardShortcut.ts",
    "content": "/**\n * Keyboard Shortcut Types\n * Defines types for configurable keyboard shortcuts\n *\n * Supports:\n * - Single key mode (e.g., j/k for vim-style navigation)\n * - Combination key mode (e.g., Alt + Arrow keys)\n * - Fully customizable by user\n */\n\n/**\n * Modifier keys for shortcuts\n */\nexport type ModifierKey = 'Alt' | 'Ctrl' | 'Shift' | 'Meta';\n\n/**\n * Any key can be used for shortcuts\n * We use string to support any keyboard key\n */\nexport type ShortcutKey = string;\n\n/**\n * Shortcut action types\n */\nexport type ShortcutAction = 'timeline:previous' | 'timeline:next';\n\n/**\n * Individual keyboard shortcut configuration\n */\nexport interface KeyboardShortcut {\n  action: ShortcutAction;\n  modifiers: ModifierKey[];\n  key: ShortcutKey;\n}\n\n/**\n * Keyboard event matcher result\n */\nexport interface ShortcutMatch {\n  action: ShortcutAction;\n  event: KeyboardEvent;\n}\n\n/**\n * Complete shortcuts configuration (single set, user-customizable)\n */\nexport interface KeyboardShortcutConfig {\n  previous: KeyboardShortcut;\n  next: KeyboardShortcut;\n}\n\n/**\n * Storage format for shortcuts\n */\nexport interface KeyboardShortcutStorage {\n  shortcuts: KeyboardShortcutConfig;\n  enabled: boolean;\n}\n"
  },
  {
    "path": "src/core/types/sync.ts",
    "content": "/**\n * Sync-related type definitions for Google Drive sync feature\n * Provides type safety for sync state management and data transfer\n */\nimport type { StarredMessagesData } from '@/pages/content/timeline/starredTypes';\n\nimport type { FolderData } from './folder';\n\n/**\n * Sync mode configuration\n * - disabled: Sync feature is off\n * - manual: User must click \"Sync Now\" to trigger sync\n * - auto: Sync happens automatically on startup and periodically\n */\nexport type SyncMode = 'disabled' | 'manual' | 'auto';\n\n/**\n * Platform identifier for sync operations\n * - gemini: Main Gemini website (gemini.google.com)\n * - aistudio: AI Studio website (aistudio.google.com, aistudio.google.cn)\n */\nexport type SyncPlatform = 'gemini' | 'aistudio';\n\nexport interface SyncAccountScope {\n  accountKey: string;\n  accountId: number;\n  routeUserId: string | null;\n}\n\n/**\n * Current sync state for UI display\n */\nexport interface SyncState {\n  /** Current sync mode setting */\n  mode: SyncMode;\n  /** Timestamp of last successful sync/download (null if never synced) - Gemini */\n  lastSyncTime: number | null;\n  /** Timestamp of last successful upload (null if never uploaded) - Gemini */\n  lastUploadTime: number | null;\n  /** Timestamp of last successful sync/download for AI Studio */\n  lastSyncTimeAIStudio: number | null;\n  /** Timestamp of last successful upload for AI Studio */\n  lastUploadTimeAIStudio: number | null;\n  /** Whether a sync operation is currently in progress */\n  isSyncing: boolean;\n  /** Last error message (null if no error) */\n  error: string | null;\n  /** Whether user is authenticated with Google */\n  isAuthenticated: boolean;\n}\n\n/**\n * Prompt item structure (mirrored from prompt manager for type safety)\n */\nexport interface PromptItem {\n  id: string;\n  text: string;\n  tags: string[];\n  createdAt: number;\n  updatedAt?: number;\n}\n\n/**\n * Folder export payload format (matches existing export format)\n */\nexport interface FolderExportPayload {\n  format: 'gemini-voyager.folders.v1';\n  exportedAt: string;\n  version: string;\n  data: FolderData;\n}\n\n/**\n * Prompt export payload format (matches existing export format)\n */\nexport interface PromptExportPayload {\n  format: 'gemini-voyager.prompts.v1';\n  exportedAt: string;\n  version?: string;\n  items: PromptItem[];\n}\n/**\n * Re-export starred message types from their canonical source\n * These are used for Google Drive sync\n */\nexport type {\n  StarredMessage as StarredMessageSync,\n  StarredMessagesData as StarredMessagesDataSync,\n} from '@/pages/content/timeline/starredTypes';\n\n/**\n * Starred messages export payload format\n */\nexport interface StarredExportPayload {\n  format: 'gemini-voyager.starred.v1';\n  exportedAt: string;\n  version?: string;\n  data: StarredMessagesData;\n}\n\n/**\n * Re-export fork node types from their canonical source\n */\nexport type {\n  ForkNode as ForkNodeSync,\n  ForkNodesData as ForkNodesDataSync,\n} from '@/pages/content/fork/forkTypes';\n\n/**\n * Fork nodes export payload format\n */\nexport interface ForkExportPayload {\n  format: 'gemini-voyager.forks.v1';\n  exportedAt: string;\n  version?: string;\n  data: import('@/pages/content/fork/forkTypes').ForkNodesData;\n}\n\n/**\n * Data payload synced to Google Drive\n * Uses embedded export formats for compatibility with import/export feature\n */\nexport interface SyncData {\n  /** Extension version that created this sync data */\n  version: string;\n  /** Format identifier for backward compatibility */\n  format: 'gemini-voyager.sync.v1';\n  /** Folder data in export format */\n  folders: FolderExportPayload;\n  /** Prompt data in export format */\n  prompts: PromptExportPayload;\n  /** Timestamp when this data was synced */\n  syncedAt: number;\n}\n\n/**\n * Storage keys for sync-related settings\n */\nexport const SyncStorageKeys = {\n  MODE: 'gvSyncMode',\n  LAST_SYNC_TIME: 'gvLastSyncTime',\n  SYNC_ERROR: 'gvSyncError',\n} as const;\n\n/**\n * Default sync state for initial load\n */\nexport const DEFAULT_SYNC_STATE: SyncState = {\n  mode: 'disabled',\n  lastSyncTime: null,\n  lastUploadTime: null,\n  lastSyncTimeAIStudio: null,\n  lastUploadTimeAIStudio: null,\n  isSyncing: false,\n  error: null,\n  isAuthenticated: false,\n};\n\n/**\n * Sync message types for background script communication\n */\nexport type SyncMessageType =\n  | 'gv.sync.authenticate'\n  | 'gv.sync.signOut'\n  | 'gv.sync.upload'\n  | 'gv.sync.download'\n  | 'gv.sync.getState'\n  | 'gv.sync.setMode';\n\n/**\n * Message payload for sync operations\n */\nexport interface SyncMessage {\n  type: SyncMessageType;\n  payload?: {\n    mode?: SyncMode;\n    data?: SyncData;\n    interactive?: boolean;\n    platform?: SyncPlatform;\n    accountScope?: SyncAccountScope;\n  };\n}\n\n/**\n * Response from sync operations\n */\nexport interface SyncResponse {\n  ok: boolean;\n  error?: string;\n  state?: SyncState;\n  data?: SyncData;\n}\n"
  },
  {
    "path": "src/core/types/timeline.ts",
    "content": "/**\n * Timeline-specific types\n * Extracted from the monolithic TimelineManager\n */\nimport type { TurnId } from './common';\n\nexport type ScrollMode = 'jump' | 'flow';\n\nexport type SpringProfile = 'ios' | 'snappy' | 'gentle';\n\nexport interface TimelineMarker {\n  readonly id: TurnId;\n  element: HTMLElement;\n  summary: string;\n  n: number; // Normalized position [0, 1]\n  baseN: number; // Original normalized position\n  dotElement: DotElement | null;\n  starred: boolean;\n  cachedOffsetTop?: number; // Cached position to avoid layout thrashing\n}\n\nexport interface DotElement extends HTMLButtonElement {\n  dataset: DOMStringMap & {\n    targetTurnId?: string;\n  };\n}\n\nexport interface TimelineConfig {\n  scrollMode: ScrollMode;\n  hideContainer: boolean;\n  draggable: boolean;\n  position: { top: number; left: number } | null;\n  flowDuration: number;\n  springProfile: SpringProfile;\n  minGap: number;\n  trackPadding: number;\n}\n\nexport interface TimelineUIElements {\n  timelineBar: HTMLElement | null;\n  tooltip: HTMLElement | null;\n  track: HTMLElement | null;\n  trackContent: HTMLElement | null;\n  slider: HTMLElement | null;\n  sliderHandle: HTMLElement | null;\n}\n\nexport interface VisibleRange {\n  start: number;\n  end: number;\n}\n\nexport interface ScrollSyncState {\n  isScrolling: boolean;\n  rafId: number | null;\n  lastActiveChangeTime: number;\n  minActiveChangeInterval: number;\n  pendingActiveId: TurnId | null;\n  activeChangeTimer: number | null;\n}\n\nexport interface TooltipState {\n  element: HTMLElement | null;\n  hideTimer: number | null;\n  showRafId: number | null;\n  hideDelay: number;\n}\n\nexport interface SliderState {\n  dragging: boolean;\n  fadeTimer: number | null;\n  fadeDelay: number;\n  alwaysVisible: boolean;\n  startClientY: number;\n  startTop: number;\n}\n"
  },
  {
    "path": "src/core/utils/__tests__/browser.test.ts",
    "content": "import { describe, expect, it, vi } from 'vitest';\n\nimport { getModifierKey, isMac, isSafari, shouldShowSafariUpdateReminder } from '../browser';\n\ndescribe('Safari Update Reminder Control', () => {\n  describe('shouldShowSafariUpdateReminder', () => {\n    it('returns false when not running on Safari', () => {\n      // Mock non-Safari browser\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(\n        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0',\n      );\n      vi.spyOn(navigator, 'vendor', 'get').mockReturnValue('Google Inc.');\n\n      expect(shouldShowSafariUpdateReminder()).toBe(false);\n    });\n\n    it('returns false by default when running on Safari (feature disabled)', () => {\n      // Mock Safari browser\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(\n        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15',\n      );\n      vi.spyOn(navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.');\n\n      // By default, the environment variable should be false\n      expect(shouldShowSafariUpdateReminder()).toBe(false);\n    });\n\n    it('isSafari correctly detects Safari browser', () => {\n      // Mock Safari browser\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(\n        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15',\n      );\n      vi.spyOn(navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.');\n\n      expect(isSafari()).toBe(true);\n    });\n\n    it('isSafari returns false for Chrome', () => {\n      // Mock Chrome browser\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(\n        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0',\n      );\n      vi.spyOn(navigator, 'vendor', 'get').mockReturnValue('Google Inc.');\n\n      expect(isSafari()).toBe(false);\n    });\n\n    it('isSafari returns false for Firefox', () => {\n      // Mock Firefox browser\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(\n        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0',\n      );\n      vi.spyOn(navigator, 'vendor', 'get').mockReturnValue('');\n\n      expect(isSafari()).toBe(false);\n    });\n  });\n});\n\ndescribe('isMac', () => {\n  it('returns true for macOS platform', () => {\n    vi.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel');\n    expect(isMac()).toBe(true);\n  });\n\n  it('returns true for Mac ARM platform', () => {\n    vi.spyOn(navigator, 'platform', 'get').mockReturnValue('MacARM');\n    expect(isMac()).toBe(true);\n  });\n\n  it('returns false for Windows platform', () => {\n    vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32');\n    vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(\n      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0',\n    );\n    expect(isMac()).toBe(false);\n  });\n\n  it('returns false for Linux platform', () => {\n    vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Linux x86_64');\n    vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(\n      'Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0.0.0',\n    );\n    expect(isMac()).toBe(false);\n  });\n\n  it('falls back to userAgent when platform is empty', () => {\n    vi.spyOn(navigator, 'platform', 'get').mockReturnValue('');\n    vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(\n      'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0',\n    );\n    expect(isMac()).toBe(true);\n  });\n});\n\ndescribe('getModifierKey', () => {\n  it('returns ⌘ on macOS', () => {\n    vi.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel');\n    expect(getModifierKey()).toBe('⌘');\n  });\n\n  it('returns Ctrl on Windows', () => {\n    vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32');\n    vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(\n      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0',\n    );\n    expect(getModifierKey()).toBe('Ctrl');\n  });\n\n  it('returns Ctrl on Linux', () => {\n    vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Linux x86_64');\n    vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(\n      'Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0.0.0',\n    );\n    expect(getModifierKey()).toBe('Ctrl');\n  });\n});\n"
  },
  {
    "path": "src/core/utils/__tests__/concurrency.test.ts",
    "content": "/**\n * Tests for concurrency control utilities\n */\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { AsyncLock, LOCK_KEYS, OperationQueue } from '../concurrency';\n\ndescribe('Concurrency Control', () => {\n  describe('AsyncLock', () => {\n    let lock: AsyncLock;\n\n    beforeEach(() => {\n      lock = new AsyncLock();\n    });\n\n    it('should acquire and release lock', async () => {\n      const release = await lock.acquire('test');\n      expect(lock.isLocked('test')).toBe(true);\n      release();\n      expect(lock.isLocked('test')).toBe(false);\n    });\n\n    it('should prevent concurrent access to same resource', async () => {\n      const results: number[] = [];\n\n      const task = async (id: number) => {\n        const release = await lock.acquire('resource');\n        try {\n          results.push(id);\n          await new Promise((resolve) => setTimeout(resolve, 10));\n          results.push(id);\n        } finally {\n          release();\n        }\n      };\n\n      await Promise.all([task(1), task(2), task(3)]);\n\n      // Results should be paired (1,1,2,2,3,3 or similar)\n      // Not interleaved like (1,2,1,3,2,3)\n      expect(results).toHaveLength(6);\n      for (let i = 0; i < results.length; i += 2) {\n        expect(results[i]).toBe(results[i + 1]);\n      }\n    });\n\n    it('should allow concurrent access to different resources', async () => {\n      const release1 = await lock.acquire('resource1');\n      const release2 = await lock.acquire('resource2');\n\n      expect(lock.isLocked('resource1')).toBe(true);\n      expect(lock.isLocked('resource2')).toBe(true);\n\n      release1();\n      release2();\n    });\n\n    it('should timeout if lock is held too long', async () => {\n      const release = await lock.acquire('test', 100);\n\n      // Don't release, try to acquire with short timeout\n      await expect(lock.acquire('test', 50)).rejects.toThrow('Lock timeout');\n\n      release();\n    });\n\n    it('should track lock duration', async () => {\n      try {\n        // Use fake timers for deterministic timing\n        vi.useFakeTimers();\n\n        const release = await lock.acquire('test');\n\n        // Advance time by exactly 50ms\n        vi.advanceTimersByTime(50);\n\n        const duration = lock.getLockDuration('test');\n        expect(duration).toBeGreaterThanOrEqual(50);\n\n        release();\n      } finally {\n        // Always restore real timers\n        vi.useRealTimers();\n      }\n    });\n\n    it('should return null duration for non-existent lock', () => {\n      expect(lock.getLockDuration('nonexistent')).toBeNull();\n    });\n\n    it('should execute function with lock protection', async () => {\n      let counter = 0;\n\n      const increment = async () => {\n        return await lock.withLock('counter', async () => {\n          const current = counter;\n          await new Promise((resolve) => setTimeout(resolve, 10));\n          counter = current + 1;\n          return counter;\n        });\n      };\n\n      const results = await Promise.all([increment(), increment(), increment()]);\n\n      expect(counter).toBe(3);\n      expect(results).toEqual([1, 2, 3]);\n    });\n\n    it('should release lock even if function throws', async () => {\n      await expect(\n        lock.withLock('test', async () => {\n          throw new Error('Test error');\n        }),\n      ).rejects.toThrow('Test error');\n\n      // Lock should be released\n      expect(lock.isLocked('test')).toBe(false);\n    });\n\n    it('tryAcquire should return null if lock is held', async () => {\n      const release = await lock.acquire('test');\n\n      const tryRelease = lock.tryAcquire('test');\n      expect(tryRelease).toBeNull();\n\n      release();\n\n      const tryRelease2 = lock.tryAcquire('test');\n      expect(tryRelease2).not.toBeNull();\n      tryRelease2!();\n    });\n\n    it('should clear all locks', async () => {\n      await lock.acquire('test1');\n      await lock.acquire('test2');\n\n      expect(lock.isLocked('test1')).toBe(true);\n      expect(lock.isLocked('test2')).toBe(true);\n\n      lock.clearAll();\n\n      expect(lock.isLocked('test1')).toBe(false);\n      expect(lock.isLocked('test2')).toBe(false);\n    });\n  });\n\n  describe('OperationQueue', () => {\n    let queue: OperationQueue;\n\n    beforeEach(() => {\n      queue = new OperationQueue();\n    });\n\n    it('should execute operations in order', async () => {\n      const results: number[] = [];\n\n      const promises = [\n        queue.enqueue(async () => {\n          await new Promise((resolve) => setTimeout(resolve, 30));\n          results.push(1);\n        }),\n        queue.enqueue(async () => {\n          await new Promise((resolve) => setTimeout(resolve, 10));\n          results.push(2);\n        }),\n        queue.enqueue(async () => {\n          results.push(3);\n        }),\n      ];\n\n      await Promise.all(promises);\n\n      expect(results).toEqual([1, 2, 3]);\n    });\n\n    it('should return operation results', async () => {\n      const result1 = queue.enqueue(async () => 'first');\n      const result2 = queue.enqueue(async () => 'second');\n\n      expect(await result1).toBe('first');\n      expect(await result2).toBe('second');\n    });\n\n    it('should handle operation errors', async () => {\n      const result1 = queue.enqueue(async () => 'success');\n      const result2 = queue.enqueue(async () => {\n        throw new Error('Test error');\n      });\n      const result3 = queue.enqueue(async () => 'after error');\n\n      expect(await result1).toBe('success');\n      await expect(result2).rejects.toThrow('Test error');\n      expect(await result3).toBe('after error');\n    });\n\n    it('should track queue length', async () => {\n      expect(queue.length).toBe(0);\n\n      const promise1 = queue.enqueue(async () => {\n        await new Promise((resolve) => setTimeout(resolve, 50));\n      });\n\n      // Queue might be processing already\n      const promise2 = queue.enqueue(async () => {});\n      const promise3 = queue.enqueue(async () => {});\n\n      // Length should be at least 1 (might be 2 if first is still processing)\n      expect(queue.length).toBeGreaterThanOrEqual(0);\n\n      await Promise.all([promise1, promise2, promise3]);\n\n      expect(queue.length).toBe(0);\n    });\n\n    it('should indicate when processing', async () => {\n      const longOperation = queue.enqueue(async () => {\n        await new Promise((resolve) => setTimeout(resolve, 50));\n      });\n\n      // Give it a moment to start processing\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(queue.isProcessing).toBe(true);\n\n      await longOperation;\n\n      expect(queue.isProcessing).toBe(false);\n    });\n\n    it('should clear queue', async () => {\n      queue.enqueue(async () => {\n        await new Promise((resolve) => setTimeout(resolve, 100));\n      });\n      queue.enqueue(async () => {});\n      queue.enqueue(async () => {});\n\n      queue.clear();\n\n      expect(queue.length).toBe(0);\n    });\n\n    it('should handle multiple concurrent enqueues', async () => {\n      const results: number[] = [];\n\n      const operations = Array.from({ length: 10 }, (_, i) =>\n        queue.enqueue(async () => {\n          results.push(i);\n        }),\n      );\n\n      await Promise.all(operations);\n\n      expect(results).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);\n    });\n  });\n\n  describe('LOCK_KEYS', () => {\n    it('should define standard lock keys', () => {\n      expect(LOCK_KEYS.FOLDER_IMPORT).toBeDefined();\n      expect(LOCK_KEYS.FOLDER_EXPORT).toBeDefined();\n      expect(LOCK_KEYS.FOLDER_DATA_WRITE).toBeDefined();\n      expect(LOCK_KEYS.FOLDER_DATA_READ).toBeDefined();\n    });\n\n    it('should have unique lock keys', () => {\n      const keys = Object.values(LOCK_KEYS);\n      const uniqueKeys = new Set(keys);\n      expect(uniqueKeys.size).toBe(keys.length);\n    });\n  });\n\n  describe('Real-world Scenarios', () => {\n    it('should prevent concurrent imports', async () => {\n      const lock = new AsyncLock();\n      const importResults: string[] = [];\n\n      const simulateImport = async (id: string) => {\n        return await lock.withLock(LOCK_KEYS.FOLDER_IMPORT, async () => {\n          importResults.push(`start-${id}`);\n          await new Promise((resolve) => setTimeout(resolve, 20));\n          importResults.push(`end-${id}`);\n          return `imported-${id}`;\n        });\n      };\n\n      const results = await Promise.all([\n        simulateImport('A'),\n        simulateImport('B'),\n        simulateImport('C'),\n      ]);\n\n      // Imports should not interleave\n      expect(importResults).toEqual(['start-A', 'end-A', 'start-B', 'end-B', 'start-C', 'end-C']);\n\n      expect(results).toEqual(['imported-A', 'imported-B', 'imported-C']);\n    });\n\n    it('should handle storage operations sequentially', async () => {\n      const queue = new OperationQueue();\n      let storageValue = 0;\n\n      const writeOperation = async (value: number) => {\n        return await queue.enqueue(async () => {\n          // Simulate read-modify-write\n          const current = storageValue;\n          await new Promise((resolve) => setTimeout(resolve, 10));\n          storageValue = current + value;\n          return storageValue;\n        });\n      };\n\n      const results = await Promise.all([writeOperation(1), writeOperation(2), writeOperation(3)]);\n\n      expect(storageValue).toBe(6); // 1 + 2 + 3\n      expect(results).toEqual([1, 3, 6]);\n    });\n\n    it('should allow read operations while preventing writes', async () => {\n      const lock = new AsyncLock();\n      const data = { value: 100 };\n\n      // Simulate concurrent reads (should be fast)\n      const readPromises = Array.from({ length: 5 }, () =>\n        lock.withLock(LOCK_KEYS.FOLDER_DATA_READ, async () => {\n          await new Promise((resolve) => setTimeout(resolve, 10));\n          return data.value;\n        }),\n      );\n\n      const startTime = Date.now();\n      const results = await Promise.all(readPromises);\n      const duration = Date.now() - startTime;\n\n      // All reads should return same value\n      expect(results).toEqual([100, 100, 100, 100, 100]);\n\n      // Should take at least ~50ms (5 * 10ms) since they're sequential, but allow slight timer inaccuracies (e.g. 49ms)\n      expect(duration).toBeGreaterThanOrEqual(45);\n    });\n  });\n});\n"
  },
  {
    "path": "src/core/utils/__tests__/extensionContext.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { isExtensionContextInvalidatedError } from '../extensionContext';\n\ndescribe('extensionContext utils', () => {\n  it('detects extension context invalidated errors', () => {\n    expect(isExtensionContextInvalidatedError(new Error('Extension context invalidated.'))).toBe(\n      true,\n    );\n    expect(\n      isExtensionContextInvalidatedError({\n        message: 'Uncaught Error: Extension context invalidated',\n      }),\n    ).toBe(true);\n  });\n\n  it('does not match unrelated errors', () => {\n    expect(isExtensionContextInvalidatedError(new Error('Network timeout'))).toBe(false);\n    expect(isExtensionContextInvalidatedError(null)).toBe(false);\n    expect(isExtensionContextInvalidatedError(undefined)).toBe(false);\n  });\n});\n"
  },
  {
    "path": "src/core/utils/__tests__/gemini.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport {\n  hasGeminiEnterpriseDomHints,\n  isGeminiEnterpriseEnvironment,\n  isGeminiEnterpriseUrl,\n} from '../gemini';\n\ndescribe('gemini enterprise detection', () => {\n  it('returns false for non-gemini hosts', () => {\n    expect(isGeminiEnterpriseUrl({ hostname: 'example.com', pathname: '/enterprise' })).toBe(false);\n    expect(\n      isGeminiEnterpriseEnvironment({ hostname: 'aistudio.google.com', pathname: '/enterprise' }),\n    ).toBe(false);\n  });\n\n  it('detects enterprise hints in URL parts', () => {\n    expect(isGeminiEnterpriseUrl({ hostname: 'gemini.google.com', pathname: '/enterprise' })).toBe(\n      true,\n    );\n    expect(\n      isGeminiEnterpriseUrl({ hostname: 'gemini.google.com', search: '?workspace=true' }),\n    ).toBe(true);\n    expect(isGeminiEnterpriseUrl({ hostname: 'gemini.google.com', hash: '#enterprise=acme' })).toBe(\n      true,\n    );\n  });\n\n  it('returns false for standard Gemini app URLs', () => {\n    expect(isGeminiEnterpriseUrl({ hostname: 'gemini.google.com', pathname: '/app' })).toBe(false);\n    expect(isGeminiEnterpriseUrl({ hostname: 'gemini.google.com', pathname: '/gem/abc123' })).toBe(\n      false,\n    );\n  });\n\n  it('treats business.gemini.google as enterprise', () => {\n    expect(isGeminiEnterpriseUrl({ hostname: 'business.gemini.google', pathname: '/home' })).toBe(\n      true,\n    );\n    expect(\n      isGeminiEnterpriseEnvironment({ hostname: 'business.gemini.google', pathname: '/' }),\n    ).toBe(true);\n  });\n\n  it('detects DOM hints on gemini host', () => {\n    const doc = document.implementation.createHTMLDocument('test');\n    doc.documentElement.className = 'gv-enterprise-shell';\n    expect(hasGeminiEnterpriseDomHints(doc)).toBe(true);\n    expect(isGeminiEnterpriseEnvironment({ hostname: 'gemini.google.com' }, doc)).toBe(true);\n  });\n});\n"
  },
  {
    "path": "src/core/utils/__tests__/rtl.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it } from 'vitest';\n\nimport { GV_RTL_CLASS, applyRTLClass, detectRTL, isRTLLanguage } from '../rtl';\n\nconst ORIGINAL_URL = window.location.href;\n\nfunction setTestUrl(pathAndQuery: string): void {\n  window.history.replaceState({}, '', pathAndQuery);\n}\n\ndescribe('rtl utilities', () => {\n  beforeEach(() => {\n    document.documentElement.removeAttribute('dir');\n    document.documentElement.removeAttribute('lang');\n    document.body.removeAttribute('dir');\n    document.body.classList.remove(GV_RTL_CLASS);\n    setTestUrl('/app/test');\n  });\n\n  afterEach(() => {\n    document.documentElement.removeAttribute('dir');\n    document.documentElement.removeAttribute('lang');\n    document.body.removeAttribute('dir');\n    document.body.classList.remove(GV_RTL_CLASS);\n    window.history.replaceState({}, '', ORIGINAL_URL);\n  });\n\n  it('detects rtl language tags', () => {\n    expect(isRTLLanguage('ar')).toBe(true);\n    expect(isRTLLanguage('ar-SA')).toBe(true);\n    expect(isRTLLanguage('EN')).toBe(false);\n  });\n\n  it('detects rtl from Gemini hl URL param', () => {\n    setTestUrl('/app/9416ff6384e9cf46?hl=ar');\n\n    expect(detectRTL()).toBe(true);\n  });\n\n  it('uses URL language hint as a direction signal before extension language', () => {\n    setTestUrl('/app/9416ff6384e9cf46?hl=en');\n\n    expect(detectRTL('ar')).toBe(false);\n  });\n\n  it('falls back to extension language when URL has no language hint', () => {\n    setTestUrl('/app/9416ff6384e9cf46');\n\n    expect(detectRTL('ar')).toBe(true);\n  });\n\n  it('toggles gv-rtl class from applyRTLClass', () => {\n    setTestUrl('/app/9416ff6384e9cf46?hl=ar');\n\n    expect(applyRTLClass()).toBe(true);\n    expect(document.body.classList.contains(GV_RTL_CLASS)).toBe(true);\n  });\n});\n"
  },
  {
    "path": "src/core/utils/__tests__/updateReminder.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { shouldShowUpdateReminderForCurrentVersion } from '../updateReminder';\n\ndescribe('shouldShowUpdateReminderForCurrentVersion', () => {\n  it('returns false when current version is missing', () => {\n    expect(\n      shouldShowUpdateReminderForCurrentVersion({\n        currentVersion: null,\n        isSafariBrowser: false,\n        safariReminderEnabled: false,\n      }),\n    ).toBe(false);\n  });\n\n  it('returns true for Safari when safari reminder flag is enabled', () => {\n    expect(\n      shouldShowUpdateReminderForCurrentVersion({\n        currentVersion: '2.5.0',\n        isSafariBrowser: true,\n        safariReminderEnabled: true,\n      }),\n    ).toBe(true);\n  });\n\n  it('returns false for Safari when safari reminder flag is disabled', () => {\n    expect(\n      shouldShowUpdateReminderForCurrentVersion({\n        currentVersion: '0.9.0',\n        isSafariBrowser: true,\n        safariReminderEnabled: false,\n      }),\n    ).toBe(false);\n  });\n\n  it('keeps non-Safari threshold behavior by default', () => {\n    expect(\n      shouldShowUpdateReminderForCurrentVersion({\n        currentVersion: '1.2.2',\n        isSafariBrowser: false,\n        safariReminderEnabled: false,\n      }),\n    ).toBe(true);\n    expect(\n      shouldShowUpdateReminderForCurrentVersion({\n        currentVersion: '1.2.3',\n        isSafariBrowser: false,\n        safariReminderEnabled: false,\n      }),\n    ).toBe(false);\n  });\n\n  it('returns false for invalid version formats', () => {\n    expect(\n      shouldShowUpdateReminderForCurrentVersion({\n        currentVersion: 'invalid',\n        isSafariBrowser: false,\n        safariReminderEnabled: false,\n      }),\n    ).toBe(false);\n  });\n});\n"
  },
  {
    "path": "src/core/utils/__tests__/version.test.ts",
    "content": "/**\n * Tests for version management utilities\n */\nimport { describe, expect, it } from 'vitest';\n\nimport {\n  EXTENSION_VERSION,\n  FORMAT_VERSIONS,\n  type FormatVersion,\n  applyMigrations,\n  compareVersions,\n  getCompatibilityInfo,\n  isSupportedFormat,\n  isVersionCompatible,\n  parseVersion,\n} from '../version';\n\ndescribe('Version Management', () => {\n  describe('parseVersion', () => {\n    it('should parse standard semantic versions', () => {\n      const result = parseVersion('1.2.3');\n      expect(result).toEqual({\n        major: 1,\n        minor: 2,\n        patch: 3,\n      });\n    });\n\n    it('should parse versions with prerelease', () => {\n      const result = parseVersion('1.2.3-beta.1');\n      expect(result).toEqual({\n        major: 1,\n        minor: 2,\n        patch: 3,\n        prerelease: 'beta.1',\n      });\n    });\n\n    it('should return null for invalid versions', () => {\n      expect(parseVersion('invalid')).toBeNull();\n      expect(parseVersion('1.2')).toBeNull();\n      expect(parseVersion('1.2.3.4')).toBeNull();\n      expect(parseVersion('')).toBeNull();\n    });\n\n    it('should handle zero versions', () => {\n      const result = parseVersion('0.0.0');\n      expect(result).toEqual({\n        major: 0,\n        minor: 0,\n        patch: 0,\n      });\n    });\n  });\n\n  describe('compareVersions', () => {\n    it('should compare major versions', () => {\n      expect(compareVersions('2.0.0', '1.0.0')).toBeGreaterThan(0);\n      expect(compareVersions('1.0.0', '2.0.0')).toBeLessThan(0);\n      expect(compareVersions('1.0.0', '1.0.0')).toBe(0);\n    });\n\n    it('should compare minor versions', () => {\n      expect(compareVersions('1.2.0', '1.1.0')).toBeGreaterThan(0);\n      expect(compareVersions('1.1.0', '1.2.0')).toBeLessThan(0);\n      expect(compareVersions('1.1.0', '1.1.0')).toBe(0);\n    });\n\n    it('should compare patch versions', () => {\n      expect(compareVersions('1.0.2', '1.0.1')).toBeGreaterThan(0);\n      expect(compareVersions('1.0.1', '1.0.2')).toBeLessThan(0);\n      expect(compareVersions('1.0.1', '1.0.1')).toBe(0);\n    });\n\n    it('should handle prerelease versions', () => {\n      expect(compareVersions('1.0.0', '1.0.0-beta')).toBeGreaterThan(0);\n      expect(compareVersions('1.0.0-beta', '1.0.0')).toBeLessThan(0);\n      expect(compareVersions('1.0.0-beta.2', '1.0.0-beta.1')).toBeGreaterThan(0);\n    });\n\n    it('should throw on invalid versions', () => {\n      expect(() => compareVersions('invalid', '1.0.0')).toThrow();\n      expect(() => compareVersions('1.0.0', 'invalid')).toThrow();\n    });\n\n    it('should handle real-world version comparisons', () => {\n      expect(compareVersions('0.7.7', '0.7.2')).toBeGreaterThan(0);\n      expect(compareVersions('0.7.0', '0.7.2')).toBeLessThan(0);\n      expect(compareVersions('1.0.0', '0.9.9')).toBeGreaterThan(0);\n    });\n  });\n\n  describe('isVersionCompatible', () => {\n    it('should accept versions meeting minimum requirement', () => {\n      expect(isVersionCompatible('0.7.0', 'gemini-voyager.folders.v1')).toBe(true);\n      expect(isVersionCompatible('0.7.5', 'gemini-voyager.folders.v1')).toBe(true);\n      expect(isVersionCompatible('1.0.0', 'gemini-voyager.folders.v1')).toBe(true);\n    });\n\n    it('should reject versions below minimum requirement', () => {\n      expect(isVersionCompatible('0.6.9', 'gemini-voyager.folders.v1')).toBe(false);\n      expect(isVersionCompatible('0.5.0', 'gemini-voyager.folders.v1')).toBe(false);\n    });\n\n    it('should reject unknown format versions', () => {\n      expect(isVersionCompatible('1.0.0', 'unknown.format.v99' as FormatVersion)).toBe(false);\n    });\n\n    it('should handle invalid version strings gracefully', () => {\n      expect(isVersionCompatible('invalid', 'gemini-voyager.folders.v1')).toBe(false);\n    });\n  });\n\n  describe('isSupportedFormat', () => {\n    it('should recognize supported formats', () => {\n      expect(isSupportedFormat('gemini-voyager.folders.v1')).toBe(true);\n    });\n\n    it('should reject unsupported formats', () => {\n      expect(isSupportedFormat('unknown.format')).toBe(false);\n      expect(isSupportedFormat('gemini-voyager.folders.v2')).toBe(false);\n      expect(isSupportedFormat('')).toBe(false);\n    });\n  });\n\n  describe('getCompatibilityInfo', () => {\n    it('should return compatible info for valid versions', () => {\n      const info = getCompatibilityInfo('0.7.5', 'gemini-voyager.folders.v1');\n      expect(info.compatible).toBe(true);\n      expect(info.currentVersion).toBe(EXTENSION_VERSION);\n      expect(info.importVersion).toBe('0.7.5');\n      expect(info.formatVersion).toBe('gemini-voyager.folders.v1');\n      expect(info.minRequiredVersion).toBe('0.7.0');\n      expect(info.reason).toBeUndefined();\n    });\n\n    it('should return incompatible info for old versions', () => {\n      const info = getCompatibilityInfo('0.6.0', 'gemini-voyager.folders.v1');\n      expect(info.compatible).toBe(false);\n      expect(info.reason).toContain('below minimum required version');\n    });\n\n    it('should return incompatible info for unsupported formats', () => {\n      const info = getCompatibilityInfo('1.0.0', 'unknown.format');\n      expect(info.compatible).toBe(false);\n      expect(info.reason).toContain('Unsupported format version');\n    });\n\n    it('should handle invalid version strings', () => {\n      const info = getCompatibilityInfo('invalid', 'gemini-voyager.folders.v1');\n      expect(info.compatible).toBe(false);\n      expect(info.reason).toContain('Invalid version format');\n    });\n  });\n\n  describe('applyMigrations', () => {\n    it('should return data unchanged when no migrations apply', () => {\n      const data = { test: 'data' };\n      const result = applyMigrations(data, '0.7.0');\n      expect(result.data).toEqual(data);\n      expect(result.migrationsApplied).toHaveLength(0);\n    });\n\n    it('should handle data without throwing', () => {\n      const data = { folders: [], folderContents: {} };\n      expect(() => applyMigrations(data, '0.7.0')).not.toThrow();\n    });\n\n    // Note: Add specific migration tests when migrations are implemented\n  });\n\n  describe('EXTENSION_VERSION', () => {\n    it('should be a valid semantic version', () => {\n      const parsed = parseVersion(EXTENSION_VERSION);\n      expect(parsed).not.toBeNull();\n      expect(parsed?.major).toBeGreaterThanOrEqual(0);\n      expect(parsed?.minor).toBeGreaterThanOrEqual(0);\n      expect(parsed?.patch).toBeGreaterThanOrEqual(0);\n    });\n\n    it('should match manifest.json version format', () => {\n      expect(EXTENSION_VERSION).toMatch(/^\\d+\\.\\d+\\.\\d+(-[\\w.]+)?$/);\n    });\n  });\n\n  describe('FORMAT_VERSIONS', () => {\n    it('should have valid minimum versions', () => {\n      Object.values(FORMAT_VERSIONS).forEach((minVersion) => {\n        const parsed = parseVersion(minVersion);\n        expect(parsed).not.toBeNull();\n      });\n    });\n\n    it('should include v1 format', () => {\n      expect(FORMAT_VERSIONS['gemini-voyager.folders.v1']).toBeDefined();\n    });\n  });\n\n  describe('Edge Cases', () => {\n    it('should handle version comparison edge cases', () => {\n      expect(compareVersions('0.0.1', '0.0.0')).toBeGreaterThan(0);\n      expect(compareVersions('10.0.0', '9.9.9')).toBeGreaterThan(0);\n      expect(compareVersions('1.10.0', '1.9.0')).toBeGreaterThan(0);\n    });\n\n    it('should handle prerelease comparison edge cases', () => {\n      expect(compareVersions('1.0.0-alpha', '1.0.0-alpha')).toBe(0);\n      expect(compareVersions('1.0.0-beta', '1.0.0-alpha')).toBeGreaterThan(0);\n    });\n  });\n\n  describe('Real-world Scenarios', () => {\n    it('should handle current extension version compatibility', () => {\n      const info = getCompatibilityInfo(EXTENSION_VERSION, 'gemini-voyager.folders.v1');\n      expect(info.compatible).toBe(true);\n    });\n\n    it('should prevent imports from future versions gracefully', () => {\n      // This test ensures we don't break if someone tries to import from a newer version\n      // The system should handle it gracefully (though we may want to add warnings)\n      const futureVersion = '99.0.0';\n      const info = getCompatibilityInfo(futureVersion, 'gemini-voyager.folders.v1');\n      expect(info.compatible).toBe(true); // Future versions are compatible\n    });\n\n    it('should handle version ranges correctly', () => {\n      const versions = ['0.6.9', '0.7.0', '0.7.1', '0.7.7', '0.8.0', '1.0.0'];\n      const format = 'gemini-voyager.folders.v1';\n\n      versions.forEach((version) => {\n        const compatible = isVersionCompatible(version, format);\n        const shouldBeCompatible = compareVersions(version, '0.7.0') >= 0;\n        expect(compatible).toBe(shouldBeCompatible);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "src/core/utils/array.ts",
    "content": "/**\n * Array utilities\n */\n\n/**\n * Filter array to only top-level elements (remove nested duplicates)\n */\nexport function filterTopLevel<T extends Element>(elements: T[]): T[] {\n  const result: T[] = [];\n\n  for (let i = 0; i < elements.length; i++) {\n    const element = elements[i];\n    let isDescendant = false;\n\n    for (let j = 0; j < elements.length; j++) {\n      if (i === j) continue;\n\n      const other = elements[j];\n      if (other.contains(element)) {\n        isDescendant = true;\n        break;\n      }\n    }\n\n    if (!isDescendant) {\n      result.push(element);\n    }\n  }\n\n  return result;\n}\n\n/**\n * Deduplicate array by key function\n */\nexport function deduplicateBy<T>(items: T[], keyFn: (item: T) => string): T[] {\n  const seen = new Set<string>();\n  const result: T[] = [];\n\n  for (const item of items) {\n    const key = keyFn(item);\n\n    if (!seen.has(key)) {\n      seen.add(key);\n      result.push(item);\n    }\n  }\n\n  return result;\n}\n\n/**\n * Binary search for lower bound\n */\nexport function lowerBound(arr: number[], target: number): number {\n  let left = 0;\n  let right = arr.length;\n\n  while (left < right) {\n    const mid = (left + right) >> 1;\n\n    if (arr[mid] < target) {\n      left = mid + 1;\n    } else {\n      right = mid;\n    }\n  }\n\n  return left;\n}\n\n/**\n * Binary search for upper bound\n */\nexport function upperBound(arr: number[], target: number): number {\n  let left = 0;\n  let right = arr.length;\n\n  while (left < right) {\n    const mid = (left + right) >> 1;\n\n    if (arr[mid] <= target) {\n      left = mid + 1;\n    } else {\n      right = mid;\n    }\n  }\n\n  return left - 1;\n}\n\n/**\n * Chunk array into smaller arrays\n */\nexport function chunk<T>(array: T[], size: number): T[][] {\n  const chunks: T[][] = [];\n\n  for (let i = 0; i < array.length; i += size) {\n    chunks.push(array.slice(i, i + size));\n  }\n\n  return chunks;\n}\n\n/**\n * Sort folders with pinned folders first, then by name using localized collation\n */\nexport function sortFolders<T extends { name: string; pinned?: boolean }>(folders: T[]): T[] {\n  return [...folders].sort((a, b) => {\n    // Pinned folders always come first\n    if (a.pinned && !b.pinned) return -1;\n    if (!a.pinned && b.pinned) return 1;\n\n    // Within the same pinned state, sort by name using localized comparison\n    return a.name.localeCompare(b.name, undefined, {\n      numeric: true,\n      sensitivity: 'base',\n    });\n  });\n}\n"
  },
  {
    "path": "src/core/utils/async.ts",
    "content": "/**\n * Async utilities\n */\n\n/**\n * Debounce function\n */\nexport function debounce<T extends (...args: Parameters<T>) => void>(\n  func: T,\n  delay: number,\n): (...args: Parameters<T>) => void {\n  let timeoutId: number | null = null;\n\n  return (...args: Parameters<T>) => {\n    if (timeoutId !== null) {\n      clearTimeout(timeoutId);\n    }\n\n    timeoutId = window.setTimeout(() => {\n      func(...args);\n      timeoutId = null;\n    }, delay);\n  };\n}\n\n/**\n * Throttle function\n */\nexport function throttle<T extends (...args: Parameters<T>) => void>(\n  func: T,\n  delay: number,\n): (...args: Parameters<T>) => void {\n  let lastCall = 0;\n\n  return (...args: Parameters<T>) => {\n    const now = Date.now();\n\n    if (now - lastCall >= delay) {\n      lastCall = now;\n      func(...args);\n    }\n  };\n}\n\n/**\n * Retry with exponential backoff\n */\nexport async function retry<T>(\n  fn: () => Promise<T>,\n  options: {\n    maxAttempts?: number;\n    initialDelay?: number;\n    maxDelay?: number;\n    backoffFactor?: number;\n  } = {},\n): Promise<T> {\n  const { maxAttempts = 3, initialDelay = 100, maxDelay = 5000, backoffFactor = 2 } = options;\n\n  let lastError: Error | undefined;\n  let delay = initialDelay;\n\n  for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n    try {\n      return await fn();\n    } catch (error) {\n      lastError = error instanceof Error ? error : new Error(String(error));\n\n      if (attempt < maxAttempts) {\n        await sleep(Math.min(delay, maxDelay));\n        delay *= backoffFactor;\n      }\n    }\n  }\n\n  throw lastError;\n}\n\n/**\n * Sleep for specified milliseconds\n */\nexport function sleep(ms: number): Promise<void> {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Timeout wrapper for promises\n */\nexport async function withTimeout<T>(\n  promise: Promise<T>,\n  timeoutMs: number,\n  timeoutError = new Error('Operation timed out'),\n): Promise<T> {\n  return Promise.race([\n    promise,\n    new Promise<T>((_, reject) => setTimeout(() => reject(timeoutError), timeoutMs)),\n  ]);\n}\n"
  },
  {
    "path": "src/core/utils/browser.ts",
    "content": "/**\n * Browser detection utilities\n * Provides reliable browser detection for Safari-specific handling\n */\n\n/**\n * Detect if the current browser is Safari\n *\n * Detection strategy:\n * 1. Check for Safari-specific vendor string (Apple Inc.)\n * 2. Ensure 'safari' is in user agent\n * 3. Ensure it's not Chrome/Chromium (which also uses webkit)\n *\n * Note: Do not rely on global objects (browser/chrome) for detection,\n * as webextension-polyfill makes browser available in all browsers,\n * and Firefox provides both browser and chrome objects.\n *\n * @returns true if running in Safari\n */\nexport function isSafari(): boolean {\n  // Reliable detection using user agent and vendor\n  const ua = navigator.userAgent.toLowerCase();\n  const vendor = navigator.vendor.toLowerCase();\n\n  // Safari has 'Apple' vendor and 'safari' in UA, but not 'chrome'\n  const isAppleVendor = vendor.includes('apple');\n  const hasSafariUA = ua.includes('safari');\n  const notChrome = !ua.includes('chrome') && !ua.includes('chromium');\n\n  return isAppleVendor && hasSafariUA && notChrome;\n}\n\n/**\n * Check if update reminders should be shown on Safari\n * This is controlled by the ENABLE_SAFARI_UPDATE_CHECK environment variable at build time\n *\n * @returns true if Safari update reminders are enabled\n */\nexport function shouldShowSafariUpdateReminder(): boolean {\n  if (!isSafari()) return false;\n\n  // Check build-time flag (injected via vite config)\n  // Default: false (disabled)\n  try {\n    return import.meta.env.ENABLE_SAFARI_UPDATE_CHECK === 'true';\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Detect if the current browser is Chrome (not Edge, Firefox, or Safari).\n * Used to conditionally show Chrome Web Store rating prompts.\n */\nexport function isChrome(): boolean {\n  if (isSafari()) return false;\n  const ua = navigator.userAgent.toLowerCase();\n  return (\n    (ua.includes('chrome') || ua.includes('chromium')) &&\n    !ua.includes('edg') &&\n    !ua.includes('firefox')\n  );\n}\n\n/**\n * Detect if the current browser is Firefox.\n */\nexport function isFirefox(): boolean {\n  const ua = navigator.userAgent.toLowerCase();\n  return ua.includes('firefox');\n}\n\n/**\n * Detect if the current platform is macOS\n *\n * @returns true if running on macOS\n */\nexport function isMac(): boolean {\n  // navigator.platform is deprecated but still widely supported and reliable\n  // Use it first, then fall back to userAgent\n  if (typeof navigator !== 'undefined') {\n    if (navigator.platform) {\n      return navigator.platform.toUpperCase().includes('MAC');\n    }\n    return /macintosh|mac os x/i.test(navigator.userAgent);\n  }\n  return false;\n}\n\n/**\n * Get the platform-appropriate modifier key label\n * macOS: ⌘ (Cmd), others: Ctrl\n *\n * @returns '⌘' on macOS, 'Ctrl' on other platforms\n */\nexport function getModifierKey(): string {\n  return isMac() ? '⌘' : 'Ctrl';\n}\n\n/**\n * Get browser name for debugging\n * Uses user agent detection for reliability\n */\nexport function getBrowserName(): string {\n  if (isSafari()) return 'Safari';\n\n  if (isFirefox()) return 'Firefox';\n\n  const ua = navigator.userAgent.toLowerCase();\n\n  // Chrome/Edge/Brave have 'chrome' or 'chromium' in UA\n  if (ua.includes('chrome') || ua.includes('chromium')) {\n    if (ua.includes('edg')) return 'Edge';\n    return 'Chrome/Chromium';\n  }\n\n  return 'Unknown';\n}\n"
  },
  {
    "path": "src/core/utils/concurrency.ts",
    "content": "/**\n * Concurrency control utilities\n * Provides lock mechanisms to prevent race conditions in async operations\n */\n\n/**\n * Simple async lock implementation to prevent concurrent operations\n * Useful for preventing data corruption during import/export operations\n */\nexport class AsyncLock {\n  private locks: Map<string, Promise<void>> = new Map();\n  private lockHolders: Map<string, number> = new Map();\n\n  /**\n   * Acquire a lock for a given key\n   * If lock is already held, waits until it's released\n   * @param key - Lock identifier\n   * @param timeout - Optional timeout in milliseconds (default: 30000ms)\n   * @returns Release function to unlock\n   */\n  async acquire(key: string, timeout: number = 30000): Promise<() => void> {\n    // Wait for existing lock to be released\n    while (this.locks.has(key)) {\n      const existingLock = this.locks.get(key)!;\n      const timeoutPromise = new Promise<void>((_, reject) => {\n        setTimeout(() => reject(new Error(`Lock timeout for key: ${key}`)), timeout);\n      });\n\n      try {\n        await Promise.race([existingLock, timeoutPromise]);\n      } catch (error) {\n        // Timeout occurred, force release the lock\n        this.forceRelease(key);\n        throw error;\n      }\n    }\n\n    // Create new lock\n    let releaseFn: () => void;\n    const lockPromise = new Promise<void>((resolve) => {\n      releaseFn = resolve;\n    });\n\n    this.locks.set(key, lockPromise);\n    this.lockHolders.set(key, Date.now());\n\n    // Return release function\n    return () => {\n      this.release(key);\n      releaseFn!();\n    };\n  }\n\n  /**\n   * Try to acquire lock without waiting\n   * @param key - Lock identifier\n   * @returns Release function if acquired, null if lock is held\n   */\n  tryAcquire(key: string): (() => void) | null {\n    if (this.locks.has(key)) {\n      return null;\n    }\n\n    let releaseFn: () => void;\n    const lockPromise = new Promise<void>((resolve) => {\n      releaseFn = resolve;\n    });\n\n    this.locks.set(key, lockPromise);\n    this.lockHolders.set(key, Date.now());\n\n    return () => {\n      this.release(key);\n      releaseFn!();\n    };\n  }\n\n  /**\n   * Release a lock\n   */\n  private release(key: string): void {\n    this.locks.delete(key);\n    this.lockHolders.delete(key);\n  }\n\n  /**\n   * Force release a lock (use with caution)\n   */\n  private forceRelease(key: string): void {\n    this.release(key);\n  }\n\n  /**\n   * Check if a lock is currently held\n   */\n  isLocked(key: string): boolean {\n    return this.locks.has(key);\n  }\n\n  /**\n   * Get how long a lock has been held (in milliseconds)\n   */\n  getLockDuration(key: string): number | null {\n    const timestamp = this.lockHolders.get(key);\n    if (!timestamp) {\n      return null;\n    }\n    return Date.now() - timestamp;\n  }\n\n  /**\n   * Execute a function with lock protection\n   * Automatically acquires and releases lock\n   */\n  async withLock<T>(key: string, fn: () => Promise<T>, timeout?: number): Promise<T> {\n    const release = await this.acquire(key, timeout);\n    try {\n      return await fn();\n    } finally {\n      release();\n    }\n  }\n\n  /**\n   * Clear all locks (use with caution, mainly for cleanup)\n   */\n  clearAll(): void {\n    this.locks.clear();\n    this.lockHolders.clear();\n  }\n}\n\n/**\n * Global lock instance for import/export operations\n * Use this to prevent concurrent import/export operations\n */\nexport const importExportLock = new AsyncLock();\n\n/**\n * Lock keys for different operations\n */\nexport const LOCK_KEYS = {\n  FOLDER_IMPORT: 'folder:import',\n  FOLDER_EXPORT: 'folder:export',\n  FOLDER_DATA_WRITE: 'folder:data:write',\n  FOLDER_DATA_READ: 'folder:data:read',\n} as const;\n\n/**\n * Decorator for methods that need lock protection\n * Usage: @withLock(LOCK_KEYS.FOLDER_IMPORT)\n */\nexport function withLock(lockKey: string, timeout?: number) {\n  return function (target: object, propertyKey: string, descriptor: PropertyDescriptor) {\n    const originalMethod = descriptor.value as (...args: unknown[]) => Promise<unknown>;\n\n    descriptor.value = async function (...args: unknown[]) {\n      return await importExportLock.withLock(\n        lockKey,\n        () => originalMethod.apply(this, args),\n        timeout,\n      );\n    };\n\n    return descriptor;\n  };\n}\n\n/**\n * Storage operation queue to prevent write conflicts\n * Ensures operations are executed in order\n */\nexport class OperationQueue {\n  private queue: Array<() => Promise<unknown>> = [];\n  private processing = false;\n\n  /**\n   * Add operation to queue\n   */\n  async enqueue<T>(operation: () => Promise<T>): Promise<T> {\n    return new Promise((resolve, reject) => {\n      this.queue.push(async () => {\n        try {\n          const result = await operation();\n          resolve(result);\n        } catch (error) {\n          reject(error);\n        }\n      });\n\n      this.process();\n    });\n  }\n\n  /**\n   * Process queue\n   */\n  private async process(): Promise<void> {\n    if (this.processing || this.queue.length === 0) {\n      return;\n    }\n\n    this.processing = true;\n\n    while (this.queue.length > 0) {\n      const operation = this.queue.shift()!;\n      try {\n        await operation();\n      } catch (error) {\n        console.error('Operation queue error:', error);\n      }\n    }\n\n    this.processing = false;\n  }\n\n  /**\n   * Get queue length\n   */\n  get length(): number {\n    return this.queue.length;\n  }\n\n  /**\n   * Check if queue is processing\n   */\n  get isProcessing(): boolean {\n    return this.processing;\n  }\n\n  /**\n   * Clear queue\n   */\n  clear(): void {\n    this.queue = [];\n  }\n}\n\n/**\n * Global operation queue for storage operations\n */\nexport const storageQueue = new OperationQueue();\n"
  },
  {
    "path": "src/core/utils/extensionContext.ts",
    "content": "const EXTENSION_CONTEXT_INVALIDATED_PATTERN = /extension context invalidated/i;\n\nfunction extractMessage(error: unknown): string {\n  if (typeof error === 'string') return error;\n  if (error instanceof Error) return error.message;\n  if (error && typeof error === 'object' && 'message' in error) {\n    const message = (error as { message?: unknown }).message;\n    return typeof message === 'string' ? message : '';\n  }\n  return '';\n}\n\nexport function isExtensionContextInvalidatedError(error: unknown): boolean {\n  const message = extractMessage(error);\n  return EXTENSION_CONTEXT_INVALIDATED_PATTERN.test(message);\n}\n\nexport function hasValidExtensionContext(): boolean {\n  try {\n    const runtime = (globalThis as typeof globalThis & { chrome?: { runtime?: { id?: string } } })\n      .chrome?.runtime;\n    return Boolean(runtime?.id);\n  } catch {\n    return false;\n  }\n}\n"
  },
  {
    "path": "src/core/utils/gemini.ts",
    "content": "const ENTERPRISE_HINTS = ['enterprise', 'workspace', 'workspaces', 'business'] as const;\n\nconst ENTERPRISE_HOSTS = new Set(['business.gemini.google']);\n\ntype UrlParts = {\n  hostname: string;\n  pathname?: string;\n  search?: string;\n  hash?: string;\n};\n\nfunction includesEnterpriseHint(value: string): boolean {\n  const haystack = value.toLowerCase();\n  return ENTERPRISE_HINTS.some((hint) => haystack.includes(hint));\n}\n\nexport function isGeminiEnterpriseUrl({\n  hostname,\n  pathname = '',\n  search = '',\n  hash = '',\n}: UrlParts): boolean {\n  const normalizedHost = hostname.toLowerCase();\n  if (ENTERPRISE_HOSTS.has(normalizedHost)) return true;\n  if (normalizedHost !== 'gemini.google.com') return false;\n  return includesEnterpriseHint(`${pathname}${search}${hash}`);\n}\n\nexport function hasGeminiEnterpriseDomHints(doc: Document): boolean {\n  const root = doc.documentElement;\n  const body = doc.body;\n\n  const classNames = `${root?.className ?? ''} ${body?.className ?? ''}`.trim();\n  if (classNames && includesEnterpriseHint(classNames)) return true;\n\n  const datasetValues = [\n    ...Object.values(root?.dataset ?? {}),\n    ...Object.values(body?.dataset ?? {}),\n  ].filter((value): value is string => typeof value === 'string' && value.length > 0);\n\n  if (datasetValues.length && includesEnterpriseHint(datasetValues.join(' '))) return true;\n\n  return false;\n}\n\nexport function isGeminiEnterpriseEnvironment(parts: UrlParts, doc?: Document): boolean {\n  if (isGeminiEnterpriseUrl(parts)) return true;\n  const normalizedHost = parts.hostname.toLowerCase();\n  if (normalizedHost !== 'gemini.google.com') return false;\n  return doc ? hasGeminiEnterpriseDomHints(doc) : false;\n}\n"
  },
  {
    "path": "src/core/utils/hash.ts",
    "content": "/**\n * Hash utilities\n * Centralized hash functions (was duplicated in 3 files)\n */\n\n/**\n * FNV-1a hash algorithm\n * Fast, good distribution for short strings\n */\nexport function hashString(input: string): string {\n  let h = 2166136261 >>> 0;\n\n  for (let i = 0; i < input.length; i++) {\n    h ^= input.charCodeAt(i);\n    h = Math.imul(h, 16777619);\n  }\n\n  return (h >>> 0).toString(36);\n}\n\n/**\n * Generate unique ID with timestamp and random component\n */\nexport function generateUniqueId(prefix = ''): string {\n  const timestamp = Date.now();\n  const random = Math.random().toString(36).slice(2, 11);\n\n  return prefix ? `${prefix}_${timestamp}_${random}` : `${timestamp}_${random}`;\n}\n\n/**\n * Hash object to stable string\n */\nexport function hashObject(obj: Record<string, unknown>): string {\n  const str = JSON.stringify(obj, Object.keys(obj).sort());\n  return hashString(str);\n}\n"
  },
  {
    "path": "src/core/utils/rtl.ts",
    "content": "/**\n * RTL (Right-to-Left) detection utilities.\n * Used to adapt the extension's UI for RTL languages like Arabic.\n */\n\n/** Language codes that use RTL text direction */\nconst RTL_LANGUAGES = new Set(['ar', 'he', 'fa', 'ur']);\n\nfunction getUrlLanguageHint(): string | null {\n  try {\n    const params = new URLSearchParams(window.location.search);\n    const candidates = [params.get('hl'), params.get('lang'), params.get('locale')];\n    for (const candidate of candidates) {\n      if (candidate && candidate.trim()) return candidate.trim();\n    }\n  } catch {\n    // Ignore URL parsing failures and continue with other signals.\n  }\n  return null;\n}\n\n/**\n * Returns true if the given BCP 47 language code is an RTL language.\n */\nexport function isRTLLanguage(lang: string): boolean {\n  return RTL_LANGUAGES.has(lang.split('-')[0].toLowerCase());\n}\n\n/**\n * Detects if the current UI context is RTL.\n *\n * Checks in priority order:\n * 1. The `dir` attribute on the `<html>` element (set by the host page)\n * 2. The `lang` attribute on the `<html>` element\n * 3. The provided extension UI language setting\n */\nexport function detectRTL(extensionLanguage?: string | null): boolean {\n  // 1. Page-level dir attribute is the most authoritative signal\n  const pageDir = document.documentElement.dir || document.body.dir;\n  if (pageDir === 'rtl') return true;\n  if (pageDir === 'ltr') return false;\n\n  // 2. Page language attribute\n  const pageLang = document.documentElement.lang?.split('-')[0]?.toLowerCase();\n  if (pageLang && RTL_LANGUAGES.has(pageLang)) return true;\n\n  // 3. Host URL hint (e.g. Gemini `?hl=ar`)\n  const urlLangHint = getUrlLanguageHint();\n  if (urlLangHint) return isRTLLanguage(urlLangHint);\n\n  // 4. Extension UI language stored in settings\n  if (extensionLanguage && isRTLLanguage(extensionLanguage)) return true;\n\n  return false;\n}\n\n/** CSS class added to `document.body` to activate RTL layout overrides */\nexport const GV_RTL_CLASS = 'gv-rtl';\n\n/**\n * Applies or removes the RTL body class based on the detected direction.\n * @returns true if RTL was applied, false otherwise\n */\nexport function applyRTLClass(extensionLanguage?: string | null): boolean {\n  const rtl = detectRTL(extensionLanguage);\n  document.body.classList.toggle(GV_RTL_CLASS, rtl);\n  return rtl;\n}\n"
  },
  {
    "path": "src/core/utils/safariStorage.ts",
    "content": "/**\n * Safari-specific storage adapter\n * Uses browser.storage.local for reliable persistence on Safari\n *\n * Why Safari needs this:\n * - Safari's localStorage has 7-day deletion policy\n * - Random data loss on iOS 13+\n * - Private mode quota exceeded errors\n *\n * Solution:\n * - Use browser.storage.local (persistent, 10MB quota)\n * - Fallback to localStorage if storage API unavailable\n */\nimport browser from 'webextension-polyfill';\n\ninterface SafariStorageAdapter {\n  getItem(key: string): Promise<string | null>;\n  setItem(key: string, value: string): Promise<void>;\n  removeItem(key: string): Promise<void>;\n}\n\n/**\n * Safari storage adapter using browser.storage.local\n * Provides async methods that replace localStorage for Safari\n */\nexport class SafariStorage implements SafariStorageAdapter {\n  /**\n   * Get item from browser.storage.local\n   */\n  async getItem(key: string): Promise<string | null> {\n    try {\n      const result = await browser.storage.local.get(key);\n      const value = result[key];\n      // Ensure we return string or null\n      if (typeof value === 'string') {\n        return value;\n      }\n      return null;\n    } catch (error) {\n      console.error('[SafariStorage] Failed to get item:', key, error);\n      // Fallback to localStorage\n      try {\n        return localStorage.getItem(key);\n      } catch {\n        return null;\n      }\n    }\n  }\n\n  /**\n   * Set item to browser.storage.local\n   */\n  async setItem(key: string, value: string): Promise<void> {\n    try {\n      await browser.storage.local.set({ [key]: value });\n    } catch (error) {\n      console.error('[SafariStorage] Failed to set item:', key, error);\n      // Fallback to localStorage\n      try {\n        localStorage.setItem(key, value);\n      } catch (fallbackError) {\n        console.error('[SafariStorage] Fallback to localStorage also failed:', fallbackError);\n        throw error;\n      }\n    }\n  }\n\n  /**\n   * Remove item from browser.storage.local\n   */\n  async removeItem(key: string): Promise<void> {\n    try {\n      await browser.storage.local.remove(key);\n    } catch (error) {\n      console.error('[SafariStorage] Failed to remove item:', key, error);\n      // Fallback to localStorage\n      try {\n        localStorage.removeItem(key);\n      } catch {\n        // Ignore fallback errors for remove\n      }\n    }\n  }\n\n  /**\n   * Migrate data from localStorage to browser.storage.local\n   * Should be called once during initialization\n   */\n  async migrateFromLocalStorage(key: string): Promise<boolean> {\n    try {\n      // Check if already migrated\n      const migrationKey = `${key}_migrated`;\n      const alreadyMigrated = await this.getItem(migrationKey);\n      if (alreadyMigrated === 'true') {\n        return true;\n      }\n\n      // Check if there's data in localStorage\n      const localData = localStorage.getItem(key);\n      if (!localData) {\n        // No data to migrate, mark as migrated\n        await this.setItem(migrationKey, 'true');\n        return true;\n      }\n\n      // Check if browser.storage.local already has data\n      const browserData = await browser.storage.local.get(key);\n      if (browserData[key]) {\n        // Data already in browser.storage.local, no migration needed\n        await this.setItem(migrationKey, 'true');\n        return true;\n      }\n\n      // Migrate data\n      await this.setItem(key, localData);\n      await this.setItem(migrationKey, 'true');\n\n      console.log(\n        `[SafariStorage] Successfully migrated ${key} from localStorage to browser.storage.local`,\n      );\n      return true;\n    } catch (error) {\n      console.error('[SafariStorage] Migration failed:', error);\n      return false;\n    }\n  }\n}\n\n/**\n * Singleton instance\n */\nexport const safariStorage = new SafariStorage();\n"
  },
  {
    "path": "src/core/utils/selectors.ts",
    "content": "/**\n * DOM selector utilities\n * Centralized selectors (was duplicated in multiple files)\n */\n\n/**\n * Get selectors for user query elements\n */\nexport function getUserTurnSelectors(): string[] {\n  return [\n    // Angular-based Gemini UI user bubble (primary)\n    '.user-query-bubble-with-background',\n    // Angular containers (fallbacks)\n    '.user-query-bubble-container',\n    '.user-query-container',\n    'user-query-content .user-query-bubble-with-background',\n    'user-query-content',\n    'user-query',\n    // Attribute-based fallbacks\n    'div[aria-label=\"User message\"]',\n    'article[data-author=\"user\"]',\n    'article[data-turn=\"user\"]',\n    '[data-message-author-role=\"user\"]',\n    'div[role=\"listitem\"][data-user=\"true\"]',\n  ];\n}\n\n/**\n * Get selectors for assistant/model response elements\n */\nexport function getAssistantTurnSelectors(): string[] {\n  return [\n    // Attribute-based roles (most reliable)\n    '[aria-label=\"Gemini response\"]',\n    '[data-message-author-role=\"assistant\"]',\n    '[data-message-author-role=\"model\"]',\n    'article[data-author=\"assistant\"]',\n    'article[data-turn=\"assistant\"]',\n    'article[data-turn=\"model\"]',\n    // Common Gemini containers\n    'model-response',\n    '.model-response',\n    'response-container',\n    '.response-container',\n    '.presented-response-container',\n    'div[role=\"listitem\"]:not([data-user=\"true\"])',\n  ];\n}\n\n/**\n * Get conversation selectors\n */\nexport function getConversationSelectors(): string[] {\n  return ['[data-test-id=\"conversation\"]', '[data-test-id^=\"history-item\"]', '.conversation-card'];\n}\n\n/**\n * Get conversation link selectors\n */\nexport function getConversationLinkSelectors(): string[] {\n  return ['a[href*=\"/app/\"]', 'a[href*=\"/gem/\"]'];\n}\n\n/**\n * Build combined selector string\n */\nexport function combineSelectors(selectors: string[]): string {\n  return selectors.join(', ');\n}\n"
  },
  {
    "path": "src/core/utils/storageMigration.ts",
    "content": "/**\n * Storage Migration Utility\n * Handles migration from localStorage to chrome.storage for cross-domain data sharing\n */\nimport { logger } from '@/core/services/LoggerService';\nimport type { IStorageService } from '@/core/services/StorageService';\nimport type { StorageKey } from '@/core/types/common';\n\nexport interface MigrationResult {\n  success: boolean;\n  migratedKeys: string[];\n  skippedKeys: string[];\n  errors: Array<{ key: string; error: string }>;\n}\n\n/**\n * Migrate data from localStorage to chrome.storage\n *\n * @param keys - Array of localStorage keys to migrate\n * @param targetStorage - Target storage service (e.g., promptStorageService)\n * @param options - Migration options\n * @returns Migration result with details\n *\n * @example\n * ```typescript\n * const result = await migrateFromLocalStorage(\n *   [StorageKeys.PROMPT_ITEMS, StorageKeys.PROMPT_PANEL_LOCKED],\n *   promptStorageService,\n *   { deleteAfterMigration: false }\n * );\n * ```\n */\nexport async function migrateFromLocalStorage(\n  keys: StorageKey[],\n  targetStorage: IStorageService,\n  options: {\n    deleteAfterMigration?: boolean; // Whether to delete from localStorage after successful migration\n    skipExisting?: boolean; // Skip keys that already exist in target storage\n  } = {},\n): Promise<MigrationResult> {\n  const migrationLogger = logger.createChild('StorageMigration');\n  const result: MigrationResult = {\n    success: true,\n    migratedKeys: [],\n    skippedKeys: [],\n    errors: [],\n  };\n\n  const { deleteAfterMigration = false, skipExisting = true } = options;\n\n  for (const key of keys) {\n    try {\n      // Check if already exists in target storage\n      if (skipExisting) {\n        const existingResult = await targetStorage.get(key);\n        if (existingResult.success) {\n          migrationLogger.info(`Skipping key \"${key}\" - already exists in target storage`);\n          result.skippedKeys.push(key);\n          continue;\n        }\n      }\n\n      // Read from localStorage\n      const localValue = localStorage.getItem(key);\n      if (localValue === null) {\n        migrationLogger.info(`Skipping key \"${key}\" - not found in localStorage`);\n        result.skippedKeys.push(key);\n        continue;\n      }\n\n      // Parse the value\n      let parsedValue: unknown;\n      try {\n        parsedValue = JSON.parse(localValue);\n      } catch (parseError) {\n        migrationLogger.error(`Failed to parse localStorage value for key \"${key}\"`, {\n          parseError,\n        });\n        result.errors.push({ key, error: 'Failed to parse JSON' });\n        result.success = false;\n        continue;\n      }\n\n      // Write to target storage\n      const writeResult = await targetStorage.set(key, parsedValue);\n      if (!writeResult.success) {\n        migrationLogger.error(`Failed to write to target storage for key \"${key}\"`, {\n          error: writeResult.error,\n        });\n        result.errors.push({\n          key,\n          error: writeResult.error?.message || 'Failed to write to target storage',\n        });\n        result.success = false;\n        continue;\n      }\n\n      migrationLogger.info(`Successfully migrated key \"${key}\"`);\n      result.migratedKeys.push(key);\n\n      // Delete from localStorage if requested\n      if (deleteAfterMigration) {\n        try {\n          localStorage.removeItem(key);\n          migrationLogger.info(`Deleted key \"${key}\" from localStorage`);\n        } catch (deleteError) {\n          migrationLogger.warn(`Failed to delete key \"${key}\" from localStorage`, { deleteError });\n          // Don't mark as failure since migration succeeded\n        }\n      }\n    } catch (error) {\n      migrationLogger.error(`Unexpected error migrating key \"${key}\"`, { error });\n      result.errors.push({\n        key,\n        error: error instanceof Error ? error.message : 'Unexpected error',\n      });\n      result.success = false;\n    }\n  }\n\n  // Log summary\n  migrationLogger.info('Migration completed', {\n    migratedCount: result.migratedKeys.length,\n    skippedCount: result.skippedKeys.length,\n    errorCount: result.errors.length,\n  });\n\n  return result;\n}\n\n/**\n * Check if migration has been completed for a specific key\n *\n * @param key - Storage key to check\n * @param targetStorage - Target storage service\n * @returns True if data exists in target storage (migration completed)\n */\nexport async function isMigrationCompleted(\n  key: StorageKey,\n  targetStorage: IStorageService,\n): Promise<boolean> {\n  const result = await targetStorage.get(key);\n  return result.success;\n}\n\n/**\n * Get migration status for multiple keys\n *\n * @param keys - Array of keys to check\n * @param targetStorage - Target storage service\n * @returns Object mapping keys to migration status\n */\nexport async function getMigrationStatus(\n  keys: StorageKey[],\n  targetStorage: IStorageService,\n): Promise<Record<string, boolean>> {\n  const status: Record<string, boolean> = {};\n\n  await Promise.all(\n    keys.map(async (key) => {\n      status[key] = await isMigrationCompleted(key, targetStorage);\n    }),\n  );\n\n  return status;\n}\n"
  },
  {
    "path": "src/core/utils/text.ts",
    "content": "/**\n * Text manipulation utilities\n */\n\n/**\n * Normalize whitespace in text\n */\nexport function normalizeText(text: string | null | undefined): string {\n  if (!text) return '';\n\n  try {\n    return String(text).replace(/\\s+/g, ' ').trim();\n  } catch {\n    return '';\n  }\n}\n\n/**\n * Truncate text to max length with ellipsis\n */\nexport function truncateText(text: string, maxLength: number): string {\n  if (text.length <= maxLength) {\n    return text;\n  }\n\n  return text.slice(0, maxLength - 1).trim() + '…';\n}\n\n/**\n * Extract first N lines from text\n */\nexport function getFirstLines(text: string, count: number): string {\n  const lines = text\n    .split('\\n')\n    .map((line) => line.trim())\n    .filter(Boolean);\n\n  return lines.slice(0, count).join('\\n');\n}\n\n/**\n * Check if text is likely truncated\n */\nexport function isTruncated(element: HTMLElement): boolean {\n  return element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight;\n}\n"
  },
  {
    "path": "src/core/utils/updateReminder.ts",
    "content": "import { compareVersions } from './version';\n\nconst DEFAULT_MINIMUM_UPDATE_REMINDER_VERSION = '1.2.3';\n\ninterface UpdateReminderPolicyInput {\n  currentVersion: string | null;\n  isSafariBrowser: boolean;\n  safariReminderEnabled: boolean;\n  minimumReminderVersion?: string;\n}\n\nexport function shouldShowUpdateReminderForCurrentVersion({\n  currentVersion,\n  isSafariBrowser,\n  safariReminderEnabled,\n  minimumReminderVersion = DEFAULT_MINIMUM_UPDATE_REMINDER_VERSION,\n}: UpdateReminderPolicyInput): boolean {\n  if (!currentVersion) return false;\n\n  if (isSafariBrowser) {\n    return safariReminderEnabled;\n  }\n\n  try {\n    return compareVersions(currentVersion, minimumReminderVersion) < 0;\n  } catch {\n    return false;\n  }\n}\n"
  },
  {
    "path": "src/core/utils/version.ts",
    "content": "/**\n * Version management utilities\n * Provides centralized version handling and semantic version comparison\n */\n// Import version from manifest to ensure single source of truth\nimport manifestChrome from '../../../manifest.json';\n\n/**\n * Current extension version from manifest.json\n * This is the single source of truth for version information\n */\nexport const EXTENSION_VERSION = manifestChrome.version;\n\n/**\n * Supported format versions for import/export\n * Maps format version to minimum compatible extension version\n */\nexport const FORMAT_VERSIONS = {\n  'gemini-voyager.folders.v1': '0.7.0', // Minimum version that supports v1 format\n} as const;\n\nexport type FormatVersion = keyof typeof FORMAT_VERSIONS;\n\n/**\n * Parse semantic version string into comparable parts\n */\nexport interface SemanticVersion {\n  major: number;\n  minor: number;\n  patch: number;\n  prerelease?: string;\n}\n\n/**\n * Parse a semantic version string\n * @param version - Version string (e.g., \"1.2.3\" or \"1.2.3-beta.1\")\n * @returns Parsed semantic version or null if invalid\n */\nexport function parseVersion(version: string): SemanticVersion | null {\n  const match = version.match(/^(\\d+)\\.(\\d+)\\.(\\d+)(?:-(.+))?$/);\n  if (!match) {\n    return null;\n  }\n\n  return {\n    major: parseInt(match[1], 10),\n    minor: parseInt(match[2], 10),\n    patch: parseInt(match[3], 10),\n    prerelease: match[4],\n  };\n}\n\n/**\n * Compare two semantic versions\n * @returns -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2\n */\nexport function compareVersions(v1: string, v2: string): number {\n  const parsed1 = parseVersion(v1);\n  const parsed2 = parseVersion(v2);\n\n  if (!parsed1 || !parsed2) {\n    throw new Error(`Invalid version format: ${!parsed1 ? v1 : v2}`);\n  }\n\n  // Compare major.minor.patch\n  if (parsed1.major !== parsed2.major) {\n    return parsed1.major - parsed2.major;\n  }\n  if (parsed1.minor !== parsed2.minor) {\n    return parsed1.minor - parsed2.minor;\n  }\n  if (parsed1.patch !== parsed2.patch) {\n    return parsed1.patch - parsed2.patch;\n  }\n\n  // Handle prerelease versions\n  if (parsed1.prerelease && !parsed2.prerelease) {\n    return -1; // 1.0.0-beta < 1.0.0\n  }\n  if (!parsed1.prerelease && parsed2.prerelease) {\n    return 1; // 1.0.0 > 1.0.0-beta\n  }\n  if (parsed1.prerelease && parsed2.prerelease) {\n    return parsed1.prerelease.localeCompare(parsed2.prerelease);\n  }\n\n  return 0;\n}\n\n/**\n * Check if a version is compatible with current extension version\n * @param importVersion - Version from imported data\n * @param formatVersion - Format version of the data\n * @returns true if compatible, false otherwise\n */\nexport function isVersionCompatible(importVersion: string, formatVersion: FormatVersion): boolean {\n  try {\n    const minVersion = FORMAT_VERSIONS[formatVersion];\n    if (!minVersion) {\n      return false; // Unknown format version\n    }\n\n    // Validate version format first\n    if (!parseVersion(importVersion)) {\n      return false; // Invalid version format\n    }\n\n    // Check if import version meets minimum requirement\n    const comparison = compareVersions(importVersion, minVersion);\n    return comparison >= 0;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Check if format version is supported\n */\nexport function isSupportedFormat(format: string): format is FormatVersion {\n  return format in FORMAT_VERSIONS;\n}\n\n/**\n * Get compatibility information for an import\n */\nexport interface CompatibilityInfo {\n  compatible: boolean;\n  currentVersion: string;\n  importVersion: string;\n  formatVersion: string;\n  minRequiredVersion?: string;\n  reason?: string;\n}\n\n/**\n * Get detailed compatibility information\n */\nexport function getCompatibilityInfo(\n  importVersion: string,\n  formatVersion: string,\n): CompatibilityInfo {\n  const info: CompatibilityInfo = {\n    compatible: false,\n    currentVersion: EXTENSION_VERSION,\n    importVersion,\n    formatVersion,\n  };\n\n  // Check if format is supported\n  if (!isSupportedFormat(formatVersion)) {\n    info.reason = `Unsupported format version: ${formatVersion}`;\n    return info;\n  }\n\n  const minVersion = FORMAT_VERSIONS[formatVersion];\n  info.minRequiredVersion = minVersion;\n\n  // Validate version format first\n  if (!parseVersion(importVersion)) {\n    info.reason = `Invalid version format: ${importVersion}`;\n    return info;\n  }\n\n  // Check version compatibility\n  try {\n    const compatible = isVersionCompatible(importVersion, formatVersion);\n    info.compatible = compatible;\n\n    if (!compatible) {\n      info.reason = `Import version ${importVersion} is below minimum required version ${minVersion}`;\n    }\n  } catch {\n    info.reason = `Invalid version format: ${importVersion}`;\n  }\n\n  return info;\n}\n\n/**\n * Version migration registry\n * Maps version ranges to migration functions\n */\nexport interface VersionMigration {\n  fromVersion: string;\n  toVersion: string;\n  migrate: (data: unknown) => unknown;\n  description: string;\n}\n\n/**\n * Registry of data migrations for version upgrades\n * Add new migrations here when data structure changes\n */\nexport const VERSION_MIGRATIONS: VersionMigration[] = [\n  // Example migration (add real migrations as needed):\n  // {\n  //   fromVersion: '0.7.0',\n  //   toVersion: '0.8.0',\n  //   migrate: (data) => {\n  //     // Transform data structure\n  //     return data;\n  //   },\n  //   description: 'Add new field to folder structure'\n  // }\n];\n\n/**\n * Apply necessary migrations to bring data up to current version\n */\nexport function applyMigrations(\n  data: unknown,\n  fromVersion: string,\n): { data: unknown; migrationsApplied: string[] } {\n  let currentData = data;\n  const migrationsApplied: string[] = [];\n\n  for (const migration of VERSION_MIGRATIONS) {\n    // Check if this migration should be applied\n    if (\n      compareVersions(fromVersion, migration.fromVersion) >= 0 &&\n      compareVersions(fromVersion, migration.toVersion) < 0\n    ) {\n      try {\n        currentData = migration.migrate(currentData);\n        migrationsApplied.push(\n          `${migration.fromVersion} → ${migration.toVersion}: ${migration.description}`,\n        );\n      } catch (error) {\n        throw new Error(\n          `Migration failed (${migration.fromVersion} → ${migration.toVersion}): ${error}`,\n        );\n      }\n    }\n  }\n\n  return { data: currentData, migrationsApplied };\n}\n"
  },
  {
    "path": "src/features/backup/index.ts",
    "content": "/**\n * Auto-backup feature exports\n */\n\nexport * from './services/BackupService';\nexport * from './services/PromptImportExportService';\nexport * from './types/backup';\n"
  },
  {
    "path": "src/features/backup/services/BackupService.ts",
    "content": "/**\n * Auto-backup service with timestamp-based folder organization\n * Uses File System Access API for persistent backup storage\n * Follows enterprise best practices with comprehensive error handling\n */\nimport { AppError, ErrorCode } from '@/core/errors/AppError';\nimport type { Result } from '@/core/types/common';\nimport type { FolderData } from '@/core/types/folder';\nimport { EXTENSION_VERSION } from '@/core/utils/version';\nimport { FolderImportExportService } from '@/features/folder/services/FolderImportExportService';\n\nimport type {\n  BackupConfig,\n  BackupFile,\n  BackupMetadata,\n  BackupResult,\n  IBackupService,\n} from '../types/backup';\nimport { PromptImportExportService } from './PromptImportExportService';\n\n/**\n * Core backup service implementation\n */\nexport class BackupService implements IBackupService {\n  /**\n   * Generate timestamp-based folder name\n   * Format: backup-YYYYMMDD-HHMMSS\n   */\n  private static generateBackupFolderName(): string {\n    const pad = (n: number) => String(n).padStart(2, '0');\n    const d = new Date();\n    const y = d.getFullYear();\n    const m = pad(d.getMonth() + 1);\n    const day = pad(d.getDate());\n    const hh = pad(d.getHours());\n    const mm = pad(d.getMinutes());\n    const ss = pad(d.getSeconds());\n    return `backup-${y}${m}${day}-${hh}${mm}${ss}`;\n  }\n\n  /**\n   * Check if File System Access API is supported\n   */\n  static isSupported(): boolean {\n    return (\n      typeof window !== 'undefined' &&\n      'showDirectoryPicker' in window &&\n      typeof (window as Window & { showDirectoryPicker?: unknown }).showDirectoryPicker ===\n        'function'\n    );\n  }\n\n  /**\n   * Request directory access from user\n   * @returns FileSystemDirectoryHandle if granted, null if denied/cancelled\n   */\n  static async requestDirectoryAccess(): Promise<FileSystemDirectoryHandle | null> {\n    try {\n      if (!this.isSupported()) {\n        throw new AppError(\n          ErrorCode.UNKNOWN_ERROR,\n          'File System Access API is not supported in this browser',\n          {},\n        );\n      }\n\n      console.log('[BackupService] Showing directory picker...');\n\n      type WindowWithFilePicker = Window & {\n        showDirectoryPicker: (options?: { mode?: string }) => Promise<FileSystemDirectoryHandle>;\n      };\n      const directoryHandle = await (window as unknown as WindowWithFilePicker).showDirectoryPicker(\n        {\n          mode: 'readwrite',\n          // Remove startIn to avoid potential issues on some systems\n        },\n      );\n\n      console.log('[BackupService] Directory selected:', directoryHandle?.name || 'null');\n\n      if (!directoryHandle) {\n        console.warn('[BackupService] showDirectoryPicker returned null/undefined');\n        return null;\n      }\n\n      return directoryHandle;\n    } catch (error) {\n      console.log('[BackupService] Directory picker error:', error);\n\n      // User cancelled the picker\n      if (error instanceof Error && error.name === 'AbortError') {\n        console.log('[BackupService] User cancelled directory selection');\n        return null;\n      }\n\n      // Permission denied or restricted directory\n      if (\n        error instanceof Error &&\n        (error.name === 'NotAllowedError' ||\n          error.name === 'SecurityError' ||\n          error.message.includes('not allowed') ||\n          error.message.includes('permission'))\n      ) {\n        console.error('[BackupService] Permission denied:', error.message);\n        throw new AppError(\n          ErrorCode.UNKNOWN_ERROR,\n          'Cannot access this directory. Please choose a different location (e.g., Documents, Downloads, or a custom folder on Desktop)',\n          { originalError: error },\n        );\n      }\n\n      console.error('[BackupService] Unexpected error:', error);\n      throw error;\n    }\n  }\n\n  /**\n   * Generate backup files without writing to filesystem\n   * Useful for testing or preview\n   */\n  async generateBackupFiles(config: BackupConfig): Promise<Result<BackupFile[]>> {\n    try {\n      const files: BackupFile[] = [];\n      let promptCount = 0;\n      let folderCount = 0;\n      let conversationCount = 0;\n\n      // Generate prompt backup if enabled\n      if (config.includePrompts) {\n        const promptResult = await PromptImportExportService.exportToJSON();\n        if (!promptResult.success) {\n          return {\n            success: false,\n            error: promptResult.error,\n          };\n        }\n\n        const promptPayload = JSON.parse(promptResult.data);\n        promptCount = promptPayload.items?.length || 0;\n\n        files.push({\n          name: 'prompts.json',\n          content: promptResult.data,\n        });\n      }\n\n      // Generate folder backup if enabled\n      if (config.includeFolders) {\n        const folderResult = await this.loadFolderData();\n        if (!folderResult.success) {\n          return {\n            success: false,\n            error: folderResult.error,\n          };\n        }\n\n        const folderData = folderResult.data;\n        const folderPayload = FolderImportExportService.exportToPayload(folderData);\n\n        folderCount = folderData.folders.length;\n        conversationCount = Object.values(folderData.folderContents).reduce(\n          (sum, convs) => sum + convs.length,\n          0,\n        );\n\n        files.push({\n          name: 'folders.json',\n          content: JSON.stringify(folderPayload, null, 2),\n        });\n      }\n\n      // Generate metadata file\n      const metadata: BackupMetadata = {\n        version: EXTENSION_VERSION,\n        timestamp: new Date().toISOString(),\n        includesPrompts: config.includePrompts,\n        includesFolders: config.includeFolders,\n        promptCount,\n        folderCount,\n        conversationCount,\n      };\n\n      files.push({\n        name: 'metadata.json',\n        content: JSON.stringify(metadata, null, 2),\n      });\n\n      return {\n        success: true,\n        data: files,\n      };\n    } catch (error) {\n      return {\n        success: false,\n        error: new AppError(\n          ErrorCode.UNKNOWN_ERROR,\n          'Failed to generate backup files',\n          { config },\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n\n  /**\n   * Create a backup with timestamp-based folder\n   */\n  async createBackup(\n    directoryHandle: FileSystemDirectoryHandle,\n    config: BackupConfig,\n  ): Promise<Result<BackupResult>> {\n    try {\n      // Generate backup files\n      const filesResult = await this.generateBackupFiles(config);\n      if (!filesResult.success) {\n        return {\n          success: false,\n          error: filesResult.error,\n        };\n      }\n\n      const files = filesResult.data;\n\n      // Create timestamp-based subdirectory\n      const backupFolderName = BackupService.generateBackupFolderName();\n      const backupDirHandle = await directoryHandle.getDirectoryHandle(backupFolderName, {\n        create: true,\n      });\n\n      // Write each file to the backup directory\n      for (const file of files) {\n        const fileHandle = await backupDirHandle.getFileHandle(file.name, { create: true });\n        const writable = await fileHandle.createWritable();\n        await writable.write(file.content);\n        await writable.close();\n      }\n\n      // Parse metadata to extract counts\n      const metadata = JSON.parse(\n        files.find((f) => f.name === 'metadata.json')?.content || '{}',\n      ) as BackupMetadata;\n\n      const result: BackupResult = {\n        timestamp: new Date().toISOString(),\n        promptCount: metadata.promptCount || 0,\n        folderCount: metadata.folderCount || 0,\n        conversationCount: metadata.conversationCount || 0,\n      };\n\n      return {\n        success: true,\n        data: result,\n      };\n    } catch (error) {\n      const errorMessage = error instanceof Error ? error.message : 'Unknown error during backup';\n\n      return {\n        success: false,\n        error: new AppError(\n          ErrorCode.UNKNOWN_ERROR,\n          'Backup operation failed',\n          { config, error: errorMessage },\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n\n  /**\n   * Check if backup is needed based on config\n   */\n  shouldBackup(config: BackupConfig): boolean {\n    if (!config.enabled) {\n      return false;\n    }\n\n    // Manual backup mode (intervalHours = 0)\n    if (config.intervalHours === 0) {\n      return false;\n    }\n\n    // No previous backup\n    if (!config.lastBackupAt) {\n      return true;\n    }\n\n    // Check if interval has elapsed\n    const lastBackup = new Date(config.lastBackupAt);\n    const now = new Date();\n    const hoursSinceBackup = (now.getTime() - lastBackup.getTime()) / (1000 * 60 * 60);\n\n    return hoursSinceBackup >= config.intervalHours;\n  }\n\n  /**\n   * Load folder data from storage\n   * Only loads Gemini data (AI Studio doesn't support import)\n   */\n  private async loadFolderData(): Promise<Result<FolderData>> {\n    try {\n      const geminiKey = 'gvFolderData';\n\n      let folderData: FolderData = {\n        folders: [],\n        folderContents: {},\n      };\n\n      // Load Gemini data only\n      const geminiRaw = localStorage.getItem(geminiKey);\n      if (geminiRaw) {\n        try {\n          const geminiData = JSON.parse(geminiRaw) as FolderData;\n          folderData = geminiData;\n        } catch (e) {\n          console.warn('Failed to parse Gemini folder data:', e);\n        }\n      }\n\n      return {\n        success: true,\n        data: folderData,\n      };\n    } catch (error) {\n      return {\n        success: false,\n        error: new AppError(\n          ErrorCode.STORAGE_READ_FAILED,\n          'Failed to load folder data',\n          {},\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n}\n\n/**\n * Singleton instance\n */\nexport const backupService = new BackupService();\n"
  },
  {
    "path": "src/features/backup/services/PromptImportExportService.ts",
    "content": "/**\n * Service for importing and exporting prompt configurations\n * Follows enterprise best practices with proper validation and error handling\n * Extracted from prompt manager to follow DRY principle\n */\nimport { AppError, ErrorCode } from '@/core/errors/AppError';\nimport type { Result } from '@/core/types/common';\nimport { EXTENSION_VERSION } from '@/core/utils/version';\n\nimport type { PromptExportPayload, PromptItem } from '../types/backup';\n\nconst EXPORT_FORMAT = 'gemini-voyager.prompts.v1' as const;\nconst STORAGE_KEY = 'gvPromptItems';\n\n/**\n * Service for handling prompt import/export operations\n */\nexport class PromptImportExportService {\n  /**\n   * Export prompt data to a JSON payload\n   * Uses centralized version management to ensure consistency\n   */\n  static exportToPayload(items: PromptItem[]): PromptExportPayload {\n    return {\n      format: EXPORT_FORMAT,\n      exportedAt: new Date().toISOString(),\n      version: EXTENSION_VERSION,\n      items,\n    };\n  }\n\n  /**\n   * Validate import payload format and structure\n   */\n  static validatePayload(payload: unknown): Result<PromptExportPayload> {\n    // Check if payload is an object\n    if (!payload || typeof payload !== 'object') {\n      return {\n        success: false,\n        error: new AppError(ErrorCode.VALIDATION_ERROR, 'Invalid payload: expected an object', {\n          payload,\n        }),\n      };\n    }\n\n    const p = payload as Record<string, unknown>;\n\n    // Check format version\n    if (p.format !== EXPORT_FORMAT) {\n      return {\n        success: false,\n        error: new AppError(\n          ErrorCode.VALIDATION_ERROR,\n          `Unsupported format: expected \"${EXPORT_FORMAT}\", got \"${p.format}\"`,\n          { format: p.format },\n        ),\n      };\n    }\n\n    // Check items array\n    if (!Array.isArray(p.items)) {\n      return {\n        success: false,\n        error: new AppError(\n          ErrorCode.VALIDATION_ERROR,\n          'Invalid \"items\" field: expected an array',\n          { items: p.items },\n        ),\n      };\n    }\n\n    // Basic structure validation for items\n    for (const item of p.items) {\n      if (!item || typeof item !== 'object') {\n        return {\n          success: false,\n          error: new AppError(ErrorCode.VALIDATION_ERROR, 'Invalid prompt item object', { item }),\n        };\n      }\n\n      const i = item as Record<string, unknown>;\n      if (!i.text || typeof i.text !== 'string') {\n        return {\n          success: false,\n          error: new AppError(\n            ErrorCode.VALIDATION_ERROR,\n            'Prompt item missing valid \"text\" field',\n            { item },\n          ),\n        };\n      }\n\n      if (!Array.isArray(i.tags)) {\n        return {\n          success: false,\n          error: new AppError(\n            ErrorCode.VALIDATION_ERROR,\n            'Prompt item missing valid \"tags\" field',\n            { item },\n          ),\n        };\n      }\n    }\n\n    return {\n      success: true,\n      data: payload as PromptExportPayload,\n    };\n  }\n\n  /**\n   * Load prompts from localStorage\n   */\n  static async loadPrompts(): Promise<Result<PromptItem[]>> {\n    try {\n      const raw = localStorage.getItem(STORAGE_KEY);\n      if (raw === null) {\n        return {\n          success: true,\n          data: [],\n        };\n      }\n\n      const items = JSON.parse(raw) as PromptItem[];\n      return {\n        success: true,\n        data: Array.isArray(items) ? items : [],\n      };\n    } catch (error) {\n      return {\n        success: false,\n        error: new AppError(\n          ErrorCode.STORAGE_READ_FAILED,\n          'Failed to load prompts from localStorage',\n          { key: STORAGE_KEY },\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n\n  /**\n   * Save prompts to localStorage\n   */\n  static async savePrompts(items: PromptItem[]): Promise<Result<void>> {\n    try {\n      const raw = JSON.stringify(items);\n      localStorage.setItem(STORAGE_KEY, raw);\n      return {\n        success: true,\n        data: undefined,\n      };\n    } catch (error) {\n      return {\n        success: false,\n        error: new AppError(\n          ErrorCode.STORAGE_WRITE_FAILED,\n          'Failed to save prompts to localStorage',\n          { key: STORAGE_KEY, itemCount: items.length },\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n\n  /**\n   * Generate filename for export with timestamp\n   */\n  static generateExportFilename(): string {\n    const pad = (n: number) => String(n).padStart(2, '0');\n    const d = new Date();\n    const y = d.getFullYear();\n    const m = pad(d.getMonth() + 1);\n    const day = pad(d.getDate());\n    const hh = pad(d.getHours());\n    const mm = pad(d.getMinutes());\n    const ss = pad(d.getSeconds());\n    return `gemini-voyager-prompts-${y}${m}${day}-${hh}${mm}${ss}.json`;\n  }\n\n  /**\n   * Export prompts to JSON string\n   */\n  static async exportToJSON(): Promise<Result<string>> {\n    const result = await this.loadPrompts();\n    if (!result.success) {\n      return result;\n    }\n\n    const payload = this.exportToPayload(result.data);\n    return {\n      success: true,\n      data: JSON.stringify(payload, null, 2),\n    };\n  }\n\n  /**\n   * Import prompts from payload\n   * Merges with existing prompts (deduplicates by text)\n   * @param payload - The import payload\n   * @returns Result with import statistics\n   */\n  static async importFromPayload(payload: PromptExportPayload): Promise<\n    Result<{\n      imported: number;\n      duplicates: number;\n      total: number;\n    }>\n  > {\n    try {\n      // Load existing prompts\n      const loadResult = await this.loadPrompts();\n      if (!loadResult.success) {\n        return loadResult;\n      }\n\n      const existingItems = loadResult.data;\n      const importItems = payload.items;\n\n      // Deduplicate and merge\n      const existingMap = new Map<string, PromptItem>();\n      for (const item of existingItems) {\n        existingMap.set(item.text.toLowerCase(), item);\n      }\n\n      let imported = 0;\n      let duplicates = 0;\n\n      for (const item of importItems) {\n        const key = item.text.toLowerCase();\n        if (existingMap.has(key)) {\n          // Merge tags if duplicate\n          const existing = existingMap.get(key)!;\n          const mergedTags = Array.from(new Set([...(existing.tags || []), ...(item.tags || [])]));\n          existing.tags = mergedTags;\n          existing.updatedAt = Date.now();\n          duplicates++;\n        } else {\n          existingMap.set(key, {\n            ...item,\n            createdAt: Date.now(),\n          });\n          imported++;\n        }\n      }\n\n      // Save merged results\n      const mergedItems = Array.from(existingMap.values()).sort(\n        (a, b) => (b.createdAt || 0) - (a.createdAt || 0),\n      );\n\n      const saveResult = await this.savePrompts(mergedItems);\n      if (!saveResult.success) {\n        return saveResult;\n      }\n\n      return {\n        success: true,\n        data: {\n          imported,\n          duplicates,\n          total: mergedItems.length,\n        },\n      };\n    } catch (error) {\n      return {\n        success: false,\n        error: new AppError(\n          ErrorCode.UNKNOWN_ERROR,\n          'Failed to import prompts',\n          { payload },\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n\n  /**\n   * Download JSON file to user's computer\n   */\n  static downloadJSON(payload: PromptExportPayload, filename?: string): void {\n    const data = JSON.stringify(payload, null, 2);\n    const blob = new Blob([data], { type: 'application/json;charset=utf-8' });\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = filename || this.generateExportFilename();\n    document.body.appendChild(a);\n    a.click();\n    setTimeout(() => {\n      try {\n        document.body.removeChild(a);\n      } catch {\n        /* ignore */\n      }\n      URL.revokeObjectURL(url);\n    }, 0);\n  }\n\n  /**\n   * Read and parse JSON file from user upload\n   */\n  static async readJSONFile(file: File): Promise<Result<unknown>> {\n    try {\n      const text = await file.text();\n      const parsed = JSON.parse(text);\n      return {\n        success: true,\n        data: parsed,\n      };\n    } catch (error) {\n      return {\n        success: false,\n        error: new AppError(\n          ErrorCode.VALIDATION_ERROR,\n          'Failed to parse JSON file',\n          { fileName: file.name },\n          error instanceof Error ? error : undefined,\n        ),\n      };\n    }\n  }\n}\n"
  },
  {
    "path": "src/features/backup/types/backup.ts",
    "content": "/**\n * Types for auto-backup functionality\n * Follows enterprise best practices with comprehensive type safety\n */\nimport type { Result } from '@/core/types/common';\n\n/**\n * Prompt item structure (matches prompt manager schema)\n */\nexport interface PromptItem {\n  id: string;\n  text: string;\n  tags: string[];\n  createdAt: number;\n  updatedAt?: number;\n}\n\n/**\n * Prompt export payload format\n */\nexport interface PromptExportPayload {\n  format: 'gemini-voyager.prompts.v1';\n  exportedAt: string;\n  version?: string;\n  items: PromptItem[];\n}\n\n/**\n * Backup configuration stored in chrome.storage\n */\nexport interface BackupConfig {\n  /** Enable auto-backup feature */\n  enabled: boolean;\n  /** Auto-backup interval in hours (0 = manual only) */\n  intervalHours: number;\n  /** Last backup timestamp (ISO 8601) */\n  lastBackupAt?: string;\n  /** Whether to include prompts in backup */\n  includePrompts: boolean;\n  /** Whether to include folders in backup */\n  includeFolders: boolean;\n}\n\n/**\n * Backup metadata stored in backup folder\n */\nexport interface BackupMetadata {\n  version: string;\n  timestamp: string;\n  includesPrompts: boolean;\n  includesFolders: boolean;\n  promptCount?: number;\n  folderCount?: number;\n  conversationCount?: number;\n}\n\n/**\n * Result of backup operation\n * Note: Wrapped in Result<T> type, so no need for success/error fields here\n */\nexport interface BackupResult {\n  /** Timestamp of backup (ISO 8601) */\n  timestamp: string;\n  /** Number of prompts backed up */\n  promptCount: number;\n  /** Number of folders backed up */\n  folderCount: number;\n  /** Number of conversations backed up */\n  conversationCount: number;\n}\n\n/**\n * File handle with name and content\n */\nexport interface BackupFile {\n  name: string;\n  content: string;\n}\n\n/**\n * Backup service interface\n */\nexport interface IBackupService {\n  /**\n   * Create a backup with timestamp-based folder\n   * @param directoryHandle - File System Access API directory handle\n   * @param config - Backup configuration\n   * @returns Result with backup statistics\n   */\n  createBackup(\n    directoryHandle: FileSystemDirectoryHandle,\n    config: BackupConfig,\n  ): Promise<Result<BackupResult>>;\n\n  /**\n   * Generate backup files without writing to filesystem\n   * Useful for testing or preview\n   * @param config - Backup configuration\n   * @returns Array of backup files\n   */\n  generateBackupFiles(config: BackupConfig): Promise<Result<BackupFile[]>>;\n\n  /**\n   * Check if backup is needed based on config\n   * @param config - Backup configuration\n   * @returns true if backup should be performed\n   */\n  shouldBackup(config: BackupConfig): boolean;\n}\n\n/**\n * Storage keys for backup configuration\n */\nexport const BACKUP_STORAGE_KEYS = {\n  CONFIG: 'gvBackupConfig',\n} as const;\n\n/**\n * Default backup configuration\n */\nexport const DEFAULT_BACKUP_CONFIG: BackupConfig = {\n  enabled: false,\n  intervalHours: 24, // Daily by default\n  includePrompts: true,\n  includeFolders: true,\n};\n"
  },
  {
    "path": "src/features/contextSync/adapters/index.ts",
    "content": "import { AdapterConfig } from '../types';\n\nexport const ADAPTERS: Record<string, AdapterConfig> = {\n  // gemini\n  'gemini.google.com': {\n    user_selector: ['div.user-query-container'],\n    ai_selector: ['.response-content'],\n  },\n  // default\n  default: {\n    selectors: ['div', 'p'],\n    aiMarkers: ['ai', 'assistant'],\n    userMarkers: ['user', 'human'],\n  },\n};\n\nexport function getMatchedAdapter(host: string): AdapterConfig {\n  for (const key of Object.keys(ADAPTERS)) {\n    if (host.includes(key)) {\n      return ADAPTERS[key];\n    }\n  }\n  return ADAPTERS.default;\n}\n"
  },
  {
    "path": "src/features/contextSync/services/SyncService.ts",
    "content": "import { DialogNode, SyncResponse } from '../types';\n\nexport class SyncService {\n  private static instance: SyncService;\n  private readonly DEFAULT_PORT = 3030;\n\n  private constructor() {}\n\n  static getInstance(): SyncService {\n    if (!this.instance) {\n      this.instance = new SyncService();\n    }\n    return this.instance;\n  }\n\n  private async getServerUrl(): Promise<string> {\n    return new Promise((resolve) => {\n      chrome.storage.sync.get(['contextSyncPort'], (result) => {\n        const port = result.contextSyncPort || this.DEFAULT_PORT;\n        resolve(`http://127.0.0.1:${port}/sync`);\n      });\n    });\n  }\n\n  async checkServerStatus(): Promise<boolean> {\n    try {\n      const url = await this.getServerUrl();\n      return new Promise((resolve) => {\n        chrome.runtime.sendMessage(\n          { type: 'gv.checkSyncStatus', url, timeout: 500 },\n          (response) => {\n            resolve(!!response?.ok);\n          },\n        );\n      });\n    } catch {\n      return false;\n    }\n  }\n\n  async syncToIDE(data: DialogNode[]): Promise<SyncResponse> {\n    console.log('📡 Syncing to Code Editor server via background...', data);\n    try {\n      const url = await this.getServerUrl();\n      return new Promise((resolve, reject) => {\n        chrome.runtime.sendMessage({ type: 'gv.syncToIDE', url, data }, (response) => {\n          if (response && response.ok) {\n            resolve(response.data);\n          } else {\n            reject(new Error(response?.error || 'Code Editor Server not responding.'));\n          }\n        });\n      });\n    } catch (err) {\n      throw new Error((err as Error).message);\n    }\n  }\n}\n"
  },
  {
    "path": "src/features/contextSync/types.ts",
    "content": "export interface DialogNode {\n  url: string;\n  className: string;\n  text: string;\n  images?: string[];\n  is_ai_likely: boolean;\n  is_user_likely: boolean;\n  rect: {\n    top: number;\n    left: number;\n    width: number;\n  };\n}\n\nexport interface SyncResponse {\n  status: 'success' | 'error';\n  data?: unknown;\n  message?: string;\n}\n\nexport interface AdapterConfig {\n  user_selector?: string[];\n  ai_selector?: string[];\n  selectors?: string[];\n  aiMarkers?: string[];\n  userMarkers?: string[];\n}\n"
  },
  {
    "path": "src/features/export/services/ConversationExportService.ts",
    "content": "/**\n * Conversation Export Service\n * Unified service for exporting conversations in multiple formats\n * Uses Strategy pattern for format-specific implementations\n */\nimport JSZip from 'jszip';\n\nimport { isSafari } from '@/core/utils/browser';\n\nimport { IMAGE_RENDER_EVENT_ERROR_CODE, isEventLikeImageRenderError } from '../types/errors';\nimport type {\n  ChatTurn,\n  ConversationMetadata,\n  ExportFormat,\n  ExportLayout,\n  ExportOptions,\n  ExportResult,\n} from '../types/export';\nimport { DOMContentExtractor } from './DOMContentExtractor';\nimport { DeepResearchPDFPrintService } from './DeepResearchPDFPrintService';\nimport { ImageExportService } from './ImageExportService';\nimport { MarkdownFormatter } from './MarkdownFormatter';\nimport { PDFPrintService } from './PDFPrintService';\n\n/**\n * Main export service\n * Coordinates different export strategies\n */\nexport class ConversationExportService {\n  private static readonly REPORT_JSON_FORMAT = 'gemini-voyager.report.v1' as const;\n\n  private static readonly CHAT_JSON_FORMAT = 'gemini-voyager.chat.v1' as const;\n\n  /**\n   * Export conversation in specified format\n   */\n  static async export(\n    turns: ChatTurn[],\n    metadata: ConversationMetadata,\n    options: ExportOptions,\n  ): Promise<ExportResult> {\n    try {\n      const layout: ExportLayout = options.layout ?? 'conversation';\n      if (layout === 'document') {\n        return await this.exportDocument(turns, metadata, options);\n      }\n\n      switch (options.format) {\n        case 'json':\n          return this.exportJSON(turns, metadata, options);\n\n        case 'markdown':\n          return await this.exportMarkdown(turns, metadata, options);\n\n        case 'pdf':\n          return await this.exportPDF(turns, metadata, options);\n\n        case 'image':\n          return await this.exportImage(turns, metadata, options);\n\n        default:\n          return {\n            success: false,\n            format: options.format,\n            error: `Unsupported format: ${options.format}`,\n          };\n      }\n    } catch (error) {\n      return {\n        success: false,\n        format: options.format,\n        error: this.normalizeError(error),\n      };\n    }\n  }\n\n  private static normalizeError(error: unknown): string {\n    if (error instanceof Error) {\n      return error.message;\n    }\n\n    if (typeof Event !== 'undefined' && error instanceof Event) {\n      return IMAGE_RENDER_EVENT_ERROR_CODE;\n    }\n\n    if (isEventLikeImageRenderError(error)) {\n      return IMAGE_RENDER_EVENT_ERROR_CODE;\n    }\n\n    return String(error);\n  }\n\n  private static async exportDocument(\n    turns: ChatTurn[],\n    metadata: ConversationMetadata,\n    options: ExportOptions,\n  ): Promise<ExportResult> {\n    const content = this.extractDocumentContent(turns);\n\n    switch (options.format) {\n      case 'json':\n        return this.exportDocumentJSON(content, metadata, options);\n      case 'markdown':\n        return await this.exportDocumentMarkdown(content, metadata, options);\n      case 'pdf':\n        return await this.exportDocumentPDF(content, metadata, options);\n      case 'image':\n        return await this.exportDocumentImage(content, metadata, options);\n      default:\n        return {\n          success: false,\n          format: options.format,\n          error: `Unsupported format: ${options.format}`,\n        };\n    }\n  }\n\n  /**\n   * Export as JSON (existing format)\n   * Now extracts content with Markdown formatting using DOMContentExtractor\n   * to ensure consistency with Markdown export\n   */\n  private static exportJSON(\n    turns: ChatTurn[],\n    metadata: ConversationMetadata,\n    options: ExportOptions,\n  ): ExportResult {\n    // Process turns to extract Markdown-formatted content from DOM elements\n    const processedItems = turns.map((turn) => {\n      let userContent = turn.user;\n      let assistantContent = turn.assistant;\n\n      // Extract rich content with Markdown formatting from DOM elements if available\n      if (turn.userElement) {\n        const extracted = DOMContentExtractor.extractUserContent(turn.userElement);\n        if (extracted.text) {\n          userContent = extracted.text;\n        }\n      }\n\n      if (turn.assistantElement) {\n        const extracted = DOMContentExtractor.extractAssistantContent(turn.assistantElement);\n        if (extracted.text) {\n          assistantContent = extracted.text;\n        }\n      }\n\n      return {\n        user: userContent,\n        assistant: assistantContent,\n        starred: turn.starred,\n      };\n    });\n\n    const payload = {\n      format: this.CHAT_JSON_FORMAT,\n      url: metadata.url,\n      exportedAt: metadata.exportedAt,\n      count: metadata.count,\n      title: metadata.title,\n      items: processedItems,\n    };\n\n    const filename = options.filename || this.generateFilename('json', metadata.title);\n    this.downloadJSON(payload, filename);\n\n    return {\n      success: true,\n      format: 'json' as ExportFormat,\n      filename,\n    };\n  }\n\n  /**\n   * Export as Markdown\n   */\n  private static async exportMarkdown(\n    turns: ChatTurn[],\n    metadata: ConversationMetadata,\n    options: ExportOptions,\n  ): Promise<ExportResult> {\n    // First create a clean markdown (no inlining)\n    let markdown = MarkdownFormatter.format(turns, metadata);\n\n    // Strip image source attribution lines if user opted out\n    if (options.includeImageSource === false) {\n      markdown = markdown.replace(/\\n\\*Source: \\[[^\\]]*\\]\\([^)]*\\)\\*\\n/g, '\\n');\n    }\n\n    const filename = options.filename || this.generateFilename('md', metadata.title);\n    const finalFilename = await this.downloadMarkdownOrZip(markdown, filename, 'chat.md');\n    return { success: true, format: 'markdown' as ExportFormat, filename: finalFilename };\n  }\n\n  /**\n   * Export as PDF (using print dialog)\n   */\n  private static async exportPDF(\n    turns: ChatTurn[],\n    metadata: ConversationMetadata,\n    options: ExportOptions,\n  ): Promise<ExportResult> {\n    await PDFPrintService.export(turns, metadata, { fontSize: options.fontSize });\n\n    // Note: We can't get the actual filename from print dialog\n    // User chooses filename in Save as PDF dialog\n    return {\n      success: true,\n      format: 'pdf' as ExportFormat,\n      filename: options.filename || this.generateFilename('pdf', metadata.title),\n    };\n  }\n\n  /**\n   * Export as image (PNG)\n   */\n  private static async exportImage(\n    turns: ChatTurn[],\n    metadata: ConversationMetadata,\n    options: ExportOptions,\n  ): Promise<ExportResult> {\n    const filename = options.filename || this.generateFilename('png', metadata.title);\n    await ImageExportService.export(turns, metadata, { filename, fontSize: options.fontSize });\n    return { success: true, format: 'image' as ExportFormat, filename };\n  }\n\n  private static exportDocumentJSON(\n    content: { markdown: string; html: string },\n    metadata: ConversationMetadata,\n    options: ExportOptions,\n  ): ExportResult {\n    const payload = {\n      format: this.REPORT_JSON_FORMAT,\n      url: metadata.url,\n      exportedAt: metadata.exportedAt,\n      title: metadata.title,\n      content: {\n        markdown: content.markdown,\n        html: content.html,\n      },\n    };\n\n    const filename = options.filename || this.generateFilename('json', metadata.title);\n    this.downloadJSON(payload, filename);\n    return {\n      success: true,\n      format: 'json' as ExportFormat,\n      filename,\n    };\n  }\n\n  private static async exportDocumentMarkdown(\n    content: { markdown: string; html: string },\n    metadata: ConversationMetadata,\n    options: ExportOptions,\n  ): Promise<ExportResult> {\n    const markdown = this.composeDocumentMarkdown(content.markdown, metadata);\n    const filename = options.filename || this.generateFilename('md', metadata.title);\n    const mdEntryName = filename.toLowerCase().endsWith('.md')\n      ? filename.split('/').pop() || 'report.md'\n      : 'report.md';\n\n    const finalFilename = await this.downloadMarkdownOrZip(markdown, filename, mdEntryName);\n    return {\n      success: true,\n      format: 'markdown' as ExportFormat,\n      filename: finalFilename,\n    };\n  }\n\n  private static async exportDocumentPDF(\n    content: { markdown: string; html: string },\n    metadata: ConversationMetadata,\n    options: ExportOptions,\n  ): Promise<ExportResult> {\n    await DeepResearchPDFPrintService.export({\n      title: metadata.title || 'Deep Research Report',\n      url: metadata.url,\n      exportedAt: metadata.exportedAt,\n      markdown: content.markdown,\n      html: content.html,\n    });\n\n    return {\n      success: true,\n      format: 'pdf' as ExportFormat,\n      filename: options.filename || this.generateFilename('pdf', metadata.title),\n    };\n  }\n\n  private static async exportDocumentImage(\n    content: { markdown: string; html: string },\n    metadata: ConversationMetadata,\n    options: ExportOptions,\n  ): Promise<ExportResult> {\n    const filename = options.filename || this.generateFilename('png', metadata.title);\n    await ImageExportService.exportDocument(\n      {\n        title: metadata.title || 'Deep Research Report',\n        url: metadata.url,\n        exportedAt: metadata.exportedAt,\n        markdown: content.markdown,\n        html: content.html,\n      },\n      { filename },\n    );\n\n    return {\n      success: true,\n      format: 'image' as ExportFormat,\n      filename,\n    };\n  }\n\n  private static extractDocumentContent(turns: ChatTurn[]): { markdown: string; html: string } {\n    const turn =\n      turns.find((item) => item.assistantElement || item.assistant.trim()) ||\n      turns.find((item) => item.userElement || item.user.trim());\n\n    if (!turn) {\n      return { markdown: '', html: '' };\n    }\n\n    if (turn.assistantElement) {\n      const extracted = DOMContentExtractor.extractAssistantContent(turn.assistantElement);\n      return {\n        markdown: extracted.text || turn.assistant,\n        html: extracted.html || this.formatPlainTextAsHtml(extracted.text || turn.assistant),\n      };\n    }\n\n    if (turn.userElement) {\n      const extracted = DOMContentExtractor.extractUserContent(turn.userElement);\n      return {\n        markdown: extracted.text || turn.user,\n        html: extracted.html || this.formatPlainTextAsHtml(extracted.text || turn.user),\n      };\n    }\n\n    const markdown = turn.assistant || turn.user || '';\n    return {\n      markdown,\n      html: this.formatPlainTextAsHtml(markdown),\n    };\n  }\n\n  private static composeDocumentMarkdown(content: string, metadata: ConversationMetadata): string {\n    const sections: string[] = [];\n    const title = metadata.title?.trim() || 'Deep Research Report';\n    const trimmedContent = content.trim() || '_No content_';\n    const startsWithHeading = this.hasLeadingMarkdownHeading(trimmedContent);\n\n    if (!startsWithHeading) {\n      sections.push(`# ${title}`);\n      sections.push('');\n    }\n\n    sections.push(trimmedContent);\n    sections.push('');\n    sections.push('---');\n    sections.push('');\n    sections.push(`Source: ${metadata.url}`);\n    sections.push(`Exported at: ${metadata.exportedAt}`);\n    return sections.join('\\n');\n  }\n\n  private static hasLeadingMarkdownHeading(content: string): boolean {\n    return /^#{1,6}\\s+\\S/m.test(content) && /^#{1,6}\\s+\\S/.test(content);\n  }\n\n  private static formatPlainTextAsHtml(content: string): string {\n    if (!content.trim()) return '';\n    const escaped = content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n    return escaped\n      .split('\\n\\n')\n      .map((paragraph) => `<p>${paragraph.replace(/\\n/g, '<br>')}</p>`)\n      .join('\\n');\n  }\n\n  private static async downloadMarkdownOrZip(\n    markdown: string,\n    filename: string,\n    markdownEntryName: string,\n  ): Promise<string> {\n    const normalizedFilename = filename.toLowerCase().endsWith('.md') ? filename : `${filename}.md`;\n\n    if (isSafari()) {\n      const degradedMarkdown = MarkdownFormatter.degradeImageMarkdownForSafari(markdown);\n      MarkdownFormatter.download(degradedMarkdown, normalizedFilename);\n      return normalizedFilename;\n    }\n\n    const imageUrls = MarkdownFormatter.extractImageUrls(markdown);\n\n    if (imageUrls.length === 0) {\n      MarkdownFormatter.download(markdown, normalizedFilename);\n      return normalizedFilename;\n    }\n\n    const zip = new JSZip();\n    const assetsFolder = zip.folder('assets');\n    const mapping = new Map<string, string>();\n\n    const fetchedByOrder = await Promise.all(\n      imageUrls.map(async (url) => {\n        // For Google images, request original size (=s0) instead of the display thumbnail\n        const fetchUrl = this.toOriginalSizeUrl(url);\n        const fetched = await this.fetchImageForMarkdownPackaging(fetchUrl);\n        if (!fetched) return null;\n        return {\n          url,\n          blob: fetched.blob,\n          contentType: fetched.contentType,\n        };\n      }),\n    );\n\n    let index = 1;\n    for (const item of fetchedByOrder) {\n      if (!item) continue;\n      const extension = this.pickImageExtension(item.contentType, item.url);\n      const fileName = `img-${String(index++).padStart(3, '0')}.${extension}`;\n      const base64Payload = await this.blobToBase64Payload(item.blob);\n      if (!base64Payload) continue;\n      assetsFolder?.file(fileName, base64Payload, { base64: true });\n      mapping.set(item.url, `assets/${fileName}`);\n    }\n\n    const packagedMarkdown = MarkdownFormatter.rewriteImageUrls(markdown, mapping);\n    zip.file(markdownEntryName, packagedMarkdown);\n\n    const zipBlob = await zip.generateAsync({ type: 'blob' });\n    const zipFilename = normalizedFilename.replace(/\\.md$/i, '.zip');\n    const url = URL.createObjectURL(zipBlob);\n    const anchor = document.createElement('a');\n    anchor.href = url;\n    anchor.download = zipFilename;\n    document.body.appendChild(anchor);\n    anchor.click();\n    setTimeout(() => {\n      try {\n        document.body.removeChild(anchor);\n      } catch {\n        /* ignore */\n      }\n      URL.revokeObjectURL(url);\n    }, 0);\n\n    return zipFilename;\n  }\n\n  /**\n   * Replace Google image URL size parameter with =s0 for original resolution.\n   * Non-Google URLs are returned unchanged.\n   */\n  private static toOriginalSizeUrl(url: string): string {\n    const isGoogle = url.includes('googleusercontent.com') || url.includes('ggpht.com');\n    if (!isGoogle) return url;\n    const GOOGLE_SIZE_PATTERN = /=[swh]\\d+[^?#]*/;\n    if (GOOGLE_SIZE_PATTERN.test(url)) {\n      return url.replace(GOOGLE_SIZE_PATTERN, '=s0');\n    }\n    return url.includes('=') ? url + '-s0' : url + '=s0';\n  }\n\n  private static pickImageExtension(contentType: string | null, url: string): string {\n    const byType: Record<string, string> = {\n      'image/png': 'png',\n      'image/jpeg': 'jpg',\n      'image/jpg': 'jpg',\n      'image/webp': 'webp',\n      'image/gif': 'gif',\n      'image/svg+xml': 'svg',\n    };\n    if (contentType && byType[contentType]) return byType[contentType];\n    const match = url.split('?')[0].match(/\\.(png|jpg|jpeg|gif|webp|svg)$/i);\n    if (match) return match[1].toLowerCase() === 'jpeg' ? 'jpg' : match[1].toLowerCase();\n    return 'bin';\n  }\n\n  private static blobToBase64Payload(blob: Blob): Promise<string | null> {\n    return new Promise((resolve) => {\n      try {\n        const reader = new FileReader();\n        reader.onload = () => {\n          const dataUrl = String(reader.result || '');\n          const commaIndex = dataUrl.indexOf(',');\n          resolve(commaIndex >= 0 ? dataUrl.slice(commaIndex + 1) : null);\n        };\n        reader.onerror = () => resolve(null);\n        reader.readAsDataURL(blob);\n      } catch {\n        resolve(null);\n      }\n    });\n  }\n\n  private static async fetchImageForMarkdownPackaging(\n    url: string,\n  ): Promise<{ blob: Blob; contentType: string | null } | null> {\n    try {\n      const response = await fetch(url, { credentials: 'include', mode: 'cors' as RequestMode });\n      if (response.ok) {\n        return {\n          blob: await response.blob(),\n          contentType: response.headers.get('Content-Type'),\n        };\n      }\n    } catch {\n      /* ignore */\n    }\n\n    // Retry without credentials for servers with wildcard CORS\n    // (Access-Control-Allow-Origin: * is incompatible with credentials: 'include')\n    try {\n      const response = await fetch(url, { credentials: 'omit', mode: 'cors' as RequestMode });\n      if (response.ok) {\n        return {\n          blob: await response.blob(),\n          contentType: response.headers.get('Content-Type'),\n        };\n      }\n    } catch {\n      /* ignore */\n    }\n\n    type RuntimeFetchImageResponse =\n      | {\n          ok: true;\n          base64: string;\n          contentType?: string;\n          data?: string;\n        }\n      | {\n          ok?: false;\n          base64?: unknown;\n          contentType?: unknown;\n        }\n      | null;\n\n    const decodeRuntimeResponse = (\n      response: RuntimeFetchImageResponse,\n    ): { blob: Blob; contentType: string } | null => {\n      if (!(response && response.ok && typeof response.base64 === 'string')) return null;\n      const contentType = String(response.contentType || 'application/octet-stream');\n      const binary = atob(response.base64);\n      const length = binary.length;\n      const bytes = new Uint8Array(length);\n      for (let idx = 0; idx < length; idx++) bytes[idx] = binary.charCodeAt(idx);\n      return {\n        blob: new Blob([bytes], { type: contentType }),\n        contentType,\n      };\n    };\n\n    const sendFetchMessage = async (\n      type: 'gv.fetchImage' | 'gv.fetchImageViaPage',\n    ): Promise<RuntimeFetchImageResponse> => {\n      const sendMessage = chrome.runtime?.sendMessage;\n      if (typeof sendMessage !== 'function') return null;\n      return await new Promise<RuntimeFetchImageResponse>((resolve) => {\n        try {\n          (sendMessage as (...args: unknown[]) => void)({ type, url }, (rawResponse: unknown) => {\n            resolve((rawResponse as RuntimeFetchImageResponse) ?? null);\n          });\n        } catch {\n          resolve(null);\n        }\n      });\n    };\n\n    try {\n      const response = await sendFetchMessage('gv.fetchImage');\n      const decoded = decodeRuntimeResponse(response);\n      if (decoded) return decoded;\n    } catch {\n      /* ignore */\n    }\n\n    // blob: URLs are page-scoped and not fetchable via background/page-message strategy.\n    if (url.startsWith('blob:')) return null;\n\n    try {\n      const response = await sendFetchMessage('gv.fetchImageViaPage');\n      const decoded = decodeRuntimeResponse(response);\n      if (decoded) return decoded;\n    } catch {\n      /* ignore */\n    }\n\n    return null;\n  }\n\n  /**\n   * Download JSON file\n   */\n  private static downloadJSON(data: unknown, filename: string): void {\n    const blob = new Blob([JSON.stringify(data, null, 2)], {\n      type: 'application/json;charset=utf-8',\n    });\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = filename;\n    document.body.appendChild(a);\n    a.click();\n    setTimeout(() => {\n      try {\n        document.body.removeChild(a);\n      } catch {\n        /* ignore */\n      }\n      URL.revokeObjectURL(url);\n    }, 0);\n  }\n\n  /**\n   * Generate filename with timestamp\n   */\n  private static generateFilename(extension: string, title?: string): string {\n    const titlePart = this.sanitizeFilenamePart(title);\n    if (titlePart) {\n      return `${titlePart}.${extension}`;\n    }\n\n    const pad = (n: number) => String(n).padStart(2, '0');\n    const d = new Date();\n    const y = d.getFullYear();\n    const m = pad(d.getMonth() + 1);\n    const day = pad(d.getDate());\n    const hh = pad(d.getHours());\n    const mm = pad(d.getMinutes());\n    const ss = pad(d.getSeconds());\n    return `gemini-chat-${y}${m}${day}-${hh}${mm}${ss}.${extension}`;\n  }\n\n  private static sanitizeFilenamePart(title?: string): string {\n    if (!title) return '';\n\n    const compact = title.trim().replace(/\\s+/g, ' ');\n    if (!compact) return '';\n\n    return compact\n      .replace(/[\\\\/:*?\"<>|]/g, '')\n      .replace(/\\s+/g, '-')\n      .replace(/\\.+$/g, '')\n      .slice(0, 80);\n  }\n\n  /**\n   * Get available export formats\n   */\n  static getAvailableFormats(): Array<{\n    format: ExportFormat;\n    label: string;\n    description: string;\n    recommended?: boolean;\n  }> {\n    return [\n      {\n        format: 'json' as ExportFormat,\n        label: 'JSON',\n        description: 'Machine-readable format for developers',\n      },\n      {\n        format: 'markdown' as ExportFormat,\n        label: 'Markdown',\n        description: 'Clean, portable text format (recommended)',\n        recommended: true,\n      },\n      {\n        format: 'pdf' as ExportFormat,\n        label: 'PDF',\n        description: 'Print-friendly format via Save as PDF',\n      },\n      {\n        format: 'image' as ExportFormat,\n        label: 'Image',\n        description: 'Single PNG image for sharing',\n      },\n    ];\n  }\n}\n"
  },
  {
    "path": "src/features/export/services/DOMContentExtractor.ts",
    "content": "/**\n * DOM Content Extractor\n * Extracts rich content from Gemini's DOM structure preserving formatting\n */\n\nexport interface ExtractedContent {\n  text: string;\n  html: string;\n  hasImages: boolean;\n  hasFormulas: boolean;\n  hasTables: boolean;\n  hasCode: boolean;\n}\n\nexport interface ExtractedTurn {\n  user: ExtractedContent;\n  assistant: ExtractedContent;\n  starred: boolean;\n}\n\n/**\n * Extracts structured content from Gemini's DOM\n * Preserves formatting including LaTeX formulas, code blocks, tables, etc.\n */\n\n/**\n * querySelector variant that skips elements nested inside model-thoughts / thoughts-container.\n * When the user expands Gemini's \"thinking\" section, a second `message-content` element\n * appears *before* the real response in DOM order.  A plain `querySelector` would match\n * the thinking panel first, causing exports to grab the wrong content.\n */\nfunction queryOutsideThoughts<T extends Element = Element>(\n  root: Element,\n  selector: string,\n): T | null {\n  const candidates = root.querySelectorAll<T>(selector);\n  for (const el of Array.from(candidates)) {\n    if (!el.closest('model-thoughts, .thoughts-container, .thoughts-content')) {\n      return el;\n    }\n  }\n  return null;\n}\nexport class DOMContentExtractor {\n  private static DEBUG = false;\n  /**\n   * Extract user query content\n   */\n  static extractUserContent(element: HTMLElement): ExtractedContent {\n    const result: ExtractedContent = {\n      text: '',\n      html: '',\n      hasImages: false,\n      hasFormulas: false,\n      hasTables: false,\n      hasCode: false,\n    };\n\n    // Check for images\n    const images = element.querySelectorAll('user-query-file-preview img, .preview-image');\n    result.hasImages = images.length > 0;\n\n    // Extract text from query-text-line paragraphs\n    const textLines = element.querySelectorAll('.query-text-line');\n    const textParts: string[] = [];\n    textLines.forEach((line) => {\n      const text = this.normalizeText(line.textContent || '');\n      if (text) textParts.push(text);\n    });\n    result.text = textParts.join('\\n');\n\n    // Build HTML representation\n    const htmlParts: string[] = [];\n\n    // Add image markdown\n    const imageMarkdown: string[] = [];\n    images.forEach((img, index) => {\n      const src = (img as HTMLImageElement).src;\n      const alt = (img as HTMLImageElement).alt || `Uploaded image ${index + 1}`;\n      htmlParts.push(`<img src=\"${src}\" alt=\"${alt}\" />`);\n      imageMarkdown.push(`![${alt}](${src})`);\n    });\n\n    // Combine image markdown and text\n    const allTextParts: string[] = [];\n    if (imageMarkdown.length > 0) {\n      allTextParts.push(imageMarkdown.join('\\n\\n'));\n    }\n    if (textParts.length > 0) {\n      allTextParts.push(textParts.join('\\n'));\n    }\n    result.text = allTextParts.join('\\n\\n');\n\n    // Add text paragraphs to HTML\n    textParts.forEach((text) => {\n      htmlParts.push(`<p>${this.escapeHtml(text)}</p>`);\n    });\n\n    result.html = htmlParts.join('\\n');\n\n    return result;\n  }\n\n  /**\n   * Extract assistant response content with rich formatting\n   */\n  static extractAssistantContent(element: HTMLElement): ExtractedContent {\n    if (this.DEBUG)\n      console.log('[DOMContentExtractor] extractAssistantContent called, element:', element);\n\n    const result: ExtractedContent = {\n      text: '',\n      html: '',\n      hasImages: false,\n      hasFormulas: false,\n      hasTables: false,\n      hasCode: false,\n    };\n\n    // Find message-content first (contains main text and formulas)\n    // Use queryOutsideThoughts to avoid matching the message-content inside\n    // the expanded thinking/reasoning panel.\n    let messageContent = queryOutsideThoughts(element, 'message-content');\n\n    if (!messageContent) {\n      // Try markdown container\n      messageContent = queryOutsideThoughts(\n        element,\n        '.markdown-main-panel, ' + '.markdown, ' + '.model-response-text',\n      );\n    }\n\n    // If still not found, check if element itself is a valid container\n    if (!messageContent) {\n      if (\n        element.classList.contains('markdown') ||\n        element.tagName.toLowerCase() === 'message-content'\n      ) {\n        messageContent = element;\n      }\n    }\n\n    if (!messageContent) {\n      // Last resort: use element directly\n      console.warn('[DOMContentExtractor] Response container not found, using element directly');\n      messageContent = element;\n    }\n\n    if (this.DEBUG)\n      console.log(\n        '[DOMContentExtractor] Using container:',\n        messageContent.tagName,\n        messageContent.className,\n      );\n\n    // Don't clone! Angular custom elements may lose content when cloned\n    // Instead, skip model-thoughts during processNodes\n    const htmlParts: string[] = [];\n    const textParts: string[] = [];\n\n    // STRATEGY CHANGE: Instead of recursing through DOM (which misses Angular-rendered elements),\n    // process the .markdown div directly and then search for response-elements\n    const markdownDiv = messageContent.querySelector('.markdown, .markdown-main-panel');\n\n    if (this.DEBUG) {\n      console.log('[DOMContentExtractor] messageContent tagName:', messageContent.tagName);\n      console.log('[DOMContentExtractor] messageContent className:', messageContent.className);\n      console.log('[DOMContentExtractor] markdownDiv found?', !!markdownDiv);\n    }\n\n    if (markdownDiv) {\n      if (this.DEBUG) {\n        console.log('[DOMContentExtractor] markdownDiv tagName:', markdownDiv.tagName);\n        console.log('[DOMContentExtractor] markdownDiv className:', markdownDiv.className);\n        console.log(\n          '[DOMContentExtractor] markdownDiv innerHTML preview:',\n          (markdownDiv as HTMLElement).innerHTML.substring(0, 300),\n        );\n      }\n\n      // First, process all direct children of markdown that are NOT response-element\n      this.processNodes(markdownDiv, htmlParts, textParts, result);\n\n      // Note: response-element contents are processed by processNodes recursion above\n    } else {\n      // Fallback to old method\n      if (this.DEBUG) console.log('[DOMContentExtractor] No markdown div found, using fallback');\n      this.processNodes(messageContent, htmlParts, textParts, result);\n    }\n\n    // Additionally, look for code blocks and tables at the element level\n    // These might be siblings to message-content in response-element containers\n    // IMPORTANT: Angular may use Shadow DOM, so we need to search both light DOM and shadow DOM\n    if (this.DEBUG) {\n      console.log(\n        '[DOMContentExtractor] Searching for code blocks in:',\n        element.tagName,\n        element.className,\n      );\n      console.log(\n        '[DOMContentExtractor] Element HTML preview:',\n        element.outerHTML.substring(0, 200),\n      );\n    }\n\n    // Helper function to search in both light DOM and shadow DOM\n    const searchAll = (root: Element, selector: string): Element[] => {\n      const results: Element[] = [];\n\n      // Search in light DOM\n      results.push(...Array.from(root.querySelectorAll(selector)));\n\n      // Search in shadow DOM recursively\n      const searchShadow = (el: Element) => {\n        const shadowRoot = el.shadowRoot;\n        if (shadowRoot) {\n          console.log(`[DOMContentExtractor] Searching in Shadow DOM of`, el.tagName);\n          results.push(...Array.from(shadowRoot.querySelectorAll(selector)));\n        }\n\n        // Recursively check children for shadow roots\n        Array.from(el.children).forEach(searchShadow);\n      };\n\n      searchShadow(root);\n      return results;\n    };\n\n    // Also search for raw code elements regardless of presence of code-block\n    const altCodeBlocks = searchAll(messageContent, 'pre > code, [data-test-id=\"code-content\"]');\n    if (this.DEBUG)\n      console.log(\n        '[DOMContentExtractor] Found',\n        altCodeBlocks.length,\n        'raw code elements with alternative selector',\n      );\n    altCodeBlocks.forEach((codeEl, idx) => {\n      // Avoid duplicates if already processed\n      if ((codeEl as Element & { processedByGV?: boolean }).processedByGV) return;\n      // Skip if inside a code-block (already handled by processNodes)\n      if (codeEl.closest && codeEl.closest('code-block')) return;\n      if (this.DEBUG)\n        console.log(\n          `[DOMContentExtractor] Processing raw code element ${idx + 1}/${altCodeBlocks.length}`,\n        );\n      const extracted = this.extractCodeFromCodeElement(codeEl as HTMLElement);\n      if (extracted.text) {\n        (codeEl as Element & { processedByGV?: boolean }).processedByGV = true;\n        result.hasCode = true;\n        htmlParts.push(extracted.html);\n        textParts.push(`\\n${extracted.text}\\n`);\n      }\n    });\n    // Note: tables and code-blocks were already processed via processNodes()\n\n    result.html = htmlParts.join('\\n');\n    // Clean up multiple newlines but preserve intentional spacing\n    let combinedText = textParts\n      .join('')\n      .replace(/\\n{3,}/g, '\\n\\n') // Max 2 consecutive newlines\n      .trim();\n    // Last-chance fallback: if no structured text captured, use plain innerText\n    if (!combinedText) {\n      const fallbackContainer =\n        (messageContent as HTMLElement) ||\n        queryOutsideThoughts<HTMLElement>(element, 'message-content') ||\n        (element as HTMLElement);\n      try {\n        const plain =\n          (fallbackContainer as HTMLElement).innerText || fallbackContainer.textContent || '';\n        combinedText = this.normalizeText(plain);\n      } catch {\n        /* ignore */\n      }\n    }\n    result.text = combinedText;\n\n    return result;\n  }\n\n  /**\n   * Process DOM nodes recursively\n   */\n  private static processNodes(\n    container: Element,\n    htmlParts: string[],\n    textParts: string[],\n    flags: Pick<ExtractedContent, 'hasImages' | 'hasFormulas' | 'hasTables' | 'hasCode'>,\n  ): void {\n    const children = Array.from(container.children);\n    if (this.DEBUG)\n      console.log(\n        `[DOMContentExtractor] processNodes: ${children.length} children in`,\n        container.tagName,\n        container.className,\n      );\n\n    // Check for Shadow DOM\n    const shadowRoot = container.shadowRoot;\n    if (shadowRoot) {\n      if (this.DEBUG)\n        console.log('[DOMContentExtractor] Found Shadow DOM! Processing shadow children');\n      this.processNodes(shadowRoot as unknown as Element, htmlParts, textParts, flags);\n    }\n\n    for (const child of children) {\n      const tagName = child.tagName.toLowerCase();\n      if (this.DEBUG)\n        console.log('[DOMContentExtractor] Processing child:', tagName, child.className);\n\n      // Skip certain elements\n      if (this.shouldSkipElement(child)) {\n        if (this.DEBUG) console.log('[DOMContentExtractor] Skipping element:', tagName);\n        continue;\n      }\n\n      // Images\n      if (tagName === 'img') {\n        const img = child as HTMLImageElement;\n        const src = img.getAttribute('src') || img.src || '';\n        if (src && src !== 'about:blank') {\n          flags.hasImages = true;\n          const altRaw = img.getAttribute('alt') || '';\n          const alt = altRaw.trim() || 'Image';\n          htmlParts.push(\n            `<img src=\"${this.escapeHtmlAttribute(src)}\" alt=\"${this.escapeHtmlAttribute(alt)}\" />`,\n          );\n          const mdAlt = alt.replace(/\\]/g, '\\\\]');\n          textParts.push(`\\n![${mdAlt}](${src})\\n`);\n        }\n        continue;\n      }\n\n      // Math block (display formula) - check both class and data-math attribute\n      if (child.classList.contains('math-block') || child.hasAttribute('data-math')) {\n        const latex = child.getAttribute('data-math') || '';\n        if (latex) {\n          if (this.DEBUG) console.log('[DOMContentExtractor] Found math-block, latex:', latex);\n          flags.hasFormulas = true;\n          // For HTML output: preserve the rendered formula HTML for PDF export\n          // Clone the element to preserve its rendered content\n          const clonedFormula = (child as HTMLElement).cloneNode(true) as HTMLElement;\n          // Ensure data-math attribute is preserved for potential re-rendering\n          if (!clonedFormula.hasAttribute('data-math')) {\n            clonedFormula.setAttribute('data-math', latex);\n          }\n          htmlParts.push(clonedFormula.outerHTML);\n          // For text output: use Markdown format\n          textParts.push(`\\n$$\\n${latex}\\n$$\\n`);\n          continue;\n        }\n      }\n\n      // Code block (check for nested code-block first)\n      const codeBlock = child.querySelector('code-block');\n      if (tagName === 'code-block' || child.classList.contains('code-block') || codeBlock) {\n        if (this.DEBUG) console.log('[DOMContentExtractor] Found code block!');\n        const elementToExtract = (codeBlock || child) as HTMLElement;\n        const codeContent = this.extractCodeBlock(elementToExtract);\n        if (this.DEBUG) console.log('[DOMContentExtractor] Code content:', codeContent.text);\n        if (codeContent.text) {\n          flags.hasCode = true;\n          htmlParts.push(codeContent.html);\n          textParts.push(`\\n${codeContent.text}\\n`);\n        }\n        continue;\n      }\n\n      // Table block (check for nested table-block first)\n      const tableBlock = child.querySelector('table-block');\n      if (tagName === 'table-block' || tableBlock || child.querySelector('table')) {\n        if (this.DEBUG) console.log('[DOMContentExtractor] Found table block!');\n        const elementToExtract = (tableBlock || child) as HTMLElement;\n        const tableContent = this.extractTable(elementToExtract);\n        if (this.DEBUG) console.log('[DOMContentExtractor] Table content:', tableContent.text);\n        if (tableContent.text) {\n          // Only add if table was successfully extracted\n          flags.hasTables = true;\n          htmlParts.push(tableContent.html);\n          textParts.push(`\\n${tableContent.text}\\n`);\n        }\n        continue;\n      }\n\n      // Search result images (web images found by Gemini)\n      // Structure: <div.attachment-container.search-images> > <response-element> >\n      //   <single-image> > <div.image-container[data-full-size-image-uri]> > ... > <img>\n      {\n        const searchImageContainers = child.querySelectorAll(\n          '.attachment-container.search-images .image-container[data-full-size-image-uri]',\n        );\n        if (searchImageContainers.length > 0) {\n          for (const container of Array.from(searchImageContainers)) {\n            const fullSizeUri = container.getAttribute('data-full-size-image-uri') || '';\n            const imgEl = container.querySelector('img.image') as HTMLImageElement | null;\n            if (!imgEl) continue;\n            // Use the Google-cached thumbnail (gstatic.com) as the downloadable src.\n            // The full-size URI points to arbitrary third-party domains that are blocked\n            // by both CORS and Gemini's CSP, so it's only usable as an attribution link.\n            const src = imgEl.src || '';\n            if (!src || src === 'about:blank') continue;\n            const alt = imgEl.alt || 'Search result image';\n            const sourceLink = container.querySelector('a.source') as HTMLAnchorElement | null;\n            const sourceUrl = sourceLink?.href || '';\n            const sourceLabel =\n              container.querySelector('.source .label')?.textContent?.trim() || '';\n\n            flags.hasImages = true;\n            htmlParts.push(\n              `<img src=\"${this.escapeHtmlAttribute(src)}\" alt=\"${this.escapeHtmlAttribute(alt)}\" />`,\n            );\n            const mdAlt = alt.replace(/\\]/g, '\\\\]');\n            // Link to the full-size image or source when available\n            const linkUrl = fullSizeUri || sourceUrl;\n            const linkLabel = sourceLabel || (sourceUrl ? sourceUrl : '');\n            if (linkUrl) {\n              textParts.push(\n                `\\n![${mdAlt}](${src})\\n*Source: [${linkLabel || linkUrl}](${linkUrl})*\\n`,\n              );\n            } else {\n              textParts.push(`\\n![${mdAlt}](${src})\\n`);\n            }\n          }\n          if (this.DEBUG)\n            console.log(\n              '[DOMContentExtractor] Extracted',\n              searchImageContainers.length,\n              'search result images',\n            );\n          continue;\n        }\n      }\n\n      // Generated images (model-generated images in assistant responses)\n      // These are typically wrapped in: <p> > <div.attachment-container.generated-images> >\n      //   <response-element> > <generated-image> > <single-image> > ... > <img>\n      // Also handle standalone generated-image / single-image custom elements\n      {\n        const generatedImgs = child.querySelectorAll(\n          'generated-image img, single-image img, .attachment-container.generated-images img',\n        );\n        if (generatedImgs.length > 0) {\n          for (const img of Array.from(generatedImgs)) {\n            const imgEl = img as HTMLImageElement;\n            const src = imgEl.src || imgEl.getAttribute('src') || '';\n            if (!src || src === 'about:blank') continue;\n            const alt = imgEl.alt || 'Generated image';\n            flags.hasImages = true;\n            htmlParts.push(\n              `<img src=\"${this.escapeHtmlAttribute(src)}\" alt=\"${this.escapeHtmlAttribute(alt)}\" />`,\n            );\n            const mdAlt = alt.replace(/\\]/g, '\\\\]');\n            textParts.push(`\\n![${mdAlt}](${src})\\n`);\n          }\n          if (this.DEBUG)\n            console.log(\n              '[DOMContentExtractor] Extracted',\n              generatedImgs.length,\n              'generated images',\n            );\n          continue;\n        }\n      }\n\n      // Horizontal rule\n      if (tagName === 'hr') {\n        htmlParts.push('<hr>');\n        textParts.push('\\n---\\n');\n        continue;\n      }\n\n      // Paragraph with possible inline formulas\n      if (tagName === 'p') {\n        const processed = this.processInlineContent(child as HTMLElement);\n        if (processed.hasFormulas) flags.hasFormulas = true;\n        htmlParts.push(`<p>${processed.html}</p>`);\n        textParts.push(`${processed.text}\\n`);\n        continue;\n      }\n\n      // Headings\n      if (/^h[1-6]$/.test(tagName)) {\n        const text = this.extractTextWithInlineFormulas(child as HTMLElement);\n        const level = tagName[1];\n        htmlParts.push(`<h${level}>${text.html}</h${level}>`);\n        textParts.push(`\\n${'#'.repeat(parseInt(level))} ${text.text}\\n`);\n        continue;\n      }\n\n      // Lists\n      if (tagName === 'ul' || tagName === 'ol') {\n        const listContent = this.extractList(child as HTMLElement);\n        htmlParts.push(listContent.html);\n        textParts.push(`\\n${listContent.text}\\n`);\n        continue;\n      }\n\n      // Generic containers - recurse into children\n      if (\n        tagName === 'response-element' ||\n        tagName === 'div' ||\n        tagName === 'section' ||\n        tagName === 'article' ||\n        tagName === 'generated-image' ||\n        tagName === 'single-image' ||\n        child.classList.contains('horizontal-scroll-wrapper') ||\n        child.classList.contains('table-block-component')\n      ) {\n        if (this.DEBUG)\n          console.log('[DOMContentExtractor] Recursing into container:', tagName, child.className);\n        // Recursively process children instead of extracting text directly\n        this.processNodes(child, htmlParts, textParts, flags);\n        continue;\n      }\n\n      // Default: extract text content for unknown inline elements\n      const text = this.normalizeText(child.textContent || '');\n      if (text) {\n        // Only add text if it's not already processed by parent\n        htmlParts.push(`<span>${this.escapeHtml(text)}</span>`);\n        textParts.push(text);\n      }\n    }\n  }\n\n  /**\n   * Check if element should be skipped\n   */\n  private static shouldSkipElement(element: Element): boolean {\n    // Skip buttons, tooltips, and action elements\n    if (\n      element.tagName === 'BUTTON' ||\n      element.tagName === 'MAT-ICON' ||\n      // Gemini inline sources/citation chips (appear as link icons in export/print)\n      element.tagName === 'SOURCES-CAROUSEL-INLINE' ||\n      element.tagName === 'SOURCE-INLINE-CHIPS' ||\n      element.tagName === 'SOURCE-INLINE-CHIP' ||\n      // Generated image overlay controls (share, copy, download buttons)\n      element.tagName === 'SHARE-BUTTON' ||\n      element.tagName === 'COPY-BUTTON' ||\n      element.tagName === 'DOWNLOAD-GENERATED-IMAGE-BUTTON'\n    ) {\n      return true;\n    }\n\n    // Skip model thoughts completely (including the toggle button)\n    if (element.tagName === 'MODEL-THOUGHTS' || element.classList.contains('model-thoughts')) {\n      return true;\n    }\n\n    // Skip action buttons and controls\n    if (\n      element.classList.contains('copy-button') ||\n      element.classList.contains('action-button') ||\n      element.classList.contains('table-footer') ||\n      element.classList.contains('export-sheets-button') ||\n      element.classList.contains('thoughts-header') ||\n      // Gemini inline source/citation container\n      element.classList.contains('source-inline-chip-container') ||\n      // NanoBanana watermark remover indicator (🍌 emoji)\n      element.classList.contains('nanobanana-indicator') ||\n      // Generated image overlay controls (share/copy/download buttons)\n      element.classList.contains('generated-image-controls') ||\n      element.classList.contains('hide-from-message-actions')\n    ) {\n      return true;\n    }\n\n    return false;\n  }\n\n  /**\n   * Process inline content (text with inline formulas)\n   */\n  private static processInlineContent(element: HTMLElement): {\n    html: string;\n    text: string;\n    hasFormulas: boolean;\n  } {\n    let hasFormulas = false;\n    const htmlParts: string[] = [];\n    const textParts: string[] = [];\n\n    // Process all child nodes including text nodes\n    const processNode = (node: Node): void => {\n      if (node.nodeType === Node.TEXT_NODE) {\n        const text = node.textContent || '';\n        if (text.trim()) {\n          htmlParts.push(this.escapeHtml(text));\n          textParts.push(text);\n        }\n      } else if (node.nodeType === Node.ELEMENT_NODE) {\n        const el = node as Element;\n\n        if (this.shouldSkipElement(el)) {\n          return;\n        }\n\n        // Inline formula - check both class and data-math attribute\n        if (el.classList.contains('math-inline') || el.hasAttribute('data-math')) {\n          const latex = el.getAttribute('data-math') || '';\n          if (latex) {\n            hasFormulas = true;\n            // For HTML output: preserve the rendered formula HTML for PDF export\n            const clonedFormula = (el as HTMLElement).cloneNode(true) as HTMLElement;\n            // Ensure data-math attribute is preserved\n            if (!clonedFormula.hasAttribute('data-math')) {\n              clonedFormula.setAttribute('data-math', latex);\n            }\n            htmlParts.push(clonedFormula.outerHTML);\n            // For text output: use Markdown format\n            textParts.push(`$${latex}$`);\n            return;\n          }\n        }\n\n        // Emphasis\n        if (el.tagName === 'I' || el.tagName === 'EM') {\n          const text = this.normalizeText(el.textContent || '');\n          htmlParts.push(`<em>${this.escapeHtml(text)}</em>`);\n          textParts.push(`*${text}*`);\n          return;\n        }\n\n        // Strong\n        if (el.tagName === 'B' || el.tagName === 'STRONG') {\n          const text = this.normalizeText(el.textContent || '');\n          htmlParts.push(`<strong>${this.escapeHtml(text)}</strong>`);\n          textParts.push(`**${text}**`);\n          return;\n        }\n\n        // Code\n        if (el.tagName === 'CODE' && !el.closest('pre')) {\n          const text = this.normalizeText(el.textContent || '');\n          htmlParts.push(`<code>${this.escapeHtml(text)}</code>`);\n          textParts.push(`\\`${text}\\``);\n          return;\n        }\n\n        // Inline images\n        if (el.tagName === 'IMG') {\n          const imgEl = el as HTMLImageElement;\n          const src = imgEl.src || imgEl.getAttribute('src') || '';\n          if (src && src !== 'about:blank') {\n            const alt = imgEl.alt || 'Image';\n            htmlParts.push(\n              `<img src=\"${this.escapeHtmlAttribute(src)}\" alt=\"${this.escapeHtmlAttribute(alt)}\" />`,\n            );\n            const mdAlt = alt.replace(/\\]/g, '\\\\]');\n            textParts.push(`![${mdAlt}](${src})`);\n          }\n          return;\n        }\n\n        // Recurse for other elements\n        Array.from(el.childNodes).forEach(processNode);\n      }\n    };\n\n    Array.from(element.childNodes).forEach(processNode);\n\n    return {\n      html: htmlParts.join(''),\n      text: textParts.join(''),\n      hasFormulas,\n    };\n  }\n\n  /**\n   * Extract text with inline formulas\n   */\n  private static extractTextWithInlineFormulas(element: HTMLElement): {\n    html: string;\n    text: string;\n  } {\n    const processed = this.processInlineContent(element);\n    return { html: processed.html, text: processed.text };\n  }\n\n  /**\n   * Extract code block content\n   */\n  private static extractCodeBlock(element: HTMLElement): { html: string; text: string } {\n    const codeElement = element.querySelector('code[role=\"text\"], code');\n    const code = codeElement?.textContent || '';\n\n    // Try to detect language from class or label\n    let language = '';\n    const langLabel = element.querySelector('.code-block-decoration');\n    if (langLabel) {\n      language = this.normalizeText(langLabel.textContent || '').toLowerCase();\n    }\n\n    return {\n      html: `<pre><code class=\"language-${language}\">${this.escapeHtml(code)}</code></pre>`,\n      text: `\\`\\`\\`${language}\\n${code}\\n\\`\\`\\``,\n    };\n  }\n\n  /**\n   * Extract code directly from a <code> element (fallback path)\n   */\n  private static extractCodeFromCodeElement(codeEl: HTMLElement): { html: string; text: string } {\n    const code = codeEl.textContent || '';\n    // Try to infer language from class names like \"language-python\"\n    let language = '';\n    const className = (codeEl.getAttribute('class') || '').toLowerCase();\n    const langMatch = className.match(/language-([a-z0-9]+)/i);\n    if (langMatch) {\n      language = langMatch[1];\n    } else {\n      // Try to find a nearby header label inside a surrounding code-block component\n      const parentBlock = codeEl.closest('code-block') as HTMLElement | null;\n      if (parentBlock) {\n        const label = parentBlock.querySelector('.code-block-decoration');\n        if (label) {\n          language = this.normalizeText(label.textContent || '').toLowerCase();\n        }\n      }\n    }\n    return {\n      html: `<pre><code class=\"language-${language}\">${this.escapeHtml(code)}</code></pre>`,\n      text: `\\`\\`\\`${language}\\n${code}\\n\\`\\`\\``,\n    };\n  }\n\n  /**\n   * Extract table content\n   */\n  private static extractTable(element: HTMLElement): { html: string; text: string } {\n    // Accept either a container that holds a <table>, or a <table> element itself\n    let table: HTMLTableElement | null = null;\n    if (element.tagName && element.tagName.toLowerCase() === 'table') {\n      table = element as HTMLTableElement;\n    } else {\n      table = element.querySelector('table') as HTMLTableElement | null;\n    }\n    if (!table) {\n      return { html: '', text: '' };\n    }\n\n    // Extract HTML (clean version)\n    const cleanTable = table.cloneNode(true) as HTMLElement;\n    this.stripExportArtifacts(cleanTable);\n\n    // Convert to Markdown\n    const rows: string[][] = [];\n    const headerCells = Array.from(table.querySelectorAll('thead tr td, thead tr th'));\n    if (headerCells.length > 0) {\n      rows.push(headerCells.map((cell) => this.normalizeText(cell.textContent || '')));\n    }\n\n    const bodyRows = table.querySelectorAll('tbody tr');\n    bodyRows.forEach((row) => {\n      const cells = Array.from(row.querySelectorAll('td, th'));\n      rows.push(cells.map((cell) => this.normalizeText(cell.textContent || '')));\n    });\n\n    // Build Markdown table\n    const markdownLines: string[] = [];\n    if (rows.length > 0) {\n      // Header\n      markdownLines.push('| ' + rows[0].join(' | ') + ' |');\n      markdownLines.push('| ' + rows[0].map(() => '---').join(' | ') + ' |');\n      // Body\n      for (let i = 1; i < rows.length; i++) {\n        markdownLines.push('| ' + rows[i].join(' | ') + ' |');\n      }\n    } else {\n      // Fallback: treat first tbody row as header if no thead present\n      const firstBodyRow = table.querySelector('tbody tr');\n      if (firstBodyRow) {\n        const header = Array.from(firstBodyRow.querySelectorAll('td, th')).map((cell) =>\n          this.normalizeText(cell.textContent || ''),\n        );\n        if (header.length > 0) {\n          markdownLines.push('| ' + header.join(' | ') + ' |');\n          markdownLines.push('| ' + header.map(() => '---').join(' | ') + ' |');\n          const rest = Array.from(table.querySelectorAll('tbody tr')).slice(1);\n          rest.forEach((row) => {\n            const cells = Array.from(row.querySelectorAll('td, th')).map((cell) =>\n              this.normalizeText(cell.textContent || ''),\n            );\n            markdownLines.push('| ' + cells.join(' | ') + ' |');\n          });\n        }\n      }\n    }\n\n    return {\n      html: cleanTable.outerHTML,\n      text: markdownLines.join('\\n'),\n    };\n  }\n\n  /**\n   * Extract list content with support for nested lists\n   */\n  private static extractList(\n    element: HTMLElement,\n    depth: number = 0,\n  ): { html: string; text: string } {\n    const isOrdered = element.tagName === 'OL';\n    const items = Array.from(element.querySelectorAll(':scope > li'));\n    const indent = '  '.repeat(depth); // 2 spaces per level\n\n    const textLines: string[] = [];\n    items.forEach((item, index) => {\n      // Create a temporary container with only direct children (excluding nested lists)\n      const tempContainer = document.createElement('div');\n      const childNodes = Array.from(item.childNodes);\n\n      childNodes.forEach((node) => {\n        if (node.nodeType === Node.TEXT_NODE) {\n          tempContainer.appendChild(node.cloneNode(true));\n        } else if (node.nodeType === Node.ELEMENT_NODE) {\n          const el = node as Element;\n          // Skip nested lists, we'll process them separately\n          if (el.tagName !== 'UL' && el.tagName !== 'OL') {\n            tempContainer.appendChild(el.cloneNode(true));\n          }\n        }\n      });\n\n      // Process inline content (handles formulas, emphasis, etc.)\n      const processed = this.processInlineContent(tempContainer);\n      const itemText = processed.text || this.normalizeText(tempContainer.textContent || '');\n\n      const prefix = isOrdered ? `${index + 1}. ` : '- ';\n      textLines.push(indent + prefix + itemText);\n\n      // Process nested lists\n      const nestedLists = item.querySelectorAll(':scope > ul, :scope > ol');\n      nestedLists.forEach((nestedList) => {\n        const nestedResult = this.extractList(nestedList as HTMLElement, depth + 1);\n        if (nestedResult.text) {\n          textLines.push(nestedResult.text);\n        }\n      });\n    });\n\n    const cleanList = element.cloneNode(true) as HTMLElement;\n    this.stripExportArtifacts(cleanList);\n\n    return {\n      html: cleanList.outerHTML,\n      text: textLines.join('\\n'),\n    };\n  }\n\n  /**\n   * Strip non-content UI artifacts from exported HTML fragments.\n   * Best-effort: safe to call multiple times.\n   */\n  private static stripExportArtifacts(root: HTMLElement): void {\n    const selector = [\n      'button',\n      'mat-icon',\n      'model-thoughts',\n      'sources-carousel-inline',\n      'source-inline-chips',\n      'source-inline-chip',\n      'share-button',\n      'copy-button',\n      'download-generated-image-button',\n      '.model-thoughts',\n      '.copy-button',\n      '.action-button',\n      '.table-footer',\n      '.export-sheets-button',\n      '.thoughts-header',\n      '.source-inline-chip-container',\n      '.nanobanana-indicator',\n      '.generated-image-controls',\n      '.hide-from-message-actions',\n    ].join(',');\n\n    root.querySelectorAll(selector).forEach((el) => el.remove());\n  }\n\n  /**\n   * Normalize whitespace in text\n   */\n  private static normalizeText(text: string): string {\n    return text.replace(/\\s+/g, ' ').trim();\n  }\n\n  /**\n   * Escape HTML special characters\n   */\n  private static escapeHtml(text: string): string {\n    const div = document.createElement('div');\n    div.textContent = text;\n    return div.innerHTML;\n  }\n\n  /**\n   * Escape HTML for attribute context.\n   */\n  private static escapeHtmlAttribute(text: string): string {\n    return String(text)\n      .replace(/&/g, '&amp;')\n      .replace(/\"/g, '&quot;')\n      .replace(/</g, '&lt;')\n      .replace(/>/g, '&gt;')\n      .replace(/'/g, '&#39;');\n  }\n}\n"
  },
  {
    "path": "src/features/export/services/DeepResearchPDFPrintService.ts",
    "content": "import { isSafari } from '@/core/utils/browser';\n\nimport type { PrintableDocumentContent } from './PDFPrintService';\n\n/**\n * Dedicated PDF print path for Deep Research reports.\n * Keeps report printing isolated from the regular conversation PDF flow.\n */\nexport class DeepResearchPDFPrintService {\n  private static PRINT_STYLES_ID = 'gv-deep-research-pdf-print-styles';\n  private static PRINT_CONTAINER_ID = 'gv-deep-research-pdf-print-container';\n  private static PRINT_BODY_CLASS = 'gv-deep-research-pdf-printing';\n  private static PRINT_SAFARI_BODY_CLASS = 'gv-deep-research-pdf-safari-printing';\n  private static CLEANUP_FALLBACK_DELAY_MS = 60_000;\n  private static INLINE_FETCH_TIMEOUT_MS = 2_000;\n  private static INLINE_DECODE_TIMEOUT_MS = 1_000;\n  private static cleanupFallbackTimer: ReturnType<typeof setTimeout> | null = null;\n  private static originalDocumentTitle: string | null = null;\n\n  static async export(content: PrintableDocumentContent): Promise<void> {\n    this.cleanup();\n\n    const container = this.createPrintContainer(content);\n    document.body.appendChild(container);\n    this.injectPrintStyles();\n    document.body.classList.add(this.PRINT_BODY_CLASS);\n\n    this.originalDocumentTitle = document.title;\n    const title = this.normalizeTitle(content.title) || 'Deep Research Report';\n    document.title = title;\n\n    const safari = isSafari();\n    const inlineImagesPromise = this.inlineImages(container).catch(() => {\n      /* ignore */\n    });\n\n    if (safari) {\n      document.body.classList.add(this.PRINT_SAFARI_BODY_CLASS);\n      this.forceStyleFlush(container);\n      this.triggerPrint();\n      this.registerCleanupHandlers();\n      void inlineImagesPromise;\n      return;\n    }\n\n    await inlineImagesPromise;\n    await this.delay(100);\n    this.triggerPrint();\n    this.registerCleanupHandlers();\n  }\n\n  private static triggerPrint(): void {\n    try {\n      window.print();\n    } catch {\n      /* ignore */\n    }\n  }\n\n  private static forceStyleFlush(container: HTMLElement): void {\n    try {\n      container.getBoundingClientRect();\n    } catch {\n      /* ignore */\n    }\n  }\n\n  private static delay(ms: number): Promise<void> {\n    return new Promise((resolve) => {\n      this.setTimeoutUnref(resolve, ms);\n    });\n  }\n\n  private static setTimeoutUnref(callback: () => void, ms: number): ReturnType<typeof setTimeout> {\n    const handle = setTimeout(callback, ms);\n    if (\n      typeof handle === 'object' &&\n      handle !== null &&\n      'unref' in handle &&\n      typeof (handle as { unref?: unknown }).unref === 'function'\n    ) {\n      (handle as { unref: () => void }).unref();\n    }\n    return handle;\n  }\n\n  private static registerCleanupHandlers(): void {\n    const cleanupNow = (): void => {\n      this.cleanup();\n    };\n\n    try {\n      window.addEventListener('afterprint', cleanupNow, { once: true });\n    } catch {\n      /* ignore */\n    }\n\n    if (this.cleanupFallbackTimer !== null) {\n      clearTimeout(this.cleanupFallbackTimer);\n    }\n\n    this.cleanupFallbackTimer = this.setTimeoutUnref(() => {\n      this.cleanup();\n    }, this.CLEANUP_FALLBACK_DELAY_MS);\n  }\n\n  private static cleanup(): void {\n    if (this.cleanupFallbackTimer !== null) {\n      clearTimeout(this.cleanupFallbackTimer);\n      this.cleanupFallbackTimer = null;\n    }\n\n    try {\n      document.body.classList.remove(this.PRINT_BODY_CLASS);\n      document.body.classList.remove(this.PRINT_SAFARI_BODY_CLASS);\n    } catch {\n      /* ignore */\n    }\n\n    const container = document.getElementById(this.PRINT_CONTAINER_ID);\n    if (container) {\n      container.remove();\n    }\n\n    const style = document.getElementById(this.PRINT_STYLES_ID);\n    if (style) {\n      style.remove();\n    }\n\n    if (this.originalDocumentTitle !== null) {\n      try {\n        document.title = this.originalDocumentTitle;\n      } catch {\n        /* ignore */\n      }\n      this.originalDocumentTitle = null;\n    }\n\n    // Notify other UI components that printing ended (mirrors PDFPrintService).\n    try {\n      window.dispatchEvent(new CustomEvent('gv-print-cleanup'));\n    } catch {\n      /* ignore */\n    }\n  }\n\n  private static createPrintContainer(content: PrintableDocumentContent): HTMLElement {\n    const container = document.createElement('div');\n    container.id = this.PRINT_CONTAINER_ID;\n    container.className = 'gv-print-only gv-deep-research-print-only';\n\n    const sanitizedHtml = this.sanitizePrintableHtml(content.html);\n    const fallbackText = this.extractPlainTextFromHtml(content.html) || content.markdown.trim();\n    const bodyHtml = sanitizedHtml || this.formatPlainTextAsHtml(fallbackText || 'No content');\n    const title = this.normalizeTitle(content.title) || 'Deep Research Report';\n    const date = this.formatDate(content.exportedAt);\n\n    container.innerHTML = `\n      <div class=\"gv-dr-print-document\">\n        <div class=\"gv-dr-print-cover-page\">\n          <div class=\"gv-dr-print-cover-content\">\n            <h1 class=\"gv-dr-print-cover-title\">${this.escapeHTML(title)}</h1>\n            <div class=\"gv-dr-print-meta\">\n              <p>${this.escapeHTML(date)}</p>\n              <p><a href=\"${this.escapeAttribute(content.url)}\">${this.escapeHTML(content.url)}</a></p>\n              <p>Deep Research Report</p>\n            </div>\n          </div>\n        </div>\n        <div class=\"gv-dr-print-content\">\n          <div class=\"gv-dr-print-report\">${bodyHtml}</div>\n        </div>\n        <div class=\"gv-dr-print-footer\">\n          <p>Exported from <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">Voyager</a></p>\n          <p>Generated on ${this.escapeHTML(date)}</p>\n        </div>\n      </div>\n    `;\n\n    return container;\n  }\n\n  private static sanitizePrintableHtml(html: string): string {\n    const trimmed = html.trim();\n    if (!trimmed) return '';\n\n    const container = document.createElement('div');\n    container.innerHTML = trimmed;\n    container.querySelectorAll('script, style, template').forEach((element) => element.remove());\n\n    const elements = Array.from(container.querySelectorAll<HTMLElement>('*'));\n    elements.forEach((element) => {\n      const attributes = Array.from(element.attributes);\n      attributes.forEach((attribute) => {\n        if (attribute.name.toLowerCase().startsWith('on')) {\n          element.removeAttribute(attribute.name);\n        }\n      });\n    });\n\n    return container.innerHTML.trim();\n  }\n\n  private static extractPlainTextFromHtml(html: string): string {\n    const trimmed = html.trim();\n    if (!trimmed) return '';\n    const container = document.createElement('div');\n    container.innerHTML = trimmed;\n    container.querySelectorAll('script, style, template').forEach((element) => element.remove());\n    return this.normalizeWhitespace(container.textContent || '');\n  }\n\n  private static normalizeWhitespace(text: string): string {\n    return text\n      .replace(/\\r/g, '')\n      .replace(/[ \\t]+\\n/g, '\\n')\n      .replace(/\\n{3,}/g, '\\n\\n')\n      .trim();\n  }\n\n  private static formatPlainTextAsHtml(text: string): string {\n    if (!text.trim()) return '';\n    const escaped = this.escapeHTML(text);\n    return escaped\n      .split('\\n\\n')\n      .map((paragraph) => `<p>${paragraph.replace(/\\n/g, '<br>')}</p>`)\n      .join('\\n');\n  }\n\n  private static normalizeTitle(title: string): string {\n    return title\n      .trim()\n      .replace(/\\s+-\\s+Gemini$/i, '')\n      .replace(/\\s+-\\s+Google Gemini$/i, '')\n      .replace(/\\s+/g, ' ')\n      .trim();\n  }\n\n  private static formatDate(isoString: string): string {\n    try {\n      const date = new Date(isoString);\n      return date.toLocaleString('en-US', {\n        year: 'numeric',\n        month: 'long',\n        day: 'numeric',\n        hour: '2-digit',\n        minute: '2-digit',\n      });\n    } catch {\n      return isoString;\n    }\n  }\n\n  private static injectPrintStyles(): void {\n    if (document.getElementById(this.PRINT_STYLES_ID)) return;\n\n    const style = document.createElement('style');\n    style.id = this.PRINT_STYLES_ID;\n    style.textContent = `\n      .gv-deep-research-print-only {\n        display: none;\n      }\n\n      @media print {\n        body.${this.PRINT_BODY_CLASS} > *:not(#${this.PRINT_CONTAINER_ID}) {\n          display: none !important;\n          visibility: hidden !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} {\n          display: block !important;\n          visibility: visible !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID},\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} * {\n          visibility: visible !important;\n        }\n\n        html,\n        body {\n          background: #fff !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} {\n          background: #fff !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} * {\n          display: revert !important;\n        }\n\n        /* Preserve KaTeX layout primitives after the global display override above.\n           Without these, sub/sup scripts (e.g. x_1) may become misaligned in PDF print. */\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex-display,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex-display > .katex,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex-display > .katex > .katex-html {\n          display: block !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .base,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .strut,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .vlist > span > span,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .mspace,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .mfrac .frac-line,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .rule,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .hline,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .hdashline,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .overline .overline-line,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .underline .underline-line,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .nulldelimiter,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .clap > .fix,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .llap > .fix,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .rlap > .fix,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .mtable .vertical-separator,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .mtable .arraycolsep,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .cd-vert-arrow,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .cd-label-left,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .cd-label-right {\n          display: inline-block !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .vlist-t {\n          display: inline-table !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .vlist-r {\n          display: table-row !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .vlist,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .vlist-s {\n          display: table-cell !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .vlist > span,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .katex-html > .newline,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .overlay,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex svg,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .stretchy {\n          display: block !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .vbox,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .hbox,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .thinbox {\n          display: inline-flex !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .gv-dr-print-report .katex {\n          line-height: 1.2 !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} {\n          font-family: Georgia, 'Times New Roman', serif;\n          color: #000;\n          background: #fff;\n        }\n\n        @page {\n          margin: 2cm;\n          size: A4;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-cover-page {\n          min-height: calc(297mm - 4cm);\n          position: relative;\n          display: flex !important;\n          align-items: center !important;\n          justify-content: center !important;\n          page-break-after: always;\n          margin: 0;\n          padding: 0;\n          border: none;\n          text-align: center;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-cover-content {\n          position: absolute;\n          top: 50%;\n          left: 50%;\n          transform: translate(-50%, -50%);\n          width: 80%;\n          max-width: 80%;\n          box-sizing: border-box;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-cover-title {\n          font-size: 36pt;\n          font-weight: 800;\n          letter-spacing: -0.02em;\n          margin: 0 0 1.5em 0;\n          color: oklch(0.7227 0.1920 149.5793);\n          line-height: 1.2;\n          word-wrap: break-word;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-meta {\n          font-size: 12pt;\n          color: #666;\n          line-height: 2;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-meta p {\n          margin: 0.3em 0;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-meta a {\n          color: #666;\n          text-decoration: none;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-meta a:after {\n          content: none !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-content {\n          margin: 2em 0;\n          line-height: 1.65;\n          font-size: 11pt;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-report p {\n          margin: 0.5em 0;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-report img {\n          max-width: 100%;\n          height: auto;\n          display: block;\n          margin: 0.75em 0;\n          page-break-inside: avoid;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-report pre,\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-report code {\n          font-family: 'Courier New', monospace;\n          font-size: 9pt;\n          background: #f5f5f5;\n          border-radius: 3px;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-report pre {\n          padding: 0.75em;\n          border-left: 3px solid #d1d5db;\n          overflow-x: auto;\n          white-space: pre-wrap;\n          word-wrap: break-word;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-report .math-inline,\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-report .math-block,\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-report [data-math] {\n          page-break-inside: avoid;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-footer {\n          margin-top: 2em;\n          padding-top: 1em;\n          border-top: 1px solid #ccc;\n          font-size: 9pt;\n          color: #666;\n          text-align: center;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-footer p {\n          margin: 0.25em 0;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-report sources-carousel-inline,\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-report source-inline-chips,\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-report source-inline-chip,\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-report .source-inline-chip-container {\n          display: none !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-report a {\n          color: #2563eb;\n          text-decoration: none;\n        }\n\n        body.${this.PRINT_BODY_CLASS} .gv-dr-print-report a[href]:after {\n          content: \" (\" attr(href) \")\";\n          font-size: 9pt;\n          color: #666;\n        }\n\n        body.${this.PRINT_BODY_CLASS}.${this.PRINT_SAFARI_BODY_CLASS} .gv-dr-print-cover-page {\n          min-height: auto !important;\n          position: static !important;\n          display: block !important;\n          padding: 0 0 1.25em 0 !important;\n          border-bottom: 1px solid #e5e7eb !important;\n          text-align: left !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS}.${this.PRINT_SAFARI_BODY_CLASS} .gv-dr-print-cover-content {\n          position: static !important;\n          top: auto !important;\n          left: auto !important;\n          transform: none !important;\n          width: 100% !important;\n          max-width: 100% !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS}.${this.PRINT_SAFARI_BODY_CLASS} .gv-dr-print-content {\n          break-before: auto !important;\n          page-break-before: auto !important;\n        }\n      }\n    `;\n\n    document.head.appendChild(style);\n  }\n\n  private static async inlineImages(container: HTMLElement): Promise<void> {\n    const images = Array.from(container.querySelectorAll('img')) as HTMLImageElement[];\n    if (images.length === 0) return;\n\n    const toDataUrl = async (url: string): Promise<string | null> => {\n      const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;\n      const timeoutHandle = this.setTimeoutUnref(() => {\n        try {\n          controller?.abort();\n        } catch {\n          /* ignore */\n        }\n      }, this.INLINE_FETCH_TIMEOUT_MS);\n\n      try {\n        const init: RequestInit = { credentials: 'include', mode: 'cors' as RequestMode };\n        if (controller) init.signal = controller.signal;\n        const response = await fetch(url, init);\n        if (!response.ok) return null;\n        const blob = await response.blob();\n        const dataUrl = await new Promise<string>((resolve, reject) => {\n          try {\n            const reader = new FileReader();\n            reader.onerror = () => reject(new Error('readAsDataURL failed'));\n            reader.onload = () => resolve(String(reader.result || ''));\n            reader.readAsDataURL(blob);\n          } catch (error) {\n            reject(error);\n          }\n        });\n        return dataUrl;\n      } catch {\n        return null;\n      } finally {\n        clearTimeout(timeoutHandle);\n      }\n    };\n\n    await Promise.all(\n      images.map(async (image) => {\n        const src = image.getAttribute('src') || '';\n        if (!/^(https?:\\/\\/|blob:)/i.test(src)) return;\n        const dataUrl = await toDataUrl(src);\n        if (dataUrl) {\n          try {\n            image.src = dataUrl;\n          } catch {\n            /* ignore */\n          }\n        }\n      }),\n    );\n\n    type DecodableImage = HTMLImageElement & { decode?: () => Promise<void> };\n    await Promise.all(\n      images.map(async (image) => {\n        const decode = (image as DecodableImage).decode;\n        if (typeof decode !== 'function') return;\n        try {\n          await Promise.race([\n            decode.call(image).catch(() => {\n              /* ignore */\n            }),\n            this.delay(this.INLINE_DECODE_TIMEOUT_MS),\n          ]);\n        } catch {\n          /* ignore */\n        }\n      }),\n    );\n  }\n\n  private static escapeHTML(text: string): string {\n    const div = document.createElement('div');\n    div.textContent = text;\n    return div.innerHTML;\n  }\n\n  private static escapeAttribute(text: string): string {\n    return this.escapeHTML(text).replace(/\"/g, '&quot;');\n  }\n}\n"
  },
  {
    "path": "src/features/export/services/ImageExportService.ts",
    "content": "/**\n * Image export service\n *\n * Generates a single PNG image from a rendered export document.\n * Uses DOM-to-image rendering and inlines remote images (best-effort).\n */\nimport { isSafari } from '@/core/utils/browser';\n\nimport { isEventLikeImageRenderError } from '../types/errors';\nimport type { ChatTurn, ConversationMetadata } from '../types/export';\nimport { DOMContentExtractor } from './DOMContentExtractor';\nimport { renderElementToImageBlob } from './ImageRenderService';\n\nexport interface RenderableDocumentContent {\n  title: string;\n  url: string;\n  exportedAt: string;\n  markdown: string;\n  html: string;\n}\n\nexport class ImageExportService {\n  private static readonly PRIMARY_RENDER_MAX_ATTEMPTS = 3;\n\n  private static readonly PRIMARY_RENDER_RETRY_DELAY_MS = 260;\n\n  static async export(\n    turns: ChatTurn[],\n    metadata: ConversationMetadata,\n    options: { filename: string; fontSize?: number },\n  ): Promise<void> {\n    const filename = options.filename.toLowerCase().endsWith('.png')\n      ? options.filename\n      : `${options.filename}.png`;\n\n    const blob = await this.renderConversationBlob(turns, metadata, options);\n    this.downloadBlob(blob, filename);\n  }\n\n  static async exportDocument(\n    content: RenderableDocumentContent,\n    options: { filename: string },\n  ): Promise<void> {\n    const filename = options.filename.toLowerCase().endsWith('.png')\n      ? options.filename\n      : `${options.filename}.png`;\n\n    const blob = await this.renderDocumentBlob(content);\n    this.downloadBlob(blob, filename);\n  }\n\n  static async renderConversationBlob(\n    turns: ChatTurn[],\n    metadata: ConversationMetadata,\n    options: { fontSize?: number },\n  ): Promise<Blob> {\n    const container = this.createRenderContainer(turns, metadata, options.fontSize);\n    return await this.renderContainerToBlob(container);\n  }\n\n  static async renderDocumentBlob(content: RenderableDocumentContent): Promise<Blob> {\n    const container = this.createDocumentRenderContainer(content);\n    return await this.renderContainerToBlob(container);\n  }\n\n  private static createRenderContainer(\n    turns: ChatTurn[],\n    metadata: ConversationMetadata,\n    fontSize?: number,\n  ): HTMLElement {\n    const outer = document.createElement('div');\n    outer.className = 'gv-image-export-container';\n    Object.assign(outer.style, {\n      position: 'fixed',\n      left: '-10000px',\n      top: '0',\n      width: '620px',\n      background: '#ffffff',\n      color: '#111827',\n      zIndex: '-1',\n      pointerEvents: 'none',\n    } as Partial<CSSStyleDeclaration>);\n\n    const title = metadata.title || 'Conversation';\n    const date = this.formatDate(metadata.exportedAt);\n    const headerHtml = `\n      <header class=\"gv-image-export-header\">\n        <h1 class=\"gv-image-export-title\">${this.escapeHTML(title)}</h1>\n        <div class=\"gv-image-export-meta\">\n          <div>${this.escapeHTML(date)}</div>\n          <div><a href=\"${this.escapeAttr(metadata.url)}\">${this.escapeHTML(metadata.url)}</a></div>\n          <div>${metadata.count} conversation turns</div>\n        </div>\n      </header>\n    `;\n\n    const turnsHtml = turns\n      .map((turn, idx) => {\n        const turnIndex = idx + 1;\n        const starred = turn.starred ? ' ⭐' : '';\n        const userHtml = turn.userElement\n          ? DOMContentExtractor.extractUserContent(turn.userElement).html\n          : this.formatPlainTextAsHtml(turn.user);\n        const assistantHtml = turn.assistantElement\n          ? DOMContentExtractor.extractAssistantContent(turn.assistantElement).html\n          : this.formatPlainTextAsHtml(turn.assistant);\n\n        if (!turn.omitEmptySections) {\n          return `\n          <article class=\"gv-image-export-turn\">\n            <div class=\"gv-image-export-turn-header\">Turn ${turnIndex}${starred}</div>\n            <section class=\"gv-image-export-block\">\n              <div class=\"gv-image-export-label\">User</div>\n              <div class=\"gv-image-export-content\">${userHtml || '<em>No content</em>'}</div>\n            </section>\n            <section class=\"gv-image-export-block\">\n              <div class=\"gv-image-export-label\">Assistant</div>\n              <div class=\"gv-image-export-content\">${assistantHtml || '<em>No content</em>'}</div>\n            </section>\n          </article>\n        `;\n        }\n\n        const hasUser = !!turn.userElement || !!turn.user.trim();\n        const hasAssistant = !!turn.assistantElement || !!turn.assistant.trim();\n\n        return `\n          <article class=\"gv-image-export-turn\">\n            <div class=\"gv-image-export-turn-header\">Turn ${turnIndex}${starred}</div>\n            ${\n              hasUser\n                ? `\n            <section class=\"gv-image-export-block\">\n              <div class=\"gv-image-export-label\">User</div>\n              <div class=\"gv-image-export-content\">${userHtml || '<em>No content</em>'}</div>\n            </section>\n            `\n                : ''\n            }\n            ${\n              hasAssistant\n                ? `\n            <section class=\"gv-image-export-block\">\n              <div class=\"gv-image-export-label\">Assistant</div>\n              <div class=\"gv-image-export-content\">${assistantHtml || '<em>No content</em>'}</div>\n            </section>\n            `\n                : ''\n            }\n          </article>\n        `;\n      })\n      .join('\\n');\n\n    const footerHtml = `\n      <footer class=\"gv-image-export-footer\">\n        <div>Exported from Voyager</div>\n        <div>Generated on ${this.escapeHTML(date)}</div>\n      </footer>\n    `;\n\n    const basePx = fontSize ?? 20;\n    const titlePx = Math.round(basePx * 2.5);\n    const metaPx = Math.max(basePx - 2, 10);\n    const headerPx = Math.round(basePx * 1.2);\n    const codePx = Math.max(basePx - 2, 10);\n    const footerPx = Math.max(basePx - 4, 10);\n\n    const style = document.createElement('style');\n    style.textContent = `\n      .gv-image-export-doc {\n        font-family: Georgia, 'Times New Roman', serif;\n        font-size: ${basePx}px;\n        line-height: 1.9;\n        padding: 26px;\n      }\n\n      .gv-image-export-header {\n        margin-bottom: 18px;\n        padding-bottom: 14px;\n        border-bottom: 1px solid rgba(0,0,0,0.12);\n      }\n\n      .gv-image-export-title {\n        margin: 0;\n        font-size: ${titlePx}px;\n        line-height: 1.2;\n        color: #111827;\n        word-break: break-word;\n      }\n\n      .gv-image-export-meta {\n        margin-top: 10px;\n        color: #6b7280;\n        font-size: ${metaPx}px;\n        display: grid;\n        gap: 8px;\n      }\n\n      .gv-image-export-turn {\n        margin: 24px 0;\n        padding: 20px 0;\n        border-bottom: 1px solid rgba(0,0,0,0.08);\n      }\n\n      .gv-image-export-turn-header {\n        font-weight: 700;\n        font-size: ${headerPx}px;\n        color: #374151;\n        margin-bottom: 14px;\n      }\n\n      .gv-image-export-block {\n        margin: 16px 0;\n      }\n\n      .gv-image-export-label {\n        font-weight: 700;\n        font-size: ${basePx}px;\n        margin-bottom: 10px;\n        color: #111827;\n      }\n\n      .gv-image-export-content {\n        font-size: ${basePx}px;\n        padding-left: 16px;\n        border-left: 3px solid rgba(0,0,0,0.10);\n      }\n\n      .gv-image-export-content img {\n        max-width: 100%;\n        height: auto;\n        display: block;\n        margin: 12px 0;\n      }\n\n      .gv-image-export-content pre {\n        background: rgba(0,0,0,0.05);\n        padding: 14px 16px;\n        border-radius: 8px;\n        overflow-x: auto;\n        white-space: pre-wrap;\n        word-break: break-word;\n        font-size: ${codePx}px;\n        line-height: 1.8;\n      }\n\n      .gv-image-export-footer {\n        margin-top: 24px;\n        padding-top: 14px;\n        border-top: 1px solid rgba(0,0,0,0.12);\n        color: #6b7280;\n        font-size: ${footerPx}px;\n        display: grid;\n        gap: 8px;\n      }\n    `;\n\n    const doc = document.createElement('div');\n    doc.className = 'gv-image-export-doc';\n    doc.innerHTML = `${headerHtml}${turnsHtml}${footerHtml}`;\n\n    outer.appendChild(style);\n    outer.appendChild(doc);\n    return outer;\n  }\n\n  private static createDocumentRenderContainer(content: RenderableDocumentContent): HTMLElement {\n    const outer = document.createElement('div');\n    outer.className = 'gv-image-export-container';\n    Object.assign(outer.style, {\n      position: 'fixed',\n      left: '-10000px',\n      top: '0',\n      width: '620px',\n      background: '#ffffff',\n      color: '#111827',\n      zIndex: '-1',\n      pointerEvents: 'none',\n    } as Partial<CSSStyleDeclaration>);\n\n    const date = this.formatDate(content.exportedAt);\n    const bodyHtml = content.html.trim() || this.formatPlainTextAsHtml(content.markdown);\n    const headerHtml = `\n      <header class=\"gv-image-export-header\">\n        <h1 class=\"gv-image-export-title\">${this.escapeHTML(content.title || 'Deep Research Report')}</h1>\n        <div class=\"gv-image-export-meta\">\n          <div>${this.escapeHTML(date)}</div>\n          <div><a href=\"${this.escapeAttr(content.url)}\">${this.escapeHTML(content.url)}</a></div>\n        </div>\n      </header>\n    `;\n\n    const footerHtml = `\n      <footer class=\"gv-image-export-footer\">\n        <div>Exported from Voyager</div>\n        <div>Generated on ${this.escapeHTML(date)}</div>\n      </footer>\n    `;\n\n    const style = document.createElement('style');\n    style.textContent = `\n      .gv-image-export-doc {\n        font-family: Georgia, 'Times New Roman', serif;\n        font-size: 20px;\n        line-height: 1.9;\n        padding: 26px;\n      }\n\n      .gv-image-export-header {\n        margin-bottom: 18px;\n        padding-bottom: 14px;\n        border-bottom: 1px solid rgba(0,0,0,0.12);\n      }\n\n      .gv-image-export-title {\n        margin: 0;\n        font-size: 50px;\n        line-height: 1.2;\n        color: #111827;\n        word-break: break-word;\n      }\n\n      .gv-image-export-meta {\n        margin-top: 10px;\n        color: #6b7280;\n        font-size: 18px;\n        display: grid;\n        gap: 8px;\n      }\n\n      .gv-image-export-report-content {\n        margin: 18px 0 24px;\n        color: #1a1a1a;\n        font-size: 20px;\n      }\n\n      .gv-image-export-report-content p {\n        margin: 12px 0;\n      }\n\n      .gv-image-export-report-content img {\n        max-width: 100%;\n        height: auto;\n        display: block;\n        margin: 12px 0;\n      }\n\n      .gv-image-export-report-content pre {\n        background: rgba(0,0,0,0.05);\n        padding: 14px 16px;\n        border-radius: 8px;\n        overflow-x: auto;\n        white-space: pre-wrap;\n        word-break: break-word;\n        font-size: 18px;\n        line-height: 1.8;\n      }\n\n      .gv-image-export-footer {\n        margin-top: 24px;\n        padding-top: 14px;\n        border-top: 1px solid rgba(0,0,0,0.12);\n        color: #6b7280;\n        font-size: 16px;\n        display: grid;\n        gap: 8px;\n      }\n    `;\n\n    const doc = document.createElement('div');\n    doc.className = 'gv-image-export-doc';\n    doc.innerHTML = `${headerHtml}<main class=\"gv-image-export-report-content\">${bodyHtml}</main>${footerHtml}`;\n\n    outer.appendChild(style);\n    outer.appendChild(doc);\n    return outer;\n  }\n\n  private static async inlineImages(container: HTMLElement): Promise<void> {\n    const imgs = Array.from(container.querySelectorAll('img')) as HTMLImageElement[];\n    if (imgs.length === 0) return;\n\n    const blobToDataUrl = async (blob: Blob): Promise<string | null> => {\n      try {\n        return await new Promise<string>((resolve, reject) => {\n          const reader = new FileReader();\n          reader.onerror = () => reject(new Error('readAsDataURL failed'));\n          reader.onload = () => resolve(String(reader.result || ''));\n          reader.readAsDataURL(blob);\n        });\n      } catch {\n        return null;\n      }\n    };\n\n    const toDataUrl = async (url: string): Promise<string | null> => {\n      if (!/^https?:\\/\\//i.test(url)) return null;\n\n      // Try content-script fetch first\n      try {\n        const resp = await fetch(url, { credentials: 'include', mode: 'cors' as RequestMode });\n        if (resp.ok) {\n          const blob = await resp.blob();\n          const data = await blobToDataUrl(blob);\n          if (data) return data;\n        }\n      } catch {\n        /* ignore */\n      }\n\n      // Fallback to background fetch (bypasses page CORS)\n      try {\n        const data = await new Promise<string | null>((resolve) => {\n          try {\n            chrome.runtime?.sendMessage?.({ type: 'gv.fetchImage', url }, (resp) => {\n              if (resp && resp.ok && resp.base64) {\n                const contentType = String(resp.contentType || 'application/octet-stream');\n                resolve(`data:${contentType};base64,${resp.base64}`);\n              } else {\n                resolve(null);\n              }\n            });\n          } catch {\n            resolve(null);\n          }\n        });\n        if (data) return data;\n      } catch {\n        /* ignore */\n      }\n\n      return null;\n    };\n\n    await Promise.all(\n      imgs.map(async (img) => {\n        let src = img.getAttribute('src') || '';\n        // For Google images, request original size (=s0) instead of thumbnail\n        if (\n          /^https?:\\/\\//i.test(src) &&\n          (src.includes('googleusercontent.com') || src.includes('ggpht.com'))\n        ) {\n          const sizePattern = /=[swh]\\d+[^?#]*/;\n          src = sizePattern.test(src) ? src.replace(sizePattern, '=s0') : src + '=s0';\n        }\n        const data = await toDataUrl(src);\n        if (data) {\n          try {\n            img.src = data;\n          } catch {\n            /* ignore */\n          }\n        }\n      }),\n    );\n\n    await Promise.all(\n      imgs.map((img) =>\n        (img as HTMLImageElement & { decode?: () => Promise<void> }).decode?.().catch(() => {\n          /* ignore */\n        }),\n      ),\n    );\n  }\n\n  private static async renderWithSafariFallback(container: HTMLElement): Promise<Blob> {\n    const primaryTarget =\n      (container.querySelector('.gv-image-export-doc') as HTMLElement | null) || container;\n    const maxPrimaryAttempts = isSafari() ? 1 : this.PRIMARY_RENDER_MAX_ATTEMPTS;\n    return await renderElementToImageBlob(primaryTarget, {\n      maxAttempts: maxPrimaryAttempts,\n      retryDelayMs: this.PRIMARY_RENDER_RETRY_DELAY_MS,\n      shouldRetry: (error) => this.shouldRetryPrimaryRender(error),\n      enableSanitizedFallback: isSafari(),\n      sanitizeSelector: 'img',\n      shouldFallback: () => true,\n    });\n  }\n\n  private static async renderContainerToBlob(container: HTMLElement): Promise<Blob> {\n    document.body.appendChild(container);\n\n    try {\n      await this.inlineImages(container);\n      return await this.renderWithSafariFallback(container);\n    } finally {\n      try {\n        container.remove();\n      } catch {\n        /* ignore */\n      }\n    }\n  }\n\n  private static shouldRetryPrimaryRender(error: unknown): boolean {\n    if (isEventLikeImageRenderError(error)) return true;\n\n    if (error instanceof Error) {\n      const message = error.message.toLowerCase();\n      return message.includes('image') || message.includes('decode') || message.includes('network');\n    }\n\n    return false;\n  }\n  private static downloadBlob(blob: Blob, filename: string): void {\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = filename;\n    document.body.appendChild(a);\n    a.click();\n    setTimeout(() => {\n      try {\n        document.body.removeChild(a);\n      } catch {\n        /* ignore */\n      }\n      URL.revokeObjectURL(url);\n    }, 0);\n  }\n\n  private static escapeHTML(text: string): string {\n    const div = document.createElement('div');\n    div.textContent = text;\n    return div.innerHTML;\n  }\n\n  private static escapeAttr(text: string): string {\n    return String(text)\n      .replace(/&/g, '&amp;')\n      .replace(/</g, '&lt;')\n      .replace(/>/g, '&gt;')\n      .replace(/\"/g, '&quot;')\n      .replace(/'/g, '&#39;');\n  }\n\n  private static formatPlainTextAsHtml(text: string): string {\n    const safe = this.escapeHTML(text || '');\n    if (!safe.trim()) return '<em>No content</em>';\n    const paras = safe\n      .split(/\\n\\n+/)\n      .map((p) => p.replace(/\\n/g, '<br>'))\n      .map((p) => `<p>${p}</p>`);\n    return paras.join('');\n  }\n\n  private static formatDate(iso: string): string {\n    try {\n      const d = new Date(iso);\n      return d.toLocaleString('en-US', {\n        year: 'numeric',\n        month: 'long',\n        day: 'numeric',\n        hour: '2-digit',\n        minute: '2-digit',\n      });\n    } catch {\n      return iso;\n    }\n  }\n}\n"
  },
  {
    "path": "src/features/export/services/ImageRenderService.ts",
    "content": "import { toBlob } from 'html-to-image';\n\nconst TRANSPARENT_IMAGE_PLACEHOLDER =\n  'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';\nconst DEFAULT_OFFSCREEN_LEFT = '-100000px';\nconst DEFAULT_SANITIZE_SELECTOR = 'img, video, iframe, canvas, svg image';\nconst DEFAULT_RENDER_WIDTH = 720;\n\nexport type RenderElementToImageBlobOptions = {\n  maxAttempts?: number;\n  retryDelayMs?: number;\n  shouldRetry?: (error: unknown) => boolean;\n  enableSanitizedFallback?: boolean;\n  sanitizeSelector?: string;\n  shouldFallback?: (error: unknown) => boolean;\n};\n\nexport function isImageResourceRenderError(error: unknown): boolean {\n  if (error instanceof Event) return true;\n  if (!(error instanceof Error)) return false;\n\n  const message = error.message.toLowerCase();\n  return (\n    message.includes('image') ||\n    message.includes('fetch') ||\n    message.includes('decode') ||\n    message.includes('resource') ||\n    message.includes('taint') ||\n    message.includes('canvas')\n  );\n}\n\nasync function renderTargetToBlob(target: HTMLElement): Promise<Blob> {\n  const blob = await toBlob(target, {\n    cacheBust: true,\n    pixelRatio: 1.2,\n    backgroundColor: '#ffffff',\n    skipFonts: true,\n    imagePlaceholder: TRANSPARENT_IMAGE_PLACEHOLDER,\n    onImageErrorHandler: () => undefined,\n  });\n\n  if (!blob) {\n    const rect = target.getBoundingClientRect();\n    const width = Math.round(rect.width);\n    const height = Math.round(rect.height);\n    throw new Error(`Image render failed (${width}x${height})`);\n  }\n\n  return blob;\n}\n\nfunction sanitizeClone(target: HTMLElement, selector: string): HTMLElement {\n  const clone = target.cloneNode(true) as HTMLElement;\n  clone.querySelectorAll(selector).forEach((element) => element.remove());\n  return clone;\n}\n\nfunction resolveRenderableWidth(target: HTMLElement): number {\n  let current: HTMLElement | null = target;\n  let depth = 0;\n  while (current && depth < 12) {\n    const width = Math.round(current.getBoundingClientRect().width);\n    if (Number.isFinite(width) && width > 24) {\n      return width;\n    }\n    current = current.parentElement;\n    depth += 1;\n  }\n\n  const viewportWidth = Math.round(globalThis.innerWidth || 0);\n  if (viewportWidth > 24) {\n    const preferred = Math.round(viewportWidth * 0.8);\n    return Math.max(360, Math.min(preferred, 1200));\n  }\n\n  return DEFAULT_RENDER_WIDTH;\n}\n\nasync function renderUsingSanitizedClone(target: HTMLElement, selector: string): Promise<Blob> {\n  const container = document.createElement('div');\n  container.style.position = 'fixed';\n  container.style.left = DEFAULT_OFFSCREEN_LEFT;\n  container.style.top = '0';\n  container.style.opacity = '0';\n  container.style.pointerEvents = 'none';\n\n  const renderRoot = document.createElement('div');\n  renderRoot.style.display = 'block';\n  renderRoot.style.width = `${resolveRenderableWidth(target)}px`;\n  renderRoot.style.background = '#ffffff';\n\n  const clone = sanitizeClone(target, selector);\n  renderRoot.appendChild(clone);\n  container.appendChild(renderRoot);\n  document.body.appendChild(container);\n\n  try {\n    return await renderTargetToBlob(renderRoot);\n  } finally {\n    container.remove();\n  }\n}\n\nasync function delay(ms: number): Promise<void> {\n  await new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nexport async function renderElementToImageBlob(\n  target: HTMLElement,\n  options: RenderElementToImageBlobOptions = {},\n): Promise<Blob> {\n  const maxAttempts = Math.max(1, options.maxAttempts ?? 1);\n  const retryDelayMs = Math.max(0, options.retryDelayMs ?? 0);\n  const shouldRetry = options.shouldRetry ?? (() => false);\n\n  let primaryError: unknown;\n  for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n    try {\n      return await renderTargetToBlob(target);\n    } catch (error) {\n      primaryError = error;\n      const canRetry = attempt < maxAttempts && shouldRetry(error);\n      if (!canRetry) break;\n      if (retryDelayMs > 0) {\n        await delay(retryDelayMs * attempt);\n      }\n    }\n  }\n\n  if (!options.enableSanitizedFallback) {\n    throw primaryError;\n  }\n\n  const shouldFallback = options.shouldFallback ?? isImageResourceRenderError;\n  if (!shouldFallback(primaryError)) {\n    throw primaryError;\n  }\n\n  return await renderUsingSanitizedClone(\n    target,\n    options.sanitizeSelector ?? DEFAULT_SANITIZE_SELECTOR,\n  );\n}\n"
  },
  {
    "path": "src/features/export/services/MarkdownFormatter.ts",
    "content": "/**\n * Markdown formatter service\n * Converts conversation to clean, standard Markdown format\n * Following the \"paper book\" philosophy - content over design\n */\nimport type { ChatTurn, ConversationMetadata } from '../types/export';\nimport { DOMContentExtractor } from './DOMContentExtractor';\n\n/**\n * Markdown formatting service\n * Produces clean, portable Markdown following CommonMark specification\n */\nexport class MarkdownFormatter {\n  /**\n   * Fetch URL as data URL (best-effort). Returns null on failure.\n   */\n  private static async fetchAsDataURL(url: string): Promise<string | null> {\n    try {\n      const resp = await fetch(url, { credentials: 'include', mode: 'cors' as RequestMode });\n      if (!resp.ok || !resp.body) return null;\n      const blob = await resp.blob();\n      return await new Promise<string>((resolve, reject) => {\n        try {\n          const reader = new FileReader();\n          reader.onerror = () => reject(new Error('readAsDataURL failed'));\n          reader.onload = () => resolve(String(reader.result || ''));\n          reader.readAsDataURL(blob);\n        } catch (e) {\n          reject(e);\n        }\n      });\n    } catch {\n      return null;\n    }\n  }\n\n  /**\n   * Extract image URLs from Markdown (http/https and blob: URLs)\n   */\n  static extractImageUrls(markdown: string): string[] {\n    const imgRegex = /!\\[[^\\]]*\\]\\(((?:https?:\\/\\/|blob:)[^\\s)]+)\\)/g;\n    const out = new Set<string>();\n    let m: RegExpExecArray | null;\n    while ((m = imgRegex.exec(markdown)) !== null) {\n      out.add(m[1]);\n    }\n    return Array.from(out);\n  }\n\n  /**\n   * Rewrite Markdown image URLs using provided mapping (original -> newUrl)\n   */\n  static rewriteImageUrls(markdown: string, mapping: Map<string, string>): string {\n    const imgRegex = /!\\[([^\\]]*)\\]\\(((?:https?:\\/\\/|blob:)[^\\s)]+)\\)/g;\n    return markdown.replace(imgRegex, (_all, alt, url) => {\n      const next = mapping.get(url);\n      return next ? `![${alt}](${next})` : _all;\n    });\n  }\n\n  /**\n   * Replace markdown image syntax with a Safari-safe text placeholder.\n   */\n  static degradeImageMarkdownForSafari(markdown: string): string {\n    const imgRegex = /!\\[([^\\]]*)\\]\\(((?:https?:\\/\\/|blob:)[^\\s)]+)\\)/g;\n    return markdown.replace(imgRegex, (_all, altText) => {\n      const alt = String(altText || '').trim();\n      const label = alt || 'image';\n      return `[Image unavailable in Safari export: ${label}]`;\n    });\n  }\n\n  /**\n   * Format conversation as Markdown\n   */\n  static format(turns: ChatTurn[], metadata: ConversationMetadata): string {\n    const sections: string[] = [];\n\n    // Header with metadata\n    sections.push(this.formatHeader(metadata));\n    sections.push(''); // Empty line\n\n    // Divider\n    sections.push('---');\n    sections.push('');\n\n    // Conversation turns\n    turns.forEach((turn, index) => {\n      sections.push(this.formatTurn(turn, index + 1));\n      sections.push(''); // Empty line between turns\n    });\n\n    // Footer\n    sections.push('---');\n    sections.push('');\n    sections.push(this.formatFooter(metadata));\n\n    return sections.join('\\n');\n  }\n\n  /**\n   * Format header with conversation metadata\n   */\n  private static formatHeader(metadata: ConversationMetadata): string {\n    const lines: string[] = [];\n\n    // Title\n    const title = metadata.title || this.extractTitleFromURL(metadata.url);\n    lines.push(`# ${this.escapeMarkdown(title)}`);\n    lines.push('');\n\n    // Metadata table\n    lines.push(`**Date**: ${this.formatDate(metadata.exportedAt)}`);\n    lines.push(`**Turns**: ${metadata.count}`);\n    lines.push(`**Source**: [Gemini Chat](${metadata.url})`);\n\n    return lines.join('\\n');\n  }\n\n  /**\n   * Format a single conversation turn\n   */\n  private static formatTurn(turn: ChatTurn, index: number): string {\n    const lines: string[] = [];\n\n    lines.push(`## Turn ${index}${turn.starred ? ' ⭐' : ''}`);\n    lines.push('');\n\n    if (!turn.omitEmptySections) {\n      lines.push('### 👤 User');\n      lines.push('');\n\n      if (turn.userElement) {\n        const extracted = DOMContentExtractor.extractUserContent(turn.userElement);\n        if (extracted.hasImages) {\n          lines.push('*[This turn includes uploaded images]*');\n          lines.push('');\n        }\n        lines.push(extracted.text || '_No content_');\n      } else {\n        lines.push(this.formatContent(turn.user) || '_No content_');\n      }\n\n      lines.push('');\n      lines.push('### 🤖 Assistant');\n      lines.push('');\n\n      if (turn.assistantElement) {\n        const extracted = DOMContentExtractor.extractAssistantContent(turn.assistantElement);\n        const fallback = this.formatContent(turn.assistant);\n        lines.push(extracted.text || fallback || '_No content_');\n      } else {\n        lines.push(this.formatContent(turn.assistant) || '_No content_');\n      }\n\n      return lines.join('\\n');\n    }\n\n    let hasAnySection = false;\n\n    const userFallback = this.formatContent(turn.user);\n    const hasUser = !!turn.userElement || !!userFallback;\n    if (hasUser) {\n      lines.push('### 👤 User');\n      lines.push('');\n\n      if (turn.userElement) {\n        const extracted = DOMContentExtractor.extractUserContent(turn.userElement);\n        if (extracted.hasImages) {\n          lines.push('*[This turn includes uploaded images]*');\n          lines.push('');\n        }\n        lines.push(extracted.text || userFallback || '_No content_');\n      } else {\n        lines.push(userFallback || '_No content_');\n      }\n\n      lines.push('');\n      hasAnySection = true;\n    }\n\n    const assistantFallback = this.formatContent(turn.assistant);\n    const hasAssistant = !!turn.assistantElement || !!assistantFallback;\n    if (hasAssistant) {\n      lines.push('### 🤖 Assistant');\n      lines.push('');\n\n      if (turn.assistantElement) {\n        const extracted = DOMContentExtractor.extractAssistantContent(turn.assistantElement);\n        lines.push(extracted.text || assistantFallback || '_No content_');\n      } else {\n        lines.push(assistantFallback || '_No content_');\n      }\n\n      hasAnySection = true;\n    }\n\n    if (!hasAnySection) {\n      lines.push('_No content_');\n    }\n\n    return lines.join('\\n');\n  }\n\n  /**\n   * Format content with proper Markdown syntax\n   * Preserves code blocks, lists, and other formatting\n   */\n  private static formatContent(content: string): string {\n    if (!content) return '';\n\n    // Content is already mostly plain text from DOM extraction\n    // We just need to ensure proper escaping and structure\n\n    let formatted = content.trim();\n\n    // Detect and preserve code blocks (already formatted by Gemini)\n    // The extractAssistantText already gives us clean text\n    // We'll just ensure proper indentation for code\n\n    return formatted;\n  }\n\n  /**\n   * Format footer\n   */\n  private static formatFooter(metadata: ConversationMetadata): string {\n    return [\n      `*Exported from [Voyager](https://github.com/Nagi-ovo/gemini-voyager)*`,\n      `*Generated on ${this.formatDate(metadata.exportedAt)}*`,\n    ].join('  \\n'); // Two spaces for line break\n  }\n\n  /**\n   * Extract title from URL\n   */\n  private static extractTitleFromURL(url: string): string {\n    try {\n      const urlObj = new URL(url);\n      const pathname = urlObj.pathname;\n\n      // Extract from Gemini URL pattern\n      // e.g., /app/conversation-id or /chat/conversation-id\n      const match = pathname.match(/\\/(app|chat)\\/([^/]+)/);\n      if (match) {\n        const id = match[2];\n        return `Gemini Conversation ${id.substring(0, 8)}`;\n      }\n\n      return 'Gemini Conversation';\n    } catch {\n      return 'Gemini Conversation';\n    }\n  }\n\n  /**\n   * Format date in readable format\n   */\n  private static formatDate(isoString: string): string {\n    try {\n      const date = new Date(isoString);\n      return date.toLocaleString('en-US', {\n        year: 'numeric',\n        month: 'long',\n        day: 'numeric',\n        hour: '2-digit',\n        minute: '2-digit',\n      });\n    } catch {\n      return isoString;\n    }\n  }\n\n  /**\n   * Escape special Markdown characters\n   */\n  private static escapeMarkdown(text: string): string {\n    // Escape special characters that could break Markdown\n    return text.replace(/([\\\\`*_{}[\\]()#+\\-.!])/g, '\\\\$1');\n  }\n\n  /**\n   * Generate filename for Markdown export\n   */\n  static generateFilename(): string {\n    const pad = (n: number) => String(n).padStart(2, '0');\n    const d = new Date();\n    const y = d.getFullYear();\n    const m = pad(d.getMonth() + 1);\n    const day = pad(d.getDate());\n    const hh = pad(d.getHours());\n    const mm = pad(d.getMinutes());\n    const ss = pad(d.getSeconds());\n    return `gemini-chat-${y}${m}${day}-${hh}${mm}${ss}.md`;\n  }\n\n  /**\n   * Download Markdown file\n   */\n  static download(content: string, filename?: string): void {\n    const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = filename || this.generateFilename();\n    document.body.appendChild(a);\n    a.click();\n    setTimeout(() => {\n      try {\n        document.body.removeChild(a);\n      } catch {\n        /* ignore */\n      }\n      URL.revokeObjectURL(url);\n    }, 0);\n  }\n}\n"
  },
  {
    "path": "src/features/export/services/PDFPrintService.ts",
    "content": "/**\n * PDF Print Service\n * Implements elegant \"paper book\" style PDF export using browser's print function\n * Philosophy: Content over design, readability over fidelity\n */\nimport { isSafari } from '@/core/utils/browser';\n\nimport type { ChatTurn, ConversationMetadata } from '../types/export';\nimport { DOMContentExtractor } from './DOMContentExtractor';\n\nexport interface PrintableDocumentContent {\n  title: string;\n  url: string;\n  exportedAt: string;\n  markdown: string;\n  html: string;\n}\n\n/**\n * PDF print service using browser's native print dialog\n * Injects optimized styles for paper-friendly output\n */\nexport class PDFPrintService {\n  private static PRINT_STYLES_ID = 'gv-pdf-print-styles';\n  private static PRINT_CONTAINER_ID = 'gv-pdf-print-container';\n  private static PRINT_BODY_CLASS = 'gv-pdf-printing';\n  private static CLEANUP_FALLBACK_DELAY_MS = 60_000;\n  private static INLINE_FETCH_TIMEOUT_MS = 2_000;\n  private static INLINE_DECODE_TIMEOUT_MS = 1_000;\n  private static cleanupFallbackTimer: ReturnType<typeof setTimeout> | null = null;\n  private static originalDocumentTitle: string | null = null;\n\n  /**\n   * Export conversation as PDF using browser print\n   */\n  static async export(\n    turns: ChatTurn[],\n    metadata: ConversationMetadata,\n    options?: { fontSize?: number },\n  ): Promise<void> {\n    await this.exportInternal(turns, metadata, false, options?.fontSize);\n  }\n\n  static async exportDocument(content: PrintableDocumentContent): Promise<void> {\n    const metadata: ConversationMetadata = {\n      url: content.url,\n      exportedAt: content.exportedAt,\n      count: 1,\n      title: content.title,\n    };\n\n    const htmlContainer = document.createElement('div');\n    htmlContainer.innerHTML = content.html.trim();\n    const fallbackFromHtml = this.extractPlainTextFromHtml(content.html);\n    const assistant = fallbackFromHtml || content.markdown.trim() || 'No content';\n    const turns: ChatTurn[] = [\n      {\n        user: '',\n        assistant,\n        starred: false,\n        omitEmptySections: true,\n        assistantElement: htmlContainer,\n      },\n    ];\n\n    await this.exportInternal(turns, metadata, true);\n  }\n\n  private static async exportInternal(\n    turns: ChatTurn[],\n    metadata: ConversationMetadata,\n    preferMetadataTitle: boolean,\n    fontSize?: number,\n  ): Promise<void> {\n    // Ensure we don't leave a previous export container around (e.g. if a prior export failed)\n    this.cleanup();\n\n    // Create print container\n    const container = this.createPrintContainer(turns, metadata, preferMetadataTitle);\n    document.body.appendChild(container);\n\n    // Remove existing print styles so we can re-inject with new font size\n    const existingStyles = document.getElementById(this.PRINT_STYLES_ID);\n    if (existingStyles) existingStyles.remove();\n\n    // Inject print styles\n    this.injectPrintStyles(fontSize);\n    document.body.classList.add(this.PRINT_BODY_CLASS);\n\n    // Keep print header/footer title aligned with conversation title in print dialog output.\n    this.originalDocumentTitle = document.title;\n    const printDialogTitle = this.getPrintDialogTitle(metadata, preferMetadataTitle);\n    if (printDialogTitle) {\n      document.title = printDialogTitle;\n    }\n\n    const safari = isSafari();\n\n    // Inline images as data URLs (best-effort) to avoid auth-bound links failing in print.\n    // Safari is very strict about `window.print()` being called with a user gesture; awaiting here\n    // may cause the print dialog to be blocked. So on Safari we do not await.\n    const inlineImagesPromise = this.inlineImages(container).catch(() => {\n      /* ignore */\n    });\n\n    if (safari) {\n      this.forceStyleFlush(container);\n      this.triggerPrint();\n      this.registerCleanupHandlers();\n      void inlineImagesPromise;\n      return;\n    }\n\n    await inlineImagesPromise;\n    await this.delay(100);\n    this.triggerPrint();\n    this.registerCleanupHandlers();\n  }\n\n  private static triggerPrint(): void {\n    try {\n      window.print();\n    } catch {\n      // Ignore: some environments (tests/iframes) may not support printing\n    }\n  }\n\n  private static forceStyleFlush(container: HTMLElement): void {\n    try {\n      // Force a synchronous style/layout flush so the print-only DOM is \"real\" before printing.\n      // (Helps on Safari/WebKit where style application can lag behind DOM insertion.)\n      container.getBoundingClientRect();\n    } catch {\n      /* ignore */\n    }\n  }\n\n  private static delay(ms: number): Promise<void> {\n    return new Promise((resolve) => {\n      this.setTimeoutUnref(resolve, ms);\n    });\n  }\n\n  private static registerCleanupHandlers(): void {\n    // Prefer afterprint (reliable when supported); keep a fallback timer in case it never fires.\n    const cleanupNow = (): void => {\n      this.cleanup();\n    };\n\n    try {\n      window.addEventListener('afterprint', cleanupNow, { once: true });\n    } catch {\n      /* ignore */\n    }\n\n    if (this.cleanupFallbackTimer !== null) {\n      clearTimeout(this.cleanupFallbackTimer);\n    }\n    this.cleanupFallbackTimer = this.setTimeoutUnref(() => {\n      this.cleanup();\n    }, this.CLEANUP_FALLBACK_DELAY_MS);\n  }\n\n  private static setTimeoutUnref(callback: () => void, ms: number): ReturnType<typeof setTimeout> {\n    const handle = setTimeout(callback, ms);\n    // Node.js timers support unref(), which avoids keeping the process alive in tests.\n    if (\n      typeof handle === 'object' &&\n      handle !== null &&\n      'unref' in handle &&\n      typeof (handle as { unref?: unknown }).unref === 'function'\n    ) {\n      (handle as { unref: () => void }).unref();\n    }\n    return handle;\n  }\n\n  /**\n   * Create HTML container for printing\n   */\n  private static createPrintContainer(\n    turns: ChatTurn[],\n    metadata: ConversationMetadata,\n    preferMetadataTitle: boolean,\n  ): HTMLElement {\n    const container = document.createElement('div');\n    container.id = this.PRINT_CONTAINER_ID;\n    container.className = 'gv-print-only';\n\n    // Build HTML content\n    container.innerHTML = `\n      <div class=\"gv-print-document\">\n        ${this.renderHeader(metadata, preferMetadataTitle)}\n        ${this.renderContent(turns)}\n        ${this.renderFooter(metadata)}\n      </div>\n    `;\n\n    return container;\n  }\n\n  private static extractPlainTextFromHtml(html: string): string {\n    const trimmed = html.trim();\n    if (!trimmed) return '';\n    const container = document.createElement('div');\n    container.innerHTML = trimmed;\n    container.querySelectorAll('script, style, template').forEach((element) => element.remove());\n    return this.normalizeWhitespace(container.textContent || '');\n  }\n\n  private static normalizeWhitespace(text: string): string {\n    return text\n      .replace(/\\r/g, '')\n      .replace(/[ \\t]+\\n/g, '\\n')\n      .replace(/\\n{3,}/g, '\\n\\n')\n      .trim();\n  }\n\n  /**\n   * Convert <img src> links in container to data URLs (best-effort)\n   */\n  private static async inlineImages(container: HTMLElement): Promise<void> {\n    const imgs = Array.from(container.querySelectorAll('img')) as HTMLImageElement[];\n    if (imgs.length === 0) return;\n    const toDataUrl = async (url: string): Promise<string | null> => {\n      const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;\n      const timeoutHandle = this.setTimeoutUnref(() => {\n        try {\n          controller?.abort();\n        } catch {\n          /* ignore */\n        }\n      }, this.INLINE_FETCH_TIMEOUT_MS);\n\n      try {\n        const init: RequestInit = { credentials: 'include', mode: 'cors' as RequestMode };\n        if (controller) init.signal = controller.signal;\n\n        const resp = await fetch(url, init);\n        if (!resp.ok) return null;\n        const blob = await resp.blob();\n        const data = await new Promise<string>((resolve, reject) => {\n          try {\n            const reader = new FileReader();\n            reader.onerror = () => reject(new Error('readAsDataURL failed'));\n            reader.onload = () => resolve(String(reader.result || ''));\n            reader.readAsDataURL(blob);\n          } catch (e) {\n            reject(e);\n          }\n        });\n        return data;\n      } catch {\n        return null;\n      } finally {\n        clearTimeout(timeoutHandle);\n      }\n    };\n\n    await Promise.all(\n      imgs.map(async (img) => {\n        let src = img.getAttribute('src') || '';\n        // Handle both http(s) and blob: URLs (watermark-removed images use blob: URLs)\n        if (!/^(https?:\\/\\/|blob:)/i.test(src)) return;\n        // For Google images, request original size (=s0) instead of thumbnail\n        if (\n          (src.includes('googleusercontent.com') || src.includes('ggpht.com')) &&\n          !src.startsWith('blob:')\n        ) {\n          const sizePattern = /=[swh]\\d+[^?#]*/;\n          src = sizePattern.test(src) ? src.replace(sizePattern, '=s0') : src + '=s0';\n        }\n        const data = await toDataUrl(src);\n        if (data) {\n          try {\n            img.src = data;\n          } catch {}\n        }\n      }),\n    );\n\n    // Attempt to wait for image decoding\n    type DecodableImage = HTMLImageElement & { decode?: () => Promise<void> };\n    await Promise.all(\n      imgs.map(async (img) => {\n        const decode = (img as DecodableImage).decode;\n        if (typeof decode !== 'function') return;\n\n        try {\n          await Promise.race([\n            decode.call(img).catch(() => {\n              /* ignore */\n            }),\n            this.delay(this.INLINE_DECODE_TIMEOUT_MS),\n          ]);\n        } catch {\n          /* ignore */\n        }\n      }),\n    );\n  }\n\n  /**\n   * Get conversation title from page\n   */\n  private static getConversationTitle(): string {\n    // Strategy 1: Get from active conversation in Gemini Voyager Folder UI (most accurate)\n    try {\n      // Prefer the folder row that is marked as selected for the current conversation\n      const activeFolderTitle =\n        document.querySelector(\n          '.gv-folder-conversation.gv-folder-conversation-selected .gv-conversation-title',\n        ) || document.querySelector('.gv-folder-conversation-selected .gv-conversation-title');\n\n      if (activeFolderTitle?.textContent?.trim()) {\n        return activeFolderTitle.textContent.trim();\n      }\n    } catch (error) {\n      console.debug('[PDF Export] Failed to get title from Folder Manager:', error);\n    }\n\n    // Strategy 1b: Get from Gemini native sidebar via current conversation ID\n    try {\n      const conversationId = this.extractConversationIdFromURL(window.location.href);\n      if (conversationId) {\n        const byId = this.extractTitleFromNativeSidebarByConversationId(conversationId);\n        if (byId) return byId;\n      }\n    } catch (error) {\n      console.debug('[PDF Export] Failed to get title from native sidebar by id:', error);\n    }\n\n    // Strategy 2: Try to get from page title\n    const titleElement = document.querySelector('title');\n    if (titleElement) {\n      const title = titleElement.textContent?.trim();\n      if (this.isMeaningfulConversationTitle(title)) {\n        return title;\n      }\n    }\n\n    // Strategy 3: Try to get from sidebar conversation list\n    try {\n      const selectors = [\n        'mat-list-item.mdc-list-item--activated [mat-line]',\n        'mat-list-item[aria-current=\"page\"] [mat-line]',\n        '.conversation-list-item.active .conversation-title',\n        '.active-conversation .title',\n      ];\n\n      for (const selector of selectors) {\n        const element = document.querySelector(selector);\n        const title = element?.textContent?.trim();\n        if (this.isMeaningfulConversationTitle(title)) {\n          return title;\n        }\n      }\n    } catch (error) {\n      console.debug('[PDF Export] Failed to get title from sidebar:', error);\n    }\n\n    // Strategy 4: URL fallback\n    const conversationId = this.extractConversationIdFromURL(window.location.href);\n    if (conversationId) {\n      return `Conversation ${conversationId.slice(0, 8)}`;\n    }\n\n    return 'Untitled Conversation';\n  }\n\n  private static isMeaningfulConversationTitle(title: string | null | undefined): title is string {\n    const t = (title || '').trim();\n    if (!t) return false;\n    if (\n      t === 'Untitled Conversation' ||\n      t === 'Gemini' ||\n      t === 'Google Gemini' ||\n      t === 'Google AI Studio' ||\n      t === 'New chat'\n    ) {\n      return false;\n    }\n    if (t.startsWith('Gemini -') || t.startsWith('Google AI Studio -')) return false;\n    return true;\n  }\n\n  private static isGemLabel(text: string | null | undefined): boolean {\n    const t = (text || '').trim().toLowerCase();\n    return t === 'gem' || t === 'gems';\n  }\n\n  private static extractConversationIdFromURL(url: string): string | null {\n    try {\n      const urlObj = new URL(url);\n      const appMatch = urlObj.pathname.match(/\\/app\\/([^/?#]+)/);\n      if (appMatch?.[1]) return appMatch[1];\n      const gemMatch = urlObj.pathname.match(/\\/gem\\/[^/]+\\/([^/?#]+)/);\n      if (gemMatch?.[1]) return gemMatch[1];\n    } catch {\n      /* ignore */\n    }\n    return null;\n  }\n\n  private static extractTitleFromLinkText(link?: HTMLAnchorElement | null): string | null {\n    if (!link) return null;\n    const text = (link.innerText || '').trim();\n    if (!text) return null;\n    const parts = text\n      .split('\\n')\n      .map((s) => s.trim())\n      .filter(Boolean)\n      .filter((s) => !this.isGemLabel(s))\n      .filter((s) => s.length >= 2);\n    if (parts.length === 0) return null;\n    return parts.reduce((a, b) => (b.length > a.length ? b : a), parts[0]) || null;\n  }\n\n  private static extractTitleFromConversationElement(conversationEl: HTMLElement): string | null {\n    const scope =\n      (conversationEl.closest('[data-test-id=\"conversation\"]') as HTMLElement) || conversationEl;\n    const bySelector = scope.querySelector(\n      '.gds-label-l, .conversation-title-text, [data-test-id=\"conversation-title\"], h3',\n    );\n    const selectorTitle = bySelector?.textContent?.trim();\n    if (this.isMeaningfulConversationTitle(selectorTitle) && !this.isGemLabel(selectorTitle)) {\n      return selectorTitle;\n    }\n\n    const link = scope.querySelector(\n      'a[href*=\"/app/\"], a[href*=\"/gem/\"]',\n    ) as HTMLAnchorElement | null;\n    const ariaTitle = link?.getAttribute('aria-label')?.trim();\n    if (this.isMeaningfulConversationTitle(ariaTitle) && !this.isGemLabel(ariaTitle)) {\n      return ariaTitle;\n    }\n    const linkTitle = link?.getAttribute('title')?.trim();\n    if (this.isMeaningfulConversationTitle(linkTitle) && !this.isGemLabel(linkTitle)) {\n      return linkTitle;\n    }\n    const fromLinkText = this.extractTitleFromLinkText(link);\n    if (this.isMeaningfulConversationTitle(fromLinkText)) {\n      return fromLinkText;\n    }\n\n    const label = scope.querySelector('.gds-body-m, .gds-label-m, .subtitle');\n    const labelText = label?.textContent?.trim();\n    if (this.isMeaningfulConversationTitle(labelText) && !this.isGemLabel(labelText)) {\n      return labelText;\n    }\n\n    const raw = scope.textContent?.trim() || '';\n    if (!raw) return null;\n    const firstLine =\n      raw\n        .split('\\n')\n        .map((s) => s.trim())\n        .filter(Boolean)[0] || raw;\n    if (this.isMeaningfulConversationTitle(firstLine) && !this.isGemLabel(firstLine)) {\n      return firstLine.slice(0, 80);\n    }\n\n    return null;\n  }\n\n  private static extractTitleFromNativeSidebarByConversationId(\n    conversationId: string,\n  ): string | null {\n    const escapedConversationId = this.escapeCssAttributeValue(conversationId);\n    const byJslog = document.querySelector(\n      `[data-test-id=\"conversation\"][jslog*=\"c_${escapedConversationId}\"]`,\n    ) as HTMLElement | null;\n    if (byJslog) {\n      const title = this.extractTitleFromConversationElement(byJslog);\n      if (title) return title;\n    }\n\n    const byHrefLink = document.querySelector(\n      `[data-test-id=\"conversation\"] a[href*=\"${escapedConversationId}\"]`,\n    ) as HTMLElement | null;\n    if (byHrefLink) {\n      const title = this.extractTitleFromConversationElement(byHrefLink);\n      if (title) return title;\n    }\n\n    return null;\n  }\n\n  /**\n   * Render document header with cover page\n   */\n  private static renderHeader(\n    metadata: ConversationMetadata,\n    preferMetadataTitle: boolean,\n  ): string {\n    const metadataTitle = this.normalizeConversationTitle(metadata.title);\n    const pageConversationTitle = this.normalizeConversationTitle(this.getConversationTitle());\n    const conversationTitle = preferMetadataTitle\n      ? metadataTitle || pageConversationTitle || 'Untitled Conversation'\n      : pageConversationTitle || metadataTitle || 'Untitled Conversation';\n    // For PDF, avoid repeating the same title in smaller text under the H1.\n    // Always derive a neutral \"source\" label from the URL instead of using metadata.title.\n    const urlTitle = this.extractTitleFromURL(metadata.url);\n    const date = this.formatDate(metadata.exportedAt);\n    const turnsCount = metadata.count;\n\n    return `\n      <div class=\"gv-print-header gv-print-cover-page\">\n        <div class=\"gv-print-cover-content\">\n          <h1 class=\"gv-print-cover-title\">${this.escapeHTML(conversationTitle)}</h1>\n          <div class=\"gv-print-meta\">\n            <p>${date}</p>\n            <p><a href=\"${this.escapeAttribute(metadata.url)}\">${this.escapeHTML(urlTitle)}</a></p>\n            <p>${turnsCount} conversation turns</p>\n          </div>\n        </div>\n      </div>\n    `;\n  }\n\n  /**\n   * Render conversation content\n   */\n  private static renderContent(turns: ChatTurn[]): string {\n    return `\n      <div class=\"gv-print-content\">\n        ${turns.map((turn, index) => this.renderTurn(turn, index + 1)).join('\\n')}\n      </div>\n    `;\n  }\n\n  /**\n   * Render a single turn\n   */\n  private static renderTurn(turn: ChatTurn, index: number): string {\n    const starredClass = turn.starred ? 'gv-print-turn-starred' : '';\n\n    const userContent = turn.userElement\n      ? DOMContentExtractor.extractUserContent(turn.userElement).html || '<em>No content</em>'\n      : this.formatContent(turn.user) || '<em>No content</em>';\n\n    const assistantContent = turn.assistantElement\n      ? DOMContentExtractor.extractAssistantContent(turn.assistantElement).html ||\n        '<em>No content</em>'\n      : this.formatContent(turn.assistant) || '<em>No content</em>';\n\n    if (!turn.omitEmptySections) {\n      return `\n      <div class=\"gv-print-turn ${starredClass}\">\n        <div class=\"gv-print-turn-header\">\n          <span class=\"gv-print-turn-number\">Turn ${index}</span>\n          ${turn.starred ? '<span class=\"gv-print-star\">⭐</span>' : ''}\n        </div>\n\n        <div class=\"gv-print-turn-user\">\n          <div class=\"gv-print-turn-label\">👤 User</div>\n          <div class=\"gv-print-turn-text\">${userContent}</div>\n        </div>\n\n        <div class=\"gv-print-turn-assistant\">\n          <div class=\"gv-print-turn-label\">🤖 Assistant</div>\n          <div class=\"gv-print-turn-text\">${assistantContent}</div>\n        </div>\n      </div>\n    `;\n    }\n\n    const hasUser = !!turn.userElement || !!turn.user.trim();\n    const hasAssistant = !!turn.assistantElement || !!turn.assistant.trim();\n\n    return `\n      <div class=\"gv-print-turn ${starredClass}\">\n        <div class=\"gv-print-turn-header\">\n          <span class=\"gv-print-turn-number\">Turn ${index}</span>\n          ${turn.starred ? '<span class=\"gv-print-star\">⭐</span>' : ''}\n        </div>\n\n        ${\n          hasUser\n            ? `\n        <div class=\"gv-print-turn-user\">\n          <div class=\"gv-print-turn-label\">👤 User</div>\n          <div class=\"gv-print-turn-text\">${userContent}</div>\n        </div>\n        `\n            : ''\n        }\n\n        ${\n          hasAssistant\n            ? `\n          <div class=\"gv-print-turn-assistant\">\n            <div class=\"gv-print-turn-label\">🤖 Assistant</div>\n            <div class=\"gv-print-turn-text\">${assistantContent}</div>\n          </div>\n        `\n            : ''\n        }\n      </div>\n    `;\n  }\n\n  /**\n   * Format content for HTML output\n   */\n  private static formatContent(content: string): string {\n    if (!content) return '<em>No content</em>';\n\n    // Escape HTML but preserve line breaks\n    let formatted = this.escapeHTML(content);\n\n    // Convert double line breaks to paragraphs\n    formatted = formatted\n      .split('\\n\\n')\n      .map((para) => `<p>${para.replace(/\\n/g, '<br>')}</p>`)\n      .join('');\n\n    return formatted;\n  }\n\n  /**\n   * Render footer\n   */\n  private static renderFooter(metadata: ConversationMetadata): string {\n    return `\n      <div class=\"gv-print-footer\">\n        <p>Exported from <a href=\"https://github.com/Nagi-ovo/gemini-voyager\">Voyager</a> • ${metadata.count} conversation turns</p>\n        <p>Generated on ${this.formatDate(metadata.exportedAt)}</p>\n      </div>\n    `;\n  }\n\n  /**\n   * Inject print-optimized styles\n   */\n  private static injectPrintStyles(fontSize?: number): void {\n    // Check if already injected\n    if (document.getElementById(this.PRINT_STYLES_ID)) return;\n\n    const basePt = fontSize ?? 11;\n    const codePt = Math.max(basePt - 2, 6);\n    const footerPt = Math.max(basePt - 2, 6);\n\n    const style = document.createElement('style');\n    style.id = this.PRINT_STYLES_ID;\n    style.textContent = `\n      /* Hide print container on screen */\n      .gv-print-only {\n        display: none;\n      }\n\n      /* Show print container when printing */\n      @media print {\n        /* Hide everything except print container */\n        body.${this.PRINT_BODY_CLASS} > *:not(#${this.PRINT_CONTAINER_ID}) {\n          display: none !important;\n          visibility: hidden !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} {\n          display: block !important;\n          visibility: visible !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID},\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} * {\n          visibility: visible !important;\n        }\n\n        /* Force white print canvas to avoid dark-theme background leaks on trailing pages */\n        html,\n        body {\n          background: #fff !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} {\n          background: #fff !important;\n        }\n\n        /* Gemini immersive-mode print CSS may force descendants to display:none */\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} * {\n          display: revert !important;\n        }\n\n        /* Preserve KaTeX layout primitives after the global display override above.\n           Without these, sub/sup scripts (e.g. x_1) may become misaligned in PDF print. */\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex-display,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex-display > .katex,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex-display > .katex > .katex-html {\n          display: block !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .base,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .strut,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .vlist > span > span,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .mspace,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .mfrac .frac-line,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .rule,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .hline,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .hdashline,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .overline .overline-line,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .underline .underline-line,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .nulldelimiter,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .clap > .fix,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .llap > .fix,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .rlap > .fix,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .mtable .vertical-separator,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .mtable .arraycolsep,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .cd-vert-arrow,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .cd-label-left,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .cd-label-right {\n          display: inline-block !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .vlist-t {\n          display: inline-table !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .vlist-r {\n          display: table-row !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .vlist,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .vlist-s {\n          display: table-cell !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .vlist > span,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .katex-html > .newline,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .overlay,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex svg,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .stretchy {\n          display: block !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .vbox,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .hbox,\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .katex .thinbox {\n          display: inline-flex !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .gv-print-turn-text .katex {\n          line-height: 1.2 !important;\n        }\n\n        /* Keep key layouts after the global descendant display override above */\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .gv-print-cover-page {\n          display: flex !important;\n          align-items: center !important;\n          justify-content: center !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .gv-print-turn-header {\n          display: flex !important;\n        }\n\n        body.${this.PRINT_BODY_CLASS} #${this.PRINT_CONTAINER_ID} .gv-print-turn-text img {\n          display: block !important;\n        }\n\n        /* Reset page styles */\n        @page {\n          margin: 2cm;\n          size: A4;\n        }\n\n        /* Document container */\n        .gv-print-document {\n          font-family: Georgia, 'Times New Roman', serif;\n          font-size: ${basePt}pt;\n          line-height: 1.6;\n          color: #000;\n          background: #fff;\n          max-width: 100%;\n        }\n\n        /* Cover Page Header */\n        .gv-print-cover-page {\n          min-height: 100vh;\n          display: flex;\n          align-items: center;\n          justify-content: center;\n          page-break-after: always;\n          margin: 0;\n          padding: 0;\n          border: none;\n        }\n\n        .gv-print-cover-content {\n          text-align: center;\n          max-width: 80%;\n        }\n\n        .gv-print-cover-title {\n          font-size: 36pt;\n          font-weight: 800;\n          letter-spacing: -0.02em;\n          margin: 0 0 1.5em 0;\n          color: oklch(0.7227 0.1920 149.5793);\n          line-height: 1.2;\n          word-wrap: break-word;\n        }\n\n        .gv-print-meta {\n          font-size: 12pt;\n          color: #666;\n          line-height: 2;\n          margin-top: 0.5em;\n        }\n\n        .gv-print-meta p {\n          margin: 0.3em 0;\n        }\n\n        .gv-print-meta a {\n          color: #666;\n          text-decoration: none;\n        }\n\n        .gv-print-meta a:after {\n          content: none !important;\n        }\n\n        /* Content */\n        .gv-print-content {\n          margin: 2em 0;\n        }\n\n        /* Turn */\n        .gv-print-turn {\n          margin-bottom: 2em;\n          page-break-inside: avoid;\n        }\n\n        .gv-print-turn-header {\n          display: flex;\n          align-items: center;\n          gap: 0.5em;\n          margin-bottom: 0.5em;\n          font-size: 12pt;\n          font-weight: bold;\n          color: #555;\n        }\n\n        .gv-print-turn-starred .gv-print-turn-header {\n          color: #d97706;\n        }\n\n        .gv-print-star {\n          font-size: 14pt;\n        }\n\n        /* Turn sections */\n        .gv-print-turn-user,\n        .gv-print-turn-assistant {\n          margin: 1em 0;\n        }\n\n        .gv-print-turn-label {\n          font-weight: 600;\n          font-size: ${basePt}pt;\n          margin-bottom: 0.5em;\n          color: #222;\n        }\n\n        .gv-print-turn-text {\n          padding-left: 1em;\n          border-left: 3px solid #e5e7eb;\n          color: #1a1a1a;\n        }\n\n        /* Constrain images to avoid oversized visuals */\n        .gv-print-turn-text img {\n          max-width: 60%;\n          height: auto;\n          display: block;\n          margin: 0.5em 0;\n          page-break-inside: avoid;\n        }\n\n        .gv-print-turn-assistant .gv-print-turn-text {\n          border-left-color: #93c5fd;\n        }\n\n        .gv-print-turn-text p {\n          margin: 0.5em 0;\n        }\n\n        .gv-print-turn-text em {\n          color: #666;\n        }\n\n        /* Code blocks (if any) */\n        .gv-print-turn-text code,\n        .gv-print-turn-text pre {\n          font-family: 'Courier New', monospace;\n          font-size: ${codePt}pt;\n          background: #f5f5f5;\n          padding: 0.2em 0.4em;\n          border-radius: 3px;\n        }\n\n        .gv-print-turn-text pre {\n          padding: 0.75em;\n          border-left: 3px solid #d1d5db;\n          overflow-x: auto;\n          white-space: pre-wrap;\n          word-wrap: break-word;\n        }\n\n        /* Math formulas */\n        .gv-print-turn-text .math-inline,\n        .gv-print-turn-text .math-block,\n        .gv-print-turn-text [data-math] {\n          page-break-inside: avoid;\n        }\n\n        .gv-print-turn-text .math-block {\n          display: block;\n          margin: 1em 0;\n          text-align: center;\n          overflow-x: auto;\n        }\n\n        .gv-print-turn-text .math-inline {\n          display: inline;\n        }\n\n        /* Footer */\n        .gv-print-footer {\n          margin-top: 2em;\n          padding-top: 1em;\n          border-top: 1px solid #ccc;\n          font-size: ${footerPt}pt;\n          color: #666;\n          text-align: center;\n        }\n\n        .gv-print-footer p {\n          margin: 0.25em 0;\n        }\n\n        /* Links */\n        a {\n          color: #2563eb;\n          text-decoration: none;\n        }\n\n        /* Hide Gemini inline source/citation chips (render as link icons) */\n        sources-carousel-inline,\n        source-inline-chips,\n        source-inline-chip,\n        .source-inline-chip-container {\n          display: none !important;\n        }\n\n        a[href]:after {\n          content: \" (\" attr(href) \")\";\n          font-size: ${footerPt}pt;\n          color: #666;\n        }\n\n        /* Utilities */\n        strong {\n          font-weight: 600;\n        }\n      }\n    `;\n\n    document.head.appendChild(style);\n  }\n\n  /**\n   * Cleanup print container and styles\n   */\n  private static cleanup(): void {\n    if (this.cleanupFallbackTimer !== null) {\n      try {\n        clearTimeout(this.cleanupFallbackTimer);\n      } catch {\n        /* ignore */\n      }\n      this.cleanupFallbackTimer = null;\n    }\n\n    const container = document.getElementById(this.PRINT_CONTAINER_ID);\n    if (container) {\n      container.remove();\n    }\n\n    try {\n      document.body.classList.remove(this.PRINT_BODY_CLASS);\n    } catch {\n      /* ignore */\n    }\n\n    if (this.originalDocumentTitle !== null) {\n      try {\n        document.title = this.originalDocumentTitle;\n      } catch {\n        /* ignore */\n      }\n      this.originalDocumentTitle = null;\n    }\n\n    // Keep styles for potential reuse\n    // They don't affect screen display anyway\n\n    // Notify other UI components (export button, folder manager) that printing ended.\n    // Gemini may re-render parts of the DOM during print, removing plugin-injected elements.\n    // This event gives them a chance to detect the loss and re-inject.\n    try {\n      window.dispatchEvent(new CustomEvent('gv-print-cleanup'));\n    } catch {\n      /* ignore */\n    }\n  }\n\n  /**\n   * Helper: Extract title from URL\n   */\n  private static extractTitleFromURL(url: string): string {\n    try {\n      const urlObj = new URL(url);\n      const pathname = urlObj.pathname;\n      const match = pathname.match(/\\/(app|chat)\\/([^/]+)/);\n      if (match) {\n        const id = match[2];\n        return `Gemini Conversation ${id.substring(0, 8)}`;\n      }\n      return 'Gemini Conversation';\n    } catch {\n      return 'Gemini Conversation';\n    }\n  }\n\n  /**\n   * Helper: Format date\n   */\n  private static formatDate(isoString: string): string {\n    try {\n      const date = new Date(isoString);\n      return date.toLocaleString('en-US', {\n        year: 'numeric',\n        month: 'long',\n        day: 'numeric',\n        hour: '2-digit',\n        minute: '2-digit',\n      });\n    } catch {\n      return isoString;\n    }\n  }\n\n  private static normalizeConversationTitle(rawTitle: string | undefined): string {\n    if (!rawTitle) return '';\n    const normalized = rawTitle\n      .trim()\n      .replace(/\\s+-\\s+Gemini$/i, '')\n      .replace(/\\s+-\\s+Google Gemini$/i, '')\n      .replace(/\\s+/g, ' ')\n      .trim();\n    return this.isMeaningfulConversationTitle(normalized) ? normalized : '';\n  }\n\n  private static getPrintDialogTitle(\n    metadata: ConversationMetadata,\n    preferMetadataTitle: boolean,\n  ): string {\n    const metadataTitle = this.normalizeConversationTitle(metadata.title);\n    const conversationTitle = this.normalizeConversationTitle(this.getConversationTitle());\n\n    if (preferMetadataTitle) {\n      return metadataTitle || conversationTitle || 'Gemini Conversation';\n    }\n\n    const base = conversationTitle || metadataTitle;\n    if (!base) return 'Gemini Conversation';\n    return `${base} - Gemini`;\n  }\n\n  /**\n   * Helper: Escape HTML\n   */\n  private static escapeHTML(text: string): string {\n    const div = document.createElement('div');\n    div.textContent = text;\n    return div.innerHTML;\n  }\n\n  private static escapeCssAttributeValue(value: string): string {\n    const escape = globalThis.CSS?.escape;\n    if (typeof escape === 'function') {\n      return escape(value);\n    }\n    return value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"');\n  }\n\n  private static escapeAttribute(text: string): string {\n    return this.escapeHTML(text).replace(/\"/g, '&quot;');\n  }\n}\n"
  },
  {
    "path": "src/features/export/services/__tests__/ConversationExportService.test.ts",
    "content": "/**\n * ConversationExportService unit tests\n */\nimport { toBlob } from 'html-to-image';\nimport { JSDOM } from 'jsdom';\nimport JSZip from 'jszip';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type { ChatTurn, ConversationMetadata, ExportLayout } from '../../types/export';\nimport { ExportFormat } from '../../types/export';\nimport { ConversationExportService } from '../ConversationExportService';\nimport { DeepResearchPDFPrintService } from '../DeepResearchPDFPrintService';\nimport { ImageExportService } from '../ImageExportService';\nimport { MarkdownFormatter } from '../MarkdownFormatter';\nimport { PDFPrintService } from '../PDFPrintService';\n\nvi.mock('html-to-image', () => {\n  return {\n    toBlob: vi.fn(),\n  };\n});\n\n// Setup DOM environment\n\nconst dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');\nglobal.document = dom.window.document as unknown as Document;\nglobal.window = dom.window as unknown as Window & typeof globalThis;\n\nfunction setUserAgentVendor(userAgent: string, vendor: string): void {\n  Object.defineProperty(global.navigator, 'userAgent', {\n    value: userAgent,\n    configurable: true,\n  });\n  Object.defineProperty(global.navigator, 'vendor', {\n    value: vendor,\n    configurable: true,\n  });\n}\n\ndescribe('ConversationExportService', () => {\n  const mockMetadata: ConversationMetadata = {\n    url: 'https://gemini.google.com/app/test',\n    exportedAt: '2025-01-15T10:30:00.000Z',\n    count: 2,\n    title: 'Premier League Fantasy',\n  };\n\n  const mockTurns: ChatTurn[] = [\n    {\n      user: 'Test question',\n      assistant: 'Test answer',\n      starred: false,\n    },\n  ];\n\n  // Mock DOM methods\n  beforeEach(() => {\n    document.body.innerHTML = '';\n    setUserAgentVendor(\n      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',\n      'Google Inc.',\n    );\n\n    // Mock URL.createObjectURL\n    global.URL.createObjectURL = vi.fn(() => 'blob:test');\n    global.URL.revokeObjectURL = vi.fn();\n\n    // Mock window.print\n    (global.window as Window & { print: () => void }).print = vi.fn();\n\n    // Mock document.createElement to prevent actual downloads\n    const originalCreateElement = document.createElement.bind(document);\n    vi.spyOn(document, 'createElement').mockImplementation((tagName) => {\n      const element = originalCreateElement(tagName);\n      if (tagName === 'a') {\n        // Mock click to prevent actual download\n        element.click = vi.fn();\n      }\n      return element;\n    });\n  });\n\n  afterEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  describe('export', () => {\n    it('should export as JSON', async () => {\n      const result = await ConversationExportService.export(mockTurns, mockMetadata, {\n        format: ExportFormat.JSON,\n      });\n\n      expect(result.success).toBe(true);\n      expect(result.format).toBe('json');\n      expect(result.filename).toMatch(/\\.json$/);\n    });\n\n    it('should export as Markdown', async () => {\n      const result = await ConversationExportService.export(mockTurns, mockMetadata, {\n        format: ExportFormat.MARKDOWN,\n      });\n\n      expect(result.success).toBe(true);\n      expect(result.format).toBe('markdown');\n      expect(result.filename).toBe('Premier-League-Fantasy.md');\n    });\n\n    it('should export as PDF', async () => {\n      const result = await ConversationExportService.export(mockTurns, mockMetadata, {\n        format: ExportFormat.PDF,\n      });\n\n      expect(result.success).toBe(true);\n      expect(result.format).toBe('pdf');\n      expect((global.window as Window & { print: () => void }).print).toHaveBeenCalled();\n      expect(result.filename).toBe('Premier-League-Fantasy.pdf');\n    });\n\n    it('triggers print for PDF export', async () => {\n      (global.window as Window & { print: () => void }).print = vi.fn();\n\n      await ConversationExportService.export(mockTurns, mockMetadata, {\n        format: ExportFormat.PDF,\n      });\n\n      expect((global.window as Window & { print: () => void }).print).toHaveBeenCalled();\n    });\n\n    it('should export as Image', async () => {\n      (toBlob as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(\n        new Blob(['x'], { type: 'image/png' }),\n      );\n\n      const result = await ConversationExportService.export(mockTurns, mockMetadata, {\n        format: ExportFormat.IMAGE,\n      });\n\n      expect(result.success).toBe(true);\n      expect(result.format).toBe('image');\n      expect(result.filename).toBe('Premier-League-Fantasy.png');\n    });\n\n    it('should export report markdown without turn wrappers in document layout', async () => {\n      const downloadSpy = vi.spyOn(MarkdownFormatter, 'download').mockImplementation(() => {});\n\n      const result = await ConversationExportService.export(\n        [\n          {\n            user: '',\n            assistant: '# Report title\\n\\nBody paragraph.',\n            starred: false,\n            omitEmptySections: true,\n          },\n        ],\n        mockMetadata,\n        {\n          format: ExportFormat.MARKDOWN,\n          layout: 'document' as ExportLayout,\n          filename: 'report.md',\n        },\n      );\n\n      expect(result.success).toBe(true);\n      expect(result.format).toBe('markdown');\n      expect(downloadSpy).toHaveBeenCalledOnce();\n      const markdown = downloadSpy.mock.calls[0][0];\n      expect(markdown).toContain('# Report title');\n      expect(markdown).not.toContain('## Turn 1');\n      expect(markdown).not.toContain('### 🤖 Assistant');\n    });\n\n    it('should avoid duplicating heading for document markdown when content already has title', async () => {\n      const downloadSpy = vi.spyOn(MarkdownFormatter, 'download').mockImplementation(() => {});\n\n      await ConversationExportService.export(\n        [\n          {\n            user: '',\n            assistant: '# Revenue Deep Research Report\\n\\n正文内容',\n            starred: false,\n            omitEmptySections: true,\n          },\n        ],\n        {\n          ...mockMetadata,\n          title: 'Revenue Deep Research Report',\n        },\n        {\n          format: ExportFormat.MARKDOWN,\n          layout: 'document' as ExportLayout,\n          filename: 'report.md',\n        },\n      );\n\n      const markdown = downloadSpy.mock.calls[0][0];\n      const titleMatches = String(markdown).match(/^# Revenue Deep Research Report$/gm) ?? [];\n      expect(titleMatches).toHaveLength(1);\n    });\n\n    it('should export report JSON payload in document layout', async () => {\n      const downloadSpy = vi.spyOn(\n        ConversationExportService as unknown as { downloadJSON: (...args: unknown[]) => unknown },\n        'downloadJSON',\n      );\n\n      const result = await ConversationExportService.export(\n        [\n          {\n            user: '',\n            assistant: 'Body paragraph.',\n            starred: false,\n            omitEmptySections: true,\n          },\n        ],\n        mockMetadata,\n        {\n          format: ExportFormat.JSON,\n          layout: 'document' as ExportLayout,\n          filename: 'report.json',\n        },\n      );\n\n      expect(result.success).toBe(true);\n      expect(result.format).toBe('json');\n      expect(downloadSpy).toHaveBeenCalledOnce();\n      const payload = downloadSpy.mock.calls[0][0] as Record<string, unknown>;\n      expect(payload.format).toBe('gemini-voyager.report.v1');\n      expect(payload).toHaveProperty('content');\n      expect(payload).not.toHaveProperty('items');\n    });\n\n    it('should use document PDF export path when layout is document', async () => {\n      const deepResearchPdfSpy = vi\n        .spyOn(DeepResearchPDFPrintService as unknown as { export: () => Promise<void> }, 'export')\n        .mockResolvedValue(undefined);\n      const pdfDocumentSpy = vi\n        .spyOn(\n          PDFPrintService as unknown as { exportDocument: () => Promise<void> },\n          'exportDocument',\n        )\n        .mockResolvedValue(undefined);\n\n      const result = await ConversationExportService.export(mockTurns, mockMetadata, {\n        format: ExportFormat.PDF,\n        layout: 'document' as ExportLayout,\n      });\n\n      expect(result.success).toBe(true);\n      expect(deepResearchPdfSpy).toHaveBeenCalledOnce();\n      expect(pdfDocumentSpy).not.toHaveBeenCalled();\n    });\n\n    it('should use document image export path when layout is document', async () => {\n      const imageDocumentSpy = vi\n        .spyOn(\n          ImageExportService as unknown as { exportDocument: () => Promise<void> },\n          'exportDocument',\n        )\n        .mockResolvedValue(undefined);\n\n      const result = await ConversationExportService.export(mockTurns, mockMetadata, {\n        format: ExportFormat.IMAGE,\n        layout: 'document' as ExportLayout,\n      });\n\n      expect(result.success).toBe(true);\n      expect(imageDocumentSpy).toHaveBeenCalledOnce();\n    });\n\n    it('should handle unsupported format', async () => {\n      const result = await ConversationExportService.export(mockTurns, mockMetadata, {\n        format: 'invalid' as ExportFormat,\n      });\n\n      expect(result.success).toBe(false);\n      expect(result.error).toContain('Unsupported format');\n    });\n\n    it('should use custom filename if provided', async () => {\n      const customFilename = 'my-export.json';\n      const result = await ConversationExportService.export(mockTurns, mockMetadata, {\n        format: ExportFormat.JSON,\n        filename: customFilename,\n      });\n\n      expect(result.success).toBe(true);\n      expect(result.filename).toBe(customFilename);\n    });\n\n    it('should handle export errors gracefully', async () => {\n      // Mock an error by throwing in the format method\n      const invalidTurns: ChatTurn[] = [\n        {\n          user: 'test',\n          assistant: 'test',\n          starred: false,\n        },\n      ];\n\n      // Mock JSON.stringify to throw\n      const originalStringify = JSON.stringify;\n      vi.spyOn(JSON, 'stringify').mockImplementationOnce(() => {\n        throw new Error('Stringify error');\n      });\n\n      const result = await ConversationExportService.export(invalidTurns, mockMetadata, {\n        format: ExportFormat.JSON,\n      });\n\n      expect(result.success).toBe(false);\n      expect(result.error).toContain('Stringify error');\n\n      // Restore\n      JSON.stringify = originalStringify;\n    });\n\n    it('normalizes image export Event errors for UI handling', async () => {\n      const imageExportSpy = vi\n        .spyOn(ImageExportService as unknown as { export: () => Promise<void> }, 'export')\n        .mockRejectedValue(new Event('error'));\n\n      const result = await ConversationExportService.export(mockTurns, mockMetadata, {\n        format: ExportFormat.IMAGE,\n      });\n\n      expect(imageExportSpy).toHaveBeenCalledOnce();\n      expect(result.success).toBe(false);\n      expect(result.error).toBe('image_render_event_error');\n    });\n  });\n\n  describe('getAvailableFormats', () => {\n    it('should return all available formats', () => {\n      const formats = ConversationExportService.getAvailableFormats();\n\n      expect(formats).toHaveLength(4);\n      expect(formats.map((f) => f.format)).toEqual(['json', 'markdown', 'pdf', 'image']);\n    });\n\n    it('should mark Markdown as recommended', () => {\n      const formats = ConversationExportService.getAvailableFormats();\n      const markdown = formats.find((f) => f.format === 'markdown');\n\n      expect(markdown?.recommended).toBe(true);\n    });\n\n    it('should include descriptions', () => {\n      const formats = ConversationExportService.getAvailableFormats();\n\n      formats.forEach((format) => {\n        expect(format.label).toBeTruthy();\n        expect(format.description).toBeTruthy();\n      });\n    });\n  });\n\n  describe('JSON export with DOM elements', () => {\n    it('should use fallback text when no DOM elements are provided', async () => {\n      const turnsWithoutDom: ChatTurn[] = [\n        {\n          user: 'Plain text user',\n          assistant: 'Plain text assistant',\n          starred: false,\n        },\n      ];\n\n      const downloadSpy = vi.spyOn(\n        ConversationExportService as unknown as { downloadJSON: (...args: unknown[]) => unknown },\n        'downloadJSON',\n      );\n      const result = await ConversationExportService.export(turnsWithoutDom, mockMetadata, {\n        format: ExportFormat.JSON,\n      });\n\n      expect(result.success).toBe(true);\n      expect(result.format).toBe('json');\n\n      expect(downloadSpy).toHaveBeenCalledOnce();\n      const payload = downloadSpy.mock.calls[0][0] as Record<string, unknown>;\n      const items = payload.items as Array<Record<string, unknown>>;\n\n      expect(items).toHaveLength(1);\n      expect(items[0].user).toBe('Plain text user');\n      expect(items[0].assistant).toBe('Plain text assistant');\n\n      expect(items[0].userElement).toBeUndefined();\n    });\n\n    // Note: Testing DOMContentExtractor integration is skipped per ROI testing strategy.\n    // DOM operations (Content Scripts) are in the \"Fragile\" category.\n    // The extractUserContent/extractAssistantContent calls are covered by defensive programming.\n  });\n\n  describe('markdown zip packaging', () => {\n    it('degrades image markdown to text placeholders on Safari instead of zip packaging', async () => {\n      setUserAgentVendor(\n        'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15',\n        'Apple Computer, Inc.',\n      );\n\n      const downloadSpy = vi.spyOn(MarkdownFormatter, 'download').mockImplementation(() => {});\n      const fetchSpy = vi.spyOn(\n        ConversationExportService as unknown as {\n          fetchImageForMarkdownPackaging: () => Promise<unknown>;\n        },\n        'fetchImageForMarkdownPackaging',\n      );\n      fetchSpy.mockResolvedValue(null);\n\n      const turnsWithImage: ChatTurn[] = [\n        {\n          user: '',\n          assistant: 'Summary ![chart](https://example.com/chart.png)',\n          starred: false,\n          omitEmptySections: true,\n        },\n      ];\n\n      const result = await ConversationExportService.export(turnsWithImage, mockMetadata, {\n        format: ExportFormat.MARKDOWN,\n      });\n\n      expect(result.success).toBe(true);\n      expect(result.filename).toMatch(/\\.md$/);\n      expect(downloadSpy).toHaveBeenCalledOnce();\n      expect(fetchSpy).not.toHaveBeenCalled();\n\n      const markdown = String(downloadSpy.mock.calls[0][0] ?? '');\n      expect(markdown).toContain('[Image unavailable in Safari export: chart]');\n      expect(markdown).not.toContain('![chart](https://example.com/chart.png)');\n    });\n\n    it('should assign image filenames in source order even when fetch resolves out of order', async () => {\n      const imageUrls = ['https://example.com/slow.png', 'https://example.com/fast.png'];\n      vi.spyOn(MarkdownFormatter, 'extractImageUrls').mockReturnValue(imageUrls);\n\n      const rewriteSpy = vi\n        .spyOn(MarkdownFormatter, 'rewriteImageUrls')\n        .mockImplementation((markdown) => markdown);\n\n      vi.spyOn(\n        ConversationExportService as unknown as {\n          fetchImageForMarkdownPackaging: (url: unknown) => Promise<unknown>;\n        },\n        'fetchImageForMarkdownPackaging',\n      ).mockImplementation(async (rawUrl: unknown) => {\n        const url = String(rawUrl);\n        if (url.includes('slow')) {\n          await new Promise((resolve) => setTimeout(resolve, 10));\n        }\n        return {\n          blob: new Blob([new TextEncoder().encode(url)], { type: 'image/png' }),\n          contentType: 'image/png',\n        };\n      });\n\n      await (\n        ConversationExportService as unknown as Record<string, (...args: unknown[]) => unknown>\n      ).downloadMarkdownOrZip(\n        '![a](https://example.com/slow.png)\\n![b](https://example.com/fast.png)',\n        'chat.md',\n        'chat.md',\n      );\n\n      expect(rewriteSpy).toHaveBeenCalledOnce();\n      const mapping = rewriteSpy.mock.calls[0][1] as Map<string, string>;\n      expect(mapping.get('https://example.com/slow.png')).toBe('assets/img-001.png');\n      expect(mapping.get('https://example.com/fast.png')).toBe('assets/img-002.png');\n    });\n\n    it('stores markdown image assets as base64 payloads for Firefox JSZip compatibility', async () => {\n      const imageUrl = 'https://example.com/photo.jpg';\n      vi.spyOn(MarkdownFormatter, 'extractImageUrls').mockReturnValue([imageUrl]);\n      vi.spyOn(MarkdownFormatter, 'rewriteImageUrls').mockImplementation((markdown) => markdown);\n\n      vi.spyOn(\n        ConversationExportService as unknown as {\n          fetchImageForMarkdownPackaging: () => Promise<unknown>;\n        },\n        'fetchImageForMarkdownPackaging',\n      ).mockResolvedValue({\n        blob: new Blob(['jpeg-bytes'], { type: 'image/jpeg' }),\n        contentType: 'image/jpeg',\n      });\n\n      let capturedAssetPayload: unknown;\n      let capturedAssetOptions: unknown;\n      type JSZipFileFn = (name: unknown, data?: unknown, options?: unknown) => unknown;\n      const originalFile = (JSZip.prototype as unknown as { file: JSZipFileFn }).file;\n      vi.spyOn(JSZip.prototype as unknown as { file: JSZipFileFn }, 'file').mockImplementation(\n        function (this: unknown, name: unknown, data?: unknown, options?: unknown) {\n          if (typeof name === 'string' && name.startsWith('img-')) {\n            capturedAssetPayload = data;\n            capturedAssetOptions = options;\n          }\n          return originalFile.call(this, name, data, options);\n        },\n      );\n\n      const finalFilename = await (\n        ConversationExportService as unknown as Record<string, (...args: unknown[]) => unknown>\n      ).downloadMarkdownOrZip(`![photo](${imageUrl})`, 'chat.md', 'chat.md');\n\n      expect(finalFilename).toBe('chat.zip');\n      expect(typeof capturedAssetPayload).toBe('string');\n      expect(capturedAssetPayload).toBeTruthy();\n      expect(capturedAssetOptions).toMatchObject({ base64: true });\n    });\n\n    it('should fallback to gv.fetchImageViaPage when direct and background fetch fail', async () => {\n      const imageUrl = 'https://lh3.googleusercontent.com/export-image.png';\n      vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network blocked'));\n\n      const sendMessageMock = vi.fn(\n        (\n          message: { type?: string; url?: string },\n          callback?: (response: unknown) => void,\n        ): void => {\n          if (message.type === 'gv.fetchImage') {\n            callback?.({ ok: false, error: 'fetch_failed' });\n            return;\n          }\n          if (message.type === 'gv.fetchImageViaPage') {\n            callback?.({\n              ok: true,\n              base64: 'aGVsbG8=',\n              contentType: 'image/png',\n            });\n            return;\n          }\n          callback?.(null);\n        },\n      );\n\n      chrome.runtime.sendMessage = sendMessageMock as unknown as typeof chrome.runtime.sendMessage;\n\n      const fetched = (await (\n        ConversationExportService as unknown as Record<string, (...args: unknown[]) => unknown>\n      ).fetchImageForMarkdownPackaging(imageUrl)) as { contentType: string } | null;\n\n      expect(fetched).not.toBeNull();\n      expect(fetched?.contentType).toBe('image/png');\n      expect(sendMessageMock).toHaveBeenCalledWith(\n        { type: 'gv.fetchImage', url: imageUrl },\n        expect.any(Function),\n      );\n      expect(sendMessageMock).toHaveBeenCalledWith(\n        { type: 'gv.fetchImageViaPage', url: imageUrl },\n        expect.any(Function),\n      );\n    });\n\n    it('should skip gv.fetchImageViaPage for blob urls', async () => {\n      const blobUrl = 'blob:https://gemini.google.com/abc-123';\n      vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('blob fetch blocked'));\n\n      const sendMessageMock = vi.fn(\n        (\n          message: { type?: string; url?: string },\n          callback?: (response: unknown) => void,\n        ): void => {\n          if (message.type === 'gv.fetchImage') {\n            callback?.({ ok: false, error: 'invalid_url' });\n            return;\n          }\n          callback?.(null);\n        },\n      );\n      chrome.runtime.sendMessage = sendMessageMock as unknown as typeof chrome.runtime.sendMessage;\n\n      const fetched = await (\n        ConversationExportService as unknown as Record<string, (...args: unknown[]) => unknown>\n      ).fetchImageForMarkdownPackaging(blobUrl);\n\n      expect(fetched).toBeNull();\n      expect(sendMessageMock).toHaveBeenCalledWith(\n        { type: 'gv.fetchImage', url: blobUrl },\n        expect.any(Function),\n      );\n      expect(sendMessageMock).not.toHaveBeenCalledWith(\n        { type: 'gv.fetchImageViaPage', url: blobUrl },\n        expect.any(Function),\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "src/features/export/services/__tests__/DOMContentExtractor.test.ts",
    "content": "/**\n * DOMContentExtractor unit tests\n */\nimport { describe, expect, it } from 'vitest';\n\nimport { DOMContentExtractor } from '../DOMContentExtractor';\n\ndescribe('DOMContentExtractor', () => {\n  it('should strip Gemini inline source chips (link icons) from assistant export', () => {\n    const assistant = document.createElement('div');\n    assistant.innerHTML = `\n      <message-content>\n        <div class=\"markdown\">\n          <p>Hello</p>\n          <sources-carousel-inline>\n            <source-inline-chips>\n              <source-inline-chip>\n                <div class=\"source-inline-chip-container\">\n                  <button aria-label=\"View source details. Opens side panel.\">\n                    <mat-icon fonticon=\"link\">link</mat-icon>\n                  </button>\n                </div>\n              </source-inline-chip>\n            </source-inline-chips>\n          </sources-carousel-inline>\n          <p>World</p>\n        </div>\n      </message-content>\n    `;\n\n    const extracted = DOMContentExtractor.extractAssistantContent(assistant);\n\n    expect(extracted.text).toContain('Hello');\n    expect(extracted.text).toContain('World');\n    expect(extracted.text).not.toMatch(/\\blink\\b/i);\n\n    expect(extracted.html).toContain('<p>Hello</p>');\n    expect(extracted.html).toContain('<p>World</p>');\n    expect(extracted.html).not.toContain('sources-carousel-inline');\n    expect(extracted.html).not.toContain('source-inline-chip');\n    expect(extracted.html).not.toContain('mat-icon');\n  });\n\n  it('should strip source chips nested in lists from exported HTML', () => {\n    const assistant = document.createElement('div');\n    assistant.innerHTML = `\n      <message-content>\n        <div class=\"markdown\">\n          <ul>\n            <li>\n              Item 1\n              <sources-carousel-inline>\n                <mat-icon fonticon=\"link\">link</mat-icon>\n              </sources-carousel-inline>\n            </li>\n            <li>Item 2</li>\n          </ul>\n        </div>\n      </message-content>\n    `;\n\n    const extracted = DOMContentExtractor.extractAssistantContent(assistant);\n\n    expect(extracted.text).toContain('Item 1');\n    expect(extracted.text).toContain('Item 2');\n    expect(extracted.text).not.toMatch(/\\blink\\b/i);\n\n    expect(extracted.html).toContain('<ul>');\n    expect(extracted.html).toMatch(/<li[^>]*>\\s*Item 1/i);\n    expect(extracted.html).toMatch(/<li[^>]*>\\s*Item 2/i);\n    expect(extracted.html).not.toContain('sources-carousel-inline');\n    expect(extracted.html).not.toContain('mat-icon');\n  });\n\n  it('should extract assistant images as markdown and html', () => {\n    const assistant = document.createElement('div');\n    assistant.innerHTML = `\n      <message-content>\n        <div class=\"markdown\">\n          <p>Hello</p>\n          <img src=\"https://example.com/a.png\" alt=\"A\" />\n          <p>World</p>\n        </div>\n      </message-content>\n    `;\n\n    const extracted = DOMContentExtractor.extractAssistantContent(assistant);\n\n    expect(extracted.hasImages).toBe(true);\n    expect(extracted.text).toContain('Hello');\n    expect(extracted.text).toContain('World');\n    expect(extracted.text).toContain('![A](https://example.com/a.png)');\n    expect(extracted.html).toContain('<img');\n    expect(extracted.html).toContain('https://example.com/a.png');\n  });\n\n  it('should skip about:blank images while preserving valid images', () => {\n    const assistant = document.createElement('div');\n    assistant.innerHTML = `\n      <message-content>\n        <div class=\"markdown\">\n          <img src=\"about:blank\" alt=\"placeholder\" />\n          <img src=\"https://example.com/real.png\" alt=\"Real\" />\n        </div>\n      </message-content>\n    `;\n\n    const extracted = DOMContentExtractor.extractAssistantContent(assistant);\n\n    expect(extracted.text).not.toContain('about:blank');\n    expect(extracted.html).not.toContain('about:blank');\n    expect(extracted.text).toContain('![Real](https://example.com/real.png)');\n    expect(extracted.html).toContain('https://example.com/real.png');\n  });\n\n  it('escapes generated image src/alt when rendered into html attributes', () => {\n    const assistant = document.createElement('div');\n    assistant.innerHTML = `\n      <message-content>\n        <div class=\"markdown\">\n          <div class=\"attachment-container generated-images\">\n            <generated-image><img /></generated-image>\n          </div>\n        </div>\n      </message-content>\n    `;\n\n    const generated = assistant.querySelector('img') as HTMLImageElement;\n    generated.setAttribute('src', 'https://example.com/a\"b.png');\n    generated.setAttribute('alt', 'A \"quoted\" image');\n\n    const extracted = DOMContentExtractor.extractAssistantContent(assistant);\n\n    expect(extracted.html).toContain('src=\"https://example.com/a%22b.png\"');\n    expect(extracted.html).toContain('alt=\"A &quot;quoted&quot; image\"');\n  });\n});\n"
  },
  {
    "path": "src/features/export/services/__tests__/DeepResearchPDFPrintService.test.ts",
    "content": "import { afterEach, describe, expect, it, vi } from 'vitest';\n\nimport { DeepResearchPDFPrintService } from '../DeepResearchPDFPrintService';\n\nfunction setUserAgentVendor(userAgent: string, vendor: string): void {\n  Object.defineProperty(global.navigator, 'userAgent', {\n    value: userAgent,\n    configurable: true,\n  });\n  Object.defineProperty(global.navigator, 'vendor', {\n    value: vendor,\n    configurable: true,\n  });\n}\n\ndescribe('DeepResearchPDFPrintService', () => {\n  afterEach(() => {\n    try {\n      window.dispatchEvent(new Event('afterprint'));\n    } catch {\n      /* ignore */\n    }\n    document.body.classList.remove('gv-deep-research-pdf-printing');\n    document.body.innerHTML = '';\n    document.head.innerHTML = '';\n    document.title = 'Gemini';\n    vi.useRealTimers();\n  });\n\n  it('uses isolated report print container and restores page state after print', async () => {\n    document.title = 'Gemini';\n    window.print = vi.fn();\n\n    await DeepResearchPDFPrintService.export({\n      title: 'Deep Research Report',\n      url: 'https://gemini.google.com/app/abc12345',\n      exportedAt: new Date().toISOString(),\n      markdown: '# Markdown title\\n\\nMarkdown body',\n      html: '<div class=\"markdown-main-panel\"><h2>HTML title</h2><p>HTML body</p></div>',\n    });\n\n    const container = document.getElementById('gv-deep-research-pdf-print-container');\n    const report = container?.querySelector('.gv-dr-print-report');\n    expect(window.print).toHaveBeenCalledOnce();\n    expect(container).toBeTruthy();\n    expect(report?.textContent).toContain('HTML title');\n    expect(document.title).toBe('Deep Research Report');\n    expect(document.body.classList.contains('gv-deep-research-pdf-printing')).toBe(true);\n\n    window.dispatchEvent(new Event('afterprint'));\n\n    expect(document.getElementById('gv-deep-research-pdf-print-container')).toBeNull();\n    expect(document.getElementById('gv-deep-research-pdf-print-styles')).toBeNull();\n    expect(document.body.classList.contains('gv-deep-research-pdf-printing')).toBe(false);\n    expect(document.title).toBe('Gemini');\n  });\n\n  it('injects print rules scoped by deep research body class', async () => {\n    window.print = vi.fn();\n\n    await DeepResearchPDFPrintService.export({\n      title: 'Report',\n      url: 'https://gemini.google.com/app/abc12345',\n      exportedAt: new Date().toISOString(),\n      markdown: 'Body',\n      html: '<p>Body</p>',\n    });\n\n    const style = document.getElementById('gv-deep-research-pdf-print-styles');\n    const styleText = style?.textContent || '';\n\n    expect(styleText).toContain(\n      'body.gv-deep-research-pdf-printing > *:not(#gv-deep-research-pdf-print-container)',\n    );\n    expect(styleText).toContain('display: none !important;');\n    expect(styleText).toContain(\n      'body.gv-deep-research-pdf-printing #gv-deep-research-pdf-print-container *',\n    );\n    expect(styleText).toContain('display: revert !important;');\n    expect(styleText).toContain('.katex .vlist-t');\n    expect(styleText).toContain('display: inline-table !important;');\n    expect(styleText).toContain('.katex .vlist-r');\n    expect(styleText).toContain('display: table-row !important;');\n    expect(styleText).toContain('.katex .vlist,');\n    expect(styleText).toContain('.katex .vlist-s');\n    expect(styleText).toContain('display: table-cell !important;');\n    expect(styleText).toContain('.gv-dr-print-report .katex');\n    expect(styleText).toContain('line-height: 1.2 !important;');\n    expect(styleText).toContain('body.gv-deep-research-pdf-printing .gv-dr-print-cover-page');\n    expect(styleText).toContain('display: flex !important;');\n    expect(styleText).toContain('align-items: center !important;');\n    expect(styleText).toContain('justify-content: center !important;');\n    expect(styleText).toContain('min-height: calc(297mm - 4cm);');\n    expect(styleText).toContain('position: relative;');\n    expect(styleText).toContain('position: absolute;');\n    expect(styleText).toContain('transform: translate(-50%, -50%);');\n    expect(styleText).toContain('html,');\n    expect(styleText).toContain('body {');\n    expect(styleText).toContain('background: #fff !important;');\n  });\n\n  it('applies Safari-only print override class and style rules', async () => {\n    setUserAgentVendor(\n      'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15',\n      'Apple Computer, Inc.',\n    );\n    window.print = vi.fn();\n\n    await DeepResearchPDFPrintService.export({\n      title: 'Report',\n      url: 'https://gemini.google.com/app/abc12345',\n      exportedAt: new Date().toISOString(),\n      markdown: 'Body',\n      html: '<p>Body</p>',\n    });\n\n    expect(document.body.classList.contains('gv-deep-research-pdf-safari-printing')).toBe(true);\n\n    const style = document.getElementById('gv-deep-research-pdf-print-styles');\n    const styleText = style?.textContent || '';\n    expect(styleText).toContain(\n      'body.gv-deep-research-pdf-printing.gv-deep-research-pdf-safari-printing .gv-dr-print-cover-page',\n    );\n    expect(styleText).toContain('position: static !important;');\n    expect(styleText).toContain('transform: none !important;');\n\n    window.dispatchEvent(new Event('afterprint'));\n    expect(document.body.classList.contains('gv-deep-research-pdf-safari-printing')).toBe(false);\n  });\n});\n"
  },
  {
    "path": "src/features/export/services/__tests__/ImageExportService.test.ts",
    "content": "import { toBlob } from 'html-to-image';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type { ChatTurn, ConversationMetadata } from '../../types/export';\nimport { ImageExportService } from '../ImageExportService';\n\nvi.mock('html-to-image', () => {\n  return {\n    toBlob: vi.fn(),\n  };\n});\n\nfunction setUserAgentVendor(userAgent: string, vendor: string): void {\n  Object.defineProperty(global.navigator, 'userAgent', {\n    value: userAgent,\n    configurable: true,\n  });\n  Object.defineProperty(global.navigator, 'vendor', {\n    value: vendor,\n    configurable: true,\n  });\n}\n\ndescribe('ImageExportService', () => {\n  const mockMetadata: ConversationMetadata = {\n    url: 'https://gemini.google.com/app/test',\n    exportedAt: '2026-01-01T00:00:00.000Z',\n    count: 1,\n    title: 'Test',\n  };\n\n  const mockTurns: ChatTurn[] = [\n    {\n      user: 'Hello',\n      assistant: 'World',\n      starred: false,\n    },\n  ];\n\n  beforeEach(() => {\n    setUserAgentVendor(\n      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',\n      'Google Inc.',\n    );\n    global.URL.createObjectURL = vi.fn(() => 'blob:test');\n    global.URL.revokeObjectURL = vi.fn();\n\n    const originalCreateElement = document.createElement.bind(document);\n    vi.spyOn(document, 'createElement').mockImplementation((tagName) => {\n      const el = originalCreateElement(tagName);\n      if (tagName === 'a') {\n        el.click = vi.fn();\n      }\n      return el;\n    });\n  });\n\n  afterEach(() => {\n    vi.restoreAllMocks();\n    document.body.innerHTML = '';\n  });\n\n  it('renders via html-to-image and downloads a png', async () => {\n    (toBlob as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(\n      new Blob(['x'], { type: 'image/png' }),\n    );\n\n    await ImageExportService.export(mockTurns, mockMetadata, { filename: 'chat.png' });\n\n    expect(toBlob).toHaveBeenCalledOnce();\n    expect(toBlob).toHaveBeenCalledWith(\n      expect.any(HTMLElement),\n      expect.objectContaining({\n        pixelRatio: 1.2,\n      }),\n    );\n    expect(global.URL.createObjectURL).toHaveBeenCalledOnce();\n    const anchors = document.querySelectorAll('a');\n    expect(anchors.length).toBeGreaterThan(0);\n    expect((anchors[0] as HTMLAnchorElement).download).toBe('chat.png');\n  });\n\n  it('renders conversation to blob without downloading', async () => {\n    const blob = new Blob(['blob'], { type: 'image/png' });\n    (toBlob as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(blob);\n\n    const result = await ImageExportService.renderConversationBlob(mockTurns, mockMetadata, {});\n\n    expect(result).toBe(blob);\n    expect(toBlob).toHaveBeenCalled();\n    expect(global.URL.createObjectURL).not.toHaveBeenCalled();\n  });\n\n  it('retries transient image render failures on Chrome and succeeds', async () => {\n    (toBlob as unknown as ReturnType<typeof vi.fn>).mockReset();\n    (toBlob as unknown as ReturnType<typeof vi.fn>)\n      .mockRejectedValueOnce(new Event('error'))\n      .mockResolvedValueOnce(new Blob(['ok'], { type: 'image/png' }));\n\n    await ImageExportService.export(mockTurns, mockMetadata, { filename: 'retry.png' });\n\n    expect(toBlob).toHaveBeenCalledTimes(2);\n    expect(global.URL.createObjectURL).toHaveBeenCalledOnce();\n  });\n\n  it('does not retry non-retryable render failures on Chrome', async () => {\n    (toBlob as unknown as ReturnType<typeof vi.fn>).mockReset();\n    (toBlob as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(\n      new Error('canvas too large'),\n    );\n\n    await expect(\n      ImageExportService.export(mockTurns, mockMetadata, { filename: 'fail.png' }),\n    ).rejects.toThrow('canvas too large');\n\n    expect(toBlob).toHaveBeenCalledTimes(1);\n    expect(global.URL.createObjectURL).not.toHaveBeenCalled();\n  });\n\n  it('uses larger typography and media sizing for mobile readability', async () => {\n    let capturedStyle = '';\n    (toBlob as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      async (node: HTMLElement) => {\n        capturedStyle =\n          (node.parentElement?.querySelector('style') as HTMLStyleElement | null)?.textContent ??\n          '';\n        return new Blob(['x'], { type: 'image/png' });\n      },\n    );\n\n    await ImageExportService.export(mockTurns, mockMetadata, { filename: 'readable.png' });\n\n    expect(capturedStyle).toContain('font-size: 20px;');\n    expect(capturedStyle).toContain('line-height: 1.9;');\n    expect(capturedStyle).toContain('font-size: 50px;');\n    expect(capturedStyle).toContain('max-width: 100%;');\n  });\n\n  it('retries image render without img elements on Safari when primary render fails', async () => {\n    setUserAgentVendor(\n      'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15',\n      'Apple Computer, Inc.',\n    );\n\n    const assistantElement = document.createElement('div');\n    assistantElement.innerHTML =\n      '<p>Body</p><img src=\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AAgMBgA9N4FoAAAAASUVORK5CYII=\" alt=\"img\" />';\n\n    const turnsWithImage: ChatTurn[] = [\n      {\n        user: '',\n        assistant: 'fallback',\n        starred: false,\n        assistantElement,\n      },\n    ];\n\n    (toBlob as unknown as ReturnType<typeof vi.fn>).mockReset();\n    (toBlob as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      async (node: HTMLElement) => {\n        if (node.querySelector('img')) {\n          throw new Error('image blocked');\n        }\n        return new Blob(['ok'], { type: 'image/png' });\n      },\n    );\n\n    await ImageExportService.export(turnsWithImage, mockMetadata, { filename: 'safari.png' });\n\n    expect(toBlob).toHaveBeenCalledTimes(2);\n    expect(global.URL.createObjectURL).toHaveBeenCalledOnce();\n\n    const firstTarget = (toBlob as unknown as ReturnType<typeof vi.fn>).mock\n      .calls[0][0] as HTMLElement;\n    const secondTarget = (toBlob as unknown as ReturnType<typeof vi.fn>).mock\n      .calls[1][0] as HTMLElement;\n    expect(firstTarget.querySelector('img')).not.toBeNull();\n    expect(secondTarget.querySelector('img')).toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/features/export/services/__tests__/ImageRenderService.test.ts",
    "content": "import { toBlob } from 'html-to-image';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { renderElementToImageBlob } from '../ImageRenderService';\n\nvi.mock('html-to-image', () => ({\n  toBlob: vi.fn(),\n}));\n\ndescribe('ImageRenderService', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    document.body.innerHTML = '';\n  });\n\n  afterEach(() => {\n    document.body.innerHTML = '';\n  });\n\n  it('renders element to blob directly when primary render succeeds', async () => {\n    const target = document.createElement('div');\n    const blob = new Blob(['ok'], { type: 'image/png' });\n    (toBlob as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(blob);\n\n    const result = await renderElementToImageBlob(target);\n\n    expect(result).toBe(blob);\n    expect(toBlob).toHaveBeenCalledTimes(1);\n  });\n\n  it('retries when shouldRetry returns true and later succeeds', async () => {\n    const target = document.createElement('div');\n    const blob = new Blob(['ok'], { type: 'image/png' });\n    (toBlob as unknown as ReturnType<typeof vi.fn>)\n      .mockRejectedValueOnce(new Event('error'))\n      .mockResolvedValueOnce(blob);\n\n    const result = await renderElementToImageBlob(target, {\n      maxAttempts: 2,\n      retryDelayMs: 0,\n      shouldRetry: (error) => error instanceof Event,\n    });\n\n    expect(result).toBe(blob);\n    expect(toBlob).toHaveBeenCalledTimes(2);\n  });\n\n  it('falls back to sanitized clone when resource rendering fails', async () => {\n    const target = document.createElement('div');\n    const image = document.createElement('img');\n    image.src = 'https://example.com/fail.png';\n    target.appendChild(image);\n    document.body.appendChild(target);\n\n    const blob = new Blob(['ok'], { type: 'image/png' });\n    (toBlob as unknown as ReturnType<typeof vi.fn>)\n      .mockRejectedValueOnce(new Error('Failed to fetch resource'))\n      .mockResolvedValueOnce(blob);\n\n    const result = await renderElementToImageBlob(target, {\n      enableSanitizedFallback: true,\n    });\n\n    expect(result).toBe(blob);\n    expect(toBlob).toHaveBeenCalledTimes(2);\n\n    const secondTarget = (toBlob as unknown as ReturnType<typeof vi.fn>).mock.calls[1][0];\n    expect(secondTarget).not.toBe(target);\n    expect((secondTarget as HTMLElement).querySelector('img')).toBeNull();\n  });\n\n  it('uses fallback render root with non-zero width for zero-size targets', async () => {\n    const target = document.createElement('div');\n    target.textContent = 'fallback';\n    document.body.appendChild(target);\n\n    let callCount = 0;\n    (toBlob as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      async (node: HTMLElement) => {\n        callCount += 1;\n        if (callCount === 1) {\n          return null;\n        }\n        const width = Number.parseInt(node.style.width || '0', 10);\n        if (width > 0) {\n          return new Blob(['ok'], { type: 'image/png' });\n        }\n        return null;\n      },\n    );\n\n    const result = await renderElementToImageBlob(target, {\n      enableSanitizedFallback: true,\n      shouldFallback: () => true,\n    });\n\n    expect(result).toBeInstanceOf(Blob);\n    expect(toBlob).toHaveBeenCalledTimes(2);\n    const secondTarget = (toBlob as unknown as ReturnType<typeof vi.fn>).mock.calls[1][0];\n    expect(Number.parseInt((secondTarget as HTMLElement).style.width || '0', 10)).toBeGreaterThan(\n      0,\n    );\n  });\n});\n"
  },
  {
    "path": "src/features/export/services/__tests__/MarkdownFormatter.test.ts",
    "content": "/**\n * MarkdownFormatter unit tests\n */\nimport { describe, expect, it } from 'vitest';\n\nimport type { ChatTurn, ConversationMetadata } from '../../types/export';\nimport { MarkdownFormatter } from '../MarkdownFormatter';\n\ndescribe('MarkdownFormatter', () => {\n  const mockMetadata: ConversationMetadata = {\n    url: 'https://gemini.google.com/app/test-conversation',\n    exportedAt: '2025-01-15T10:30:00.000Z',\n    count: 2,\n    title: 'Test Conversation',\n  };\n\n  const mockTurns: ChatTurn[] = [\n    {\n      user: 'Hello, how are you?',\n      assistant: 'I am doing well, thanks!',\n      starred: false,\n    },\n    {\n      user: 'Can you help me with TypeScript?',\n      assistant: 'Of course! TypeScript is a superset of JavaScript...',\n      starred: true,\n    },\n  ];\n\n  describe('format', () => {\n    it('should generate valid Markdown', () => {\n      const markdown = MarkdownFormatter.format(mockTurns, mockMetadata);\n\n      expect(markdown).toBeTruthy();\n      expect(markdown).toContain('# Test Conversation');\n      expect(markdown).toContain('---');\n    });\n\n    it('should include metadata', () => {\n      const markdown = MarkdownFormatter.format(mockTurns, mockMetadata);\n\n      expect(markdown).toContain('**Date**:');\n      expect(markdown).toContain('**Turns**: 2');\n      expect(markdown).toContain('[Gemini Chat]');\n    });\n\n    it('should format turns correctly', () => {\n      const markdown = MarkdownFormatter.format(mockTurns, mockMetadata);\n\n      expect(markdown).toContain('## Turn 1');\n      expect(markdown).toContain('## Turn 2 ⭐');\n      expect(markdown).toContain('### 👤 User');\n      expect(markdown).toContain('### 🤖 Assistant');\n    });\n\n    it('should include user content', () => {\n      const markdown = MarkdownFormatter.format(mockTurns, mockMetadata);\n\n      expect(markdown).toContain('Hello, how are you?');\n      expect(markdown).toContain('Can you help me with TypeScript?');\n    });\n\n    it('should include assistant content', () => {\n      const markdown = MarkdownFormatter.format(mockTurns, mockMetadata);\n\n      expect(markdown).toContain('I am doing well, thanks!');\n      expect(markdown).toContain('Of course! TypeScript is a superset of JavaScript...');\n    });\n\n    it('should mark starred turns', () => {\n      const markdown = MarkdownFormatter.format(mockTurns, mockMetadata);\n\n      const lines = markdown.split('\\n');\n      const turn2Line = lines.find((l) => l.startsWith('## Turn 2'));\n\n      expect(turn2Line).toContain('⭐');\n    });\n\n    it('should include footer', () => {\n      const markdown = MarkdownFormatter.format(mockTurns, mockMetadata);\n\n      expect(markdown).toContain('Voyager');\n      expect(markdown).toContain('Generated on');\n    });\n\n    it('should handle empty assistant response', () => {\n      const turnsWithEmpty: ChatTurn[] = [\n        {\n          user: 'Test question',\n          assistant: '',\n          starred: false,\n        },\n      ];\n\n      const markdown = MarkdownFormatter.format(turnsWithEmpty, mockMetadata);\n\n      expect(markdown).toContain('Test question');\n      expect(markdown).toContain('### 🤖 Assistant');\n    });\n\n    it('should handle special characters', () => {\n      const turnsWithSpecial: ChatTurn[] = [\n        {\n          user: 'Test with *asterisks* and _underscores_',\n          assistant: 'Response with `code` and [links]',\n          starred: false,\n        },\n      ];\n\n      const markdown = MarkdownFormatter.format(turnsWithSpecial, mockMetadata);\n\n      // Should escape special characters in title but not in content\n      expect(markdown).toBeTruthy();\n    });\n  });\n\n  describe('generateFilename', () => {\n    it('should generate filename with timestamp', () => {\n      const filename = MarkdownFormatter.generateFilename();\n\n      expect(filename).toMatch(/^gemini-chat-\\d{8}-\\d{6}\\.md$/);\n    });\n\n    it('should have .md extension', () => {\n      const filename = MarkdownFormatter.generateFilename();\n\n      expect(filename.endsWith('.md')).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "src/features/export/services/__tests__/PDFPrintService.safari.test.ts",
    "content": "import { JSDOM } from 'jsdom';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type { ChatTurn, ConversationMetadata } from '../../types/export';\nimport { PDFPrintService } from '../PDFPrintService';\n\nconst dom = new JSDOM('<!DOCTYPE html><html><head></head><body></body></html>');\nglobalThis.document = dom.window.document;\nglobalThis.window = dom.window as unknown as Window & typeof globalThis;\nglobalThis.navigator = dom.window.navigator;\n\nfunction mockSafariUserAgent(): void {\n  Object.defineProperty(globalThis.navigator, 'userAgent', {\n    value:\n      'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',\n    configurable: true,\n  });\n  Object.defineProperty(globalThis.navigator, 'vendor', {\n    value: 'Apple Computer, Inc.',\n    configurable: true,\n  });\n}\n\ndescribe('PDFPrintService (Safari)', () => {\n  const mockMetadata: ConversationMetadata = {\n    url: 'https://gemini.google.com/app/test',\n    exportedAt: '2025-01-15T10:30:00.000Z',\n    count: 1,\n  };\n\n  beforeEach(() => {\n    document.body.innerHTML = '';\n    (window as unknown as { print: () => void }).print = vi.fn();\n    mockSafariUserAgent();\n  });\n\n  afterEach(() => {\n    vi.restoreAllMocks();\n    vi.unstubAllGlobals();\n  });\n\n  it('triggers print even if image inlining never resolves', async () => {\n    const assistantElement = document.createElement('div');\n    assistantElement.innerHTML = '<img src=\"https://example.com/img.png\" alt=\"x\" />';\n\n    const turns: ChatTurn[] = [\n      {\n        user: 'hello',\n        assistant: 'world',\n        starred: false,\n        assistantElement,\n      },\n    ];\n\n    vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>(() => {})) as unknown as typeof fetch);\n\n    let timeoutHandle: ReturnType<typeof setTimeout> | null = null;\n    const timeout = new Promise<never>((_, reject) => {\n      timeoutHandle = setTimeout(() => reject(new Error('export timed out')), 50);\n    });\n\n    await expect(\n      Promise.race([PDFPrintService.export(turns, mockMetadata), timeout]),\n    ).resolves.toBe(undefined);\n    if (timeoutHandle !== null) clearTimeout(timeoutHandle);\n    expect((window as unknown as { print: () => void }).print).toHaveBeenCalledOnce();\n  });\n});\n"
  },
  {
    "path": "src/features/export/services/__tests__/PDFPrintService.test.ts",
    "content": "import { afterEach, describe, expect, it, vi } from 'vitest';\n\nimport { PDFPrintService } from '../PDFPrintService';\n\ndescribe('PDFPrintService', () => {\n  afterEach(() => {\n    try {\n      window.dispatchEvent(new Event('afterprint'));\n    } catch {\n      /* ignore */\n    }\n    vi.useRealTimers();\n    document.body.innerHTML = '';\n    document.title = 'Gemini';\n    try {\n      window.history.pushState({}, '', '/');\n    } catch {\n      /* ignore */\n    }\n  });\n\n  it('triggers print and cleans up container on afterprint', async () => {\n    vi.useFakeTimers();\n    window.print = vi.fn();\n\n    const exportPromise = PDFPrintService.export([{ user: 'u', assistant: 'a', starred: false }], {\n      url: 'https://gemini.google.com/app/x',\n      exportedAt: new Date().toISOString(),\n      count: 1,\n      title: 'My Chat',\n    });\n\n    await vi.advanceTimersByTimeAsync(100);\n    await exportPromise;\n\n    expect(window.print).toHaveBeenCalledOnce();\n    expect(document.getElementById('gv-pdf-print-container')).toBeTruthy();\n\n    window.dispatchEvent(new Event('afterprint'));\n    expect(document.getElementById('gv-pdf-print-container')).toBeNull();\n  });\n\n  it('injects print rules scoped by pdf printing class with white page reset', async () => {\n    window.print = vi.fn();\n\n    await PDFPrintService.export([{ user: 'u', assistant: 'a', starred: false }], {\n      url: 'https://gemini.google.com/app/x',\n      exportedAt: new Date().toISOString(),\n      count: 1,\n      title: 'Scoped Print Styles',\n    });\n\n    const style = document.getElementById('gv-pdf-print-styles');\n    const styleText = style?.textContent || '';\n\n    expect(styleText).toContain('body.gv-pdf-printing > *:not(#gv-pdf-print-container)');\n    expect(styleText).toContain('html,');\n    expect(styleText).toContain('body {');\n    expect(styleText).toContain('background: #fff !important;');\n  });\n\n  it('injects descendant display override to survive immersive-mode print rules', async () => {\n    window.print = vi.fn();\n\n    await PDFPrintService.export([{ user: 'u', assistant: 'a', starred: false }], {\n      url: 'https://gemini.google.com/app/x',\n      exportedAt: new Date().toISOString(),\n      count: 1,\n      title: 'Immersive Print Override',\n    });\n\n    const style = document.getElementById('gv-pdf-print-styles');\n    const styleText = style?.textContent || '';\n\n    expect(styleText).toMatch(\n      /body\\.gv-pdf-printing #gv-pdf-print-container \\*\\s*\\{\\s*display:\\s*revert !important;/,\n    );\n  });\n\n  it('restores KaTeX layout primitives after immersive-mode display override', async () => {\n    window.print = vi.fn();\n\n    await PDFPrintService.export([{ user: 'u', assistant: 'a', starred: false }], {\n      url: 'https://gemini.google.com/app/x',\n      exportedAt: new Date().toISOString(),\n      count: 1,\n      title: 'KaTeX Print Layout',\n    });\n\n    const style = document.getElementById('gv-pdf-print-styles');\n    const styleText = style?.textContent || '';\n\n    expect(styleText).toContain('.katex .vlist-t');\n    expect(styleText).toContain('display: inline-table !important;');\n    expect(styleText).toContain('.katex .vlist-r');\n    expect(styleText).toContain('display: table-row !important;');\n    expect(styleText).toContain('.katex .vlist,');\n    expect(styleText).toContain('.katex .vlist-s');\n    expect(styleText).toContain('display: table-cell !important;');\n    expect(styleText).toContain('.gv-print-turn-text .katex');\n    expect(styleText).toContain('line-height: 1.2 !important;');\n  });\n\n  it('keeps cover page centered under immersive-mode display override', async () => {\n    window.print = vi.fn();\n\n    await PDFPrintService.export([{ user: 'u', assistant: 'a', starred: false }], {\n      url: 'https://gemini.google.com/app/x',\n      exportedAt: new Date().toISOString(),\n      count: 1,\n      title: 'Centered Cover',\n    });\n\n    const style = document.getElementById('gv-pdf-print-styles');\n    const styleText = style?.textContent || '';\n\n    expect(styleText).toMatch(\n      /body\\.gv-pdf-printing #gv-pdf-print-container \\.gv-print-cover-page\\s*\\{[\\s\\S]*display:\\s*flex !important;/,\n    );\n  });\n\n  it('reuses conversation print markup for document PDF content', async () => {\n    document.title = 'Original Title';\n    window.print = vi.fn();\n\n    await PDFPrintService.exportDocument({\n      title: 'Deep Research Report',\n      url: 'https://gemini.google.com/app/x',\n      exportedAt: new Date().toISOString(),\n      markdown: '# Markdown heading',\n      html: '<div class=\"markdown-main-panel\"><h2>HTML heading</h2><p>HTML body</p></div>',\n    });\n\n    const turn = document.querySelector('.gv-print-turn');\n    const reportContainer = document.querySelector('.gv-print-report-content');\n    const coverTitle = document.querySelector('.gv-print-cover-title');\n    const turnText = document.querySelector('.gv-print-turn-text');\n    expect(turn).toBeTruthy();\n    expect(reportContainer).toBeNull();\n    expect(coverTitle?.textContent).toContain('Deep Research Report');\n    expect(turnText?.textContent).toContain('HTML heading');\n    expect(turnText?.textContent).not.toContain('Markdown heading');\n  });\n\n  it('uses classed div containers instead of semantic print tags', async () => {\n    window.print = vi.fn();\n\n    await PDFPrintService.export([{ user: 'u', assistant: 'a', starred: false }], {\n      url: 'https://gemini.google.com/app/x',\n      exportedAt: new Date().toISOString(),\n      count: 1,\n      title: 'No Semantic Tags',\n    });\n\n    const container = document.getElementById('gv-pdf-print-container');\n    expect(container).toBeTruthy();\n    expect(container?.querySelector('header, main, article, footer')).toBeNull();\n  });\n\n  it('normalizes metadata title suffix when page title is generic', async () => {\n    document.title = 'Gemini';\n    window.print = vi.fn();\n\n    await PDFPrintService.export([{ user: 'u', assistant: 'a', starred: false }], {\n      url: 'https://gemini.google.com/app/x',\n      exportedAt: new Date().toISOString(),\n      count: 1,\n      title: '房贷还款方式对比分析 - Gemini',\n    });\n\n    const coverTitle = document.querySelector('.gv-print-cover-title');\n    expect(coverTitle?.textContent).toBe('房贷还款方式对比分析');\n  });\n\n  it('extracts title from native sidebar by conversation id and restores page title after print', async () => {\n    vi.useFakeTimers();\n    document.title = 'Google Gemini';\n    window.print = vi.fn();\n\n    window.history.pushState({}, '', '/app/abc12345');\n    const nativeConversation = document.createElement('div');\n    nativeConversation.setAttribute('data-test-id', 'conversation');\n    nativeConversation.setAttribute('jslog', 'x c_abc12345 y');\n    const link = document.createElement('a');\n    link.setAttribute('href', '/app/abc12345');\n    const text = document.createElement('span');\n    text.className = 'conversation-title-text';\n    text.textContent = '房贷还款方式对比分析';\n    link.appendChild(text);\n    nativeConversation.appendChild(link);\n    document.body.appendChild(nativeConversation);\n\n    const exportPromise = PDFPrintService.export([{ user: 'u', assistant: 'a', starred: false }], {\n      url: 'https://gemini.google.com/app/abc12345',\n      exportedAt: new Date().toISOString(),\n      count: 1,\n      title: 'Untitled Conversation',\n    });\n\n    await vi.advanceTimersByTimeAsync(100);\n    await exportPromise;\n\n    const coverTitle = document.querySelector('.gv-print-cover-title');\n    expect(coverTitle?.textContent).toBe('房贷还款方式对比分析');\n    expect(document.title).toBe('房贷还款方式对比分析 - Gemini');\n\n    window.dispatchEvent(new Event('afterprint'));\n    expect(document.title).toBe('Google Gemini');\n  });\n\n  it('keeps omitEmptySections behavior for selected exports', async () => {\n    document.title = 'Original Title';\n    window.print = vi.fn();\n\n    await PDFPrintService.export(\n      [\n        {\n          user: '',\n          assistant: 'Assistant only message',\n          starred: false,\n          omitEmptySections: true,\n        },\n      ],\n      {\n        url: 'https://gemini.google.com/app/x',\n        exportedAt: new Date().toISOString(),\n        count: 1,\n        title: 'Selection Export',\n      },\n    );\n\n    const userSection = document.querySelector('.gv-print-turn-user');\n    const assistantSection = document.querySelector('.gv-print-turn-assistant');\n    expect(userSection).toBeNull();\n    expect(assistantSection?.textContent).toContain('Assistant only message');\n  });\n\n  it('still calls window.print when bridge element exists but has no listener', async () => {\n    window.print = vi.fn();\n    const bridge = document.createElement('div');\n    bridge.id = 'gv-print-bridge';\n    document.body.appendChild(bridge);\n\n    await PDFPrintService.export([{ user: 'u', assistant: 'a', starred: false }], {\n      url: 'https://gemini.google.com/app/x',\n      exportedAt: new Date().toISOString(),\n      count: 1,\n      title: 'Bridge Fallback',\n    });\n\n    expect(window.print).toHaveBeenCalledOnce();\n  });\n\n  it('escapes quotes in header link href attribute', async () => {\n    window.print = vi.fn();\n\n    await PDFPrintService.export([{ user: 'u', assistant: 'a', starred: false }], {\n      url: 'https://gemini.google.com/app/x\" onclick=\"alert(1)',\n      exportedAt: new Date().toISOString(),\n      count: 1,\n      title: 'Attribute Escape',\n    });\n\n    const link = document.querySelector('.gv-print-meta a');\n    expect(link).toBeTruthy();\n    expect(link?.getAttribute('onclick')).toBeNull();\n    expect(link?.getAttribute('href')).toContain('\" onclick=\"');\n  });\n\n  it('handles special CSS characters in conversation id selectors', () => {\n    const conversationId = 'ab\"]\\\\cd';\n    const nativeConversation = document.createElement('div');\n    nativeConversation.setAttribute('data-test-id', 'conversation');\n    nativeConversation.setAttribute('jslog', `x c_${conversationId} y`);\n\n    const link = document.createElement('a');\n    link.setAttribute('href', `/app/${conversationId}`);\n    const text = document.createElement('span');\n    text.className = 'conversation-title-text';\n    text.textContent = 'Escaped Selector Title';\n    link.appendChild(text);\n    nativeConversation.appendChild(link);\n    document.body.appendChild(nativeConversation);\n\n    let title: string | null = null;\n    expect(() => {\n      title = (\n        PDFPrintService as unknown as {\n          extractTitleFromNativeSidebarByConversationId: (id: unknown) => string | null;\n        }\n      ).extractTitleFromNativeSidebarByConversationId(conversationId);\n    }).not.toThrow();\n    expect(title).toBe('Escaped Selector Title');\n  });\n});\n"
  },
  {
    "path": "src/features/export/types/errors.ts",
    "content": "export const IMAGE_RENDER_EVENT_ERROR_CODE = 'image_render_event_error' as const;\n\nexport function isEventLikeImageRenderError(error: unknown): boolean {\n  if (!error || typeof error !== 'object') return false;\n\n  const maybeEvent = error as {\n    type?: unknown;\n    preventDefault?: unknown;\n    stopPropagation?: unknown;\n  };\n\n  return (\n    maybeEvent.type === 'error' &&\n    typeof maybeEvent.preventDefault === 'function' &&\n    typeof maybeEvent.stopPropagation === 'function'\n  );\n}\n"
  },
  {
    "path": "src/features/export/types/export.ts",
    "content": "/**\n * Export feature type definitions\n * Supports multiple export formats with extensible architecture\n */\n\n/**\n * Chat turn representing a user-assistant exchange\n */\nexport interface ChatTurn {\n  user: string;\n  assistant: string;\n  starred: boolean;\n  omitEmptySections?: boolean;\n  // Optional DOM elements for rich content extraction\n  userElement?: HTMLElement;\n  assistantElement?: HTMLElement;\n}\n\n/**\n * Conversation metadata\n */\nexport interface ConversationMetadata {\n  url: string;\n  exportedAt: string;\n  title?: string;\n  count: number;\n}\n\n/**\n * Supported export formats\n */\nexport enum ExportFormat {\n  JSON = 'json',\n  MARKDOWN = 'markdown',\n  PDF = 'pdf',\n  IMAGE = 'image',\n}\n\nexport type ExportLayout = 'conversation' | 'document';\n\n/**\n * Export format labels for UI\n */\nexport interface ExportFormatInfo {\n  format: ExportFormat;\n  label: string;\n  description: string;\n  extension: string;\n  recommended?: boolean;\n}\n\n/**\n * Export options\n */\nexport interface ExportOptions {\n  format: ExportFormat;\n  layout?: ExportLayout;\n  includeMetadata?: boolean;\n  includeStarred?: boolean;\n  filename?: string;\n  // Image handling for markdown/pdf\n  // - 'inline': try to inline images as data URLs when possible\n  // - 'none': keep remote URLs as-is\n  embedImages?: 'inline' | 'none';\n  // Font size for PDF (pt) and Image (px) exports\n  fontSize?: number;\n  /** Whether to include image source attribution in markdown (default: true) */\n  includeImageSource?: boolean;\n}\n\n/**\n * Base export payload\n */\nexport interface BaseExportPayload {\n  format: string;\n  url: string;\n  exportedAt: string;\n  count: number;\n  /**\n   * Optional human-readable conversation title\n   * Added in a backward-compatible way for JSON/Markdown exports\n   */\n  title?: string;\n}\n\n/**\n * JSON export payload (existing format)\n */\nexport interface JSONExportPayload extends BaseExportPayload {\n  format: 'gemini-voyager.chat.v1';\n  items: ChatTurn[];\n}\n\n/**\n * Export result\n */\nexport interface ExportResult {\n  success: boolean;\n  format: ExportFormat;\n  filename?: string;\n  error?: string;\n}\n"
  },
  {
    "path": "src/features/export/ui/ExportDialog.ts",
    "content": "/**\n * Export Dialog UI\n * Material Design styled format selection dialog\n */\nimport { isSafari } from '@/core/utils/browser';\n\nimport { ConversationExportService } from '../services/ConversationExportService';\nimport type { ExportFormat } from '../types/export';\n\nexport interface ExportDialogOptions {\n  onExport: (format: ExportFormat, fontSize?: number) => void;\n  onCancel: () => void;\n  translations: {\n    title: string;\n    selectFormat: string;\n    warning: string;\n    safariCmdpHint: string;\n    safariMarkdownHint: string;\n    cancel: string;\n    export: string;\n    fontSizeLabel: string;\n    fontSizePreview: string;\n    formatDescriptions: Record<ExportFormat, string>;\n  };\n}\n\n/**\n * Export format selection dialog\n */\n/** Default font sizes per format */\nconst PDF_DEFAULT_FONT_SIZE = 11;\nconst IMAGE_DEFAULT_FONT_SIZE = 20;\nconst PDF_MIN = 8;\nconst PDF_MAX = 16;\nconst IMAGE_MIN = 14;\nconst IMAGE_MAX = 28;\n\nexport class ExportDialog {\n  private overlay: HTMLElement | null = null;\n  private selectedFormat: ExportFormat = 'markdown' as ExportFormat;\n  private fontSize: number = PDF_DEFAULT_FONT_SIZE;\n\n  /**\n   * Show export dialog\n   */\n  show(options: ExportDialogOptions): void {\n    this.overlay = this.createDialog(options);\n    document.body.appendChild(this.overlay);\n\n    // Keep initial focus on container to avoid showing a browser focus ring on JSON radio.\n    const dialog = this.overlay.querySelector('.gv-export-dialog') as HTMLElement | null;\n    dialog?.focus();\n  }\n\n  /**\n   * Hide and cleanup dialog\n   */\n  hide(): void {\n    if (this.overlay) {\n      this.overlay.remove();\n      this.overlay = null;\n    }\n  }\n\n  /**\n   * Create dialog element\n   */\n  private createDialog(options: ExportDialogOptions): HTMLElement {\n    const overlay = document.createElement('div');\n    overlay.className = 'gv-export-dialog-overlay';\n\n    const dialog = document.createElement('div');\n    dialog.className = 'gv-export-dialog';\n    dialog.tabIndex = -1;\n\n    // Title\n    const title = document.createElement('div');\n    title.className = 'gv-export-dialog-title';\n    title.textContent = options.translations.title;\n\n    // Subtitle\n    const subtitle = document.createElement('div');\n    subtitle.className = 'gv-export-dialog-subtitle';\n    subtitle.textContent = options.translations.selectFormat;\n\n    // Format options\n    const formatsList = document.createElement('div');\n    formatsList.className = 'gv-export-format-list';\n\n    const formats = ConversationExportService.getAvailableFormats();\n    formats.forEach((formatInfo) => {\n      const localizedDescription =\n        options.translations.formatDescriptions[formatInfo.format] || formatInfo.description;\n\n      const option = this.createFormatOption(\n        { ...formatInfo, description: localizedDescription },\n        options.translations.safariCmdpHint,\n        options.translations.safariMarkdownHint,\n      );\n      formatsList.appendChild(option);\n    });\n\n    // Font size section (visible only for PDF/Image)\n    const fontSizeSection = this.createFontSizeSection(options);\n\n    // Buttons\n    const buttons = document.createElement('div');\n    buttons.className = 'gv-export-dialog-buttons';\n\n    const cancelBtn = document.createElement('button');\n    cancelBtn.className = 'gv-export-dialog-btn gv-export-dialog-btn-secondary';\n    cancelBtn.textContent = options.translations.cancel;\n    cancelBtn.addEventListener('click', () => {\n      options.onCancel();\n      this.hide();\n    });\n\n    const exportBtn = document.createElement('button');\n    exportBtn.className = 'gv-export-dialog-btn gv-export-dialog-btn-primary';\n    exportBtn.textContent = options.translations.export;\n    exportBtn.addEventListener('click', () => {\n      const isPdfOrImage =\n        this.selectedFormat === ('pdf' as ExportFormat) ||\n        this.selectedFormat === ('image' as ExportFormat);\n      options.onExport(this.selectedFormat, isPdfOrImage ? this.fontSize : undefined);\n      this.hide();\n    });\n\n    buttons.appendChild(cancelBtn);\n    buttons.appendChild(exportBtn);\n\n    // Assemble dialog\n    dialog.appendChild(title);\n    dialog.appendChild(subtitle);\n    if (options.translations.warning.trim()) {\n      const warning = document.createElement('div');\n      warning.className = 'gv-export-dialog-warning';\n      warning.textContent = options.translations.warning;\n      dialog.appendChild(warning);\n    }\n    dialog.appendChild(formatsList);\n    dialog.appendChild(fontSizeSection);\n    dialog.appendChild(buttons);\n    overlay.appendChild(dialog);\n\n    // Close on overlay click\n    overlay.addEventListener('click', (e) => {\n      if (e.target === overlay) {\n        options.onCancel();\n        this.hide();\n      }\n    });\n\n    // Close on Escape key\n    const handleEscape = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        options.onCancel();\n        this.hide();\n        document.removeEventListener('keydown', handleEscape);\n      }\n    };\n    document.addEventListener('keydown', handleEscape);\n\n    return overlay;\n  }\n\n  /**\n   * Create format option radio button\n   */\n  private createFormatOption(\n    formatInfo: {\n      format: ExportFormat;\n      label: string;\n      description: string;\n      recommended?: boolean;\n    },\n    safariCmdpHint: string,\n    safariMarkdownHint: string,\n  ): HTMLElement {\n    const option = document.createElement('label');\n    option.className = 'gv-export-format-option';\n\n    const radio = document.createElement('input');\n    radio.type = 'radio';\n    radio.name = 'export-format';\n    radio.value = formatInfo.format;\n    radio.checked = formatInfo.format === 'markdown';\n\n    if (radio.checked) {\n      this.selectedFormat = formatInfo.format;\n    }\n\n    radio.addEventListener('change', () => {\n      if (radio.checked) {\n        this.selectedFormat = formatInfo.format;\n        this.updateFontSizeSection();\n      }\n    });\n\n    const content = document.createElement('div');\n    content.className = 'gv-export-format-content';\n\n    const labelDiv = document.createElement('div');\n    labelDiv.className = 'gv-export-format-label';\n    labelDiv.textContent = formatInfo.label;\n\n    if (formatInfo.recommended) {\n      const badge = document.createElement('span');\n      badge.className = 'gv-export-format-badge';\n      badge.textContent = 'Recommended';\n      labelDiv.appendChild(badge);\n    }\n\n    const desc = document.createElement('div');\n    desc.className = 'gv-export-format-description';\n    let hintText = formatInfo.description;\n\n    if (isSafari()) {\n      if (formatInfo.format === ('pdf' as ExportFormat)) {\n        hintText = `${formatInfo.description} ${safariCmdpHint}`;\n      } else if (\n        formatInfo.format === ('markdown' as ExportFormat) ||\n        formatInfo.format === ('image' as ExportFormat)\n      ) {\n        hintText = `${formatInfo.description} ${safariMarkdownHint}`;\n      }\n    }\n\n    desc.textContent = hintText;\n\n    content.appendChild(labelDiv);\n    content.appendChild(desc);\n\n    option.appendChild(radio);\n    option.appendChild(content);\n\n    return option;\n  }\n\n  /**\n   * Create font size control section with slider and preview\n   */\n  private createFontSizeSection(options: ExportDialogOptions): HTMLElement {\n    const section = document.createElement('div');\n    section.className = 'gv-export-fontsize-section';\n    // Hidden by default since markdown is initially selected\n    section.style.display = 'none';\n\n    // Header row: label + value\n    const header = document.createElement('div');\n    header.className = 'gv-export-fontsize-header';\n\n    const label = document.createElement('span');\n    label.className = 'gv-export-fontsize-label';\n    label.textContent = options.translations.fontSizeLabel;\n\n    const value = document.createElement('span');\n    value.className = 'gv-export-fontsize-value';\n    value.textContent = `${this.fontSize}pt`;\n\n    header.appendChild(label);\n    header.appendChild(value);\n\n    // Slider\n    const slider = document.createElement('input');\n    slider.type = 'range';\n    slider.className = 'gv-export-fontsize-slider';\n    slider.min = String(PDF_MIN);\n    slider.max = String(PDF_MAX);\n    slider.step = '1';\n    slider.value = String(this.fontSize);\n\n    // Preview text\n    const preview = document.createElement('div');\n    preview.className = 'gv-export-fontsize-preview';\n    preview.textContent = options.translations.fontSizePreview;\n    preview.style.fontSize = `${this.fontSize}pt`;\n\n    slider.addEventListener('input', () => {\n      this.fontSize = Number(slider.value);\n      const unit = this.selectedFormat === ('image' as ExportFormat) ? 'px' : 'pt';\n      value.textContent = `${this.fontSize}${unit}`;\n      preview.style.fontSize = `${this.fontSize}${unit}`;\n    });\n\n    section.appendChild(header);\n    section.appendChild(slider);\n    section.appendChild(preview);\n\n    return section;\n  }\n\n  /**\n   * Update font size section visibility and slider range based on selected format\n   */\n  private updateFontSizeSection(): void {\n    if (!this.overlay) return;\n\n    const section = this.overlay.querySelector('.gv-export-fontsize-section') as HTMLElement | null;\n    if (!section) return;\n\n    const isPdf = this.selectedFormat === ('pdf' as ExportFormat);\n    const isImage = this.selectedFormat === ('image' as ExportFormat);\n\n    if (!isPdf && !isImage) {\n      section.style.display = 'none';\n      return;\n    }\n\n    section.style.display = 'block';\n\n    const slider = section.querySelector('.gv-export-fontsize-slider') as HTMLInputElement | null;\n    const value = section.querySelector('.gv-export-fontsize-value') as HTMLElement | null;\n    const preview = section.querySelector('.gv-export-fontsize-preview') as HTMLElement | null;\n\n    if (isPdf) {\n      this.fontSize = PDF_DEFAULT_FONT_SIZE;\n      if (slider) {\n        slider.min = String(PDF_MIN);\n        slider.max = String(PDF_MAX);\n        slider.value = String(this.fontSize);\n      }\n      if (value) value.textContent = `${this.fontSize}pt`;\n      if (preview) preview.style.fontSize = `${this.fontSize}pt`;\n    } else {\n      this.fontSize = IMAGE_DEFAULT_FONT_SIZE;\n      if (slider) {\n        slider.min = String(IMAGE_MIN);\n        slider.max = String(IMAGE_MAX);\n        slider.value = String(this.fontSize);\n      }\n      if (value) value.textContent = `${this.fontSize}px`;\n      if (preview) preview.style.fontSize = `${this.fontSize}px`;\n    }\n  }\n}\n"
  },
  {
    "path": "src/features/export/ui/ExportErrorMessage.ts",
    "content": "import { IMAGE_RENDER_EVENT_ERROR_CODE } from '../types/errors';\n\nexport function resolveExportErrorMessage(\n  error: unknown,\n  t: (key: 'export_error_generic' | 'export_error_refresh_retry') => string,\n): string {\n  const raw = typeof error === 'string' ? error.trim() : String(error || '').trim();\n\n  if (raw === IMAGE_RENDER_EVENT_ERROR_CODE) {\n    return t('export_error_refresh_retry');\n  }\n\n  const genericTemplate = t('export_error_generic');\n  const detail = raw || 'unknown error';\n\n  if (genericTemplate.includes('{error}')) {\n    return genericTemplate.replace('{error}', detail);\n  }\n\n  return `${genericTemplate} ${detail}`.trim();\n}\n"
  },
  {
    "path": "src/features/export/ui/ExportToast.ts",
    "content": "type ExportToastOptions = {\n  autoDismissMs?: number;\n};\n\nconst TOAST_SELECTOR = '.gv-export-toast';\nconst TOAST_TRANSITION_MS = 300;\nconst DEFAULT_AUTO_DISMISS_MS = 2200;\n\nlet dismissTimer: ReturnType<typeof setTimeout> | null = null;\n\nfunction getOrCreateToast(): HTMLDivElement {\n  const existing = document.querySelector(TOAST_SELECTOR);\n  if (existing instanceof HTMLDivElement) {\n    return existing;\n  }\n\n  const toast = document.createElement('div');\n  toast.className = 'gv-notification gv-notification-info gv-export-toast';\n  document.body.appendChild(toast);\n  return toast;\n}\n\nexport function showExportToast(message: string, options?: ExportToastOptions): void {\n  if (!message) return;\n\n  const toast = getOrCreateToast();\n  toast.textContent = message;\n  toast.classList.add('show');\n\n  if (dismissTimer) {\n    clearTimeout(dismissTimer);\n    dismissTimer = null;\n  }\n\n  const autoDismissMs = Math.max(options?.autoDismissMs ?? DEFAULT_AUTO_DISMISS_MS, 0);\n  dismissTimer = setTimeout(() => {\n    toast.classList.remove('show');\n    setTimeout(() => {\n      if (!toast.classList.contains('show')) {\n        toast.remove();\n      }\n    }, TOAST_TRANSITION_MS);\n  }, autoDismissMs);\n}\n"
  },
  {
    "path": "src/features/export/ui/__tests__/ExportDialog.safariHint.test.ts",
    "content": "import { JSDOM } from 'jsdom';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { ExportDialog } from '../ExportDialog';\n\nconst dom = new JSDOM('<!DOCTYPE html><html><head></head><body></body></html>');\nglobalThis.document = dom.window.document;\nglobalThis.window = dom.window as unknown as Window & typeof globalThis;\nglobalThis.navigator = dom.window.navigator;\n\nfunction setUserAgentVendor(userAgent: string, vendor: string): void {\n  Object.defineProperty(globalThis.navigator, 'userAgent', {\n    value: userAgent,\n    configurable: true,\n  });\n  Object.defineProperty(globalThis.navigator, 'vendor', {\n    value: vendor,\n    configurable: true,\n  });\n}\n\ndescribe('ExportDialog (Safari hint)', () => {\n  const baseOptions = {\n    onExport: vi.fn(),\n    onCancel: vi.fn(),\n    translations: {\n      title: 'Export',\n      selectFormat: 'Select',\n      warning: 'Warning',\n      safariCmdpHint: 'Safari tip: press ⌘P.',\n      safariMarkdownHint: 'Safari tip: use PDF.',\n      cancel: 'Cancel',\n      export: 'Export',\n      fontSizeLabel: 'Font Size',\n      fontSizePreview: 'The quick brown fox jumps over the lazy dog.',\n      formatDescriptions: {\n        json: 'JSON desc',\n        markdown: 'MD desc',\n        pdf: 'PDF desc',\n        image: 'Image desc',\n      },\n    },\n  };\n\n  beforeEach(() => {\n    document.body.innerHTML = '';\n    vi.restoreAllMocks();\n  });\n\n  it('appends ⌘P hint to PDF option description on Safari', () => {\n    setUserAgentVendor(\n      'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',\n      'Apple Computer, Inc.',\n    );\n\n    const dialog = new ExportDialog();\n    dialog.show(baseOptions);\n\n    const pdfRadio = document.querySelector(\n      'input[type=\"radio\"][name=\"export-format\"][value=\"pdf\"]',\n    ) as HTMLInputElement | null;\n    expect(pdfRadio).not.toBeNull();\n\n    const pdfOption = pdfRadio?.closest('.gv-export-format-option') as HTMLElement | null;\n    expect(pdfOption).not.toBeNull();\n\n    const desc = pdfOption?.querySelector('.gv-export-format-description') as HTMLElement | null;\n    expect(desc?.textContent || '').toContain(baseOptions.translations.safariCmdpHint);\n  });\n\n  it('does not append ⌘P hint on non-Safari browsers', () => {\n    setUserAgentVendor(\n      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',\n      'Google Inc.',\n    );\n\n    const dialog = new ExportDialog();\n    dialog.show(baseOptions);\n\n    const pdfRadio = document.querySelector(\n      'input[type=\"radio\"][name=\"export-format\"][value=\"pdf\"]',\n    ) as HTMLInputElement | null;\n    expect(pdfRadio).not.toBeNull();\n\n    const pdfOption = pdfRadio?.closest('.gv-export-format-option') as HTMLElement | null;\n    const desc = pdfOption?.querySelector('.gv-export-format-description') as HTMLElement | null;\n    expect(desc?.textContent || '').not.toContain(baseOptions.translations.safariCmdpHint);\n  });\n\n  it('appends warning hint to Markdown option description on Safari', () => {\n    setUserAgentVendor(\n      'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',\n      'Apple Computer, Inc.',\n    );\n\n    const dialog = new ExportDialog();\n    dialog.show(baseOptions);\n\n    const mdRadio = document.querySelector(\n      'input[type=\"radio\"][name=\"export-format\"][value=\"markdown\"]',\n    ) as HTMLInputElement | null;\n    expect(mdRadio).not.toBeNull();\n\n    const mdOption = mdRadio?.closest('.gv-export-format-option') as HTMLElement | null;\n    const desc = mdOption?.querySelector('.gv-export-format-description') as HTMLElement | null;\n    expect(desc?.textContent || '').toContain(baseOptions.translations.safariMarkdownHint);\n  });\n\n  it('appends warning hint to image option description on Safari', () => {\n    setUserAgentVendor(\n      'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',\n      'Apple Computer, Inc.',\n    );\n\n    const dialog = new ExportDialog();\n    dialog.show(baseOptions);\n\n    const imageRadio = document.querySelector(\n      'input[type=\"radio\"][name=\"export-format\"][value=\"image\"]',\n    ) as HTMLInputElement | null;\n    expect(imageRadio).not.toBeNull();\n\n    const imageOption = imageRadio?.closest('.gv-export-format-option') as HTMLElement | null;\n    const desc = imageOption?.querySelector('.gv-export-format-description') as HTMLElement | null;\n    expect(desc?.textContent || '').toContain(baseOptions.translations.safariMarkdownHint);\n  });\n});\n"
  },
  {
    "path": "src/features/export/ui/__tests__/ExportDialog.test.ts",
    "content": "import { afterEach, describe, expect, it, vi } from 'vitest';\n\nimport { ExportDialog } from '../ExportDialog';\n\ndescribe('ExportDialog', () => {\n  afterEach(() => {\n    document.body.innerHTML = '';\n    vi.useRealTimers();\n  });\n\n  it('does not autofocus the first (json) radio option', () => {\n    vi.useFakeTimers();\n\n    const dialog = new ExportDialog();\n    dialog.show({\n      onExport: () => {},\n      onCancel: () => {},\n      translations: {\n        title: 'Export Chat',\n        selectFormat: 'Select format',\n        warning: 'Warning',\n        safariCmdpHint: 'Safari tip',\n        safariMarkdownHint: 'Safari markdown tip',\n        cancel: 'Cancel',\n        export: 'Export',\n        fontSizeLabel: 'Font Size',\n        fontSizePreview: 'The quick brown fox jumps over the lazy dog.',\n        formatDescriptions: {\n          json: 'JSON format',\n          markdown: 'Markdown format',\n          pdf: 'PDF format',\n          image: 'Image format',\n        },\n      },\n    });\n\n    const firstRadio = document.querySelector(\n      'input[name=\"export-format\"][value=\"json\"]',\n    ) as HTMLInputElement | null;\n    const wrapper = document.querySelector('.gv-export-dialog') as HTMLElement | null;\n    expect(firstRadio).not.toBeNull();\n    expect(wrapper).not.toBeNull();\n\n    vi.advanceTimersByTime(120);\n\n    expect(document.activeElement).toBe(wrapper);\n    expect(document.activeElement).not.toBe(firstRadio);\n  });\n\n  it('does not render warning block when warning is empty', () => {\n    const dialog = new ExportDialog();\n    dialog.show({\n      onExport: () => {},\n      onCancel: () => {},\n      translations: {\n        title: 'Export',\n        selectFormat: 'Select format',\n        warning: '',\n        safariCmdpHint: 'Safari tip',\n        safariMarkdownHint: 'Safari markdown tip',\n        cancel: 'Cancel',\n        export: 'Export',\n        fontSizeLabel: 'Font Size',\n        fontSizePreview: 'The quick brown fox jumps over the lazy dog.',\n        formatDescriptions: {\n          json: 'JSON format',\n          markdown: 'Markdown format',\n          pdf: 'PDF format',\n          image: 'Image format',\n        },\n      },\n    });\n\n    const warning = document.querySelector('.gv-export-dialog-warning') as HTMLElement | null;\n    expect(warning).toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/features/export/ui/__tests__/ExportErrorMessage.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { resolveExportErrorMessage } from '../ExportErrorMessage';\n\ndescribe('resolveExportErrorMessage', () => {\n  const t = (key: 'export_error_generic' | 'export_error_refresh_retry'): string => {\n    if (key === 'export_error_refresh_retry') {\n      return 'Image export failed to load some resources. Please refresh the page and try exporting again.';\n    }\n    return 'Export failed: {error}';\n  };\n\n  it('returns refresh guidance for image render event errors', () => {\n    const message = resolveExportErrorMessage('image_render_event_error', t);\n    expect(message).toContain('Please refresh the page');\n  });\n\n  it('returns generic export error for other failures', () => {\n    const message = resolveExportErrorMessage('network timeout', t);\n    expect(message).toBe('Export failed: network timeout');\n  });\n});\n"
  },
  {
    "path": "src/features/export/ui/__tests__/ExportToast.test.ts",
    "content": "import { afterEach, describe, expect, it, vi } from 'vitest';\n\nimport { showExportToast } from '../ExportToast';\n\ndescribe('ExportToast', () => {\n  afterEach(() => {\n    vi.useRealTimers();\n    document.querySelectorAll('.gv-export-toast').forEach((node) => node.remove());\n  });\n\n  it('creates toast and auto-dismisses it', async () => {\n    vi.useFakeTimers();\n\n    showExportToast('Safari tip');\n\n    const toast = document.querySelector('.gv-export-toast') as HTMLElement | null;\n    expect(toast).not.toBeNull();\n    expect(toast?.textContent).toBe('Safari tip');\n    expect(toast?.classList.contains('show')).toBe(true);\n\n    await vi.advanceTimersByTimeAsync(2500);\n    expect(document.querySelector('.gv-export-toast')).toBeNull();\n  });\n\n  it('is idempotent and reuses one toast element', async () => {\n    vi.useFakeTimers();\n\n    showExportToast('first');\n    showExportToast('second');\n\n    const toasts = document.querySelectorAll('.gv-export-toast');\n    expect(toasts).toHaveLength(1);\n    expect((toasts[0] as HTMLElement).textContent).toBe('second');\n\n    await vi.advanceTimersByTimeAsync(1000);\n    expect(document.querySelector('.gv-export-toast')).not.toBeNull();\n\n    await vi.advanceTimersByTimeAsync(2000);\n    expect(document.querySelector('.gv-export-toast')).toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/features/folder/services/FolderImportExportService.ts",
    "content": "/**\n * Service for importing and exporting folder configurations\n * Follows enterprise best practices with proper validation and error handling\n */\nimport { AppError, ErrorCode } from '@/core/errors/AppError';\nimport type { Result } from '@/core/types/common';\nimport type { ConversationReference, Folder, FolderData } from '@/core/types/folder';\nimport { LOCK_KEYS, importExportLock } from '@/core/utils/concurrency';\nimport {\n  EXTENSION_VERSION,\n  type FormatVersion,\n  applyMigrations,\n  getCompatibilityInfo,\n  isSupportedFormat,\n} from '@/core/utils/version';\nimport { SESSION_BACKUP_KEY, SESSION_BACKUP_TIMESTAMP_KEY } from '@/pages/content/folder/manager';\n\nimport {\n  type FolderExportPayload,\n  type ImportOptions,\n  type ImportResult,\n  type ValidationError,\n  ValidationErrorType,\n} from '../types/import-export';\n\nconst EXPORT_FORMAT: FormatVersion = 'gemini-voyager.folders.v1' as const;\n\n/**\n * Service for handling folder import/export operations\n */\nexport class FolderImportExportService {\n  /**\n   * Export folder data to a downloadable JSON payload\n   * Uses centralized version management to ensure consistency\n   */\n  static exportToPayload(data: FolderData): FolderExportPayload {\n    return {\n      format: EXPORT_FORMAT,\n      exportedAt: new Date().toISOString(),\n      version: EXTENSION_VERSION, // Automatically synced from manifest.json\n      data: {\n        folders: data.folders,\n        folderContents: data.folderContents,\n      },\n    };\n  }\n\n  /**\n   * Validate import payload format and structure\n   * Includes version compatibility checking\n   */\n  static validatePayload(payload: unknown): Result<FolderExportPayload, ValidationError> {\n    // Check if payload is an object\n    if (!payload || typeof payload !== 'object') {\n      return {\n        success: false,\n        error: {\n          type: ValidationErrorType.INVALID_FORMAT,\n          message: 'Invalid payload: expected an object',\n          details: payload,\n        },\n      };\n    }\n\n    const p = payload as Record<string, unknown>;\n\n    // Check format version\n    if (typeof p.format !== 'string' || !isSupportedFormat(p.format)) {\n      return {\n        success: false,\n        error: {\n          type: ValidationErrorType.INVALID_VERSION,\n          message: `Unsupported format: expected \"${EXPORT_FORMAT}\", got \"${p.format}\"`,\n          details: { format: p.format },\n        },\n      };\n    }\n\n    // Check version compatibility\n    if (typeof p.version === 'string') {\n      const compatInfo = getCompatibilityInfo(p.version, p.format);\n      if (!compatInfo.compatible) {\n        return {\n          success: false,\n          error: {\n            type: ValidationErrorType.INVALID_VERSION,\n            message: compatInfo.reason || 'Version incompatible',\n            details: compatInfo,\n          },\n        };\n      }\n    }\n\n    // Check required fields\n    if (!p.data || typeof p.data !== 'object') {\n      return {\n        success: false,\n        error: {\n          type: ValidationErrorType.MISSING_DATA,\n          message: 'Missing or invalid \"data\" field',\n          details: p,\n        },\n      };\n    }\n\n    const data = p.data as Record<string, unknown>;\n\n    // Validate folders array\n    if (!Array.isArray(data.folders)) {\n      return {\n        success: false,\n        error: {\n          type: ValidationErrorType.CORRUPTED_DATA,\n          message: 'Invalid \"folders\" field: expected an array',\n          details: data.folders,\n        },\n      };\n    }\n\n    // Validate folderContents object\n    if (!data.folderContents || typeof data.folderContents !== 'object') {\n      return {\n        success: false,\n        error: {\n          type: ValidationErrorType.CORRUPTED_DATA,\n          message: 'Invalid \"folderContents\" field: expected an object',\n          details: data.folderContents,\n        },\n      };\n    }\n\n    // Basic structure validation for folders\n    for (const folder of data.folders) {\n      if (!folder || typeof folder !== 'object') {\n        return {\n          success: false,\n          error: {\n            type: ValidationErrorType.CORRUPTED_DATA,\n            message: 'Invalid folder object',\n            details: folder,\n          },\n        };\n      }\n\n      const f = folder as Record<string, unknown>;\n      if (!f.id || typeof f.id !== 'string') {\n        return {\n          success: false,\n          error: {\n            type: ValidationErrorType.CORRUPTED_DATA,\n            message: 'Folder missing valid \"id\" field',\n            details: folder,\n          },\n        };\n      }\n\n      if (!f.name || typeof f.name !== 'string') {\n        return {\n          success: false,\n          error: {\n            type: ValidationErrorType.CORRUPTED_DATA,\n            message: 'Folder missing valid \"name\" field',\n            details: folder,\n          },\n        };\n      }\n    }\n\n    return {\n      success: true,\n      data: payload as FolderExportPayload,\n    };\n  }\n\n  /**\n   * Merge imported data with existing data\n   * Skips duplicate folders (by ID) and conversations (by conversationId)\n   */\n  static mergeData(\n    existing: FolderData,\n    imported: FolderData,\n  ): { merged: FolderData; stats: ImportResult } {\n    const existingFolderIds = new Set(existing.folders.map((f) => f.id));\n    const newFolders: Folder[] = [];\n    let duplicatesFoldersSkipped = 0;\n\n    // Merge folders (skip duplicates)\n    for (const folder of imported.folders) {\n      if (!existingFolderIds.has(folder.id)) {\n        newFolders.push(folder);\n      } else {\n        duplicatesFoldersSkipped++;\n      }\n    }\n\n    // Merge folder contents\n    const mergedContents: Record<string, ConversationReference[]> = { ...existing.folderContents };\n    let conversationsImported = 0;\n    let duplicatesConversationsSkipped = 0;\n\n    for (const [folderId, conversations] of Object.entries(imported.folderContents)) {\n      if (!mergedContents[folderId]) {\n        mergedContents[folderId] = [];\n      }\n\n      const existingConvIds = new Set(mergedContents[folderId].map((c) => c.conversationId));\n\n      for (const conv of conversations) {\n        if (!existingConvIds.has(conv.conversationId)) {\n          mergedContents[folderId].push(conv);\n          conversationsImported++;\n        } else {\n          duplicatesConversationsSkipped++;\n        }\n      }\n    }\n\n    const merged: FolderData = {\n      folders: [...existing.folders, ...newFolders],\n      folderContents: mergedContents,\n    };\n\n    const stats: ImportResult = {\n      foldersImported: newFolders.length,\n      conversationsImported,\n      duplicatesFoldersSkipped,\n      duplicatesConversationsSkipped,\n    };\n\n    return { merged, stats };\n  }\n\n  /**\n   * Import folder data from payload\n   * @param payload - The import payload\n   * @param currentData - Current folder data\n   * @param options - Import options (strategy, backup)\n   * @returns Result with import statistics\n   *\n   * Note: This method should be called through importFromPayloadWithLock for concurrency protection\n   */\n  private static importFromPayloadInternal(\n    payload: FolderExportPayload,\n    currentData: FolderData,\n    options: ImportOptions,\n  ): Result<{ data: FolderData; stats: ImportResult }> {\n    try {\n      const { strategy, createBackup = true } = options;\n\n      // Apply any necessary data migrations\n      let importData = payload.data;\n      const migrationsApplied: string[] = [];\n\n      if (payload.version) {\n        try {\n          const migrationResult = applyMigrations(payload.data, payload.version);\n          importData = migrationResult.data as FolderData;\n          migrationsApplied.push(...migrationResult.migrationsApplied);\n\n          if (migrationsApplied.length > 0) {\n            console.log('Applied migrations:', migrationsApplied);\n          }\n        } catch (error) {\n          console.warn('Migration failed, using original data:', error);\n        }\n      }\n\n      // Create backup if requested\n      let backupData: FolderData | null = null;\n      if (createBackup) {\n        backupData = {\n          folders: [...currentData.folders],\n          folderContents: { ...currentData.folderContents },\n        };\n      }\n\n      let resultData: FolderData;\n      let stats: ImportResult;\n\n      if (strategy === 'overwrite') {\n        // Overwrite: completely replace with imported data\n        resultData = {\n          folders: [...importData.folders],\n          folderContents: { ...importData.folderContents },\n        };\n\n        const totalConversations = Object.values(importData.folderContents).reduce(\n          (sum, convs) => sum + convs.length,\n          0,\n        );\n\n        stats = {\n          foldersImported: importData.folders.length,\n          conversationsImported: totalConversations,\n          backupCreated: createBackup,\n        };\n      } else {\n        // Merge: combine with existing data\n        const mergeResult = this.mergeData(currentData, importData);\n        resultData = mergeResult.merged;\n        stats = {\n          ...mergeResult.stats,\n          backupCreated: createBackup,\n        };\n      }\n\n      // Store backup in sessionStorage if created\n      if (backupData) {\n        try {\n          sessionStorage.setItem(SESSION_BACKUP_KEY, JSON.stringify(backupData));\n          sessionStorage.setItem(SESSION_BACKUP_TIMESTAMP_KEY, new Date().toISOString());\n        } catch (error) {\n          // Backup storage failed, but continue with import\n          console.warn('Failed to store backup in sessionStorage', error);\n        }\n      }\n\n      return {\n        success: true,\n        data: { data: resultData, stats },\n      };\n    } catch (error) {\n      return {\n        success: false,\n        error: new AppError(ErrorCode.UNKNOWN_ERROR, 'Import failed', { originalError: error }),\n      };\n    }\n  }\n\n  /**\n   * Import folder data from payload with concurrency protection\n   * This is the public method that should be used for imports\n   * @param payload - The import payload\n   * @param currentData - Current folder data\n   * @param options - Import options (strategy, backup)\n   * @returns Result with import statistics\n   */\n  static async importFromPayload(\n    payload: FolderExportPayload,\n    currentData: FolderData,\n    options: ImportOptions,\n  ): Promise<Result<{ data: FolderData; stats: ImportResult }>> {\n    // Use lock to prevent concurrent imports\n    return await importExportLock.withLock(\n      LOCK_KEYS.FOLDER_IMPORT,\n      () => Promise.resolve(this.importFromPayloadInternal(payload, currentData, options)),\n      30000, // 30 second timeout\n    );\n  }\n\n  /**\n   * Generate filename for export with timestamp\n   */\n  static generateExportFilename(): string {\n    const pad = (n: number) => String(n).padStart(2, '0');\n    const d = new Date();\n    const y = d.getFullYear();\n    const m = pad(d.getMonth() + 1);\n    const day = pad(d.getDate());\n    const hh = pad(d.getHours());\n    const mm = pad(d.getMinutes());\n    const ss = pad(d.getSeconds());\n    return `gemini-voyager-folders-${y}${m}${day}-${hh}${mm}${ss}.json`;\n  }\n\n  /**\n   * Download JSON file to user's computer\n   */\n  static downloadJSON(payload: FolderExportPayload, filename?: string): void {\n    const data = JSON.stringify(payload, null, 2);\n    const blob = new Blob([data], { type: 'application/json;charset=utf-8' });\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = filename || this.generateExportFilename();\n    document.body.appendChild(a);\n    a.click();\n    setTimeout(() => {\n      try {\n        document.body.removeChild(a);\n      } catch {\n        /* ignore */\n      }\n      URL.revokeObjectURL(url);\n    }, 0);\n  }\n\n  /**\n   * Read and parse JSON file from user upload\n   */\n  static async readJSONFile(file: File): Promise<Result<unknown>> {\n    try {\n      const text = await file.text();\n      const parsed = JSON.parse(text);\n      return {\n        success: true,\n        data: parsed,\n      };\n    } catch (error) {\n      return {\n        success: false,\n        error: new AppError(ErrorCode.VALIDATION_ERROR, 'Failed to parse JSON file', {\n          originalError: error,\n        }),\n      };\n    }\n  }\n\n  /**\n   * Restore from backup stored in sessionStorage\n   */\n  static restoreFromBackup(): Result<FolderData> {\n    try {\n      const backupStr = sessionStorage.getItem(SESSION_BACKUP_KEY);\n      if (!backupStr) {\n        return {\n          success: false,\n          error: new AppError(ErrorCode.STORAGE_READ_FAILED, 'No backup found'),\n        };\n      }\n\n      const backup = JSON.parse(backupStr) as FolderData;\n      return {\n        success: true,\n        data: backup,\n      };\n    } catch (error) {\n      return {\n        success: false,\n        error: new AppError(ErrorCode.UNKNOWN_ERROR, 'Failed to restore backup', {\n          originalError: error,\n        }),\n      };\n    }\n  }\n\n  /**\n   * Clear backup from sessionStorage\n   */\n  static clearBackup(): void {\n    try {\n      sessionStorage.removeItem(SESSION_BACKUP_KEY);\n      sessionStorage.removeItem(SESSION_BACKUP_TIMESTAMP_KEY);\n    } catch {\n      /* ignore */\n    }\n  }\n\n  /**\n   * Check if backup exists\n   */\n  static hasBackup(): boolean {\n    try {\n      return sessionStorage.getItem(SESSION_BACKUP_KEY) !== null;\n    } catch {\n      return false;\n    }\n  }\n\n  /**\n   * Get backup timestamp\n   */\n  static getBackupTimestamp(): string | null {\n    try {\n      return sessionStorage.getItem(SESSION_BACKUP_TIMESTAMP_KEY);\n    } catch {\n      return null;\n    }\n  }\n}\n"
  },
  {
    "path": "src/features/folder/types/import-export.ts",
    "content": "/**\n * Types for folder configuration import/export\n */\nimport type { FolderData } from '@/core/types/folder';\n\n/**\n * Export payload format with versioning\n */\nexport interface FolderExportPayload {\n  format: 'gemini-voyager.folders.v1';\n  exportedAt: string; // ISO 8601 timestamp\n  version: string; // Extension version\n  data: FolderData;\n}\n\n/**\n * Import strategy options\n */\nexport type ImportStrategy = 'merge' | 'overwrite';\n\n/**\n * Import options\n */\nexport interface ImportOptions {\n  strategy: ImportStrategy;\n  createBackup?: boolean; // Default: true\n}\n\n/**\n * Import result details\n */\nexport interface ImportResult {\n  foldersImported: number;\n  conversationsImported: number;\n  duplicatesFoldersSkipped?: number;\n  duplicatesConversationsSkipped?: number;\n  backupCreated?: boolean;\n}\n\n/**\n * Validation error types\n */\nexport enum ValidationErrorType {\n  INVALID_FORMAT = 'INVALID_FORMAT',\n  INVALID_VERSION = 'INVALID_VERSION',\n  MISSING_DATA = 'MISSING_DATA',\n  CORRUPTED_DATA = 'CORRUPTED_DATA',\n}\n\n/**\n * Validation error\n */\nexport interface ValidationError {\n  type: ValidationErrorType;\n  message: string;\n  details?: unknown;\n}\n"
  },
  {
    "path": "src/features/formulaCopy/FormulaCopyService.test.ts",
    "content": "import temml from 'temml';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { FormulaCopyService } from './FormulaCopyService';\n\n// Mock dependencies\nvi.mock('webextension-polyfill', () => ({\n  default: {\n    storage: {\n      sync: {\n        get: vi.fn().mockResolvedValue({}),\n      },\n      onChanged: {\n        addListener: vi.fn(),\n        removeListener: vi.fn(),\n      },\n    },\n    i18n: {\n      getMessage: vi.fn((key) => key),\n    },\n  },\n}));\n\nvi.mock('temml', () => ({\n  default: {\n    renderToString: vi.fn(),\n  },\n}));\n\ndescribe('FormulaCopyService', () => {\n  let service: FormulaCopyService;\n\n  // Mock clipboard API\n  const writeMock = vi.fn();\n  const writeTextMock = vi.fn();\n\n  const originalBlob = globalThis.Blob;\n\n  class TestBlob {\n    private readonly parts: string[];\n\n    constructor(parts: BlobPart[], _options?: BlobPropertyBag) {\n      this.parts = parts.map((part) => (typeof part === 'string' ? part : String(part)));\n    }\n\n    public async text(): Promise<string> {\n      return this.parts.join('');\n    }\n  }\n\n  class TestClipboardItem {\n    public readonly dataByType: Record<string, Blob>;\n\n    constructor(dataByType: Record<string, Blob>) {\n      this.dataByType = dataByType;\n    }\n  }\n\n  function resetSingleton(): void {\n    (FormulaCopyService as unknown as { instance: FormulaCopyService | null }).instance = null;\n  }\n\n  beforeEach(() => {\n    // Reset mocks\n    vi.clearAllMocks();\n\n    // Mock navigator.clipboard\n    Object.assign(navigator, {\n      clipboard: {\n        write: writeMock,\n        writeText: writeTextMock,\n      },\n    });\n\n    // Mock ClipboardItem\n    (globalThis as unknown as { ClipboardItem: typeof TestClipboardItem }).ClipboardItem =\n      TestClipboardItem;\n    (globalThis as unknown as { Blob: typeof TestBlob }).Blob = TestBlob;\n\n    resetSingleton();\n    service = FormulaCopyService.getInstance();\n  });\n\n  afterEach(() => {\n    if (service) {\n      service.destroy();\n    }\n    document.body.innerHTML = '';\n    (globalThis as unknown as { Blob: typeof originalBlob }).Blob = originalBlob;\n    vi.clearAllMocks();\n  });\n\n  it('should initialize correctly', () => {\n    service.initialize();\n    expect(service.isServiceInitialized()).toBe(true);\n  });\n\n  it('should generate MathML when format is unicodemath (now mapped to MathML)', async () => {\n    // Setup\n    vi.mocked(temml.renderToString).mockReturnValue(\n      '<math xmlns=\"http://www.w3.org/1998/Math/MathML\" class=\"tml-display\" style=\"display:block math;\"><semantics><mrow><mtext class=\"tml-text\">Result</mtext></mrow><annotation encoding=\"application/x-tex\">x^2</annotation></semantics></math>',\n    );\n\n    // Reset instance first\n    resetSingleton();\n    service = FormulaCopyService.getInstance({ format: 'unicodemath' });\n\n    // Create a mock event and element\n    const mathElement = document.createElement('span');\n    mathElement.setAttribute('data-math', 'x^2');\n    mathElement.classList.add('math-inline');\n    document.body.appendChild(mathElement);\n\n    const clickEvent = new MouseEvent('click', {\n      bubbles: true,\n      cancelable: true,\n      clientX: 100,\n      clientY: 100,\n    });\n\n    // Dispatch click on the element\n    service.initialize();\n    mathElement.dispatchEvent(clickEvent);\n    await Promise.resolve();\n\n    // Assertions\n    expect(temml.renderToString).toHaveBeenCalledWith(\n      'x^2',\n      expect.objectContaining({\n        annotate: false,\n        colorIsTextColor: true,\n        displayMode: false,\n        throwOnError: true,\n        trust: false,\n        xml: true,\n      }),\n    );\n\n    // Verify clipboard write was called with rich content\n    expect(writeMock).toHaveBeenCalled();\n    const writtenItemsUnknown = writeMock.mock.calls[0]?.[0] as unknown;\n    expect(Array.isArray(writtenItemsUnknown)).toBe(true);\n    const writtenItems = writtenItemsUnknown as TestClipboardItem[];\n    expect(writtenItems.length).toBeGreaterThan(0);\n\n    const clipboardItem = writtenItems[0];\n\n    expect(clipboardItem.dataByType['text/html']).toBeDefined();\n    expect(clipboardItem.dataByType['text/plain']).toBeDefined();\n    expect(clipboardItem.dataByType['application/mathml+xml']).toBeDefined();\n\n    const htmlContent = await clipboardItem.dataByType['text/html'].text();\n    const textContent = await clipboardItem.dataByType['text/plain'].text();\n    const mathmlContent = await clipboardItem.dataByType['application/mathml+xml'].text();\n\n    // Word-friendly MathML should be prefixed and must not include KaTeX <annotation> TeX payloads.\n    expect(htmlContent).toContain('xmlns:mml=\"http://www.w3.org/1998/Math/MathML\"');\n    expect(htmlContent).toContain('<!--StartFragment-->');\n    expect(htmlContent).toContain('<mml:math');\n    expect(htmlContent).not.toContain('<annotation');\n    expect(htmlContent).not.toContain('class=');\n    expect(htmlContent).not.toContain('style=');\n    expect(textContent).toContain('<mml:math');\n    expect(mathmlContent).toContain('<mml:math');\n\n    // Cleanup\n    document.body.removeChild(mathElement);\n  });\n\n  it('should fall back to legacy copy when MathML MIME is rejected', async () => {\n    vi.mocked(temml.renderToString).mockReturnValue(\n      '<math xmlns=\"http://www.w3.org/1998/Math/MathML\"><mrow><mtext>Result</mtext></mrow></math>',\n    );\n\n    const originalExecCommand = document.execCommand;\n    Object.assign(document, {\n      execCommand: vi.fn().mockReturnValue(true),\n    });\n\n    const unsupportedError =\n      typeof DOMException === 'function'\n        ? new DOMException('Type application/mathml+xml not supported on write.', 'NotAllowedError')\n        : Object.assign(new Error('Type application/mathml+xml not supported on write.'), {\n            name: 'NotAllowedError',\n          });\n\n    writeMock.mockRejectedValueOnce(unsupportedError).mockResolvedValueOnce(undefined);\n\n    resetSingleton();\n    service = FormulaCopyService.getInstance({ format: 'unicodemath' });\n\n    const mathElement = document.createElement('span');\n    mathElement.setAttribute('data-math', 'x^2');\n    mathElement.classList.add('math-inline');\n    document.body.appendChild(mathElement);\n\n    service.initialize();\n    mathElement.dispatchEvent(new MouseEvent('click', { bubbles: true }));\n    await Promise.resolve();\n\n    expect(writeMock).toHaveBeenCalledTimes(1);\n\n    const firstItemsUnknown = writeMock.mock.calls[0]?.[0] as unknown;\n    const firstItems = firstItemsUnknown as TestClipboardItem[];\n    const firstClipboardItem = firstItems[0];\n    expect(firstClipboardItem.dataByType['application/mathml+xml']).toBeDefined();\n    expect(\n      (document.execCommand as unknown as { mock?: { calls: unknown[] } }).mock?.calls.length,\n    ).toBeGreaterThan(0);\n\n    document.body.removeChild(mathElement);\n    Object.assign(document, {\n      execCommand: originalExecCommand,\n    });\n  });\n\n  it('should fall back to writeText if write is not available', async () => {\n    const clipboard = navigator.clipboard as unknown as { write?: unknown };\n    clipboard.write = undefined;\n\n    resetSingleton();\n    service = FormulaCopyService.getInstance({ format: 'latex' });\n\n    const mathElement = document.createElement('span');\n    mathElement.setAttribute('data-math', 'x^2');\n    document.body.appendChild(mathElement);\n\n    service.initialize();\n    mathElement.dispatchEvent(new MouseEvent('click', { bubbles: true }));\n    await Promise.resolve();\n\n    expect(writeTextMock).toHaveBeenCalledWith('$x^2$');\n\n    document.body.removeChild(mathElement);\n  });\n\n  it('should find data-math inside math container subtree', async () => {\n    const clipboard = navigator.clipboard as unknown as { write?: unknown };\n    clipboard.write = undefined;\n\n    resetSingleton();\n    service = FormulaCopyService.getInstance({ format: 'latex' });\n\n    const container = document.createElement('span');\n    container.classList.add('math-inline');\n\n    const inner = document.createElement('span');\n    inner.setAttribute('data-math', 'x^2');\n    inner.textContent = 'x²';\n    container.appendChild(inner);\n    document.body.appendChild(container);\n\n    service.initialize();\n    container.dispatchEvent(new MouseEvent('click', { bubbles: true }));\n    await Promise.resolve();\n\n    expect(writeTextMock).toHaveBeenCalledWith('$x^2$');\n\n    document.body.removeChild(container);\n  });\n\n  it('should copy when clicking deep descendant inside math container', async () => {\n    const clipboard = navigator.clipboard as unknown as { write?: unknown };\n    clipboard.write = undefined;\n\n    resetSingleton();\n    service = FormulaCopyService.getInstance({ format: 'latex' });\n\n    const container = document.createElement('span');\n    container.classList.add('math-inline');\n\n    const dataMathEl = document.createElement('span');\n    dataMathEl.setAttribute('data-math', 'x^2');\n    container.appendChild(dataMathEl);\n\n    let deepest: HTMLElement = dataMathEl;\n    for (let i = 0; i < 25; i += 1) {\n      const next = document.createElement('span');\n      next.textContent = `d${i}`;\n      deepest.appendChild(next);\n      deepest = next;\n    }\n\n    document.body.appendChild(container);\n\n    service.initialize();\n    deepest.dispatchEvent(new MouseEvent('click', { bubbles: true }));\n    await Promise.resolve();\n\n    expect(writeTextMock).toHaveBeenCalledWith('$x^2$');\n\n    document.body.removeChild(container);\n  });\n\n  it('should copy formula from AI Studio ms-katex container with annotation', async () => {\n    const clipboard = navigator.clipboard as unknown as { write?: unknown };\n    clipboard.write = undefined;\n\n    resetSingleton();\n    service = FormulaCopyService.getInstance({ format: 'latex' });\n\n    // Create AI Studio ms-katex structure based on the real DOM\n    const msKatex = document.createElement('ms-katex');\n    msKatex.classList.add('inline', 'ng-star-inserted');\n\n    const pre = document.createElement('pre');\n    const code = document.createElement('code');\n    code.classList.add('rendered');\n\n    const katexSpan = document.createElement('span');\n    katexSpan.classList.add('katex');\n\n    // Create the katex-mathml part with annotation\n    const katexMathml = document.createElement('span');\n    katexMathml.classList.add('katex-mathml');\n\n    const math = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'math');\n    const semantics = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'semantics');\n\n    const mrow = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'mrow');\n    const msub = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'msub');\n    const mi1 = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'mi');\n    mi1.textContent = 'π';\n    const mi2 = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'mi');\n    mi2.textContent = 'θ';\n    msub.appendChild(mi1);\n    msub.appendChild(mi2);\n    mrow.appendChild(msub);\n\n    const annotation = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'annotation');\n    annotation.setAttribute('encoding', 'application/x-tex');\n    annotation.textContent = '\\\\pi_\\\\theta';\n\n    semantics.appendChild(mrow);\n    semantics.appendChild(annotation);\n    math.appendChild(semantics);\n    katexMathml.appendChild(math);\n\n    // Create the katex-html part (visual rendering)\n    const katexHtml = document.createElement('span');\n    katexHtml.classList.add('katex-html');\n    katexHtml.setAttribute('aria-hidden', 'true');\n    katexHtml.innerHTML = '<span class=\"base\"><span class=\"mord\">π<sub>θ</sub></span></span>';\n\n    katexSpan.appendChild(katexMathml);\n    katexSpan.appendChild(katexHtml);\n    code.appendChild(katexSpan);\n    pre.appendChild(code);\n    msKatex.appendChild(pre);\n    document.body.appendChild(msKatex);\n\n    service.initialize();\n\n    // Click on the katex-html part (where users typically click)\n    katexHtml.dispatchEvent(new MouseEvent('click', { bubbles: true }));\n    await Promise.resolve();\n\n    expect(writeTextMock).toHaveBeenCalledWith('$\\\\pi_\\\\theta$');\n\n    document.body.removeChild(msKatex);\n  });\n\n  it('should detect display mode for AI Studio block formulas', async () => {\n    const clipboard = navigator.clipboard as unknown as { write?: unknown };\n    clipboard.write = undefined;\n\n    resetSingleton();\n    service = FormulaCopyService.getInstance({ format: 'latex' });\n\n    // Create AI Studio ms-katex structure with display=\"block\"\n    const msKatex = document.createElement('ms-katex');\n    msKatex.classList.add('block');\n\n    const pre = document.createElement('pre');\n    const code = document.createElement('code');\n    code.classList.add('rendered');\n\n    const katexSpan = document.createElement('span');\n    katexSpan.classList.add('katex');\n\n    const katexMathml = document.createElement('span');\n    katexMathml.classList.add('katex-mathml');\n\n    // Math element with display=\"block\" attribute\n    const math = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'math');\n    math.setAttribute('display', 'block');\n\n    const semantics = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'semantics');\n    const mrow = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'mrow');\n    const mi = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'mi');\n    mi.textContent = 'E';\n    mrow.appendChild(mi);\n\n    const annotation = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'annotation');\n    annotation.setAttribute('encoding', 'application/x-tex');\n    annotation.textContent = 'E = mc^2';\n\n    semantics.appendChild(mrow);\n    semantics.appendChild(annotation);\n    math.appendChild(semantics);\n    katexMathml.appendChild(math);\n\n    katexSpan.appendChild(katexMathml);\n    code.appendChild(katexSpan);\n    pre.appendChild(code);\n    msKatex.appendChild(pre);\n    document.body.appendChild(msKatex);\n\n    service.initialize();\n    msKatex.dispatchEvent(new MouseEvent('click', { bubbles: true }));\n    await Promise.resolve();\n\n    // Display mode should use $$ delimiters\n    expect(writeTextMock).toHaveBeenCalledWith('$$E = mc^2$$');\n\n    document.body.removeChild(msKatex);\n  });\n});\n"
  },
  {
    "path": "src/features/formulaCopy/FormulaCopyService.ts",
    "content": "/**\n * Formula Copy Service\n * Handles copying LaTeX/MathJax formulas from Gemini chat conversations\n * Uses enterprise patterns: Singleton, Service Layer, Event Delegation\n */\nimport temml from 'temml';\nimport browser from 'webextension-polyfill';\n\nimport { logger } from '@/core';\nimport { StorageKeys } from '@/core/types/common';\nimport type { ILogger } from '@/core/types/common';\n\n/**\n * Formula copy format options\n */\nexport type FormulaCopyFormat = 'latex' | 'unicodemath' | 'no-dollar';\n\n/**\n * Configuration for the formula copy service\n */\nexport interface FormulaCopyConfig {\n  toastDuration?: number;\n  toastOffsetY?: number;\n  maxTraversalDepth?: number;\n  format?: FormulaCopyFormat;\n}\n\n/**\n * Service class for handling formula copy functionality\n * Implements Singleton pattern for single instance management\n */\nexport class FormulaCopyService {\n  private static instance: FormulaCopyService | null = null;\n  private static readonly MATHML_NS = 'http://www.w3.org/1998/Math/MathML';\n  private readonly logger: ILogger;\n  private readonly config: Required<Omit<FormulaCopyConfig, 'format'>>;\n  private currentFormat: FormulaCopyFormat = 'latex';\n\n  // Storage change listener, extracted so it can be removed on destroy\n  private readonly handleStorageChange: Parameters<\n    typeof browser.storage.onChanged.addListener\n  >[0] = (changes, areaName) => {\n    if (areaName === 'sync' && changes[StorageKeys.FORMULA_COPY_FORMAT]) {\n      const newFormat = changes[StorageKeys.FORMULA_COPY_FORMAT].newValue as FormulaCopyFormat;\n      if (newFormat === 'latex' || newFormat === 'unicodemath' || newFormat === 'no-dollar') {\n        this.currentFormat = newFormat;\n        this.logger.debug('Formula format changed', { format: newFormat });\n      }\n    }\n  };\n\n  private isInitialized = false;\n  private copyToast: HTMLDivElement | null = null;\n  private i18nMessages: Record<string, string> = {};\n\n  private constructor(config: FormulaCopyConfig = {}) {\n    this.logger = logger.createChild('FormulaCopy');\n    this.config = {\n      toastDuration: config.toastDuration ?? 2000,\n      toastOffsetY: config.toastOffsetY ?? 40,\n      maxTraversalDepth: config.maxTraversalDepth ?? 10,\n    };\n    this.currentFormat = config.format ?? 'latex';\n    this.loadI18nMessages();\n    this.loadFormatPreference();\n  }\n\n  /**\n   * Get singleton instance\n   */\n  public static getInstance(config?: FormulaCopyConfig): FormulaCopyService {\n    if (!FormulaCopyService.instance) {\n      FormulaCopyService.instance = new FormulaCopyService(config);\n    }\n    return FormulaCopyService.instance;\n  }\n\n  /**\n   * Load i18n messages for toast notifications\n   */\n  private loadI18nMessages(): void {\n    try {\n      this.i18nMessages = {\n        copied: browser.i18n.getMessage('formula_copied') || '✓ Formula copied',\n        failed: browser.i18n.getMessage('formula_copy_failed') || '✗ Failed to copy',\n      };\n    } catch (error) {\n      this.logger.warn('Failed to load i18n messages, using defaults', { error });\n      this.i18nMessages = {\n        copied: '✓ Formula copied',\n        failed: '✗ Failed to copy',\n      };\n    }\n  }\n\n  /**\n   * Load format preference from storage\n   */\n  private async loadFormatPreference(): Promise<void> {\n    try {\n      const result = await browser.storage.sync.get(StorageKeys.FORMULA_COPY_FORMAT);\n      const format = result[StorageKeys.FORMULA_COPY_FORMAT] as FormulaCopyFormat | undefined;\n      if (format === 'latex' || format === 'unicodemath' || format === 'no-dollar') {\n        this.currentFormat = format;\n        this.logger.debug('Loaded formula format preference', { format });\n      }\n    } catch (error) {\n      this.logger.warn('Failed to load format preference, using default', { error });\n    }\n\n    // Listen for format changes\n    browser.storage.onChanged.addListener(this.handleStorageChange);\n  }\n\n  /**\n   * Initialize the formula copy feature\n   */\n  public initialize(): void {\n    if (this.isInitialized) {\n      this.logger.warn('Service already initialized');\n      return;\n    }\n\n    document.addEventListener('click', this.handleClick, true);\n    this.isInitialized = true;\n    this.logger.info('Formula copy service initialized');\n  }\n\n  /**\n   * Clean up the service (for extension unloading)\n   */\n  public destroy(): void {\n    // Always detach storage change listener\n    try {\n      browser.storage.onChanged.removeListener(this.handleStorageChange);\n    } catch (error) {\n      this.logger.warn('Failed to remove storage change listener', { error });\n    }\n\n    if (!this.isInitialized) {\n      this.logger.warn('Service not initialized, cannot destroy');\n      return;\n    }\n\n    document.removeEventListener('click', this.handleClick, true);\n    this.removeCopyToast();\n    this.isInitialized = false;\n    this.logger.info('Formula copy service destroyed');\n  }\n\n  /**\n   * Handle click events using event delegation\n   */\n  private handleClick = (event: MouseEvent): void => {\n    const target = event.target as HTMLElement;\n    const mathElement = this.findMathElement(target);\n\n    if (!mathElement) {\n      return;\n    }\n\n    // Try to extract LaTeX: first from data-math (Gemini), then from annotation (AI Studio)\n    const latexSource = this.extractLatexSource(mathElement);\n    if (!latexSource) {\n      this.logger.warn('Math element found but no LaTeX source available');\n      return;\n    }\n\n    // Wrap formula with delimiters based on display type\n    const isDisplayMode = this.isDisplayMode(mathElement);\n    const { text, html } = this.wrapFormula(latexSource, isDisplayMode);\n\n    this.copyFormula(text, html, event.clientX, event.clientY);\n    event.stopPropagation();\n  };\n\n  /**\n   * Extract LaTeX source from a math element\n   * Supports both Gemini (data-math attribute) and AI Studio (annotation element)\n   */\n  private extractLatexSource(element: HTMLElement): string | null {\n    // 1. Try Gemini's data-math attribute\n    const dataMath = element.getAttribute('data-math');\n    if (dataMath) {\n      return dataMath;\n    }\n\n    // 2. Try AI Studio's annotation element with encoding=\"application/x-tex\"\n    const annotation = element.querySelector('annotation[encoding=\"application/x-tex\"]');\n    if (annotation?.textContent) {\n      return annotation.textContent.trim();\n    }\n\n    // 3. Fallback: try any annotation element\n    const anyAnnotation = element.querySelector('annotation');\n    if (anyAnnotation?.textContent) {\n      return anyAnnotation.textContent.trim();\n    }\n\n    return null;\n  }\n\n  /**\n   * Copy formula to clipboard and show notification\n   */\n  private async copyFormula(\n    text: string,\n    html: string | undefined,\n    x: number,\n    y: number,\n  ): Promise<void> {\n    try {\n      const success = await this.copyToClipboard(text, html);\n\n      if (success) {\n        this.showToast(this.i18nMessages.copied, x, y, true);\n        this.logger.debug('Formula copied successfully', { length: text.length, hasHtml: !!html });\n      } else {\n        this.showToast(this.i18nMessages.failed, x, y, false);\n        this.logger.error('Failed to copy formula');\n      }\n    } catch (error) {\n      this.showToast(this.i18nMessages.failed, x, y, false);\n      this.logger.error('Error copying formula', { error });\n    }\n  }\n\n  /**\n   * Copy text to clipboard using modern API with fallback\n   */\n  private async copyToClipboard(text: string, html?: string): Promise<boolean> {\n    // Try modern Clipboard API first (supports MIME types)\n    if (navigator.clipboard?.write) {\n      const items: Record<string, Blob> = {\n        'text/plain': new Blob([text], { type: 'text/plain' }),\n      };\n\n      if (html) {\n        items['text/html'] = new Blob([html], { type: 'text/html' });\n        if (html.includes(`xmlns:mml=\"${FormulaCopyService.MATHML_NS}\"`)) {\n          items['application/mathml+xml'] = new Blob([text], { type: 'application/mathml+xml' });\n        }\n      }\n\n      try {\n        await navigator.clipboard.write([new ClipboardItem(items)]);\n        return true;\n      } catch (error) {\n        if (this.isMathMLClipboardUnsupported(error)) {\n          return this.copyToClipboardLegacy(text);\n        }\n\n        this.logger.error('Clipboard API failed, trying fallback', { error });\n        return this.copyToClipboardLegacy(text);\n      }\n    }\n\n    // Fallback: If only writeText is available (no MIME support)\n    if (navigator.clipboard?.writeText && !html) {\n      try {\n        await navigator.clipboard.writeText(text);\n        return true;\n      } catch (error) {\n        this.logger.error('Clipboard API failed, trying fallback', { error });\n        return this.copyToClipboardLegacy(text);\n      }\n    }\n\n    // Fallback to execCommand for older browsers (text only)\n    return this.copyToClipboardLegacy(text);\n  }\n\n  /**\n   * Legacy clipboard copy method using execCommand\n   */\n  private copyToClipboardLegacy(text: string): boolean {\n    try {\n      const textarea = document.createElement('textarea');\n      textarea.value = text;\n      textarea.style.position = 'fixed';\n      textarea.style.opacity = '0';\n      textarea.style.pointerEvents = 'none';\n\n      document.body.appendChild(textarea);\n      textarea.select();\n\n      const success = document.execCommand('copy');\n      document.body.removeChild(textarea);\n\n      return success;\n    } catch (error) {\n      this.logger.error('Legacy clipboard copy failed', { error });\n      return false;\n    }\n  }\n\n  private isMathMLClipboardUnsupported(error: unknown): boolean {\n    const name = this.getErrorName(error);\n    const nameMatches = name === 'notallowederror' || name === 'notsupportederror';\n    if (!nameMatches) {\n      return false;\n    }\n\n    const message = this.getErrorMessage(error);\n    if (!message) {\n      return true;\n    }\n\n    const lowerMessage = message.toLowerCase();\n    return lowerMessage.includes('mathml') || lowerMessage.includes('application/mathml+xml');\n  }\n\n  private getErrorMessage(error: unknown): string | null {\n    if (error instanceof Error) {\n      return error.message;\n    }\n\n    return typeof error === 'string' ? error : null;\n  }\n\n  private getErrorName(error: unknown): string | null {\n    if (error instanceof DOMException) {\n      return error.name.toLowerCase();\n    }\n\n    if (error instanceof Error) {\n      return error.name.toLowerCase();\n    }\n\n    return null;\n  }\n\n  /**\n   * Find the nearest math element in the DOM tree\n   * Supports both Gemini (data-math attribute) and AI Studio (ms-katex container)\n   */\n  private findMathElement(target: HTMLElement): HTMLElement | null {\n    // 1. Try Gemini's data-math attribute (direct)\n    const direct = target.closest('[data-math]');\n    if (direct instanceof HTMLElement) {\n      return direct;\n    }\n\n    // 2. Try Gemini's .math-inline, .math-block containers\n    const geminiContainer = target.closest('.math-inline, .math-block');\n    if (geminiContainer instanceof HTMLElement) {\n      return this.findDataMathInSubtree(geminiContainer);\n    }\n\n    // 3. Try AI Studio's ms-katex container\n    const aiStudioContainer = target.closest('ms-katex');\n    if (aiStudioContainer instanceof HTMLElement) {\n      return aiStudioContainer;\n    }\n\n    // 4. Try AI Studio: clicked inside .katex element\n    const katexElement = target.closest('.katex');\n    if (katexElement instanceof HTMLElement) {\n      // Find the parent ms-katex container\n      const parentMsKatex = katexElement.closest('ms-katex');\n      if (parentMsKatex instanceof HTMLElement) {\n        return parentMsKatex;\n      }\n    }\n\n    return null;\n  }\n\n  /**\n   * Check if element is a math container\n   */\n  private isMathContainer(element: HTMLElement): boolean {\n    return element.classList.contains('math-inline') || element.classList.contains('math-block');\n  }\n\n  /**\n   * Check if formula is in display mode (block formula)\n   * Supports both Gemini (.math-block class) and AI Studio (math display=\"block\" attribute)\n   */\n  private isDisplayMode(element: HTMLElement): boolean {\n    // 1. Gemini: check for .math-block container\n    if (element.closest('.math-block') !== null) {\n      return true;\n    }\n\n    // 2. AI Studio: check for math element with display=\"block\" attribute\n    const mathElement = element.querySelector('math[display=\"block\"]');\n    if (mathElement) {\n      return true;\n    }\n\n    // 3. AI Studio: check if ms-katex container has block-like styling\n    // (display formulas are typically block-level in AI Studio)\n    if (element.tagName.toLowerCase() === 'ms-katex') {\n      const style = window.getComputedStyle(element);\n      if (style.display === 'block' || style.display === 'flex') {\n        return true;\n      }\n    }\n\n    return false;\n  }\n\n  /**\n   * Wrap formula with appropriate delimiters based on format\n   * @param formula - Raw LaTeX formula\n   * @param isDisplayMode - Whether formula is in display mode\n   * @returns Object containing text and optional html\n   */\n  private wrapFormula(formula: string, isDisplayMode: boolean): { text: string; html?: string } {\n    if (this.currentFormat === 'unicodemath') {\n      // Convert to Word-friendly MathML (replaces previous UnicodeMath)\n      try {\n        const strippedFormula = this.stripMathDelimiters(formula);\n        const rawMathML = temml.renderToString(strippedFormula, {\n          displayMode: isDisplayMode,\n          xml: true,\n          annotate: false,\n          throwOnError: true,\n          colorIsTextColor: true,\n          trust: false,\n        });\n        const sanitizedMathML = this.stripMathMLAnnotations(rawMathML);\n        const namespacedMathML = this.ensureMathMLNamespace(sanitizedMathML);\n        const wordMathML = this.toWordMathML(namespacedMathML);\n        const htmlWrapped = this.wrapMathMLForWordHtml(wordMathML);\n\n        return { text: wordMathML, html: htmlWrapped };\n      } catch (error) {\n        this.logger.error('MathML conversion failed', { error });\n        return { text: formula };\n      }\n    }\n\n    if (this.currentFormat === 'no-dollar') {\n      return { text: formula };\n    }\n\n    // Default: LaTeX format with delimiters\n    const wrapped = isDisplayMode ? `$$${formula}$$` : `$${formula}$`;\n    return { text: wrapped };\n  }\n\n  private ensureMathMLNamespace(mathML: string): string {\n    if (mathML.includes('xmlns=')) {\n      return mathML;\n    }\n\n    return mathML.replace('<math', `<math xmlns=\"${FormulaCopyService.MATHML_NS}\"`);\n  }\n\n  private toWordMathML(mathML: string): string {\n    const parsed = new DOMParser().parseFromString(mathML, 'application/xml');\n    if (parsed.getElementsByTagName('parsererror').length > 0) {\n      return this.stripMathMLAnnotations(mathML);\n    }\n\n    const root = parsed.documentElement;\n    if (root.localName !== 'math') {\n      return this.stripMathMLAnnotations(mathML);\n    }\n\n    // Remove annotations (<annotation> and <annotation-xml>)\n    for (const annotation of Array.from(root.getElementsByTagName('annotation'))) {\n      annotation.parentNode?.removeChild(annotation);\n    }\n    for (const annotationXml of Array.from(root.getElementsByTagName('annotation-xml'))) {\n      annotationXml.parentNode?.removeChild(annotationXml);\n    }\n\n    // Unwrap <semantics> if present at root\n    const semantics = Array.from(root.getElementsByTagName('semantics')).find(\n      (node) => node.parentElement === root,\n    );\n    if (semantics) {\n      const presentation = semantics.firstElementChild;\n      if (presentation) {\n        while (root.firstChild) {\n          root.removeChild(root.firstChild);\n        }\n        root.appendChild(presentation);\n      }\n    }\n\n    this.stripPresentationAttributes(root);\n\n    const output = document.implementation.createDocument(\n      FormulaCopyService.MATHML_NS,\n      'mml:math',\n      null,\n    );\n    const outputRoot = output.documentElement;\n\n    // Copy root attributes (display, etc.), excluding namespace declarations\n    for (const attr of Array.from(root.attributes)) {\n      if (attr.name.startsWith('xmlns')) {\n        continue;\n      }\n      outputRoot.setAttribute(attr.name, attr.value);\n    }\n\n    for (const child of Array.from(root.childNodes)) {\n      outputRoot.appendChild(this.cloneNodeWithMathMLPrefix(output, child));\n    }\n\n    return new XMLSerializer().serializeToString(outputRoot);\n  }\n\n  private cloneNodeWithMathMLPrefix(targetDocument: Document, sourceNode: Node): Node {\n    if (sourceNode.nodeType === Node.TEXT_NODE) {\n      return targetDocument.createTextNode(sourceNode.nodeValue ?? '');\n    }\n\n    if (sourceNode.nodeType !== Node.ELEMENT_NODE) {\n      return targetDocument.importNode(sourceNode, true);\n    }\n\n    const sourceElement = sourceNode as Element;\n    const namespaceUri = sourceElement.namespaceURI;\n    const localName = sourceElement.localName;\n\n    const isMathMl = namespaceUri === FormulaCopyService.MATHML_NS || namespaceUri === null;\n    const qualifiedName = isMathMl ? `mml:${localName}` : sourceElement.tagName;\n    const element = isMathMl\n      ? targetDocument.createElementNS(FormulaCopyService.MATHML_NS, qualifiedName)\n      : targetDocument.createElement(qualifiedName);\n\n    for (const attr of Array.from(sourceElement.attributes)) {\n      if (attr.name.startsWith('xmlns')) {\n        continue;\n      }\n      element.setAttribute(attr.name, attr.value);\n    }\n\n    for (const child of Array.from(sourceElement.childNodes)) {\n      element.appendChild(this.cloneNodeWithMathMLPrefix(targetDocument, child));\n    }\n\n    return element;\n  }\n\n  private wrapMathMLForWordHtml(mathML: string): string {\n    // Word's HTML importer is sensitive to fragments; include Start/End markers.\n    return [\n      `<html xmlns:mml=\"${FormulaCopyService.MATHML_NS}\">`,\n      '<head><meta charset=\"utf-8\"></head>',\n      '<body><!--StartFragment-->',\n      mathML,\n      '<!--EndFragment--></body></html>',\n    ].join('');\n  }\n\n  private stripMathMLAnnotations(mathML: string): string {\n    return mathML\n      .replace(/<annotation(?:-xml)?[\\s\\S]*?<\\/annotation(?:-xml)?>/g, '')\n      .replace(/<semantics>\\s*([\\s\\S]*?)\\s*<\\/semantics>/g, '$1');\n  }\n\n  private stripPresentationAttributes(root: Element): void {\n    if (root.hasAttribute('class')) {\n      root.removeAttribute('class');\n    }\n    if (root.hasAttribute('style')) {\n      root.removeAttribute('style');\n    }\n\n    for (const element of Array.from(root.getElementsByTagName('*'))) {\n      if (element.hasAttribute('class')) {\n        element.removeAttribute('class');\n      }\n      if (element.hasAttribute('style')) {\n        element.removeAttribute('style');\n      }\n    }\n  }\n\n  private stripMathDelimiters(formula: string): string {\n    const trimmed = formula.trim();\n\n    if (trimmed.startsWith('$$') && trimmed.endsWith('$$')) {\n      return trimmed.slice(2, -2);\n    }\n\n    if (trimmed.startsWith('\\\\[') && trimmed.endsWith('\\\\]')) {\n      return trimmed.slice(2, -2);\n    }\n\n    if (trimmed.startsWith('\\\\(') && trimmed.endsWith('\\\\)')) {\n      return trimmed.slice(2, -2);\n    }\n\n    if (trimmed.startsWith('$') && trimmed.endsWith('$')) {\n      return trimmed.slice(1, -1);\n    }\n\n    return formula;\n  }\n\n  /**\n   * Search for data-math attribute in element subtree\n   */\n  private findDataMathInSubtree(root: HTMLElement): HTMLElement | null {\n    const direct = root.querySelector('[data-math]');\n    return direct instanceof HTMLElement ? direct : null;\n  }\n\n  /**\n   * Show toast notification\n   */\n  private showToast(message: string, x: number, y: number, isSuccess: boolean): void {\n    if (!this.copyToast) {\n      this.copyToast = this.createCopyToast();\n    }\n\n    this.copyToast.textContent = message;\n    this.copyToast.style.left = `${x}px`;\n    this.copyToast.style.top = `${y - this.config.toastOffsetY}px`;\n\n    // Update toast style based on success/failure\n    if (isSuccess) {\n      this.copyToast.classList.remove('gv-copy-toast-error');\n      this.copyToast.classList.add('gv-copy-toast-success');\n    } else {\n      this.copyToast.classList.remove('gv-copy-toast-success');\n      this.copyToast.classList.add('gv-copy-toast-error');\n    }\n\n    this.copyToast.classList.add('gv-copy-toast-show');\n\n    setTimeout(() => {\n      this.copyToast?.classList.remove('gv-copy-toast-show');\n    }, this.config.toastDuration);\n  }\n\n  /**\n   * Create toast element\n   */\n  private createCopyToast(): HTMLDivElement {\n    const toast = document.createElement('div');\n    toast.className = 'gv-copy-toast';\n    document.body.appendChild(toast);\n    return toast;\n  }\n\n  /**\n   * Remove toast element from DOM\n   */\n  private removeCopyToast(): void {\n    if (this.copyToast?.parentElement) {\n      this.copyToast.parentElement.removeChild(this.copyToast);\n      this.copyToast = null;\n    }\n  }\n\n  /**\n   * Check if service is initialized\n   */\n  public isServiceInitialized(): boolean {\n    return this.isInitialized;\n  }\n}\n\n// Export singleton instance getter\nexport const getFormulaCopyService = (config?: FormulaCopyConfig) =>\n  FormulaCopyService.getInstance(config);\n"
  },
  {
    "path": "src/features/formulaCopy/index.ts",
    "content": "// Convenience function for backward compatibility\nimport { getFormulaCopyService } from './FormulaCopyService';\n\n/**\n * Formula Copy Feature Entry Point\n * Exports the service and provides a simple initialization function\n */\n\nexport { FormulaCopyService, getFormulaCopyService } from './FormulaCopyService';\nexport type { FormulaCopyConfig } from './FormulaCopyService';\n\nexport function startFormulaCopy(): void {\n  const service = getFormulaCopyService();\n  service.initialize();\n}\n\nexport function stopFormulaCopy(): void {\n  const service = getFormulaCopyService();\n  service.destroy();\n}\n"
  },
  {
    "path": "src/global.d.ts",
    "content": "declare module '*.svg' {\n  import React = require('react');\n  export const ReactComponent: React.SFC<React.SVGProps<SVGSVGElement>>;\n  const src: string;\n  export default src;\n}\n\ndeclare module '*.png' {\n  const src: string;\n  export default src;\n}\n\ndeclare module '*.json' {\n  const content: string;\n  export default content;\n}\n"
  },
  {
    "path": "src/hooks/useDarkMode.ts",
    "content": "import React, { useCallback, useEffect, useState } from 'react';\n\nexport function useDarkMode() {\n  const [isDark, setIsDark] = useState(() => {\n    // Check saved preference first\n    const saved = localStorage.getItem('darkMode');\n    if (saved !== null) {\n      return saved === 'true';\n    }\n    // Fall back to system preference\n    return window.matchMedia('(prefers-color-scheme: dark)').matches;\n  });\n\n  useEffect(() => {\n    // Apply dark class to document\n    document.documentElement.classList.toggle('dark', isDark);\n\n    // Save preference\n    localStorage.setItem('darkMode', String(isDark));\n  }, [isDark]);\n\n  // Listen for system preference changes\n  useEffect(() => {\n    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n    const handleChange = (e: MediaQueryListEvent) => {\n      // Only auto-switch if user hasn't set a preference\n      const saved = localStorage.getItem('darkMode');\n      if (saved === null) {\n        setIsDark(e.matches);\n      }\n    };\n\n    mediaQuery.addEventListener('change', handleChange);\n    return () => mediaQuery.removeEventListener('change', handleChange);\n  }, []);\n\n  const toggleDarkMode = useCallback(\n    (event?: React.MouseEvent) => {\n      const newIsDark = !isDark;\n\n      const applyTheme = () => {\n        document.documentElement.classList.toggle('dark', newIsDark);\n        localStorage.setItem('darkMode', String(newIsDark));\n        setIsDark(newIsDark);\n      };\n\n      // Skip animation if no event, no View Transition API, or user prefers reduced motion\n      const startViewTransition = (\n        document as { startViewTransition?: (cb: () => void) => { ready: Promise<void> } }\n      ).startViewTransition;\n      if (\n        !event ||\n        !startViewTransition ||\n        window.matchMedia('(prefers-reduced-motion: reduce)').matches\n      ) {\n        applyTheme();\n        return;\n      }\n\n      const x = event.clientX;\n      const y = event.clientY;\n      const endRadius = Math.hypot(\n        Math.max(x, window.innerWidth - x),\n        Math.max(y, window.innerHeight - y),\n      );\n\n      const transition = startViewTransition.call(document, applyTheme);\n\n      transition.ready.then(() => {\n        document.documentElement.animate(\n          {\n            clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`],\n          },\n          {\n            duration: 500,\n            easing: 'ease-in-out',\n            pseudoElement: '::view-transition-new(root)',\n          },\n        );\n      });\n    },\n    [isDark],\n  );\n\n  return { isDark, toggleDarkMode };\n}\n"
  },
  {
    "path": "src/hooks/useWidthAdjuster.ts",
    "content": "import { useEffect, useRef, useState } from 'react';\n\ninterface UseWidthAdjusterOptions {\n  storageKey: string;\n  defaultValue: number;\n  onApply: (value: number) => void;\n  /**\n   * Optional normalization hook (e.g., migrate legacy px to %)\n   */\n  normalize?: (value: number) => number;\n}\n\n/**\n * Custom hook for managing width adjustment with debounced storage writes\n * Follows DRY principle by extracting common width adjustment logic\n */\nexport function useWidthAdjuster({\n  storageKey,\n  defaultValue,\n  onApply,\n  normalize,\n}: UseWidthAdjusterOptions) {\n  const initial = normalize ? normalize(defaultValue) : defaultValue;\n  const [width, setWidth] = useState<number>(initial);\n  const pendingWidth = useRef<number | null>(null);\n  const hydrated = useRef(false);\n  const isInteracting = useRef(false);\n\n  // Load initial width from storage\n  useEffect(() => {\n    try {\n      chrome.storage?.sync?.get({ [storageKey]: defaultValue }, (res) => {\n        const storedWidth = res?.[storageKey];\n        if (typeof storedWidth === 'number') {\n          const normalized = normalize ? normalize(storedWidth) : storedWidth;\n          if (Number.isFinite(normalized)) {\n            // Avoid overriding user drag in progress\n            if (!isInteracting.current) {\n              setWidth(normalized);\n            }\n          }\n        }\n        hydrated.current = true;\n      });\n    } catch {}\n  }, [storageKey, defaultValue, normalize]);\n\n  // Cleanup and save pending changes on unmount\n  useEffect(() => {\n    return () => {\n      if (pendingWidth.current !== null) {\n        onApply(pendingWidth.current);\n      }\n    };\n  }, [onApply]);\n\n  const handleChange = (newWidth: number) => {\n    isInteracting.current = true;\n    setWidth(newWidth);\n    pendingWidth.current = newWidth;\n  };\n\n  const handleChangeComplete = () => {\n    // Save once when user releases the slider\n    if (pendingWidth.current !== null) {\n      onApply(pendingWidth.current);\n      pendingWidth.current = null;\n    }\n    // Allow future external sync after interaction ends\n    isInteracting.current = false;\n  };\n\n  return {\n    width,\n    handleChange,\n    handleChangeComplete,\n  };\n}\n"
  },
  {
    "path": "src/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
  },
  {
    "path": "src/locales/ar/messages.json",
    "content": "{\n  \"extName\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Extension name\"\n  },\n  \"extDescription\": {\n    \"message\": \"عزز تجربة Gemini™: جدول زمني، مجلدات، مطالبات، وتصدير.\",\n    \"description\": \"Extension description\"\n  },\n  \"scrollMode\": {\n    \"message\": \"وضع التمرير\",\n    \"description\": \"Scroll mode label\"\n  },\n  \"flow\": {\n    \"message\": \"تدفق\",\n    \"description\": \"Flow mode\"\n  },\n  \"jump\": {\n    \"message\": \"قفز\",\n    \"description\": \"Jump mode\"\n  },\n  \"hideOuterContainer\": {\n    \"message\": \"إخفاء الحاوية الخارجية\",\n    \"description\": \"Hide outer container label\"\n  },\n  \"draggableTimeline\": {\n    \"message\": \"جدول زمني قابل للسحب\",\n    \"description\": \"Draggable Timeline label\"\n  },\n  \"enableMarkerLevel\": {\n    \"message\": \"تمكين مستويات العقدة\",\n    \"description\": \"Enable timeline node level feature\"\n  },\n  \"enableMarkerLevelHint\": {\n    \"message\": \"انقر بزر الماوس الأيمن فوق عقد الجدول الزمني لتعيين مستواها وطي العناصر الفرعية\",\n    \"description\": \"Node level feature hint\"\n  },\n  \"experimentalLabel\": {\n    \"message\": \"تجريبي\",\n    \"description\": \"Experimental feature label\"\n  },\n  \"resetPosition\": {\n    \"message\": \"إعادة تعيين الموضع\",\n    \"description\": \"Reset Position button\"\n  },\n  \"resetTimelinePosition\": {\n    \"message\": \"إعادة تعيين موضع الجدول الزمني\",\n    \"description\": \"Reset timeline position button in timeline options\"\n  },\n  \"language\": {\n    \"message\": \"اللغة\",\n    \"description\": \"Language label\"\n  },\n  \"pm_title\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Prompt Manager title\"\n  },\n  \"pm_add\": {\n    \"message\": \"إضافة\",\n    \"description\": \"Add prompt button\"\n  },\n  \"pm_search_placeholder\": {\n    \"message\": \"البحث في المطالبات أو العلامات\",\n    \"description\": \"Search placeholder\"\n  },\n  \"pm_import\": {\n    \"message\": \"استيراد\",\n    \"description\": \"Import button\"\n  },\n  \"pm_export\": {\n    \"message\": \"تصدير\",\n    \"description\": \"Export button\"\n  },\n  \"pm_prompt_placeholder\": {\n    \"message\": \"نص المطالبة\",\n    \"description\": \"Prompt textarea placeholder\"\n  },\n  \"pm_tags_placeholder\": {\n    \"message\": \"العلامات (مفصولة بفواصل)\",\n    \"description\": \"Tags input placeholder\"\n  },\n  \"pm_save\": {\n    \"message\": \"حفظ\",\n    \"description\": \"Save prompt\"\n  },\n  \"pm_cancel\": {\n    \"message\": \"إلغاء\",\n    \"description\": \"Cancel\"\n  },\n  \"pm_all_tags\": {\n    \"message\": \"الكل\",\n    \"description\": \"All tags chip\"\n  },\n  \"pm_empty\": {\n    \"message\": \"لا توجد مطالبات حتى الآن\",\n    \"description\": \"Empty state\"\n  },\n  \"pm_copy\": {\n    \"message\": \"نسخ\",\n    \"description\": \"Copy prompt\"\n  },\n  \"pm_copied\": {\n    \"message\": \"تم النسخ\",\n    \"description\": \"Copied notice\"\n  },\n  \"pm_delete\": {\n    \"message\": \"حذف\",\n    \"description\": \"Delete prompt\"\n  },\n  \"pm_delete_confirm\": {\n    \"message\": \"هل تريد حذف هذه المطالبة؟\",\n    \"description\": \"Delete confirm\"\n  },\n  \"pm_lock\": {\n    \"message\": \"قفل الموضع\",\n    \"description\": \"Lock panel position\"\n  },\n  \"pm_unlock\": {\n    \"message\": \"فتح الموضع\",\n    \"description\": \"Unlock panel position\"\n  },\n  \"pm_import_invalid\": {\n    \"message\": \"تنسيق ملف غير صالح\",\n    \"description\": \"Import invalid\"\n  },\n  \"pm_import_success\": {\n    \"message\": \"تم استيراد {count} مطالبة\",\n    \"description\": \"Import success\"\n  },\n  \"pm_duplicate\": {\n    \"message\": \"تكرار المطالبة\",\n    \"description\": \"Duplicate on add\"\n  },\n  \"pm_deleted\": {\n    \"message\": \"تم الحذف\",\n    \"description\": \"Deleted notice\"\n  },\n  \"pm_edit\": {\n    \"message\": \"تعديل\",\n    \"description\": \"Edit prompt\"\n  },\n  \"pm_expand\": {\n    \"message\": \"توسيع\",\n    \"description\": \"Expand prompt text\"\n  },\n  \"pm_collapse\": {\n    \"message\": \"طي\",\n    \"description\": \"Collapse prompt text\"\n  },\n  \"pm_saved\": {\n    \"message\": \"تم الحفظ\",\n    \"description\": \"Saved notice\"\n  },\n  \"pm_settings\": {\n    \"message\": \"الإعدادات\",\n    \"description\": \"Settings button\"\n  },\n  \"pm_settings_tooltip\": {\n    \"message\": \"فتح إعدادات الامتداد\",\n    \"description\": \"Settings button tooltip\"\n  },\n  \"pm_settings_fallback\": {\n    \"message\": \"يرجى النقر فوق رمز الامتداد في شريط المتصفح لفتح الإعدادات\",\n    \"description\": \"Settings open failed message\"\n  },\n  \"pm_backup\": {\n    \"message\": \"نسخ احتياطي محلي\",\n    \"description\": \"Local backup button\"\n  },\n  \"pm_backup_tooltip\": {\n    \"message\": \"نسخ احتياطي للمطالبات والمجلدات في مجلد محدد بختم زمني\",\n    \"description\": \"Backup button tooltip\"\n  },\n  \"pm_backup_cancelled\": {\n    \"message\": \"تم إلغاء النسخ الاحتياطي\",\n    \"description\": \"Backup cancelled hint\"\n  },\n  \"pm_backup_error\": {\n    \"message\": \"✗ فشل النسخ الاحتياطي\",\n    \"description\": \"Backup failed hint\"\n  },\n  \"pm_backup_hint_options\": {\n    \"message\": \"تم دمج الميزة في مدير المطالبات على صفحة Gemini.\",\n    \"description\": \"Backup hint in options page\"\n  },\n  \"pm_backup_step1\": {\n    \"message\": \"افتح صفحة Gemini (gemini.google.com)\",\n    \"description\": \"Backup step 1\"\n  },\n  \"pm_backup_step2\": {\n    \"message\": \"انقر فوق رمز الامتداد في أسفل اليمين لفتح مدير المطالبات\",\n    \"description\": \"Backup step 2\"\n  },\n  \"pm_backup_step3\": {\n    \"message\": \"انقر فوق زر \\\"💾 نسخ احتياطي محلي\\\" وحدد مجلد النسخ الاحتياطي\",\n    \"description\": \"Backup step 3\"\n  },\n  \"pm_backup_note\": {\n    \"message\": \"تشمل النسخ الاحتياطية جميع المطالبات والمجلدات، ويتم حفظها في مجلد بختم زمني (التنسيق: backup-YYYYMMDD-HHMMSS)\",\n    \"description\": \"Backup feature note\"\n  },\n  \"pm_theme_light\": {\n    \"message\": \"التبديل إلى الوضع الفاتح\",\n    \"description\": \"Theme toggle tooltip - switch to light\"\n  },\n  \"pm_theme_dark\": {\n    \"message\": \"التبديل إلى الوضع الداكن\",\n    \"description\": \"Theme toggle tooltip - switch to dark\"\n  },\n  \"extensionVersion\": {\n    \"message\": \"الإصدار\",\n    \"description\": \"Extension version label\"\n  },\n  \"newVersionAvailable\": {\n    \"message\": \"يتوفر إصدار جديد\",\n    \"description\": \"Update reminder title\"\n  },\n  \"currentVersionLabel\": {\n    \"message\": \"الحالي\",\n    \"description\": \"Label for current version number\"\n  },\n  \"latestVersionLabel\": {\n    \"message\": \"الأحدث\",\n    \"description\": \"Label for latest version number\"\n  },\n  \"updateNow\": {\n    \"message\": \"تحديث\",\n    \"description\": \"CTA to update to latest version\"\n  },\n  \"safariUpdateNotSynced\": {\n    \"message\": \"مصدر التحديث غير متزامن بعد، يرجى المحاولة مرة أخرى بعد بضع ساعات\",\n    \"description\": \"Shown on Safari when the DMG file is not yet available on GitHub releases\"\n  },\n  \"starProject\": {\n    \"message\": \"ادعمني ⭐\",\n    \"description\": \"Github callout\"\n  },\n  \"officialDocs\": {\n    \"message\": \"الوثائق الرسمية\",\n    \"description\": \"Official docs link\"\n  },\n  \"exportChatJson\": {\n    \"message\": \"تصدير سجل المحادثة\",\n    \"description\": \"Tooltip for export button\"\n  },\n  \"folder_title\": {\n    \"message\": \"المجلدات\",\n    \"description\": \"Folder section title\"\n  },\n  \"folder_create\": {\n    \"message\": \"إنشاء مجلد\",\n    \"description\": \"Create folder button tooltip\"\n  },\n  \"folder_name_prompt\": {\n    \"message\": \"أدخل اسم المجلد:\",\n    \"description\": \"Create folder name prompt\"\n  },\n  \"folder_rename_prompt\": {\n    \"message\": \"أدخل الاسم الجديد:\",\n    \"description\": \"Rename folder prompt\"\n  },\n  \"folder_delete_confirm\": {\n    \"message\": \"هل تريد حذف هذا المجلد وجميع محتوياته؟\",\n    \"description\": \"Delete folder confirmation\"\n  },\n  \"folder_create_subfolder\": {\n    \"message\": \"إنشاء مجلد فرعي\",\n    \"description\": \"Create subfolder menu item\"\n  },\n  \"folder_rename\": {\n    \"message\": \"إعادة تسمية\",\n    \"description\": \"Rename folder menu item\"\n  },\n  \"folder_change_color\": {\n    \"message\": \"تغيير اللون\",\n    \"description\": \"Change folder color menu item\"\n  },\n  \"folder_delete\": {\n    \"message\": \"حذف\",\n    \"description\": \"Delete folder menu item\"\n  },\n  \"folder_color_default\": {\n    \"message\": \"افتراضي\",\n    \"description\": \"Default folder color name\"\n  },\n  \"folder_color_red\": {\n    \"message\": \"أحمر\",\n    \"description\": \"Red folder color name\"\n  },\n  \"folder_color_orange\": {\n    \"message\": \"برتقالي\",\n    \"description\": \"Orange folder color name\"\n  },\n  \"folder_color_yellow\": {\n    \"message\": \"أصفر\",\n    \"description\": \"Yellow folder color name\"\n  },\n  \"folder_color_green\": {\n    \"message\": \"أخضر\",\n    \"description\": \"Green folder color name\"\n  },\n  \"folder_color_blue\": {\n    \"message\": \"أزرق\",\n    \"description\": \"Blue folder color name\"\n  },\n  \"folder_color_purple\": {\n    \"message\": \"أرجواني\",\n    \"description\": \"Purple folder color name\"\n  },\n  \"folder_color_pink\": {\n    \"message\": \"وردي\",\n    \"description\": \"Pink folder color name\"\n  },\n  \"folder_color_custom\": {\n    \"message\": \"مخصص\",\n    \"description\": \"Custom folder color name\"\n  },\n  \"folder_empty\": {\n    \"message\": \"لا توجد مجلدات حتى الآن\",\n    \"description\": \"Empty state placeholder when no folders exist\"\n  },\n  \"folder_pin\": {\n    \"message\": \"تثبيت المجلد\",\n    \"description\": \"Pin folder menu item\"\n  },\n  \"folder_unpin\": {\n    \"message\": \"إلغاء تثبيت المجلد\",\n    \"description\": \"Unpin folder menu item\"\n  },\n  \"folder_remove_conversation\": {\n    \"message\": \"إزالة من المجلد\",\n    \"description\": \"Remove conversation button tooltip\"\n  },\n  \"folder_remove_conversation_confirm\": {\n    \"message\": \"هل تريد إزالة \\\"{title}\\\" من هذا المجلد؟\",\n    \"description\": \"Remove conversation confirmation\"\n  },\n  \"conversation_more\": {\n    \"message\": \"خيارات إضافية\",\n    \"description\": \"Conversation more options button tooltip\"\n  },\n  \"conversation_move_to_folder\": {\n    \"message\": \"نقل إلى مجلد\",\n    \"description\": \"Move conversation to folder menu item\"\n  },\n  \"conversation_move_to_folder_title\": {\n    \"message\": \"نقل إلى مجلد\",\n    \"description\": \"Move conversation to folder dialog title\"\n  },\n  \"chatWidth\": {\n    \"message\": \"عرض الدردشة\",\n    \"description\": \"Chat width adjustment label\"\n  },\n  \"chatWidthNarrow\": {\n    \"message\": \"ضيق\",\n    \"description\": \"Narrow chat width label\"\n  },\n  \"chatWidthWide\": {\n    \"message\": \"عريض\",\n    \"description\": \"Wide chat width label\"\n  },\n  \"sidebarWidth\": {\n    \"message\": \"عرض الشريط الجانبي\",\n    \"description\": \"Sidebar width adjustment label\"\n  },\n  \"sidebarWidthNarrow\": {\n    \"message\": \"ضيق\",\n    \"description\": \"Narrow sidebar width label\"\n  },\n  \"sidebarWidthWide\": {\n    \"message\": \"عريض\",\n    \"description\": \"Wide sidebar width label\"\n  },\n  \"formula_copied\": {\n    \"message\": \"✓ تم نسخ الصيغة\",\n    \"description\": \"Formula copied success message\"\n  },\n  \"formula_copy_failed\": {\n    \"message\": \"✗ فشل النسخ\",\n    \"description\": \"Formula copy failed message\"\n  },\n  \"formulaCopyFormat\": {\n    \"message\": \"تنسيق نسخ الصيغة\",\n    \"description\": \"Formula copy format setting label\"\n  },\n  \"formulaCopyFormatLatex\": {\n    \"message\": \"LaTeX\",\n    \"description\": \"LaTeX format option\"\n  },\n  \"formulaCopyFormatUnicodeMath\": {\n    \"message\": \"MathML (Word)\",\n    \"description\": \"Label for UnicodeMath formula copy format option\"\n  },\n  \"formulaCopyFormatHint\": {\n    \"message\": \"اختر التنسيق عند نسخ الصيغ بالنقر\",\n    \"description\": \"Formula copy format hint\"\n  },\n  \"formulaCopyFormatNoDollar\": {\n    \"message\": \"LaTeX (بدون علامة الدولار)\",\n    \"description\": \"LaTeX format option without dollar signs\"\n  },\n  \"folder_filter_current_user\": {\n    \"message\": \"عزل الحساب\",\n    \"description\": \"Show only conversations from the current Google account\"\n  },\n  \"folder_export\": {\n    \"message\": \"تصدير المجلدات\",\n    \"description\": \"Export folders button tooltip\"\n  },\n  \"folder_import_export\": {\n    \"message\": \"استيراد/تصدير المجلدات\",\n    \"description\": \"Combined import/export button tooltip\"\n  },\n  \"folder_cloud_upload\": {\n    \"message\": \"رفع إلى السحابة\",\n    \"description\": \"Cloud upload button tooltip\"\n  },\n  \"folder_cloud_sync\": {\n    \"message\": \"مزامنة من السحابة\",\n    \"description\": \"Cloud sync button tooltip\"\n  },\n  \"folder_import\": {\n    \"message\": \"استيراد المجلدات\",\n    \"description\": \"Import folders button tooltip\"\n  },\n  \"folder_import_title\": {\n    \"message\": \"استيراد تكوين المجلد\",\n    \"description\": \"Import dialog title\"\n  },\n  \"folder_import_strategy\": {\n    \"message\": \"استراتيجية الاستيراد:\",\n    \"description\": \"Import strategy label\"\n  },\n  \"folder_import_merge\": {\n    \"message\": \"دمج مع المجلدات الموجودة\",\n    \"description\": \"Merge import strategy\"\n  },\n  \"folder_import_overwrite\": {\n    \"message\": \"الكتابة فوق المجلدات الموجودة\",\n    \"description\": \"Overwrite import strategy\"\n  },\n  \"folder_import_select_file\": {\n    \"message\": \"حدد ملف JSON للاستيراد\",\n    \"description\": \"Import file selection prompt\"\n  },\n  \"folder_import_success\": {\n    \"message\": \"✓ تم استيراد {folders} مجلد، و {conversations} محادثة\",\n    \"description\": \"Import success message\"\n  },\n  \"folder_import_success_skipped\": {\n    \"message\": \"✓ تم استيراد {folders} مجلد، و {conversations} محادثة (تم تخطي {skipped} مكرر)\",\n    \"description\": \"Import success with skipped message\"\n  },\n  \"folder_import_error\": {\n    \"message\": \"✗ فشل الاستيراد: {error}\",\n    \"description\": \"Import error message\"\n  },\n  \"folder_export_success\": {\n    \"message\": \"✓ تم تصدير المجلدات بنجاح\",\n    \"description\": \"Export success message\"\n  },\n  \"folder_import_invalid_format\": {\n    \"message\": \"تنسيق ملف غير صالح. يرجى تحديد ملف تكوين مجلد صالح.\",\n    \"description\": \"Invalid format error\"\n  },\n  \"folder_import_confirm_overwrite\": {\n    \"message\": \"سيؤدي هذا إلى استبدال جميع المجلدات الموجودة. سيتم إنشاء نسخة احتياطية. متابعة؟\",\n    \"description\": \"Overwrite confirmation\"\n  },\n  \"folder_import_paste_json\": {\n    \"message\": \"أو لصق JSON مباشرة\",\n    \"description\": \"زر تبديل لإظهار منطقة لصق النص في حوار الاستيراد\"\n  },\n  \"folder_import_paste_placeholder\": {\n    \"message\": \"الصق JSON هنا...\",\n    \"description\": \"نص نائب لمنطقة اللصق\"\n  },\n  \"export_dialog_title\": {\n    \"message\": \"تصدير المحادثة\",\n    \"description\": \"Export dialog title\"\n  },\n  \"export_dialog_select\": {\n    \"message\": \"اختر تنسيق التصدير:\",\n    \"description\": \"Export format selection label\"\n  },\n  \"export_dialog_warning\": {\n    \"message\": \"⚠️ بعد النقر على تصدير، سيقفز النظام تلقائياً إلى الرسالة الأولى لتحميل المحتوى بالكامل. يرجى عدم إجراء أي عمليات؛ سيستمر التصدير تلقائياً بعد القفز.\",\n    \"description\": \"Warning about auto-jump and loading completeness\"\n  },\n  \"export_dialog_safari_cmdp_hint\": {\n    \"message\": \"نصيحة Safari: انقر فوق 'تصدير' أدناه، وانتظر لحظة، ثم اضغط على ⌘P واختر 'حفظ بتنسيق PDF'.\",\n    \"description\": \"Safari-specific hint for PDF export\"\n  },\n  \"export_toast_safari_pdf_ready\": {\n    \"message\": \"يمكنك الآن الضغط على Command + P لتصدير ملف PDF.\",\n    \"description\": \"Safari-specific toast shown after PDF export is prepared\"\n  },\n  \"export_dialog_safari_markdown_hint\": {\n    \"message\": \"تنبيه: بسبب قيود Safari، لا يمكن استخراج الصور من سجل المحادثة. للحصول على تصدير كامل، يُنصح باستخدام تصدير PDF.\",\n    \"description\": \"Warning about image references in Safari markdown export\"\n  },\n  \"export_format_json_description\": {\n    \"message\": \"تنسيق مقروء آلياً للمطورين\",\n    \"description\": \"Description for JSON export format\"\n  },\n  \"export_format_markdown_description\": {\n    \"message\": \"تنسيق نصي نظيف ومحمول (موصى به)\",\n    \"description\": \"Description for Markdown export format\"\n  },\n  \"export_format_pdf_description\": {\n    \"message\": \"تنسيق مناسب للطباعة عبر حفظ بتنسيق PDF\",\n    \"description\": \"Description for PDF export format\"\n  },\n  \"editInputWidth\": {\n    \"message\": \"عرض حقل التحرير\",\n    \"description\": \"Edit input width adjustment label\"\n  },\n  \"editInputWidthNarrow\": {\n    \"message\": \"ضيق\",\n    \"description\": \"Narrow edit input width label\"\n  },\n  \"editInputWidthWide\": {\n    \"message\": \"عريض\",\n    \"description\": \"Wide edit input width label\"\n  },\n  \"timelineOptions\": {\n    \"message\": \"خيارات الجدول الزمني\",\n    \"description\": \"Timeline options section label\"\n  },\n  \"folderOptions\": {\n    \"message\": \"خيارات المجلد\",\n    \"description\": \"Folder options section label\"\n  },\n  \"enableFolderFeature\": {\n    \"message\": \"تمكين ميزة المجلد\",\n    \"description\": \"Enable or disable the folder feature\"\n  },\n  \"hideArchivedConversations\": {\n    \"message\": \"إخفاء المحادثات المؤرشفة\",\n    \"description\": \"Hide conversations that are in folders from the main list\"\n  },\n  \"conversation_star\": {\n    \"message\": \"تمييز المحادثة بنجمة\",\n    \"description\": \"Star conversation button tooltip\"\n  },\n  \"conversation_unstar\": {\n    \"message\": \"إزالة النجمة من المحادثة\",\n    \"description\": \"Unstar conversation button tooltip\"\n  },\n  \"conversation_untitled\": {\n    \"message\": \"بدون عنوان\",\n    \"description\": \"Fallback title for conversations without a name\"\n  },\n  \"geminiOnlyNotice\": {\n    \"message\": \"يعمل على Gemini (بما في ذلك Enterprise) و AI Studio بشكل افتراضي. أضف مواقع أخرى أدناه لتمكين مدير المطالبات هناك.\",\n    \"description\": \"Notice that defaults are limited to Gemini/AI Studio\"\n  },\n  \"folderManager_dataLossWarning\": {\n    \"message\": \"⚠️ تحذير: فشل تحميل بيانات المجلد. ربما تكون مجلداتك تالفة. يرجى التحقق من وحدة تحكم المتصفح للحصول على التفاصيل ومحاولة الاستعادة من النسخة الاحتياطية إذا كانت متوفرة.\",\n    \"description\": \"Warning shown when folder data fails to load\"\n  },\n  \"backupOptions\": {\n    \"message\": \"نسخ احتياطي تلقائي\",\n    \"description\": \"Backup options section label\"\n  },\n  \"backupEnabled\": {\n    \"message\": \"تمكين النسخ الاحتياطي التلقائي\",\n    \"description\": \"Enable auto backup toggle\"\n  },\n  \"backupNow\": {\n    \"message\": \"نسخ احتياطي الآن\",\n    \"description\": \"Backup now button\"\n  },\n  \"backupSelectFolder\": {\n    \"message\": \"تحديد مجلد النسخ الاحتياطي\",\n    \"description\": \"Select backup folder button\"\n  },\n  \"backupFolderSelected\": {\n    \"message\": \"مجلد النسخ الاحتياطي: {folder}\",\n    \"description\": \"Shows selected backup folder\"\n  },\n  \"backupIncludePrompts\": {\n    \"message\": \"تضمين المطالبات\",\n    \"description\": \"Include prompts in backup toggle\"\n  },\n  \"backupIncludeFolders\": {\n    \"message\": \"تضمين المجلدات\",\n    \"description\": \"Include folders in backup toggle\"\n  },\n  \"backupIntervalLabel\": {\n    \"message\": \"فاصل النسخ الاحتياطي\",\n    \"description\": \"Backup interval label\"\n  },\n  \"backupIntervalManual\": {\n    \"message\": \"يدوي فقط\",\n    \"description\": \"Manual backup mode\"\n  },\n  \"backupIntervalDaily\": {\n    \"message\": \"يومي\",\n    \"description\": \"Daily backup interval\"\n  },\n  \"backupIntervalWeekly\": {\n    \"message\": \"أسبوعي (7 أيام)\",\n    \"description\": \"Weekly backup interval\"\n  },\n  \"backupLastBackup\": {\n    \"message\": \"آخر نسخ احتياطي: {time}\",\n    \"description\": \"Last backup timestamp\"\n  },\n  \"backupNever\": {\n    \"message\": \"أبدا\",\n    \"description\": \"Never backed up\"\n  },\n  \"backupSuccess\": {\n    \"message\": \"✓ تم إنشاء النسخ الاحتياطي: {prompts} مطالبة، {folders} مجلد، {conversations} محادثة\",\n    \"description\": \"Backup success message\"\n  },\n  \"backupError\": {\n    \"message\": \"✗ فشل النسخ الاحتياطي: {error}\",\n    \"description\": \"Backup error message\"\n  },\n  \"backupNotSupported\": {\n    \"message\": \"⚠️ يتطلب النسخ الاحتياطي التلقائي متصفحًا حديثًا يدعم File System Access API\",\n    \"description\": \"Browser not supported message\"\n  },\n  \"backupSelectFolderFirst\": {\n    \"message\": \"يرجى تحديد مجلد نسخ احتياطي أولاً\",\n    \"description\": \"No folder selected warning\"\n  },\n  \"backupUserCancelled\": {\n    \"message\": \"تم إلغاء النسخ الاحتياطي\",\n    \"description\": \"User cancelled folder selection\"\n  },\n  \"backupConfigSaved\": {\n    \"message\": \"✓ تم حفظ إعدادات النسخ الاحتياطي\",\n    \"description\": \"Config saved message\"\n  },\n  \"backupPermissionDenied\": {\n    \"message\": \"⚠️ لا يمكن الوصول إلى هذا الدليل. يرجى اختيار موقع مختلف (مثل المستندات أو التنزيلات أو مجلد في سطح المكتب)\",\n    \"description\": \"Permission denied for restricted directory\"\n  },\n  \"backupConfigureInOptions\": {\n    \"message\": \"يتطلب تكوين النسخ الاحتياطي صفحة الخيارات. انقر فوق الزر أدناه لفتحها (انقر فوق النقاط الثلاث بجوار رمز الامتداد ← خيارات).\",\n    \"description\": \"Backup configure in options page explanation\"\n  },\n  \"openOptionsPage\": {\n    \"message\": \"فتح صفحة الخيارات\",\n    \"description\": \"Open options page button\"\n  },\n  \"optionsPageSubtitle\": {\n    \"message\": \"خيارات الامتداد\",\n    \"description\": \"Options page subtitle\"\n  },\n  \"optionsComingSoon\": {\n    \"message\": \"المزيد من الخيارات قريبًا...\",\n    \"description\": \"Options page placeholder text\"\n  },\n  \"backupDataAccessNotice\": {\n    \"message\": \"قيود الوصول إلى البيانات\",\n    \"description\": \"Backup data access limitation title\"\n  },\n  \"backupDataAccessHint\": {\n    \"message\": \"نظرًا لقيود أمان المتصفح، لا يمكن لصفحة الخيارات هذه الوصول مباشرة إلى المطالبات وبيانات المجلد من صفحات Gemini. يرجى استخدام مدير المطالبات وميزات تصدير المجلد على صفحة Gemini للنسخ الاحتياطي اليدوي.\",\n    \"description\": \"Backup data access limitation explanation\"\n  },\n  \"promptManagerOptions\": {\n    \"message\": \"مدير المطالبات\",\n    \"description\": \"Prompt Manager options section label\"\n  },\n  \"hidePromptManager\": {\n    \"message\": \"إخفاء مدير المطالبات\",\n    \"description\": \"Hide the prompt manager floating ball\"\n  },\n  \"hidePromptManagerHint\": {\n    \"message\": \"إخفاء الزر العائم لمدير المطالبات على الصفحة\",\n    \"description\": \"Hint for hiding prompt manager feature\"\n  },\n  \"customWebsites\": {\n    \"message\": \"مواقع مخصصة\",\n    \"description\": \"Custom websites for prompt manager\"\n  },\n  \"customWebsitesPlaceholder\": {\n    \"message\": \"أدخل عنوان URL للموقع (مثل chatgpt.com)\",\n    \"description\": \"Custom websites input placeholder\"\n  },\n  \"customWebsitesHint\": {\n    \"message\": \"أضف مواقع الويب التي تريد استخدام مدير المطالبات فيها. سيتم تنشيط مدير المطالبات فقط في هذه المواقع.\",\n    \"description\": \"Custom websites hint\"\n  },\n  \"addWebsite\": {\n    \"message\": \"إضافة موقع ويب\",\n    \"description\": \"Add website button\"\n  },\n  \"removeWebsite\": {\n    \"message\": \"إزالة\",\n    \"description\": \"Remove website button\"\n  },\n  \"invalidUrl\": {\n    \"message\": \"تنسيق عنوان URL غير صالح\",\n    \"description\": \"Invalid URL error message\"\n  },\n  \"websiteAdded\": {\n    \"message\": \"تمت إضافة موقع الويب\",\n    \"description\": \"Website added success message\"\n  },\n  \"websiteRemoved\": {\n    \"message\": \"تمت إزالة موقع الويب\",\n    \"description\": \"Website removed success message\"\n  },\n  \"permissionDenied\": {\n    \"message\": \"تم رفض الإذن. يرجى السماح بالوصول إلى موقع الويب هذا.\",\n    \"description\": \"Permission denied error message\"\n  },\n  \"permissionRequestFailed\": {\n    \"message\": \"فشل طلب الإذن. يرجى المحاولة مرة أخرى أو منح الوصول في إعدادات الامتداد.\",\n    \"description\": \"Permission request failure message\"\n  },\n  \"customWebsitesNote\": {\n    \"message\": \"نصيحة: سيُطلب منك السماح بالوصول عند إضافة موقع. بعد منح الإذن، قم بإعادة تحميل هذا الموقع حتى يتمكن مدير المطالبات من البدء.\",\n    \"description\": \"Custom websites reload/permission note\"\n  },\n  \"starredHistory\": {\n    \"message\": \"السجل المميز بنجمة\",\n    \"description\": \"Starred history title\"\n  },\n  \"viewStarredHistory\": {\n    \"message\": \"عرض السجل المميز بنجمة\",\n    \"description\": \"View starred history button\"\n  },\n  \"noStarredMessages\": {\n    \"message\": \"لا توجد رسائل مميزة بنجمة حتى الآن\",\n    \"description\": \"No starred messages placeholder\"\n  },\n  \"removeFromStarred\": {\n    \"message\": \"إزالة من المفضلة\",\n    \"description\": \"Remove from starred tooltip\"\n  },\n  \"justNow\": {\n    \"message\": \"الآن\",\n    \"description\": \"Just now time label\"\n  },\n  \"hoursAgo\": {\n    \"message\": \"منذ ساعات\",\n    \"description\": \"Hours ago label\"\n  },\n  \"yesterday\": {\n    \"message\": \"أمس\",\n    \"description\": \"Yesterday date label\"\n  },\n  \"daysAgo\": {\n    \"message\": \"منذ أيام\",\n    \"description\": \"Days ago label\"\n  },\n  \"loading\": {\n    \"message\": \"جارٍ التحميل...\",\n    \"description\": \"Loading message\"\n  },\n  \"keyboardShortcuts\": {\n    \"message\": \"اختصارات لوحة المفاتيح\",\n    \"description\": \"Keyboard shortcuts section title\"\n  },\n  \"enableShortcuts\": {\n    \"message\": \"تمكين اختصارات لوحة المفاتيح\",\n    \"description\": \"Enable shortcuts toggle label\"\n  },\n  \"previousNode\": {\n    \"message\": \"العقدة السابقة\",\n    \"description\": \"Previous timeline node shortcut label\"\n  },\n  \"nextNode\": {\n    \"message\": \"العقدة التالية\",\n    \"description\": \"Next timeline node shortcut label\"\n  },\n  \"shortcutKey\": {\n    \"message\": \"مفتاح\",\n    \"description\": \"Shortcut key label\"\n  },\n  \"shortcutModifiers\": {\n    \"message\": \"معدّلات\",\n    \"description\": \"Shortcut modifiers label\"\n  },\n  \"resetShortcuts\": {\n    \"message\": \"إعادة التعيين إلى الافتراضيات\",\n    \"description\": \"Reset shortcuts button\"\n  },\n  \"shortcutsResetSuccess\": {\n    \"message\": \"تمت إعادة تعيين الاختصارات\",\n    \"description\": \"Shortcuts reset success message\"\n  },\n  \"shortcutsDescription\": {\n    \"message\": \"التنقل عبر عقد الجدول الزمني باستخدام اختصارات لوحة المفاتيح\",\n    \"description\": \"Shortcuts description\"\n  },\n  \"modifierNone\": {\n    \"message\": \"لا شيء\",\n    \"description\": \"No modifier key\"\n  },\n  \"modifierAlt\": {\n    \"message\": \"Alt\",\n    \"description\": \"Alt modifier key\"\n  },\n  \"modifierCtrl\": {\n    \"message\": \"Ctrl\",\n    \"description\": \"Ctrl modifier key\"\n  },\n  \"modifierShift\": {\n    \"message\": \"Shift\",\n    \"description\": \"Shift modifier key\"\n  },\n  \"modifierMeta\": {\n    \"message\": \"Cmd/Win\",\n    \"description\": \"Meta/Command/Windows modifier key\"\n  },\n  \"preventAutoScroll\": {\n    \"message\": \"منع التمرير التلقائي\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"preventAutoScrollHint\": {\n    \"message\": \"يمنع المتصفح من القفز إلى الأسفل عند الضغط على مفتاح الإدخال أثناء قراءة المحادثات السابقة.\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"cloudSync\": {\n    \"message\": \"مزامنة سحابية\",\n    \"description\": \"Cloud sync section title\"\n  },\n  \"cloudSyncDescription\": {\n    \"message\": \"مزامنة المجلدات والمطالبات مع Google Drive\",\n    \"description\": \"Cloud sync description\"\n  },\n  \"syncModeDisabled\": {\n    \"message\": \"معطل\",\n    \"description\": \"Sync mode disabled option\"\n  },\n  \"syncModeManual\": {\n    \"message\": \"يدوي\",\n    \"description\": \"Sync mode manual option\"\n  },\n  \"syncServerPort\": {\n    \"message\": \"منفذ الخادم\",\n    \"description\": \"Port number for the sync server\"\n  },\n  \"syncNow\": {\n    \"message\": \"مزامنة الآن\",\n    \"description\": \"Sync now button\"\n  },\n  \"lastSynced\": {\n    \"message\": \"آخر مزامنة: {time}\",\n    \"description\": \"Last sync timestamp\"\n  },\n  \"neverSynced\": {\n    \"message\": \"لم تتم المزامنة من قبل\",\n    \"description\": \"Never synced message\"\n  },\n  \"lastUploaded\": {\n    \"message\": \"تم الرفع: {time}\",\n    \"description\": \"Last upload timestamp\"\n  },\n  \"neverUploaded\": {\n    \"message\": \"لم يتم الرفع من قبل\",\n    \"description\": \"Never uploaded message\"\n  },\n  \"syncSuccess\": {\n    \"message\": \"✓ تمت المزامنة بنجاح\",\n    \"description\": \"Sync success message\"\n  },\n  \"syncError\": {\n    \"message\": \"✗ فشلت المزامنة: {error}\",\n    \"description\": \"Sync error message\"\n  },\n  \"syncInProgress\": {\n    \"message\": \"جارٍ المزامنة...\",\n    \"description\": \"Sync in progress message\"\n  },\n  \"uploadInProgress\": {\n    \"message\": \"Uploading...\",\n    \"description\": \"Upload in progress message\"\n  },\n  \"uploadSuccess\": {\n    \"message\": \"✓ Uploaded successfully\",\n    \"description\": \"Upload success message\"\n  },\n  \"downloadInProgress\": {\n    \"message\": \"Downloading...\",\n    \"description\": \"Download in progress message\"\n  },\n  \"downloadMergeSuccess\": {\n    \"message\": \"✓ Downloaded and merged\",\n    \"description\": \"Download and merge success message\"\n  },\n  \"syncUpload\": {\n    \"message\": \"رفع\",\n    \"description\": \"Upload button label\"\n  },\n  \"syncMerge\": {\n    \"message\": \"دمج\",\n    \"description\": \"Merge button label\"\n  },\n  \"syncMode\": {\n    \"message\": \"وضع المزامنة\",\n    \"description\": \"Sync mode label\"\n  },\n  \"minutesAgo\": {\n    \"message\": \"منذ دقائق\",\n    \"description\": \"Minutes ago label\"\n  },\n  \"syncNoData\": {\n    \"message\": \"لم يتم العثور على بيانات مزامنة في Drive\",\n    \"description\": \"Error message when no data is found in Drive\"\n  },\n  \"syncUploadFailed\": {\n    \"message\": \"فشل الرفع\",\n    \"description\": \"Error message when upload fails\"\n  },\n  \"syncDownloadFailed\": {\n    \"message\": \"فشل التنزيل\",\n    \"description\": \"Error message when download fails\"\n  },\n  \"syncAuthFailed\": {\n    \"message\": \"فشل المصادقة\",\n    \"description\": \"Error message when authentication fails\"\n  },\n  \"signOut\": {\n    \"message\": \"تسجيل الخروج\",\n    \"description\": \"Sign out button\"\n  },\n  \"signInWithGoogle\": {\n    \"message\": \"تسجيل الدخول باستخدام Google\",\n    \"description\": \"Sign in with Google button\"\n  },\n  \"nanobananaOptions\": {\n    \"message\": \"خيارات NanoBanana\",\n    \"description\": \"NanoBanana options section label\"\n  },\n  \"enableNanobananaWatermarkRemover\": {\n    \"message\": \"إزالة علامة NanoBanana المائية\",\n    \"description\": \"Enable automatic watermark removal for NanoBanana images\"\n  },\n  \"nanobananaWatermarkRemoverHint\": {\n    \"message\": \"يزيل تلقائيًا علامات Gemini المائية المرئية من الصور التي تم إنشاؤها\",\n    \"description\": \"Watermark remover feature hint\"\n  },\n  \"nanobananaDownloadTooltip\": {\n    \"message\": \"تنزيل الصورة بدون علامة مائية (NanoBanana)\",\n    \"description\": \"Download button tooltip\"\n  },\n  \"deepResearchDownload\": {\n    \"message\": \"تنزيل محتوى التفكير\",\n    \"description\": \"Deep Research download button text\"\n  },\n  \"deepResearchDownloadTooltip\": {\n    \"message\": \"تنزيل محتوى التفكير بتنسيق Markdown\",\n    \"description\": \"Deep Research download button tooltip\"\n  },\n  \"folder_uncategorized\": {\n    \"message\": \"غير مصنف\",\n    \"description\": \"Label for root-level conversations not in any folder\"\n  },\n  \"timelineLevelTitle\": {\n    \"message\": \"مستوى العقدة\",\n    \"description\": \"Title for timeline node level context menu\"\n  },\n  \"timelineLevel1\": {\n    \"message\": \"مستوى 1\",\n    \"description\": \"Timeline node level 1 option\"\n  },\n  \"timelineLevel2\": {\n    \"message\": \"مستوى 2\",\n    \"description\": \"Timeline node level 2 option\"\n  },\n  \"timelineLevel3\": {\n    \"message\": \"مستوى 3\",\n    \"description\": \"Timeline node level 3 option\"\n  },\n  \"timelineCollapse\": {\n    \"message\": \"طي\",\n    \"description\": \"Collapse children\"\n  },\n  \"timelineExpand\": {\n    \"message\": \"توسيع\",\n    \"description\": \"Expand children\"\n  },\n  \"timelinePreviewSearch\": {\n    \"message\": \"...بحث\",\n    \"description\": \"Timeline preview panel search placeholder\"\n  },\n  \"timelinePreviewNoResults\": {\n    \"message\": \"لا توجد نتائج\",\n    \"description\": \"Timeline preview panel no search results\"\n  },\n  \"timelinePreviewNoMessages\": {\n    \"message\": \"لا توجد رسائل\",\n    \"description\": \"Timeline preview panel no messages\"\n  },\n  \"quoteReply\": {\n    \"message\": \"رد باقتباس\",\n    \"description\": \"Quote reply button text\"\n  },\n  \"inputCollapseOptions\": {\n    \"message\": \"خيارات الإدخال\",\n    \"description\": \"Input options section label\"\n  },\n  \"enableInputCollapse\": {\n    \"message\": \"تمكين طي الإدخال\",\n    \"description\": \"Enable input collapse toggle label\"\n  },\n  \"enableInputCollapseHint\": {\n    \"message\": \"طي منطقة الإدخال عندما تكون فارغة للحصول على مساحة قراءة أكبر\",\n    \"description\": \"Input collapse feature hint\"\n  },\n  \"inputCollapseShortcutHint\": {\n    \"message\": \"{modifier}+I للتوسيع\",\n    \"description\": \"Shortcut hint for expanding collapsed input. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"inputCollapsePlaceholder\": {\n    \"message\": \"راسل Gemini\",\n    \"description\": \"Placeholder text shown in collapsed input\"\n  },\n  \"allowCollapseWhenNotEmpty\": {\n    \"message\": \"السماح بالطي مع وجود محتوى\",\n    \"description\": \"Allow input to collapse even when it has text or attachments\"\n  },\n  \"allowCollapseWhenNotEmptyHint\": {\n    \"message\": \"طي منطقة الإدخال حتى عند احتوائها على نص أو مرفقات\",\n    \"description\": \"Hint text explaining the collapse when not empty feature\"\n  },\n  \"ctrlEnterSend\": {\n    \"message\": \"{modifier}+Enter للإرسال\",\n    \"description\": \"Modifier+Enter to send toggle label. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"ctrlEnterSendHint\": {\n    \"message\": \"اضغط {modifier}+Enter لإرسال الرسائل، Enter لإضافة سطر جديد\",\n    \"description\": \"Modifier+Enter to send feature hint. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"batch_delete_confirm\": {\n    \"message\": \"هل أنت متأكد من أنك تريد حذف {count} محادثة؟ لا يمكن التراجع عن هذا الإجراء.\",\n    \"description\": \"Batch delete confirmation message\"\n  },\n  \"batch_delete_in_progress\": {\n    \"message\": \"جارٍ الحذف... ({current}/{total})\",\n    \"description\": \"Batch delete progress message\"\n  },\n  \"batch_delete_success\": {\n    \"message\": \"✓ تم حذف {count} محادثة\",\n    \"description\": \"Batch delete success message\"\n  },\n  \"batch_delete_partial\": {\n    \"message\": \"اكتمل الحذف: {success} بنجاح، {failed} فشل\",\n    \"description\": \"Batch delete partial success message\"\n  },\n  \"batch_delete_limit_reached\": {\n    \"message\": \"يمكن تحديد {max} محادثة كحد أقصى في وقت واحد\",\n    \"description\": \"Batch delete limit warning\"\n  },\n  \"batch_delete_button\": {\n    \"message\": \"حذف المحدد\",\n    \"description\": \"Batch delete button tooltip in multi-select mode\"\n  },\n  \"batch_delete_match_patterns\": {\n    \"message\": \"delete,confirm,yes,ok,remove,حذف,تأكيد,نعم,موافق,إزالة\",\n    \"description\": \"Comma-separated list of keywords to match native delete/confirm buttons (case-insensitive)\"\n  },\n  \"generalOptions\": {\n    \"message\": \"خيارات عامة\",\n    \"description\": \"General options section label\"\n  },\n  \"enableTabTitleUpdate\": {\n    \"message\": \"مزامنة عنوان علامة التبويب مع المحادثة\",\n    \"description\": \"Enable tab title sync toggle label\"\n  },\n  \"enableTabTitleUpdateHint\": {\n    \"message\": \"تحديث عنوان علامة تبويب المتصفح تلقائيًا لمطابقة عنوان المحادثة الحالية\",\n    \"description\": \"Tab title sync feature hint\"\n  },\n  \"enableMermaidRendering\": {\n    \"message\": \"تمكين عرض مخططات Mermaid\",\n    \"description\": \"Enable Mermaid diagram rendering toggle label\"\n  },\n  \"enableMermaidRenderingHint\": {\n    \"message\": \"عرض مخططات Mermaid تلقائيًا في كتل التعليمات البرمجية\",\n    \"description\": \"Mermaid rendering feature hint\"\n  },\n  \"enableQuoteReply\": {\n    \"message\": \"تمكين الرد بالاقتباس\",\n    \"description\": \"Enable quote reply toggle label\"\n  },\n  \"enableQuoteReplyHint\": {\n    \"message\": \"إظهار زر عائم لاقتباس النص المحدد عند تحديد نص في المحادثات\",\n    \"description\": \"Quote reply feature hint\"\n  },\n  \"contextSync\": {\n    \"message\": \"مزامنة السياق\",\n    \"description\": \"Context Sync title\"\n  },\n  \"contextSyncDescription\": {\n    \"message\": \"مزامنة سياق الدردشة مع IDE المحلي الخاص بك\",\n    \"description\": \"Context Sync description\"\n  },\n  \"syncToIDE\": {\n    \"message\": \"مزامنة مع IDE\",\n    \"description\": \"Sync to IDE button\"\n  },\n  \"ideOnline\": {\n    \"message\": \"IDE متصل\",\n    \"description\": \"IDE Online status\"\n  },\n  \"ideOffline\": {\n    \"message\": \"IDE غير متصل\",\n    \"description\": \"IDE Offline status\"\n  },\n  \"syncing\": {\n    \"message\": \"جارٍ المزامنة...\",\n    \"description\": \"Syncing status\"\n  },\n  \"checkServer\": {\n    \"message\": \"يرجى تشغيل خادم AI Sync في VS Code\",\n    \"description\": \"Check server hint\"\n  },\n  \"capturing\": {\n    \"message\": \"جارٍ التقاط الحوار...\",\n    \"description\": \"Capturing status\"\n  },\n  \"syncedSuccess\": {\n    \"message\": \"تمت المزامنة بنجاح!\",\n    \"description\": \"Synced success message\"\n  },\n  \"downloadingOriginal\": {\n    \"message\": \"جارٍ تنزيل الصورة الأصلية\",\n    \"description\": \"Status when downloading original image\"\n  },\n  \"downloadingOriginalLarge\": {\n    \"message\": \"جارٍ تنزيل الصورة الأصلية (ملف كبير)\",\n    \"description\": \"Status when downloading large original image\"\n  },\n  \"downloadLargeWarning\": {\n    \"message\": \"تحذير ملف كبير\",\n    \"description\": \"Warning shown for large downloads\"\n  },\n  \"downloadProcessing\": {\n    \"message\": \"جارٍ معالجة العلامة المائية\",\n    \"description\": \"Status when processing watermark removal\"\n  },\n  \"downloadSuccess\": {\n    \"message\": \"جارٍ التنزيل...\",\n    \"description\": \"Status when download starts\"\n  },\n  \"downloadError\": {\n    \"message\": \"فشل\",\n    \"description\": \"Status prefix when download fails\"\n  },\n  \"recentsHide\": {\n    \"message\": \"إخفاء العناصر الأخيرة\",\n    \"description\": \"تلميح لإخفاء قسم العناصر الأخيرة\"\n  },\n  \"recentsShow\": {\n    \"message\": \"إظهار العناصر الأخيرة\",\n    \"description\": \"تلميح لإظهار قسم العناصر الأخيرة\"\n  },\n  \"gemsHide\": {\n    \"message\": \"إخفاء Gems\",\n    \"description\": \"تلميح لإخفاء قسم قائمة Gems\"\n  },\n  \"gemsShow\": {\n    \"message\": \"إظهار Gems\",\n    \"description\": \"تلميح لإظهار قسم قائمة Gems\"\n  },\n  \"setAsDefaultModel\": {\n    \"message\": \"Set as default model\",\n    \"description\": \"Tooltip for setting default model\"\n  },\n  \"cancelDefaultModel\": {\n    \"message\": \"Cancel default model\",\n    \"description\": \"Tooltip for cancelling default model\"\n  },\n  \"defaultModelSet\": {\n    \"message\": \"Default model set: $1\",\n    \"description\": \"Toast message when default model is set\"\n  },\n  \"defaultModelCleared\": {\n    \"message\": \"Default model cleared\",\n    \"description\": \"Toast message when default model is cleared\"\n  },\n  \"sidebarAutoHide\": {\n    \"message\": \"إخفاء الشريط الجانبي تلقائياً\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"sidebarAutoHideHint\": {\n    \"message\": \"طي الشريط الجانبي تلقائياً عند مغادرة الماوس، وتوسيعه عند الدخول\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"sidebarFullHide\": {\n    \"message\": \"إخفاء الشريط الجانبي بالكامل\",\n    \"description\": \"Fully hide sidebar toggle label\"\n  },\n  \"sidebarFullHideHint\": {\n    \"message\": \"إخفاء الشريط الجانبي المطوي بالكامل، مرر الماوس على الحافة اليسرى لإظهاره\",\n    \"description\": \"Fully hide sidebar feature hint\"\n  },\n  \"folderSpacing\": {\n    \"message\": \"تباعد المجلدات\",\n    \"description\": \"Folder spacing adjustment label\"\n  },\n  \"folderSpacingCompact\": {\n    \"message\": \"مضغوط\",\n    \"description\": \"Compact folder spacing label\"\n  },\n  \"folderSpacingSpacious\": {\n    \"message\": \"واسع\",\n    \"description\": \"Spacious folder spacing label\"\n  },\n  \"folderTreeIndent\": {\n    \"message\": \"إزاحة المجلدات الفرعية\",\n    \"description\": \"Subfolder tree indentation adjustment label\"\n  },\n  \"folderTreeIndentCompact\": {\n    \"message\": \"أضيق\",\n    \"description\": \"Narrow subfolder indentation label\"\n  },\n  \"folderTreeIndentSpacious\": {\n    \"message\": \"أوسع\",\n    \"description\": \"Wide subfolder indentation label\"\n  },\n  \"export_select_mode_select_all\": {\n    \"message\": \"تحديد الكل\",\n    \"description\": \"Selection mode select all toggle\"\n  },\n  \"export_select_mode_count\": {\n    \"message\": \"تم تحديد {count}\",\n    \"description\": \"Selection mode selected message count\"\n  },\n  \"export_select_mode_toggle\": {\n    \"message\": \"Select message\",\n    \"description\": \"Selection mode per-message toggle tooltip\"\n  },\n  \"export_select_mode_select_below\": {\n    \"message\": \"Select messages below\",\n    \"description\": \"Selection mode top-left button to select messages below current line\"\n  },\n  \"export_select_mode_empty\": {\n    \"message\": \"Please select at least one message to export.\",\n    \"description\": \"Selection mode validation when nothing is selected\"\n  },\n  \"export_format_image_description\": {\n    \"message\": \"صورة PNG واحدة، مناسبة للمشاركة عبر الهاتف.\",\n    \"description\": \"Description for Image export format\"\n  },\n  \"export_image_progress\": {\n    \"message\": \"Generating image...\",\n    \"description\": \"Progress message while generating image export\"\n  },\n  \"deepResearchSaveReport\": {\n    \"message\": \"Save Report\",\n    \"description\": \"Deep Research save report button\"\n  },\n  \"deepResearchSaveReportTooltip\": {\n    \"message\": \"Export this research report\",\n    \"description\": \"Deep Research save report tooltip\"\n  },\n  \"export_fontsize_label\": {\n    \"message\": \"حجم الخط\",\n    \"description\": \"Label for font size slider in export dialog\"\n  },\n  \"export_fontsize_preview\": {\n    \"message\": \"نص عربي لمعاينة حجم الخط في التصدير.\",\n    \"description\": \"Preview text for font size slider in export dialog\"\n  },\n  \"export_error_generic\": {\n    \"message\": \"فشل التصدير: {error}\",\n    \"description\": \"رسالة فشل تصدير عامة مع السبب التفصيلي\"\n  },\n  \"export_error_refresh_retry\": {\n    \"message\": \"فشل تصدير الصورة بسبب مشكلة في الشبكة. يُرجى تحديث الصفحة ثم إعادة المحاولة.\",\n    \"description\": \"إرشاد يظهر عند فشل تحميل مؤقت أثناء تصدير الصورة\"\n  },\n  \"export_md_include_source_confirm\": {\n    \"message\": \"يحتوي المحتوى المُصدَّر على صور من نتائج البحث. هل تريد تضمين روابط مصدر الصور في Markdown؟\",\n    \"description\": \"Confirm dialog when exporting markdown with web search images\"\n  },\n  \"deepResearch_exportedAt\": {\n    \"message\": \"وقت التصدير\",\n    \"description\": \"تسمية الطابع الزمني للتصدير في Deep Research\"\n  },\n  \"deepResearch_totalPhases\": {\n    \"message\": \"إجمالي المراحل\",\n    \"description\": \"تسمية إجمالي مراحل التفكير في Deep Research\"\n  },\n  \"deepResearch_thinkingPhase\": {\n    \"message\": \"مرحلة التفكير\",\n    \"description\": \"عنوان مرحلة التفكير في Deep Research\"\n  },\n  \"deepResearch_researchedWebsites\": {\n    \"message\": \"المواقع المبحوثة\",\n    \"description\": \"عنوان قسم المواقع المبحوثة في Deep Research\"\n  },\n  \"visualEffect\": {\n    \"message\": \"المؤثرات البصرية\",\n    \"description\": \"Visual effect selector label\"\n  },\n  \"visualEffectHint\": {\n    \"message\": \"أضف أجواء موسمية للصفحة\",\n    \"description\": \"Visual effect feature hint\"\n  },\n  \"visualEffectOff\": {\n    \"message\": \"إيقاف\",\n    \"description\": \"Visual effect off option\"\n  },\n  \"visualEffectSnow\": {\n    \"message\": \"ثلج\",\n    \"description\": \"Visual effect snow option\"\n  },\n  \"visualEffectSakura\": {\n    \"message\": \"ساكورا\",\n    \"description\": \"Visual effect sakura option\"\n  },\n  \"visualEffectRain\": {\n    \"message\": \"مطر\",\n    \"description\": \"Visual effect rain option\"\n  },\n  \"changelog_title\": {\n    \"message\": \"الجديد\",\n    \"description\": \"Changelog modal title\"\n  },\n  \"changelog_close\": {\n    \"message\": \"فهمت\",\n    \"description\": \"Changelog modal close button\"\n  },\n  \"changelog_docs_link\": {\n    \"message\": \"المستندات\",\n    \"description\": \"Changelog modal documentation link text\"\n  },\n  \"changelog_recommendation\": {\n    \"message\": \"إذا ساعدك Voyager، شاركه مع أصدقائك أو على وسائل التواصل الاجتماعي — لا تنسَ الإشارة إلى المطور!\",\n    \"description\": \"Changelog modal recommendation message\"\n  },\n  \"changelog_social_xiaohongshu\": {\n    \"message\": \"Xiaohongshu\",\n    \"description\": \"Xiaohongshu platform name for social links\"\n  },\n  \"changelog_social_zhihu\": {\n    \"message\": \"Zhihu\",\n    \"description\": \"Zhihu platform name for social links\"\n  },\n  \"changelog_docs_hint\": {\n    \"message\": \"التفاصيل في المستندات\",\n    \"description\": \"Changelog annotation pointing to docs icon\"\n  },\n  \"changelog_rate_chrome\": {\n    \"message\": \"هل أعجبك Voyager؟ تقييمك على متجر Chrome الإلكتروني يساعد الآخرين على اكتشافه!\",\n    \"description\": \"Chrome Web Store rating prompt in changelog modal\"\n  },\n  \"changelog_rate_chrome_cta\": {\n    \"message\": \"تقييم الآن\",\n    \"description\": \"Chrome Web Store rating CTA button text\"\n  },\n  \"changelog_badge_mode\": {\n    \"message\": \"الإشعار عبر شارة NEW على الزر العائم بدلاً من هذه النافذة\",\n    \"description\": \"Changelog notification mode toggle label\"\n  },\n  \"forkConversation\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork conversation button label\"\n  },\n  \"forkConfirm\": {\n    \"message\": \"Fork conversation from this point?\",\n    \"description\": \"Fork confirmation prompt\"\n  },\n  \"forkConfirmBtn\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork confirmation button\"\n  },\n  \"forkCancel\": {\n    \"message\": \"Cancel\",\n    \"description\": \"Fork cancel button\"\n  },\n  \"forkBranch\": {\n    \"message\": \"Branch\",\n    \"description\": \"Fork branch label\"\n  },\n  \"forkOriginal\": {\n    \"message\": \"Original\",\n    \"description\": \"Original fork branch tag\"\n  },\n  \"forkCurrent\": {\n    \"message\": \"Current\",\n    \"description\": \"Current fork branch tag\"\n  },\n  \"forkPreparing\": {\n    \"message\": \"Preparing fork...\",\n    \"description\": \"Fork preparation status\"\n  },\n  \"forkPasteHint\": {\n    \"message\": \"Press Enter to send and create the fork\",\n    \"description\": \"Fork paste hint text\"\n  },\n  \"forkDeleteData\": {\n    \"message\": \"حذف هذا التفرع\",\n    \"description\": \"Delete fork branch metadata button\"\n  },\n  \"forkDeleteDataConfirm\": {\n    \"message\": \"هل تريد حذف رابط هذا التفرع؟ سيتم حذف بيانات Voyager الوصفية للتفرع فقط ولن يتم حذف أي محادثة.\",\n    \"description\": \"Fork metadata delete confirmation prompt\"\n  },\n  \"enableForkFeature\": {\n    \"message\": \"تفعيل تفريع المحادثة\",\n    \"description\": \"Enable conversation fork feature toggle label\"\n  },\n  \"enableForkFeatureHint\": {\n    \"message\": \"إظهار زر Fork ومؤشرات الفروع داخل محادثات Gemini\",\n    \"description\": \"Enable conversation fork feature toggle hint\"\n  },\n  \"enableAccountIsolation\": {\n    \"message\": \"Enable Hard Isolation\",\n    \"description\": \"Enable hard isolation for account-scoped folder data and cloud sync.\"\n  },\n  \"enableAccountIsolationHint\": {\n    \"message\": \"Strictly isolate folder and cloud sync data by Google account to prevent cross-account mixing.\",\n    \"description\": \"Hint text for account isolation option.\"\n  },\n  \"currentPlatform\": {\n    \"message\": \"Current platform\",\n    \"description\": \"Current active platform label in popup.\"\n  },\n  \"platformGemini\": {\n    \"message\": \"Gemini\",\n    \"description\": \"Gemini platform label.\"\n  },\n  \"platformAIStudio\": {\n    \"message\": \"AI Studio\",\n    \"description\": \"AI Studio platform label.\"\n  },\n  \"enableOnAIStudio\": {\n    \"message\": \"التفعيل في AI Studio\",\n    \"description\": \"Toggle to enable/disable Voyager on AI Studio\"\n  },\n  \"enableOnAIStudioHint\": {\n    \"message\": \"أوقف التشغيل لتعطيل ميزات Voyager على AI Studio (يبقى Prompt Manager نشطاً)\",\n    \"description\": \"Hint for AI Studio enable toggle\"\n  },\n  \"export_select_mode_only_user\": {\n    \"message\": \"تحديد المستخدم فقط\",\n    \"description\": \"Export selection mode: select only user messages\"\n  },\n  \"export_select_mode_only_ai\": {\n    \"message\": \"تحديد الذكاء الاصطناعي فقط\",\n    \"description\": \"Export selection mode: select only AI messages\"\n  },\n  \"aiOrgCopyButton\": {\n    \"message\": \"تنظيم AI\",\n    \"description\": \"زر لنسخ هيكل المجلدات والمحادثات لتنظيم AI\"\n  },\n  \"aiOrgCopied\": { \"message\": \"تم النسخ!\", \"description\": \"تأكيد بعد نسخ موجه تنظيم AI\" },\n  \"aiOrgError\": {\n    \"message\": \"فشل — افتح Gemini أولاً\",\n    \"description\": \"خطأ عند فشل نسخ موجه تنظيم AI\"\n  },\n  \"aiOrgCopyHint\": {\n    \"message\": \"ينسخ جميع المحادثات وهيكل المجلدات كموجه. الصقه في Gemini للحصول على خطة مجلدات قابلة للاستيراد.\",\n    \"description\": \"نص تلميح أسفل زر نسخ تنظيم AI\"\n  },\n  \"aiOrgCurrentFolders\": {\n    \"message\": \"هيكل المجلدات الحالي\",\n    \"description\": \"عنوان في موجه تنظيم AI\"\n  },\n  \"aiOrgUnfiledConversations\": {\n    \"message\": \"محادثات غير مصنفة\",\n    \"description\": \"عنوان للمحادثات غير الموجودة في أي مجلد\"\n  },\n  \"aiOrgEmpty\": { \"message\": \"فارغ\", \"description\": \"تسمية للمجلدات الفارغة في موجه تنظيم AI\" },\n  \"aiOrgInstructions\": { \"message\": \"التعليمات\", \"description\": \"عنوان قسم تعليمات AI\" },\n  \"aiOrgInstructionsBody\": {\n    \"message\": \"بناءً على المحادثات وهيكل المجلدات أعلاه، يرجى إعادة تنظيمها في تسلسل هرمي منطقي للمجلدات. أخرج ملف JSON باستخدام التنسيق أدناه يمكن استيراده مباشرة إلى إضافة Gemini Voyager (استخدم استراتيجية الاستيراد \\\"دمج\\\"). احتفظ بالمجلدات المنظمة جيداً الحالية، وأنشئ مجلدات جديدة حسب الحاجة، وانقل المحادثات غير المصنفة إلى مجلدات مناسبة. يحتاج كل مجلد إلى معرف فريد (استخدم سلسلة عشوائية قصيرة)، ويجب أن تحتفظ كل محادثة بـ conversationId و url الأصليين.\",\n    \"description\": \"نص التعليمات لـ AI لإنشاء هيكل مجلدات قابل للاستيراد\"\n  },\n  \"showMessageTimestamps\": {\n    \"message\": \"إظهار طوابع وقت الرسائل\",\n    \"description\": \"عرض طابع الوقت لكل رسالة\"\n  },\n  \"showMessageTimestampsHint\": {\n    \"message\": \"إظهار وقت إرسال كل رسالة\",\n    \"description\": \"تلميح لميزة طوابع الوقت\"\n  },\n  \"moveSectionUp\": {\n    \"message\": \"تحريك لأعلى\",\n    \"description\": \"Tooltip for moving a settings section up in popup\"\n  },\n  \"moveSectionDown\": {\n    \"message\": \"تحريك لأسفل\",\n    \"description\": \"Tooltip for moving a settings section down in popup\"\n  }\n}\n"
  },
  {
    "path": "src/locales/en/messages.json",
    "content": "{\n  \"extName\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Extension name\"\n  },\n  \"extDescription\": {\n    \"message\": \"Supercharge your Gemini™ experience: timeline, folders, prompt vault, and chat export.\",\n    \"description\": \"Extension description\"\n  },\n  \"scrollMode\": {\n    \"message\": \"Scroll mode\",\n    \"description\": \"Scroll mode label\"\n  },\n  \"flow\": {\n    \"message\": \"Flow\",\n    \"description\": \"Flow mode\"\n  },\n  \"jump\": {\n    \"message\": \"Jump\",\n    \"description\": \"Jump mode\"\n  },\n  \"hideOuterContainer\": {\n    \"message\": \"Hide outer container\",\n    \"description\": \"Hide outer container label\"\n  },\n  \"draggableTimeline\": {\n    \"message\": \"Draggable Timeline\",\n    \"description\": \"Draggable Timeline label\"\n  },\n  \"enableMarkerLevel\": {\n    \"message\": \"Enable Node Levels\",\n    \"description\": \"Enable timeline node level feature\"\n  },\n  \"enableMarkerLevelHint\": {\n    \"message\": \"Right-click timeline nodes to set their level and collapse children\",\n    \"description\": \"Node level feature hint\"\n  },\n  \"experimentalLabel\": {\n    \"message\": \"Experimental\",\n    \"description\": \"Experimental feature label\"\n  },\n  \"resetPosition\": {\n    \"message\": \"Reset Position\",\n    \"description\": \"Reset Position button\"\n  },\n  \"resetTimelinePosition\": {\n    \"message\": \"Reset Timeline Position\",\n    \"description\": \"Reset timeline position button in timeline options\"\n  },\n  \"language\": {\n    \"message\": \"Language\",\n    \"description\": \"Language label\"\n  },\n  \"pm_title\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Prompt Manager title\"\n  },\n  \"pm_add\": {\n    \"message\": \"Add\",\n    \"description\": \"Add prompt button\"\n  },\n  \"pm_search_placeholder\": {\n    \"message\": \"Search prompts or tags\",\n    \"description\": \"Search placeholder\"\n  },\n  \"pm_import\": {\n    \"message\": \"Import\",\n    \"description\": \"Import button\"\n  },\n  \"pm_export\": {\n    \"message\": \"Export\",\n    \"description\": \"Export button\"\n  },\n  \"pm_prompt_placeholder\": {\n    \"message\": \"Prompt text\",\n    \"description\": \"Prompt textarea placeholder\"\n  },\n  \"pm_tags_placeholder\": {\n    \"message\": \"Tags (comma separated)\",\n    \"description\": \"Tags input placeholder\"\n  },\n  \"pm_save\": {\n    \"message\": \"Save\",\n    \"description\": \"Save prompt\"\n  },\n  \"pm_cancel\": {\n    \"message\": \"Cancel\",\n    \"description\": \"Cancel\"\n  },\n  \"pm_all_tags\": {\n    \"message\": \"All\",\n    \"description\": \"All tags chip\"\n  },\n  \"pm_empty\": {\n    \"message\": \"No prompts yet\",\n    \"description\": \"Empty state\"\n  },\n  \"pm_copy\": {\n    \"message\": \"Copy\",\n    \"description\": \"Copy prompt\"\n  },\n  \"pm_copied\": {\n    \"message\": \"Copied\",\n    \"description\": \"Copied notice\"\n  },\n  \"pm_delete\": {\n    \"message\": \"Delete\",\n    \"description\": \"Delete prompt\"\n  },\n  \"pm_delete_confirm\": {\n    \"message\": \"Delete this prompt?\",\n    \"description\": \"Delete confirm\"\n  },\n  \"pm_lock\": {\n    \"message\": \"Lock position\",\n    \"description\": \"Lock panel position\"\n  },\n  \"pm_unlock\": {\n    \"message\": \"Unlock position\",\n    \"description\": \"Unlock panel position\"\n  },\n  \"pm_import_invalid\": {\n    \"message\": \"Invalid file format\",\n    \"description\": \"Import invalid\"\n  },\n  \"pm_import_success\": {\n    \"message\": \"Imported {count} prompts\",\n    \"description\": \"Import success\"\n  },\n  \"pm_duplicate\": {\n    \"message\": \"Duplicate prompt\",\n    \"description\": \"Duplicate on add\"\n  },\n  \"pm_deleted\": {\n    \"message\": \"Deleted\",\n    \"description\": \"Deleted notice\"\n  },\n  \"pm_edit\": {\n    \"message\": \"Edit\",\n    \"description\": \"Edit prompt\"\n  },\n  \"pm_expand\": {\n    \"message\": \"Expand\",\n    \"description\": \"Expand prompt text\"\n  },\n  \"pm_collapse\": {\n    \"message\": \"Collapse\",\n    \"description\": \"Collapse prompt text\"\n  },\n  \"pm_saved\": {\n    \"message\": \"Saved\",\n    \"description\": \"Saved notice\"\n  },\n  \"pm_settings\": {\n    \"message\": \"Settings\",\n    \"description\": \"Settings button\"\n  },\n  \"pm_settings_tooltip\": {\n    \"message\": \"Open extension settings\",\n    \"description\": \"Settings button tooltip\"\n  },\n  \"pm_settings_fallback\": {\n    \"message\": \"Please click the extension icon in the browser toolbar to open settings\",\n    \"description\": \"Settings open failed message\"\n  },\n  \"pm_backup\": {\n    \"message\": \"Local Backup\",\n    \"description\": \"Local backup button\"\n  },\n  \"pm_backup_tooltip\": {\n    \"message\": \"Backup prompts and folders to a timestamped folder\",\n    \"description\": \"Backup button tooltip\"\n  },\n  \"pm_backup_cancelled\": {\n    \"message\": \"Backup cancelled\",\n    \"description\": \"Backup cancelled hint\"\n  },\n  \"pm_backup_error\": {\n    \"message\": \"✗ Backup failed\",\n    \"description\": \"Backup failed hint\"\n  },\n  \"pm_backup_hint_options\": {\n    \"message\": \"The feature is integrated into the Prompt Manager on the Gemini page.\",\n    \"description\": \"Backup hint in options page\"\n  },\n  \"pm_backup_step1\": {\n    \"message\": \"Open the Gemini page (gemini.google.com)\",\n    \"description\": \"Backup step 1\"\n  },\n  \"pm_backup_step2\": {\n    \"message\": \"Click the extension icon in the bottom-right to open Prompt Manager\",\n    \"description\": \"Backup step 2\"\n  },\n  \"pm_backup_step3\": {\n    \"message\": \"Click the \\\"💾 Local Backup\\\" button and select a backup folder\",\n    \"description\": \"Backup step 3\"\n  },\n  \"pm_backup_note\": {\n    \"message\": \"Backups include all prompts and folders, saved in a timestamped folder (format: backup-YYYYMMDD-HHMMSS)\",\n    \"description\": \"Backup feature note\"\n  },\n  \"pm_theme_light\": {\n    \"message\": \"Switch to light mode\",\n    \"description\": \"Theme toggle tooltip - switch to light\"\n  },\n  \"pm_theme_dark\": {\n    \"message\": \"Switch to dark mode\",\n    \"description\": \"Theme toggle tooltip - switch to dark\"\n  },\n  \"extensionVersion\": {\n    \"message\": \"Version\",\n    \"description\": \"Extension version label\"\n  },\n  \"newVersionAvailable\": {\n    \"message\": \"New version available\",\n    \"description\": \"Update reminder title\"\n  },\n  \"currentVersionLabel\": {\n    \"message\": \"Current\",\n    \"description\": \"Label for current version number\"\n  },\n  \"latestVersionLabel\": {\n    \"message\": \"Latest\",\n    \"description\": \"Label for latest version number\"\n  },\n  \"updateNow\": {\n    \"message\": \"Update\",\n    \"description\": \"CTA to update to latest version\"\n  },\n  \"safariUpdateNotSynced\": {\n    \"message\": \"Update source not synced yet, please try again in a few hours\",\n    \"description\": \"Shown on Safari when the DMG file is not yet available on GitHub releases\"\n  },\n  \"starProject\": {\n    \"message\": \"Support me ⭐\",\n    \"description\": \"Github callout\"\n  },\n  \"officialDocs\": {\n    \"message\": \"Official Docs\",\n    \"description\": \"Official docs link\"\n  },\n  \"exportChatJson\": {\n    \"message\": \"Export conversation history\",\n    \"description\": \"Tooltip for export button\"\n  },\n  \"folder_title\": {\n    \"message\": \"Folders\",\n    \"description\": \"Folder section title\"\n  },\n  \"folder_create\": {\n    \"message\": \"Create folder\",\n    \"description\": \"Create folder button tooltip\"\n  },\n  \"folder_name_prompt\": {\n    \"message\": \"Enter folder name:\",\n    \"description\": \"Create folder name prompt\"\n  },\n  \"folder_rename_prompt\": {\n    \"message\": \"Enter new name:\",\n    \"description\": \"Rename folder prompt\"\n  },\n  \"folder_delete_confirm\": {\n    \"message\": \"Delete this folder and all its contents?\",\n    \"description\": \"Delete folder confirmation\"\n  },\n  \"folder_create_subfolder\": {\n    \"message\": \"Create subfolder\",\n    \"description\": \"Create subfolder menu item\"\n  },\n  \"folder_rename\": {\n    \"message\": \"Rename\",\n    \"description\": \"Rename folder menu item\"\n  },\n  \"folder_change_color\": {\n    \"message\": \"Change Color\",\n    \"description\": \"Change folder color menu item\"\n  },\n  \"folder_delete\": {\n    \"message\": \"Delete\",\n    \"description\": \"Delete folder menu item\"\n  },\n  \"folder_color_default\": {\n    \"message\": \"Default\",\n    \"description\": \"Default folder color name\"\n  },\n  \"folder_color_red\": {\n    \"message\": \"Red\",\n    \"description\": \"Red folder color name\"\n  },\n  \"folder_color_orange\": {\n    \"message\": \"Orange\",\n    \"description\": \"Orange folder color name\"\n  },\n  \"folder_color_yellow\": {\n    \"message\": \"Yellow\",\n    \"description\": \"Yellow folder color name\"\n  },\n  \"folder_color_green\": {\n    \"message\": \"Green\",\n    \"description\": \"Green folder color name\"\n  },\n  \"folder_color_blue\": {\n    \"message\": \"Blue\",\n    \"description\": \"Blue folder color name\"\n  },\n  \"folder_color_purple\": {\n    \"message\": \"Purple\",\n    \"description\": \"Purple folder color name\"\n  },\n  \"folder_color_pink\": {\n    \"message\": \"Pink\",\n    \"description\": \"Pink folder color name\"\n  },\n  \"folder_color_custom\": {\n    \"message\": \"Custom\",\n    \"description\": \"Custom folder color name\"\n  },\n  \"folder_empty\": {\n    \"message\": \"No folders yet\",\n    \"description\": \"Empty state placeholder when no folders exist\"\n  },\n  \"folder_pin\": {\n    \"message\": \"Pin folder\",\n    \"description\": \"Pin folder menu item\"\n  },\n  \"folder_unpin\": {\n    \"message\": \"Unpin folder\",\n    \"description\": \"Unpin folder menu item\"\n  },\n  \"folder_remove_conversation\": {\n    \"message\": \"Remove from folder\",\n    \"description\": \"Remove conversation button tooltip\"\n  },\n  \"folder_remove_conversation_confirm\": {\n    \"message\": \"Remove \\\"{title}\\\" from this folder?\",\n    \"description\": \"Remove conversation confirmation\"\n  },\n  \"conversation_more\": {\n    \"message\": \"More options\",\n    \"description\": \"Conversation more options button tooltip\"\n  },\n  \"conversation_move_to_folder\": {\n    \"message\": \"Move to folder\",\n    \"description\": \"Move conversation to folder menu item\"\n  },\n  \"conversation_move_to_folder_title\": {\n    \"message\": \"Move to folder\",\n    \"description\": \"Move conversation to folder dialog title\"\n  },\n  \"chatWidth\": {\n    \"message\": \"Chat width\",\n    \"description\": \"Chat width adjustment label\"\n  },\n  \"chatWidthNarrow\": {\n    \"message\": \"Narrow\",\n    \"description\": \"Narrow chat width label\"\n  },\n  \"chatWidthWide\": {\n    \"message\": \"Wide\",\n    \"description\": \"Wide chat width label\"\n  },\n  \"sidebarWidth\": {\n    \"message\": \"Sidebar width\",\n    \"description\": \"Sidebar width adjustment label\"\n  },\n  \"sidebarWidthNarrow\": {\n    \"message\": \"Narrow\",\n    \"description\": \"Narrow sidebar width label\"\n  },\n  \"sidebarWidthWide\": {\n    \"message\": \"Wide\",\n    \"description\": \"Wide sidebar width label\"\n  },\n  \"formula_copied\": {\n    \"message\": \"✓ Formula copied\",\n    \"description\": \"Formula copied success message\"\n  },\n  \"formula_copy_failed\": {\n    \"message\": \"✗ Failed to copy\",\n    \"description\": \"Formula copy failed message\"\n  },\n  \"formulaCopyFormat\": {\n    \"message\": \"Formula copy format\",\n    \"description\": \"Formula copy format setting label\"\n  },\n  \"formulaCopyFormatLatex\": {\n    \"message\": \"LaTeX\",\n    \"description\": \"LaTeX format option\"\n  },\n  \"formulaCopyFormatUnicodeMath\": {\n    \"message\": \"MathML (Word)\",\n    \"description\": \"Label for MathML formula copy format option (formerly UnicodeMath)\"\n  },\n  \"formulaCopyFormatHint\": {\n    \"message\": \"Choose the format when copying formulas by clicking\",\n    \"description\": \"Formula copy format hint\"\n  },\n  \"formulaCopyFormatNoDollar\": {\n    \"message\": \"LaTeX (No Dollar Sign)\",\n    \"description\": \"LaTeX format option without dollar signs\"\n  },\n  \"folder_filter_current_user\": {\n    \"message\": \"Account Isolation\",\n    \"description\": \"Show only conversations from the current Google account\"\n  },\n  \"folder_export\": {\n    \"message\": \"Export folders\",\n    \"description\": \"Export folders button tooltip\"\n  },\n  \"folder_import_export\": {\n    \"message\": \"Import/Export folders\",\n    \"description\": \"Combined import/export button tooltip\"\n  },\n  \"folder_cloud_upload\": {\n    \"message\": \"Upload to Cloud\",\n    \"description\": \"Cloud upload button tooltip\"\n  },\n  \"folder_cloud_sync\": {\n    \"message\": \"Sync from Cloud\",\n    \"description\": \"Cloud sync button tooltip\"\n  },\n  \"folder_import\": {\n    \"message\": \"Import folders\",\n    \"description\": \"Import folders button tooltip\"\n  },\n  \"folder_import_title\": {\n    \"message\": \"Import Folder Configuration\",\n    \"description\": \"Import dialog title\"\n  },\n  \"folder_import_strategy\": {\n    \"message\": \"Import strategy:\",\n    \"description\": \"Import strategy label\"\n  },\n  \"folder_import_merge\": {\n    \"message\": \"Merge with existing folders\",\n    \"description\": \"Merge import strategy\"\n  },\n  \"folder_import_overwrite\": {\n    \"message\": \"Overwrite existing folders\",\n    \"description\": \"Overwrite import strategy\"\n  },\n  \"folder_import_select_file\": {\n    \"message\": \"Select a JSON file to import\",\n    \"description\": \"Import file selection prompt\"\n  },\n  \"folder_import_success\": {\n    \"message\": \"✓ Imported {folders} folders, {conversations} conversations\",\n    \"description\": \"Import success message\"\n  },\n  \"folder_import_success_skipped\": {\n    \"message\": \"✓ Imported {folders} folders, {conversations} conversations ({skipped} duplicates skipped)\",\n    \"description\": \"Import success with skipped message\"\n  },\n  \"folder_import_error\": {\n    \"message\": \"✗ Import failed: {error}\",\n    \"description\": \"Import error message\"\n  },\n  \"folder_export_success\": {\n    \"message\": \"✓ Folders exported successfully\",\n    \"description\": \"Export success message\"\n  },\n  \"folder_import_invalid_format\": {\n    \"message\": \"Invalid file format. Please select a valid folder configuration file.\",\n    \"description\": \"Invalid format error\"\n  },\n  \"folder_import_confirm_overwrite\": {\n    \"message\": \"This will replace all existing folders. A backup will be created. Continue?\",\n    \"description\": \"Overwrite confirmation\"\n  },\n  \"folder_import_paste_json\": {\n    \"message\": \"Or paste JSON directly\",\n    \"description\": \"Toggle button to show paste textarea in import dialog\"\n  },\n  \"folder_import_paste_placeholder\": {\n    \"message\": \"Paste your JSON here...\",\n    \"description\": \"Placeholder text for the paste textarea\"\n  },\n  \"export_dialog_title\": {\n    \"message\": \"Export Conversation\",\n    \"description\": \"Export dialog title\"\n  },\n  \"export_dialog_select\": {\n    \"message\": \"Choose export format:\",\n    \"description\": \"Export format selection label\"\n  },\n  \"export_dialog_warning\": {\n    \"message\": \"⚠️ After clicking export, the system will auto-jump to the beginning to load all content. Please do not operate; export will continue automatically after the jump.\",\n    \"description\": \"Warning about auto-jump and loading completeness\"\n  },\n  \"export_dialog_safari_cmdp_hint\": {\n    \"message\": \"Safari tip: Click 'Export' below, wait a moment, then press ⌘P and choose 'Save to PDF'.\",\n    \"description\": \"Safari-specific hint for PDF export\"\n  },\n  \"export_toast_safari_pdf_ready\": {\n    \"message\": \"You can now press Command + P to export PDF.\",\n    \"description\": \"Safari-specific toast shown after PDF export is prepared\"\n  },\n  \"export_dialog_safari_markdown_hint\": {\n    \"message\": \"Note: Due to Safari limitations, images in chat history cannot be extracted. For complete export, use PDF export.\",\n    \"description\": \"Warning about image references in Safari markdown export\"\n  },\n  \"export_format_json_description\": {\n    \"message\": \"Machine-readable format for developers\",\n    \"description\": \"Description for JSON export format\"\n  },\n  \"export_format_markdown_description\": {\n    \"message\": \"Clean, portable text format (recommended)\",\n    \"description\": \"Description for Markdown export format\"\n  },\n  \"export_format_pdf_description\": {\n    \"message\": \"Print-friendly format via Save as PDF\",\n    \"description\": \"Description for PDF export format\"\n  },\n  \"editInputWidth\": {\n    \"message\": \"Edit input width\",\n    \"description\": \"Edit input width adjustment label\"\n  },\n  \"editInputWidthNarrow\": {\n    \"message\": \"Narrow\",\n    \"description\": \"Narrow edit input width label\"\n  },\n  \"editInputWidthWide\": {\n    \"message\": \"Wide\",\n    \"description\": \"Wide edit input width label\"\n  },\n  \"timelineOptions\": {\n    \"message\": \"Timeline Options\",\n    \"description\": \"Timeline options section label\"\n  },\n  \"folderOptions\": {\n    \"message\": \"Folder Options\",\n    \"description\": \"Folder options section label\"\n  },\n  \"enableFolderFeature\": {\n    \"message\": \"Enable folder feature\",\n    \"description\": \"Enable or disable the folder feature\"\n  },\n  \"hideArchivedConversations\": {\n    \"message\": \"Hide archived conversations\",\n    \"description\": \"Hide conversations that are in folders from the main list\"\n  },\n  \"conversation_star\": {\n    \"message\": \"Star conversation\",\n    \"description\": \"Star conversation button tooltip\"\n  },\n  \"conversation_unstar\": {\n    \"message\": \"Unstar conversation\",\n    \"description\": \"Unstar conversation button tooltip\"\n  },\n  \"conversation_untitled\": {\n    \"message\": \"Untitled\",\n    \"description\": \"Fallback title for conversations without a name\"\n  },\n  \"geminiOnlyNotice\": {\n    \"message\": \"Runs on Gemini (including Enterprise) & AI Studio by default. Add other sites below to enable the Prompt Manager there.\",\n    \"description\": \"Notice that defaults are limited to Gemini/AI Studio\"\n  },\n  \"folderManager_dataLossWarning\": {\n    \"message\": \"⚠️ Warning: Failed to load folder data. Your folders may have been corrupted. Please check the browser console for details and try restoring from backup if available.\",\n    \"description\": \"Warning shown when folder data fails to load\"\n  },\n  \"backupOptions\": {\n    \"message\": \"Auto Backup\",\n    \"description\": \"Backup options section label\"\n  },\n  \"backupEnabled\": {\n    \"message\": \"Enable auto backup\",\n    \"description\": \"Enable auto backup toggle\"\n  },\n  \"backupNow\": {\n    \"message\": \"Backup Now\",\n    \"description\": \"Backup now button\"\n  },\n  \"backupSelectFolder\": {\n    \"message\": \"Select Backup Folder\",\n    \"description\": \"Select backup folder button\"\n  },\n  \"backupFolderSelected\": {\n    \"message\": \"Backup folder: {folder}\",\n    \"description\": \"Shows selected backup folder\"\n  },\n  \"backupIncludePrompts\": {\n    \"message\": \"Include prompts\",\n    \"description\": \"Include prompts in backup toggle\"\n  },\n  \"backupIncludeFolders\": {\n    \"message\": \"Include folders\",\n    \"description\": \"Include folders in backup toggle\"\n  },\n  \"backupIntervalLabel\": {\n    \"message\": \"Backup interval\",\n    \"description\": \"Backup interval label\"\n  },\n  \"backupIntervalManual\": {\n    \"message\": \"Manual only\",\n    \"description\": \"Manual backup mode\"\n  },\n  \"backupIntervalDaily\": {\n    \"message\": \"Daily\",\n    \"description\": \"Daily backup interval\"\n  },\n  \"backupIntervalWeekly\": {\n    \"message\": \"Weekly (7 days)\",\n    \"description\": \"Weekly backup interval\"\n  },\n  \"backupLastBackup\": {\n    \"message\": \"Last backup: {time}\",\n    \"description\": \"Last backup timestamp\"\n  },\n  \"backupNever\": {\n    \"message\": \"Never\",\n    \"description\": \"Never backed up\"\n  },\n  \"backupSuccess\": {\n    \"message\": \"✓ Backup created: {prompts} prompts, {folders} folders, {conversations} conversations\",\n    \"description\": \"Backup success message\"\n  },\n  \"backupError\": {\n    \"message\": \"✗ Backup failed: {error}\",\n    \"description\": \"Backup error message\"\n  },\n  \"backupNotSupported\": {\n    \"message\": \"⚠️ Auto backup requires a modern browser with File System Access API support\",\n    \"description\": \"Browser not supported message\"\n  },\n  \"backupSelectFolderFirst\": {\n    \"message\": \"Please select a backup folder first\",\n    \"description\": \"No folder selected warning\"\n  },\n  \"backupUserCancelled\": {\n    \"message\": \"Backup cancelled\",\n    \"description\": \"User cancelled folder selection\"\n  },\n  \"backupConfigSaved\": {\n    \"message\": \"✓ Backup settings saved\",\n    \"description\": \"Config saved message\"\n  },\n  \"backupPermissionDenied\": {\n    \"message\": \"⚠️ Cannot access this directory. Please choose a different location (e.g., Documents, Downloads, or a folder in Desktop)\",\n    \"description\": \"Permission denied for restricted directory\"\n  },\n  \"backupConfigureInOptions\": {\n    \"message\": \"Backup configuration requires the Options page. Click the button below to open it (click the three dots next to the extension icon → Options).\",\n    \"description\": \"Backup configure in options page explanation\"\n  },\n  \"openOptionsPage\": {\n    \"message\": \"Open Options Page\",\n    \"description\": \"Open options page button\"\n  },\n  \"optionsPageSubtitle\": {\n    \"message\": \"Extension Options\",\n    \"description\": \"Options page subtitle\"\n  },\n  \"optionsComingSoon\": {\n    \"message\": \"More options coming soon...\",\n    \"description\": \"Options page placeholder text\"\n  },\n  \"backupDataAccessNotice\": {\n    \"message\": \"Data Access Limitation\",\n    \"description\": \"Backup data access limitation title\"\n  },\n  \"backupDataAccessHint\": {\n    \"message\": \"Due to browser security restrictions, this Options page cannot directly access prompts and folder data from Gemini pages. Please use the Prompt Manager and Folder Export features on the Gemini page for manual backups.\",\n    \"description\": \"Backup data access limitation explanation\"\n  },\n  \"promptManagerOptions\": {\n    \"message\": \"Prompt Manager\",\n    \"description\": \"Prompt Manager options section label\"\n  },\n  \"hidePromptManager\": {\n    \"message\": \"Hide Prompt Manager\",\n    \"description\": \"Hide the prompt manager floating ball\"\n  },\n  \"hidePromptManagerHint\": {\n    \"message\": \"Hide the Prompt Manager floating ball on the page\",\n    \"description\": \"Hint for hiding prompt manager feature\"\n  },\n  \"customWebsites\": {\n    \"message\": \"Custom websites\",\n    \"description\": \"Custom websites for prompt manager\"\n  },\n  \"customWebsitesPlaceholder\": {\n    \"message\": \"Enter website URL (e.g., chatgpt.com)\",\n    \"description\": \"Custom websites input placeholder\"\n  },\n  \"customWebsitesHint\": {\n    \"message\": \"Add websites where you want to use the Prompt Manager. Only the Prompt Manager will be activated on these sites.\",\n    \"description\": \"Custom websites hint\"\n  },\n  \"addWebsite\": {\n    \"message\": \"Add website\",\n    \"description\": \"Add website button\"\n  },\n  \"removeWebsite\": {\n    \"message\": \"Remove\",\n    \"description\": \"Remove website button\"\n  },\n  \"invalidUrl\": {\n    \"message\": \"Invalid URL format\",\n    \"description\": \"Invalid URL error message\"\n  },\n  \"websiteAdded\": {\n    \"message\": \"Website added\",\n    \"description\": \"Website added success message\"\n  },\n  \"websiteRemoved\": {\n    \"message\": \"Website removed\",\n    \"description\": \"Website removed success message\"\n  },\n  \"permissionDenied\": {\n    \"message\": \"Permission denied. Please allow access to this website.\",\n    \"description\": \"Permission denied error message\"\n  },\n  \"permissionRequestFailed\": {\n    \"message\": \"Permission request failed. Please try again or grant access in the extension settings.\",\n    \"description\": \"Permission request failure message\"\n  },\n  \"customWebsitesNote\": {\n    \"message\": \"Tip: You'll be asked to allow access when adding a site. After granting permission, reload that site so the Prompt Manager can start.\",\n    \"description\": \"Custom websites reload/permission note\"\n  },\n  \"starredHistory\": {\n    \"message\": \"Starred History\",\n    \"description\": \"Starred history title\"\n  },\n  \"viewStarredHistory\": {\n    \"message\": \"View Starred History\",\n    \"description\": \"View starred history button\"\n  },\n  \"noStarredMessages\": {\n    \"message\": \"No starred messages yet\",\n    \"description\": \"No starred messages placeholder\"\n  },\n  \"removeFromStarred\": {\n    \"message\": \"Remove from starred\",\n    \"description\": \"Remove from starred tooltip\"\n  },\n  \"justNow\": {\n    \"message\": \"Just now\",\n    \"description\": \"Just now time label\"\n  },\n  \"hoursAgo\": {\n    \"message\": \"hours ago\",\n    \"description\": \"Hours ago label\"\n  },\n  \"yesterday\": {\n    \"message\": \"Yesterday\",\n    \"description\": \"Yesterday date label\"\n  },\n  \"daysAgo\": {\n    \"message\": \"days ago\",\n    \"description\": \"Days ago label\"\n  },\n  \"loading\": {\n    \"message\": \"Loading...\",\n    \"description\": \"Loading message\"\n  },\n  \"keyboardShortcuts\": {\n    \"message\": \"Keyboard Shortcuts\",\n    \"description\": \"Keyboard shortcuts section title\"\n  },\n  \"enableShortcuts\": {\n    \"message\": \"Enable keyboard shortcuts\",\n    \"description\": \"Enable shortcuts toggle label\"\n  },\n  \"previousNode\": {\n    \"message\": \"Previous node\",\n    \"description\": \"Previous timeline node shortcut label\"\n  },\n  \"nextNode\": {\n    \"message\": \"Next node\",\n    \"description\": \"Next timeline node shortcut label\"\n  },\n  \"shortcutKey\": {\n    \"message\": \"Key\",\n    \"description\": \"Shortcut key label\"\n  },\n  \"shortcutModifiers\": {\n    \"message\": \"Modifiers\",\n    \"description\": \"Shortcut modifiers label\"\n  },\n  \"resetShortcuts\": {\n    \"message\": \"Reset to defaults\",\n    \"description\": \"Reset shortcuts button\"\n  },\n  \"shortcutsResetSuccess\": {\n    \"message\": \"Shortcuts reset to defaults\",\n    \"description\": \"Shortcuts reset success message\"\n  },\n  \"shortcutsDescription\": {\n    \"message\": \"Navigate timeline nodes using keyboard shortcuts\",\n    \"description\": \"Shortcuts description\"\n  },\n  \"modifierNone\": {\n    \"message\": \"None\",\n    \"description\": \"No modifier key\"\n  },\n  \"modifierAlt\": {\n    \"message\": \"Alt\",\n    \"description\": \"Alt modifier key\"\n  },\n  \"modifierCtrl\": {\n    \"message\": \"Ctrl\",\n    \"description\": \"Ctrl modifier key\"\n  },\n  \"modifierShift\": {\n    \"message\": \"Shift\",\n    \"description\": \"Shift modifier key\"\n  },\n  \"modifierMeta\": {\n    \"message\": \"Cmd/Win\",\n    \"description\": \"Meta/Command/Windows modifier key\"\n  },\n  \"cloudSync\": {\n    \"message\": \"Cloud Sync\",\n    \"description\": \"Cloud sync section title\"\n  },\n  \"cloudSyncDescription\": {\n    \"message\": \"Sync folders and prompts to Google Drive\",\n    \"description\": \"Cloud sync description\"\n  },\n  \"preventAutoScroll\": {\n    \"message\": \"Prevent auto-scroll to bottom\",\n    \"description\": \"Feature to prevent auto scrolling to bottom when generating answers\"\n  },\n  \"preventAutoScrollHint\": {\n    \"message\": \"Prevents Gemini from jumping to the bottom when you hit enter while reading previous responses.\",\n    \"description\": \"Hint for prevent auto scroll feature\"\n  },\n  \"syncModeDisabled\": {\n    \"message\": \"Disabled\",\n    \"description\": \"Sync mode disabled option\"\n  },\n  \"syncModeManual\": {\n    \"message\": \"Manual\",\n    \"description\": \"Sync mode manual option\"\n  },\n  \"syncServerPort\": {\n    \"message\": \"Server Port\",\n    \"description\": \"Port number for the sync server\"\n  },\n  \"syncNow\": {\n    \"message\": \"Sync Now\",\n    \"description\": \"Sync now button\"\n  },\n  \"lastSynced\": {\n    \"message\": \"Last synced: {time}\",\n    \"description\": \"Last sync timestamp\"\n  },\n  \"neverSynced\": {\n    \"message\": \"Never synced\",\n    \"description\": \"Never synced message\"\n  },\n  \"lastUploaded\": {\n    \"message\": \"Uploaded: {time}\",\n    \"description\": \"Last upload timestamp\"\n  },\n  \"neverUploaded\": {\n    \"message\": \"Never uploaded\",\n    \"description\": \"Never uploaded message\"\n  },\n  \"syncSuccess\": {\n    \"message\": \"✓ Synced successfully\",\n    \"description\": \"Sync success message\"\n  },\n  \"syncError\": {\n    \"message\": \"✗ Sync failed: {error}\",\n    \"description\": \"Sync error message\"\n  },\n  \"syncInProgress\": {\n    \"message\": \"Syncing...\",\n    \"description\": \"Sync in progress message\"\n  },\n  \"uploadInProgress\": {\n    \"message\": \"Uploading...\",\n    \"description\": \"Upload in progress message\"\n  },\n  \"uploadSuccess\": {\n    \"message\": \"✓ Uploaded successfully\",\n    \"description\": \"Upload success message\"\n  },\n  \"downloadInProgress\": {\n    \"message\": \"Downloading...\",\n    \"description\": \"Download in progress message\"\n  },\n  \"downloadMergeSuccess\": {\n    \"message\": \"✓ Downloaded and merged\",\n    \"description\": \"Download and merge success message\"\n  },\n  \"syncUpload\": {\n    \"message\": \"Upload\",\n    \"description\": \"Upload button label\"\n  },\n  \"syncMerge\": {\n    \"message\": \"Download & Merge\",\n    \"description\": \"Merge button label\"\n  },\n  \"syncMode\": {\n    \"message\": \"Sync Mode\",\n    \"description\": \"Sync mode label\"\n  },\n  \"minutesAgo\": {\n    \"message\": \"minutes ago\",\n    \"description\": \"Minutes ago label\"\n  },\n  \"syncNoData\": {\n    \"message\": \"No sync data found in Drive\",\n    \"description\": \"Error message when no data is found in Drive\"\n  },\n  \"syncUploadFailed\": {\n    \"message\": \"Upload failed\",\n    \"description\": \"Error message when upload fails\"\n  },\n  \"syncDownloadFailed\": {\n    \"message\": \"Download failed\",\n    \"description\": \"Error message when download fails\"\n  },\n  \"syncAuthFailed\": {\n    \"message\": \"Authentication failed\",\n    \"description\": \"Error message when authentication fails\"\n  },\n  \"signOut\": {\n    \"message\": \"Sign out\",\n    \"description\": \"Sign out button\"\n  },\n  \"signInWithGoogle\": {\n    \"message\": \"Sign in with Google\",\n    \"description\": \"Sign in with Google button\"\n  },\n  \"nanobananaOptions\": {\n    \"message\": \"NanoBanana Options\",\n    \"description\": \"NanoBanana options section label\"\n  },\n  \"enableNanobananaWatermarkRemover\": {\n    \"message\": \"Remove NanoBanana watermark\",\n    \"description\": \"Enable automatic watermark removal for NanoBanana images\"\n  },\n  \"nanobananaWatermarkRemoverHint\": {\n    \"message\": \"Automatically removes visible Gemini watermarks from generated images\",\n    \"description\": \"Watermark remover feature hint\"\n  },\n  \"nanobananaDownloadTooltip\": {\n    \"message\": \"Download unwatermarked image (NanoBanana)\",\n    \"description\": \"Download button tooltip\"\n  },\n  \"deepResearchDownload\": {\n    \"message\": \"Download thinking content\",\n    \"description\": \"Deep Research download button text\"\n  },\n  \"deepResearchDownloadTooltip\": {\n    \"message\": \"Download thinking content as Markdown\",\n    \"description\": \"Deep Research download button tooltip\"\n  },\n  \"folder_uncategorized\": {\n    \"message\": \"Uncategorized\",\n    \"description\": \"Label for root-level conversations not in any folder\"\n  },\n  \"timelineLevelTitle\": {\n    \"message\": \"Node Level\",\n    \"description\": \"Title for timeline node level context menu\"\n  },\n  \"timelineLevel1\": {\n    \"message\": \"Level 1\",\n    \"description\": \"Timeline node level 1 option\"\n  },\n  \"timelineLevel2\": {\n    \"message\": \"Level 2\",\n    \"description\": \"Timeline node level 2 option\"\n  },\n  \"timelineLevel3\": {\n    \"message\": \"Level 3\",\n    \"description\": \"Timeline node level 3 option\"\n  },\n  \"timelineCollapse\": {\n    \"message\": \"Collapse\",\n    \"description\": \"Collapse children\"\n  },\n  \"timelineExpand\": {\n    \"message\": \"Expand\",\n    \"description\": \"Expand children\"\n  },\n  \"timelinePreviewSearch\": {\n    \"message\": \"Search...\",\n    \"description\": \"Timeline preview panel search placeholder\"\n  },\n  \"timelinePreviewNoResults\": {\n    \"message\": \"No results\",\n    \"description\": \"Timeline preview panel no search results\"\n  },\n  \"timelinePreviewNoMessages\": {\n    \"message\": \"No messages\",\n    \"description\": \"Timeline preview panel no messages\"\n  },\n  \"quoteReply\": {\n    \"message\": \"Quote Reply\",\n    \"description\": \"Quote reply button text\"\n  },\n  \"inputCollapseOptions\": {\n    \"message\": \"Input Options\",\n    \"description\": \"Input options section label\"\n  },\n  \"enableInputCollapse\": {\n    \"message\": \"Enable input collapse\",\n    \"description\": \"Enable input collapse toggle label\"\n  },\n  \"enableInputCollapseHint\": {\n    \"message\": \"Collapse the input area when empty to gain more reading space\",\n    \"description\": \"Input collapse feature hint\"\n  },\n  \"inputCollapseShortcutHint\": {\n    \"message\": \"{modifier}+I to expand\",\n    \"description\": \"Shortcut hint for expanding collapsed input. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"inputCollapsePlaceholder\": {\n    \"message\": \"Message Gemini\",\n    \"description\": \"Placeholder text shown in collapsed input\"\n  },\n  \"allowCollapseWhenNotEmpty\": {\n    \"message\": \"Allow collapse with content\",\n    \"description\": \"Allow input to collapse even when it has text or attachments\"\n  },\n  \"allowCollapseWhenNotEmptyHint\": {\n    \"message\": \"Collapse input area even when it contains text or attachments\",\n    \"description\": \"Hint text explaining the collapse when not empty feature\"\n  },\n  \"ctrlEnterSend\": {\n    \"message\": \"{modifier}+Enter to send\",\n    \"description\": \"Modifier+Enter to send toggle label. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"ctrlEnterSendHint\": {\n    \"message\": \"Press {modifier}+Enter to send messages, Enter to add a new line\",\n    \"description\": \"Modifier+Enter to send feature hint. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"batch_delete_confirm\": {\n    \"message\": \"Are you sure you want to delete {count} conversation(s)? This action cannot be undone.\",\n    \"description\": \"Batch delete confirmation message\"\n  },\n  \"batch_delete_in_progress\": {\n    \"message\": \"Deleting... ({current}/{total})\",\n    \"description\": \"Batch delete progress message\"\n  },\n  \"batch_delete_success\": {\n    \"message\": \"✓ Deleted {count} conversation(s)\",\n    \"description\": \"Batch delete success message\"\n  },\n  \"batch_delete_partial\": {\n    \"message\": \"Deletion complete: {success} succeeded, {failed} failed\",\n    \"description\": \"Batch delete partial success message\"\n  },\n  \"batch_delete_limit_reached\": {\n    \"message\": \"Maximum {max} conversations can be selected at once\",\n    \"description\": \"Batch delete limit warning\"\n  },\n  \"batch_delete_button\": {\n    \"message\": \"Delete selected\",\n    \"description\": \"Batch delete button tooltip in multi-select mode\"\n  },\n  \"batch_delete_match_patterns\": {\n    \"message\": \"delete,confirm,yes,ok,remove，删除，确认，确定，是\",\n    \"description\": \"Comma-separated list of keywords to match native delete/confirm buttons (case-insensitive)\"\n  },\n  \"generalOptions\": {\n    \"message\": \"General Options\",\n    \"description\": \"General options section label\"\n  },\n  \"enableTabTitleUpdate\": {\n    \"message\": \"Sync tab title with conversation\",\n    \"description\": \"Enable tab title sync toggle label\"\n  },\n  \"enableTabTitleUpdateHint\": {\n    \"message\": \"Automatically update the browser tab title to match the current conversation title\",\n    \"description\": \"Tab title sync feature hint\"\n  },\n  \"enableMermaidRendering\": {\n    \"message\": \"Enable Mermaid diagram rendering\",\n    \"description\": \"Enable Mermaid diagram rendering toggle label\"\n  },\n  \"enableMermaidRenderingHint\": {\n    \"message\": \"Automatically render Mermaid diagrams in code blocks\",\n    \"description\": \"Mermaid rendering feature hint\"\n  },\n  \"enableQuoteReply\": {\n    \"message\": \"Enable quote reply\",\n    \"description\": \"Enable quote reply toggle label\"\n  },\n  \"enableQuoteReplyHint\": {\n    \"message\": \"Show a floating button to quote selected text when text is selected in conversations\",\n    \"description\": \"Quote reply feature hint\"\n  },\n  \"contextSync\": {\n    \"message\": \"Context Sync\",\n    \"description\": \"Context Sync title\"\n  },\n  \"contextSyncDescription\": {\n    \"message\": \"Sync chat context to your local IDE\",\n    \"description\": \"Context Sync description\"\n  },\n  \"syncToIDE\": {\n    \"message\": \"Sync to IDE\",\n    \"description\": \"Sync to IDE button\"\n  },\n  \"ideOnline\": {\n    \"message\": \"IDE Online\",\n    \"description\": \"IDE Online status\"\n  },\n  \"ideOffline\": {\n    \"message\": \"IDE Offline\",\n    \"description\": \"IDE Offline status\"\n  },\n  \"syncing\": {\n    \"message\": \"Syncing...\",\n    \"description\": \"Syncing status\"\n  },\n  \"checkServer\": {\n    \"message\": \"Please start AI Sync server in VS Code\",\n    \"description\": \"Check server hint\"\n  },\n  \"capturing\": {\n    \"message\": \"Capturing dialogue...\",\n    \"description\": \"Capturing status\"\n  },\n  \"syncedSuccess\": {\n    \"message\": \"Synced successfully!\",\n    \"description\": \"Synced success message\"\n  },\n  \"downloadingOriginal\": {\n    \"message\": \"Downloading original image\",\n    \"description\": \"Status when downloading original image\"\n  },\n  \"downloadingOriginalLarge\": {\n    \"message\": \"Downloading original image (large file)\",\n    \"description\": \"Status when downloading large original image\"\n  },\n  \"downloadLargeWarning\": {\n    \"message\": \"Large file warning\",\n    \"description\": \"Warning shown for large downloads\"\n  },\n  \"downloadProcessing\": {\n    \"message\": \"Processing watermark\",\n    \"description\": \"Status when processing watermark removal\"\n  },\n  \"downloadSuccess\": {\n    \"message\": \"Downloading...\",\n    \"description\": \"Status when download starts\"\n  },\n  \"downloadError\": {\n    \"message\": \"Failed\",\n    \"description\": \"Status prefix when download fails\"\n  },\n  \"recentsHide\": {\n    \"message\": \"Hide recent items\",\n    \"description\": \"Tooltip for hiding recents preview section\"\n  },\n  \"recentsShow\": {\n    \"message\": \"Show recent items\",\n    \"description\": \"Tooltip for showing recents preview section\"\n  },\n  \"gemsHide\": {\n    \"message\": \"Hide Gems\",\n    \"description\": \"Tooltip for hiding Gems list section\"\n  },\n  \"gemsShow\": {\n    \"message\": \"Show Gems\",\n    \"description\": \"Tooltip for showing Gems list section\"\n  },\n  \"setAsDefaultModel\": {\n    \"message\": \"Set as default model\",\n    \"description\": \"Tooltip for setting default model\"\n  },\n  \"cancelDefaultModel\": {\n    \"message\": \"Cancel default model\",\n    \"description\": \"Tooltip for cancelling default model\"\n  },\n  \"defaultModelSet\": {\n    \"message\": \"Default model set: $1\",\n    \"description\": \"Toast message when default model is set\"\n  },\n  \"defaultModelCleared\": {\n    \"message\": \"Default model cleared\",\n    \"description\": \"Toast message when default model is cleared\"\n  },\n  \"sidebarAutoHide\": {\n    \"message\": \"Auto-hide sidebar\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"sidebarAutoHideHint\": {\n    \"message\": \"Automatically collapse sidebar when mouse leaves, expand when mouse enters\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"sidebarFullHide\": {\n    \"message\": \"Fully hide sidebar\",\n    \"description\": \"Fully hide sidebar toggle label\"\n  },\n  \"sidebarFullHideHint\": {\n    \"message\": \"Completely hide the collapsed sidebar, hover left edge to reveal\",\n    \"description\": \"Fully hide sidebar feature hint\"\n  },\n  \"folderSpacing\": {\n    \"message\": \"Folder spacing\",\n    \"description\": \"Folder spacing adjustment label\"\n  },\n  \"folderSpacingCompact\": {\n    \"message\": \"Compact\",\n    \"description\": \"Compact folder spacing label\"\n  },\n  \"folderSpacingSpacious\": {\n    \"message\": \"Spacious\",\n    \"description\": \"Spacious folder spacing label\"\n  },\n  \"folderTreeIndent\": {\n    \"message\": \"Subfolder indent\",\n    \"description\": \"Subfolder tree indentation adjustment label\"\n  },\n  \"folderTreeIndentCompact\": {\n    \"message\": \"Narrow\",\n    \"description\": \"Narrow subfolder indentation label\"\n  },\n  \"folderTreeIndentSpacious\": {\n    \"message\": \"Wide\",\n    \"description\": \"Wide subfolder indentation label\"\n  },\n  \"export_select_mode_select_all\": {\n    \"message\": \"Select all\",\n    \"description\": \"Selection mode select all toggle\"\n  },\n  \"export_select_mode_count\": {\n    \"message\": \"Selected {count}\",\n    \"description\": \"Selection mode selected message count\"\n  },\n  \"export_select_mode_toggle\": {\n    \"message\": \"Select message\",\n    \"description\": \"Selection mode per-message toggle tooltip\"\n  },\n  \"export_select_mode_select_below\": {\n    \"message\": \"Select messages below\",\n    \"description\": \"Selection mode top-left button to select messages below current line\"\n  },\n  \"export_select_mode_empty\": {\n    \"message\": \"Please select at least one message to export.\",\n    \"description\": \"Selection mode validation when nothing is selected\"\n  },\n  \"export_format_image_description\": {\n    \"message\": \"Single PNG image, great for mobile sharing.\",\n    \"description\": \"Description for Image export format\"\n  },\n  \"export_image_progress\": {\n    \"message\": \"Generating image...\",\n    \"description\": \"Progress message while generating image export\"\n  },\n  \"deepResearchSaveReport\": {\n    \"message\": \"Save Report\",\n    \"description\": \"Deep Research save report button\"\n  },\n  \"deepResearchSaveReportTooltip\": {\n    \"message\": \"Export this research report\",\n    \"description\": \"Deep Research save report tooltip\"\n  },\n  \"export_fontsize_label\": {\n    \"message\": \"Font Size\",\n    \"description\": \"Label for font size slider in export dialog\"\n  },\n  \"export_fontsize_preview\": {\n    \"message\": \"The quick brown fox jumps over the lazy dog.\",\n    \"description\": \"Preview text for font size slider in export dialog\"\n  },\n  \"export_error_generic\": {\n    \"message\": \"Export failed: {error}\",\n    \"description\": \"Generic export failure with detailed reason\"\n  },\n  \"export_error_refresh_retry\": {\n    \"message\": \"Image export failed due to network issues. Please refresh the page and try again.\",\n    \"description\": \"Guidance shown when image export fails due to transient loading issues\"\n  },\n  \"export_md_include_source_confirm\": {\n    \"message\": \"The exported content contains web search images. Include image source links in the Markdown?\",\n    \"description\": \"Confirm dialog when exporting markdown with web search images\"\n  },\n  \"deepResearch_exportedAt\": {\n    \"message\": \"Exported At\",\n    \"description\": \"Deep Research export timestamp label\"\n  },\n  \"deepResearch_totalPhases\": {\n    \"message\": \"Total Phases\",\n    \"description\": \"Deep Research total thinking phases label\"\n  },\n  \"deepResearch_thinkingPhase\": {\n    \"message\": \"Thinking Phase\",\n    \"description\": \"Deep Research thinking phase header\"\n  },\n  \"deepResearch_researchedWebsites\": {\n    \"message\": \"Researched Websites\",\n    \"description\": \"Deep Research researched websites section header\"\n  },\n  \"visualEffect\": {\n    \"message\": \"Visual effects\",\n    \"description\": \"Visual effect selector label\"\n  },\n  \"visualEffectHint\": {\n    \"message\": \"Add seasonal atmosphere to the page\",\n    \"description\": \"Visual effect feature hint\"\n  },\n  \"visualEffectOff\": {\n    \"message\": \"Off\",\n    \"description\": \"Visual effect off option\"\n  },\n  \"visualEffectSnow\": {\n    \"message\": \"Snow\",\n    \"description\": \"Visual effect snow option\"\n  },\n  \"visualEffectSakura\": {\n    \"message\": \"Sakura\",\n    \"description\": \"Visual effect sakura option\"\n  },\n  \"visualEffectRain\": {\n    \"message\": \"Rain\",\n    \"description\": \"Visual effect rain option\"\n  },\n  \"changelog_title\": {\n    \"message\": \"What's New\",\n    \"description\": \"Changelog modal title\"\n  },\n  \"changelog_close\": {\n    \"message\": \"Got it\",\n    \"description\": \"Changelog modal close button\"\n  },\n  \"changelog_docs_link\": {\n    \"message\": \"Docs\",\n    \"description\": \"Changelog modal documentation link text\"\n  },\n  \"changelog_recommendation\": {\n    \"message\": \"If Voyager has been helpful, share it with friends or on social media — feel free to tag the author!\",\n    \"description\": \"Changelog modal recommendation message\"\n  },\n  \"changelog_social_xiaohongshu\": {\n    \"message\": \"Xiaohongshu\",\n    \"description\": \"Xiaohongshu platform name for social links\"\n  },\n  \"changelog_social_zhihu\": {\n    \"message\": \"Zhihu\",\n    \"description\": \"Zhihu platform name for social links\"\n  },\n  \"changelog_docs_hint\": {\n    \"message\": \"Details in docs\",\n    \"description\": \"Changelog annotation pointing to docs icon\"\n  },\n  \"changelog_rate_chrome\": {\n    \"message\": \"Love Voyager? A quick rating on the Chrome Web Store makes a huge difference!\",\n    \"description\": \"Chrome Web Store rating prompt in changelog modal\"\n  },\n  \"changelog_rate_chrome_cta\": {\n    \"message\": \"Rate Now\",\n    \"description\": \"Chrome Web Store rating CTA button text\"\n  },\n  \"changelog_badge_mode\": {\n    \"message\": \"Notify via badge on the floating button instead of this popup\",\n    \"description\": \"Changelog notification mode toggle label\"\n  },\n  \"forkConversation\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork conversation button label\"\n  },\n  \"forkConfirm\": {\n    \"message\": \"Fork conversation from this point?\",\n    \"description\": \"Fork confirmation prompt\"\n  },\n  \"forkConfirmBtn\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork confirmation button\"\n  },\n  \"forkCancel\": {\n    \"message\": \"Cancel\",\n    \"description\": \"Fork cancel button\"\n  },\n  \"forkBranch\": {\n    \"message\": \"Branch\",\n    \"description\": \"Fork branch label\"\n  },\n  \"forkOriginal\": {\n    \"message\": \"Original\",\n    \"description\": \"Original fork branch tag\"\n  },\n  \"forkCurrent\": {\n    \"message\": \"Current\",\n    \"description\": \"Current fork branch tag\"\n  },\n  \"forkPreparing\": {\n    \"message\": \"Preparing fork...\",\n    \"description\": \"Fork preparation status\"\n  },\n  \"forkPasteHint\": {\n    \"message\": \"Press Enter to send and create the fork\",\n    \"description\": \"Fork paste hint text\"\n  },\n  \"forkDeleteData\": {\n    \"message\": \"Delete this branch\",\n    \"description\": \"Delete fork branch metadata button\"\n  },\n  \"forkDeleteDataConfirm\": {\n    \"message\": \"Delete this branch link? This only removes Voyager fork metadata and will not delete any conversation.\",\n    \"description\": \"Fork metadata delete confirmation prompt\"\n  },\n  \"enableForkFeature\": {\n    \"message\": \"Enable conversation fork\",\n    \"description\": \"Enable conversation fork feature toggle label\"\n  },\n  \"enableForkFeatureHint\": {\n    \"message\": \"Show Fork button and branch indicators in Gemini conversations\",\n    \"description\": \"Enable conversation fork feature toggle hint\"\n  },\n  \"enableAccountIsolation\": {\n    \"message\": \"Enable Hard Isolation\",\n    \"description\": \"Enable hard isolation for account-scoped folder data and cloud sync.\"\n  },\n  \"enableAccountIsolationHint\": {\n    \"message\": \"Strictly isolate folder and cloud sync data by Google account to prevent cross-account mixing.\",\n    \"description\": \"Hint text for account isolation option.\"\n  },\n  \"currentPlatform\": {\n    \"message\": \"Current platform\",\n    \"description\": \"Current active platform label in popup.\"\n  },\n  \"platformGemini\": {\n    \"message\": \"Gemini\",\n    \"description\": \"Gemini platform label.\"\n  },\n  \"platformAIStudio\": {\n    \"message\": \"AI Studio\",\n    \"description\": \"AI Studio platform label.\"\n  },\n  \"enableOnAIStudio\": {\n    \"message\": \"Enable on AI Studio\",\n    \"description\": \"Toggle to enable/disable Voyager on AI Studio\"\n  },\n  \"enableOnAIStudioHint\": {\n    \"message\": \"Turn off to disable Voyager features on AI Studio (Prompt Manager stays active)\",\n    \"description\": \"Hint for AI Studio enable toggle\"\n  },\n  \"export_select_mode_only_user\": {\n    \"message\": \"Select User Only\",\n    \"description\": \"Export selection mode: select only user messages\"\n  },\n  \"export_select_mode_only_ai\": {\n    \"message\": \"Select AI Only\",\n    \"description\": \"Export selection mode: select only AI messages\"\n  },\n  \"aiOrgCopyButton\": {\n    \"message\": \"AI Organize\",\n    \"description\": \"Button to copy folder structure and conversations for AI-based organization\"\n  },\n  \"aiOrgCopied\": {\n    \"message\": \"Copied!\",\n    \"description\": \"Confirmation after copying AI organization prompt\"\n  },\n  \"aiOrgError\": {\n    \"message\": \"Failed — open Gemini first\",\n    \"description\": \"Error when copying AI organization prompt fails\"\n  },\n  \"aiOrgCopyHint\": {\n    \"message\": \"Copies all conversations & folder structure as a prompt. Paste into Gemini to get an importable folder plan.\",\n    \"description\": \"Hint text below the AI organization copy button\"\n  },\n  \"aiOrgCurrentFolders\": {\n    \"message\": \"Current Folder Structure\",\n    \"description\": \"Heading in the AI organization prompt\"\n  },\n  \"aiOrgUnfiledConversations\": {\n    \"message\": \"Unfiled Conversations\",\n    \"description\": \"Heading for conversations not in any folder\"\n  },\n  \"aiOrgEmpty\": {\n    \"message\": \"empty\",\n    \"description\": \"Label for empty folders in the AI organization prompt\"\n  },\n  \"aiOrgInstructions\": {\n    \"message\": \"Instructions\",\n    \"description\": \"Heading for AI instructions section\"\n  },\n  \"aiOrgInstructionsBody\": {\n    \"message\": \"Based on the conversations and folder structure above, please reorganize them into a logical folder hierarchy. Output a JSON file using the exact format below that can be directly imported into the Gemini Voyager extension (use the \\\"Merge\\\" import strategy). Keep existing well-organized folders, create new ones as needed, and move unfiled conversations into appropriate folders. Each folder needs a unique ID (use a short random string), and each conversation must keep its original conversationId and url.\",\n    \"description\": \"Instructions body for AI to generate importable folder structure\"\n  },\n  \"showMessageTimestamps\": {\n    \"message\": \"Show Message Timestamps\",\n    \"description\": \"Display timestamp for each message\"\n  },\n  \"showMessageTimestampsHint\": {\n    \"message\": \"Show when each message was sent\",\n    \"description\": \"Hint for message timestamps feature\"\n  },\n  \"moveSectionUp\": {\n    \"message\": \"Move up\",\n    \"description\": \"Tooltip for moving a settings section up in popup\"\n  },\n  \"moveSectionDown\": {\n    \"message\": \"Move down\",\n    \"description\": \"Tooltip for moving a settings section down in popup\"\n  }\n}\n"
  },
  {
    "path": "src/locales/es/messages.json",
    "content": "{\n  \"extName\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Extension name\"\n  },\n  \"extDescription\": {\n    \"message\": \"Mejora tu experiencia Gemini™: línea de tiempo, carpetas, prompts y exportación.\",\n    \"description\": \"Extension description\"\n  },\n  \"scrollMode\": {\n    \"message\": \"Modo desplazamiento\",\n    \"description\": \"Scroll mode label\"\n  },\n  \"flow\": {\n    \"message\": \"Flujo\",\n    \"description\": \"Flow mode\"\n  },\n  \"jump\": {\n    \"message\": \"Salto\",\n    \"description\": \"Jump mode\"\n  },\n  \"hideOuterContainer\": {\n    \"message\": \"Ocultar contenedor exterior\",\n    \"description\": \"Hide outer container label\"\n  },\n  \"draggableTimeline\": {\n    \"message\": \"Línea de tiempo arrastrable\",\n    \"description\": \"Draggable Timeline label\"\n  },\n  \"enableMarkerLevel\": {\n    \"message\": \"Habilitar niveles de nodo\",\n    \"description\": \"Enable timeline node level feature\"\n  },\n  \"enableMarkerLevelHint\": {\n    \"message\": \"Haz clic derecho en los nodos de la línea de tiempo para establecer su nivel y contraer hijos\",\n    \"description\": \"Node level feature hint\"\n  },\n  \"experimentalLabel\": {\n    \"message\": \"Experimental\",\n    \"description\": \"Experimental feature label\"\n  },\n  \"resetPosition\": {\n    \"message\": \"Restablecer posición\",\n    \"description\": \"Reset Position button\"\n  },\n  \"resetTimelinePosition\": {\n    \"message\": \"Restablecer posición de la línea de tiempo\",\n    \"description\": \"Reset timeline position button in timeline options\"\n  },\n  \"language\": {\n    \"message\": \"Idioma\",\n    \"description\": \"Language label\"\n  },\n  \"pm_title\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Prompt Manager title\"\n  },\n  \"pm_add\": {\n    \"message\": \"Añadir\",\n    \"description\": \"Add prompt button\"\n  },\n  \"pm_search_placeholder\": {\n    \"message\": \"Buscar prompts o etiquetas\",\n    \"description\": \"Search placeholder\"\n  },\n  \"pm_import\": {\n    \"message\": \"Importar\",\n    \"description\": \"Import button\"\n  },\n  \"pm_export\": {\n    \"message\": \"Exportar\",\n    \"description\": \"Export button\"\n  },\n  \"pm_prompt_placeholder\": {\n    \"message\": \"Texto del prompt\",\n    \"description\": \"Prompt textarea placeholder\"\n  },\n  \"pm_tags_placeholder\": {\n    \"message\": \"Etiquetas (separadas por comas)\",\n    \"description\": \"Tags input placeholder\"\n  },\n  \"pm_save\": {\n    \"message\": \"Guardar\",\n    \"description\": \"Save prompt\"\n  },\n  \"pm_cancel\": {\n    \"message\": \"Cancelar\",\n    \"description\": \"Cancel\"\n  },\n  \"pm_all_tags\": {\n    \"message\": \"Todo\",\n    \"description\": \"All tags chip\"\n  },\n  \"pm_empty\": {\n    \"message\": \"No hay prompts todavía\",\n    \"description\": \"Empty state\"\n  },\n  \"pm_copy\": {\n    \"message\": \"Copiar\",\n    \"description\": \"Copy prompt\"\n  },\n  \"pm_copied\": {\n    \"message\": \"Copiado\",\n    \"description\": \"Copied notice\"\n  },\n  \"pm_delete\": {\n    \"message\": \"Eliminar\",\n    \"description\": \"Delete prompt\"\n  },\n  \"pm_delete_confirm\": {\n    \"message\": \"¿Eliminar este prompt?\",\n    \"description\": \"Delete confirm\"\n  },\n  \"pm_lock\": {\n    \"message\": \"Bloquear posición\",\n    \"description\": \"Lock panel position\"\n  },\n  \"pm_unlock\": {\n    \"message\": \"Desbloquear posición\",\n    \"description\": \"Unlock panel position\"\n  },\n  \"pm_import_invalid\": {\n    \"message\": \"Formato de archivo inválido\",\n    \"description\": \"Import invalid\"\n  },\n  \"pm_import_success\": {\n    \"message\": \"Importados {count} prompts\",\n    \"description\": \"Import success\"\n  },\n  \"pm_duplicate\": {\n    \"message\": \"Duplicar prompt\",\n    \"description\": \"Duplicate on add\"\n  },\n  \"pm_deleted\": {\n    \"message\": \"Eliminado\",\n    \"description\": \"Deleted notice\"\n  },\n  \"pm_edit\": {\n    \"message\": \"Editar\",\n    \"description\": \"Edit prompt\"\n  },\n  \"pm_expand\": {\n    \"message\": \"Expandir\",\n    \"description\": \"Expand prompt text\"\n  },\n  \"pm_collapse\": {\n    \"message\": \"Contraer\",\n    \"description\": \"Collapse prompt text\"\n  },\n  \"pm_saved\": {\n    \"message\": \"Guardado\",\n    \"description\": \"Saved notice\"\n  },\n  \"pm_settings\": {\n    \"message\": \"Configuración\",\n    \"description\": \"Settings button\"\n  },\n  \"pm_settings_tooltip\": {\n    \"message\": \"Abrir configuración de la extensión\",\n    \"description\": \"Settings button tooltip\"\n  },\n  \"pm_settings_fallback\": {\n    \"message\": \"Por favor, haz clic en el icono de la extensión en la barra del navegador para abrir la configuración\",\n    \"description\": \"Settings open failed message\"\n  },\n  \"pm_backup\": {\n    \"message\": \"Respaldo local\",\n    \"description\": \"Local backup button\"\n  },\n  \"pm_backup_tooltip\": {\n    \"message\": \"Respaldar prompts y carpetas en una carpeta con marca de tiempo\",\n    \"description\": \"Backup button tooltip\"\n  },\n  \"pm_backup_cancelled\": {\n    \"message\": \"Respaldo cancelado\",\n    \"description\": \"Backup cancelled hint\"\n  },\n  \"pm_backup_error\": {\n    \"message\": \"✗ Error en el respaldo\",\n    \"description\": \"Backup failed hint\"\n  },\n  \"pm_backup_hint_options\": {\n    \"message\": \"La función está integrada en el Gestor de Prompts en la página de Gemini.\",\n    \"description\": \"Backup hint in options page\"\n  },\n  \"pm_backup_step1\": {\n    \"message\": \"Abre la página de Gemini (gemini.google.com)\",\n    \"description\": \"Backup step 1\"\n  },\n  \"pm_backup_step2\": {\n    \"message\": \"Haz clic en el icono de la extensión en la parte inferior derecha para abrir el Gestor de Prompts\",\n    \"description\": \"Backup step 2\"\n  },\n  \"pm_backup_step3\": {\n    \"message\": \"Haz clic en el botón \\\"💾 Respaldo local\\\" y selecciona una carpeta de respaldo\",\n    \"description\": \"Backup step 3\"\n  },\n  \"pm_backup_note\": {\n    \"message\": \"Los respaldos incluyen todos los prompts y carpetas, guardados en una carpeta con marca de tiempo (formato: backup-YYYYMMDD-HHMMSS)\",\n    \"description\": \"Backup feature note\"\n  },\n  \"pm_theme_light\": {\n    \"message\": \"Cambiar a modo claro\",\n    \"description\": \"Theme toggle tooltip - switch to light\"\n  },\n  \"pm_theme_dark\": {\n    \"message\": \"Cambiar a modo oscuro\",\n    \"description\": \"Theme toggle tooltip - switch to dark\"\n  },\n  \"extensionVersion\": {\n    \"message\": \"Versión\",\n    \"description\": \"Extension version label\"\n  },\n  \"newVersionAvailable\": {\n    \"message\": \"Nueva versión disponible\",\n    \"description\": \"Update reminder title\"\n  },\n  \"currentVersionLabel\": {\n    \"message\": \"Actual\",\n    \"description\": \"Label for current version number\"\n  },\n  \"latestVersionLabel\": {\n    \"message\": \"Última\",\n    \"description\": \"Label for latest version number\"\n  },\n  \"updateNow\": {\n    \"message\": \"Actualizar\",\n    \"description\": \"CTA to update to latest version\"\n  },\n  \"safariUpdateNotSynced\": {\n    \"message\": \"La actualización aún no está sincronizada, inténtelo de nuevo en unas horas\",\n    \"description\": \"Shown on Safari when the DMG file is not yet available on GitHub releases\"\n  },\n  \"starProject\": {\n    \"message\": \"Apóyame ⭐\",\n    \"description\": \"Github callout\"\n  },\n  \"officialDocs\": {\n    \"message\": \"Documentación\",\n    \"description\": \"Official docs link\"\n  },\n  \"exportChatJson\": {\n    \"message\": \"Exportar historial de conversación\",\n    \"description\": \"Tooltip for export button\"\n  },\n  \"folder_title\": {\n    \"message\": \"Carpetas\",\n    \"description\": \"Folder section title\"\n  },\n  \"folder_create\": {\n    \"message\": \"Crear carpeta\",\n    \"description\": \"Create folder button tooltip\"\n  },\n  \"folder_name_prompt\": {\n    \"message\": \"Introduce el nombre de la carpeta:\",\n    \"description\": \"Create folder name prompt\"\n  },\n  \"folder_rename_prompt\": {\n    \"message\": \"Introduce el nuevo nombre:\",\n    \"description\": \"Rename folder prompt\"\n  },\n  \"folder_delete_confirm\": {\n    \"message\": \"¿Eliminar esta carpeta y todo su contenido?\",\n    \"description\": \"Delete folder confirmation\"\n  },\n  \"folder_create_subfolder\": {\n    \"message\": \"Crear subcarpeta\",\n    \"description\": \"Create subfolder menu item\"\n  },\n  \"folder_rename\": {\n    \"message\": \"Renombrar\",\n    \"description\": \"Rename folder menu item\"\n  },\n  \"folder_change_color\": {\n    \"message\": \"Cambiar color\",\n    \"description\": \"Change folder color menu item\"\n  },\n  \"folder_delete\": {\n    \"message\": \"Eliminar\",\n    \"description\": \"Delete folder menu item\"\n  },\n  \"folder_color_default\": {\n    \"message\": \"Predeterminado\",\n    \"description\": \"Default folder color name\"\n  },\n  \"folder_color_red\": {\n    \"message\": \"Rojo\",\n    \"description\": \"Red folder color name\"\n  },\n  \"folder_color_orange\": {\n    \"message\": \"Naranja\",\n    \"description\": \"Orange folder color name\"\n  },\n  \"folder_color_yellow\": {\n    \"message\": \"Amarillo\",\n    \"description\": \"Yellow folder color name\"\n  },\n  \"folder_color_green\": {\n    \"message\": \"Verde\",\n    \"description\": \"Green folder color name\"\n  },\n  \"folder_color_blue\": {\n    \"message\": \"Azul\",\n    \"description\": \"Blue folder color name\"\n  },\n  \"folder_color_purple\": {\n    \"message\": \"Morado\",\n    \"description\": \"Purple folder color name\"\n  },\n  \"folder_color_pink\": {\n    \"message\": \"Rosa\",\n    \"description\": \"Pink folder color name\"\n  },\n  \"folder_color_custom\": {\n    \"message\": \"Personalizado\",\n    \"description\": \"Custom folder color name\"\n  },\n  \"folder_empty\": {\n    \"message\": \"No hay carpetas\",\n    \"description\": \"Empty state placeholder when no folders exist\"\n  },\n  \"folder_pin\": {\n    \"message\": \"Fijar carpeta\",\n    \"description\": \"Pin folder menu item\"\n  },\n  \"folder_unpin\": {\n    \"message\": \"Desfijar carpeta\",\n    \"description\": \"Unpin folder menu item\"\n  },\n  \"folder_remove_conversation\": {\n    \"message\": \"Eliminar de la carpeta\",\n    \"description\": \"Remove conversation button tooltip\"\n  },\n  \"folder_remove_conversation_confirm\": {\n    \"message\": \"¿Eliminar \\\"{title}\\\" de esta carpeta?\",\n    \"description\": \"Remove conversation confirmation\"\n  },\n  \"conversation_more\": {\n    \"message\": \"Más opciones\",\n    \"description\": \"Conversation more options button tooltip\"\n  },\n  \"conversation_move_to_folder\": {\n    \"message\": \"Mover a carpeta\",\n    \"description\": \"Move conversation to folder menu item\"\n  },\n  \"conversation_move_to_folder_title\": {\n    \"message\": \"Mover a carpeta\",\n    \"description\": \"Move conversation to folder dialog title\"\n  },\n  \"chatWidth\": {\n    \"message\": \"Ancho del chat\",\n    \"description\": \"Chat width adjustment label\"\n  },\n  \"chatWidthNarrow\": {\n    \"message\": \"Estrecho\",\n    \"description\": \"Narrow chat width label\"\n  },\n  \"chatWidthWide\": {\n    \"message\": \"Ancho\",\n    \"description\": \"Wide chat width label\"\n  },\n  \"sidebarWidth\": {\n    \"message\": \"Ancho de la barra lateral\",\n    \"description\": \"Sidebar width adjustment label\"\n  },\n  \"sidebarWidthNarrow\": {\n    \"message\": \"Estrecho\",\n    \"description\": \"Narrow sidebar width label\"\n  },\n  \"sidebarWidthWide\": {\n    \"message\": \"Ancho\",\n    \"description\": \"Wide sidebar width label\"\n  },\n  \"formula_copied\": {\n    \"message\": \"✓ Fórmula copiada\",\n    \"description\": \"Formula copied success message\"\n  },\n  \"formula_copy_failed\": {\n    \"message\": \"✗ Error al copiar\",\n    \"description\": \"Formula copy failed message\"\n  },\n  \"formulaCopyFormat\": {\n    \"message\": \"Formato de copia de fórmula\",\n    \"description\": \"Formula copy format setting label\"\n  },\n  \"formulaCopyFormatLatex\": {\n    \"message\": \"LaTeX\",\n    \"description\": \"LaTeX format option\"\n  },\n  \"formulaCopyFormatUnicodeMath\": {\n    \"message\": \"MathML (Word)\",\n    \"description\": \"Label for UnicodeMath formula copy format option\"\n  },\n  \"formulaCopyFormatHint\": {\n    \"message\": \"Elige el formato al copiar fórmulas haciendo clic\",\n    \"description\": \"Formula copy format hint\"\n  },\n  \"formulaCopyFormatNoDollar\": {\n    \"message\": \"LaTeX (Sin signo de dólar)\",\n    \"description\": \"LaTeX format option without dollar signs\"\n  },\n  \"folder_filter_current_user\": {\n    \"message\": \"Aislamiento de cuenta\",\n    \"description\": \"Show only conversations from the current Google account\"\n  },\n  \"folder_export\": {\n    \"message\": \"Exportar carpetas\",\n    \"description\": \"Export folders button tooltip\"\n  },\n  \"folder_import_export\": {\n    \"message\": \"Importar/Exportar carpetas\",\n    \"description\": \"Combined import/export button tooltip\"\n  },\n  \"folder_cloud_upload\": {\n    \"message\": \"Subir a la nube\",\n    \"description\": \"Cloud upload button tooltip\"\n  },\n  \"folder_cloud_sync\": {\n    \"message\": \"Sincronizar desde la nube\",\n    \"description\": \"Cloud sync button tooltip\"\n  },\n  \"folder_import\": {\n    \"message\": \"Importar carpetas\",\n    \"description\": \"Import folders button tooltip\"\n  },\n  \"folder_import_title\": {\n    \"message\": \"Importar Configuración de Carpetas\",\n    \"description\": \"Import dialog title\"\n  },\n  \"folder_import_strategy\": {\n    \"message\": \"Estrategia de importación:\",\n    \"description\": \"Import strategy label\"\n  },\n  \"folder_import_merge\": {\n    \"message\": \"Fusionar con carpetas existentes\",\n    \"description\": \"Merge import strategy\"\n  },\n  \"folder_import_overwrite\": {\n    \"message\": \"Sobrescribir carpetas existentes\",\n    \"description\": \"Overwrite import strategy\"\n  },\n  \"folder_import_select_file\": {\n    \"message\": \"Selecciona un archivo JSON para importar\",\n    \"description\": \"Import file selection prompt\"\n  },\n  \"folder_import_success\": {\n    \"message\": \"✓ Importadas {folders} carpetas, {conversations} conversaciones\",\n    \"description\": \"Import success message\"\n  },\n  \"folder_import_success_skipped\": {\n    \"message\": \"✓ Importadas {folders} carpetas, {conversations} conversaciones ({skipped} duplicados omitidos)\",\n    \"description\": \"Import success with skipped message\"\n  },\n  \"folder_import_error\": {\n    \"message\": \"✗ Error en la importación: {error}\",\n    \"description\": \"Import error message\"\n  },\n  \"folder_export_success\": {\n    \"message\": \"✓ Carpetas exportadas correctamente\",\n    \"description\": \"Export success message\"\n  },\n  \"folder_import_invalid_format\": {\n    \"message\": \"Formato de archivo inválido. Por favor selecciona un archivo de configuración de carpetas válido.\",\n    \"description\": \"Invalid format error\"\n  },\n  \"folder_import_confirm_overwrite\": {\n    \"message\": \"Esto reemplazará todas las carpetas existentes. Se creará una copia de seguridad. ¿Continuar?\",\n    \"description\": \"Overwrite confirmation\"\n  },\n  \"folder_import_paste_json\": {\n    \"message\": \"O pegar JSON directamente\",\n    \"description\": \"Botón para mostrar el área de texto de pegado en el diálogo de importación\"\n  },\n  \"folder_import_paste_placeholder\": {\n    \"message\": \"Pega tu JSON aquí...\",\n    \"description\": \"Texto de marcador para el área de pegado\"\n  },\n  \"export_dialog_title\": {\n    \"message\": \"Exportar Conversación\",\n    \"description\": \"Export dialog title\"\n  },\n  \"export_dialog_select\": {\n    \"message\": \"Elige formato de exportación:\",\n    \"description\": \"Export format selection label\"\n  },\n  \"export_dialog_warning\": {\n    \"message\": \"⚠️ Tras hacer clic en exportar, el sistema saltará automáticamente al primer mensaje para cargar todo el contenido. Por favor, no realice ninguna acción; la exportación continuará automáticamente tras el salto.\",\n    \"description\": \"Warning about auto-jump and loading completeness\"\n  },\n  \"export_dialog_safari_cmdp_hint\": {\n    \"message\": \"Consejo para Safari: haz clic en 'Exportar' abajo, espera un momento, luego presiona ⌘P y elige 'Guardar como PDF'.\",\n    \"description\": \"Safari-specific hint for PDF export\"\n  },\n  \"export_toast_safari_pdf_ready\": {\n    \"message\": \"Ahora puedes pulsar Command + P para exportar el PDF.\",\n    \"description\": \"Safari-specific toast shown after PDF export is prepared\"\n  },\n  \"export_dialog_safari_markdown_hint\": {\n    \"message\": \"Aviso: debido a limitaciones de Safari, no se pueden extraer las imágenes del historial del chat. Para una exportación completa, usa la exportación en PDF.\",\n    \"description\": \"Warning about image references in Safari markdown export\"\n  },\n  \"export_format_json_description\": {\n    \"message\": \"Formato legible por máquina para desarrolladores\",\n    \"description\": \"Description for JSON export format\"\n  },\n  \"export_format_markdown_description\": {\n    \"message\": \"Formato de texto limpio y portátil (recomendado)\",\n    \"description\": \"Description for Markdown export format\"\n  },\n  \"export_format_pdf_description\": {\n    \"message\": \"Formato apto para impresión mediante Guardar como PDF\",\n    \"description\": \"Description for PDF export format\"\n  },\n  \"editInputWidth\": {\n    \"message\": \"Ancho del campo de edición\",\n    \"description\": \"Edit input width adjustment label\"\n  },\n  \"editInputWidthNarrow\": {\n    \"message\": \"Estrecho\",\n    \"description\": \"Narrow edit input width label\"\n  },\n  \"editInputWidthWide\": {\n    \"message\": \"Ancho\",\n    \"description\": \"Wide edit input width label\"\n  },\n  \"timelineOptions\": {\n    \"message\": \"Opciones de Línea de Tiempo\",\n    \"description\": \"Timeline options section label\"\n  },\n  \"folderOptions\": {\n    \"message\": \"Opciones de Carpetas\",\n    \"description\": \"Folder options section label\"\n  },\n  \"enableFolderFeature\": {\n    \"message\": \"Habilitar función de carpetas\",\n    \"description\": \"Enable or disable the folder feature\"\n  },\n  \"hideArchivedConversations\": {\n    \"message\": \"Ocultar conversaciones archivadas\",\n    \"description\": \"Hide conversations that are in folders from the main list\"\n  },\n  \"conversation_star\": {\n    \"message\": \"Destacar conversación\",\n    \"description\": \"Star conversation button tooltip\"\n  },\n  \"conversation_unstar\": {\n    \"message\": \"Quitar destacado de conversación\",\n    \"description\": \"Unstar conversation button tooltip\"\n  },\n  \"conversation_untitled\": {\n    \"message\": \"Sin título\",\n    \"description\": \"Fallback title for conversations without a name\"\n  },\n  \"geminiOnlyNotice\": {\n    \"message\": \"Funciona en Gemini (incluyendo Enterprise) y AI Studio por defecto. Añade otros sitios abajo para habilitar el Gestor de Prompts allí.\",\n    \"description\": \"Notice that defaults are limited to Gemini/AI Studio\"\n  },\n  \"folderManager_dataLossWarning\": {\n    \"message\": \"⚠️ Advertencia: Error al cargar datos de carpetas. Tus carpetas pueden estar corruptas. Por favor revisa la consola del navegador para más detalles e intenta restaurar desde una copia de seguridad si está disponible.\",\n    \"description\": \"Warning shown when folder data fails to load\"\n  },\n  \"backupOptions\": {\n    \"message\": \"Respaldo Automático\",\n    \"description\": \"Backup options section label\"\n  },\n  \"backupEnabled\": {\n    \"message\": \"Habilitar respaldo automático\",\n    \"description\": \"Enable auto backup toggle\"\n  },\n  \"backupNow\": {\n    \"message\": \"Respaldar Ahora\",\n    \"description\": \"Backup now button\"\n  },\n  \"backupSelectFolder\": {\n    \"message\": \"Seleccionar Carpeta de Respaldo\",\n    \"description\": \"Select backup folder button\"\n  },\n  \"backupFolderSelected\": {\n    \"message\": \"Carpeta de respaldo: {folder}\",\n    \"description\": \"Shows selected backup folder\"\n  },\n  \"backupIncludePrompts\": {\n    \"message\": \"Incluir prompts\",\n    \"description\": \"Include prompts in backup toggle\"\n  },\n  \"backupIncludeFolders\": {\n    \"message\": \"Incluir carpetas\",\n    \"description\": \"Include folders in backup toggle\"\n  },\n  \"backupIntervalLabel\": {\n    \"message\": \"Intervalo de respaldo\",\n    \"description\": \"Backup interval label\"\n  },\n  \"backupIntervalManual\": {\n    \"message\": \"Solo manual\",\n    \"description\": \"Manual backup mode\"\n  },\n  \"backupIntervalDaily\": {\n    \"message\": \"Diario\",\n    \"description\": \"Daily backup interval\"\n  },\n  \"backupIntervalWeekly\": {\n    \"message\": \"Semanal (7 días)\",\n    \"description\": \"Weekly backup interval\"\n  },\n  \"backupLastBackup\": {\n    \"message\": \"Último respaldo: {time}\",\n    \"description\": \"Last backup timestamp\"\n  },\n  \"backupNever\": {\n    \"message\": \"Nunca\",\n    \"description\": \"Never backed up\"\n  },\n  \"backupSuccess\": {\n    \"message\": \"✓ Respaldo creado: {prompts} prompts, {folders} carpetas, {conversations} conversaciones\",\n    \"description\": \"Backup success message\"\n  },\n  \"backupError\": {\n    \"message\": \"✗ Error en el respaldo: {error}\",\n    \"description\": \"Backup error message\"\n  },\n  \"backupNotSupported\": {\n    \"message\": \"⚠️ El respaldo automático requiere un navegador moderno con soporte para File System Access API\",\n    \"description\": \"Browser not supported message\"\n  },\n  \"backupSelectFolderFirst\": {\n    \"message\": \"Por favor selecciona primero una carpeta de respaldo\",\n    \"description\": \"No folder selected warning\"\n  },\n  \"backupUserCancelled\": {\n    \"message\": \"Respaldo cancelado\",\n    \"description\": \"User cancelled folder selection\"\n  },\n  \"backupConfigSaved\": {\n    \"message\": \"✓ Configuración de respaldo guardada\",\n    \"description\": \"Config saved message\"\n  },\n  \"backupPermissionDenied\": {\n    \"message\": \"⚠️ No se puede acceder a este directorio. Por favor elige una ubicación diferente (ej. Documentos, Descargas, o una carpeta en el Escritorio)\",\n    \"description\": \"Permission denied for restricted directory\"\n  },\n  \"backupConfigureInOptions\": {\n    \"message\": \"La configuración de respaldo requiere la página de Opciones. Haz clic en el botón de abajo para abrirla (clic en los tres puntos junto al icono de la extensión → Opciones).\",\n    \"description\": \"Backup configure in options page explanation\"\n  },\n  \"openOptionsPage\": {\n    \"message\": \"Abrir Página de Opciones\",\n    \"description\": \"Open options page button\"\n  },\n  \"optionsPageSubtitle\": {\n    \"message\": \"Opciones de la Extensión\",\n    \"description\": \"Options page subtitle\"\n  },\n  \"optionsComingSoon\": {\n    \"message\": \"Más opciones próximamente...\",\n    \"description\": \"Options page placeholder text\"\n  },\n  \"backupDataAccessNotice\": {\n    \"message\": \"Limitación de Acceso a Datos\",\n    \"description\": \"Backup data access limitation title\"\n  },\n  \"backupDataAccessHint\": {\n    \"message\": \"Debido a restricciones de seguridad del navegador, esta página de Opciones no puede acceder directamente a los prompts y datos de carpetas de las páginas de Gemini. Por favor usa el Gestor de Prompts y las funciones de Exportación de Carpetas en la página de Gemini para respaldos manuales.\",\n    \"description\": \"Backup data access limitation explanation\"\n  },\n  \"promptManagerOptions\": {\n    \"message\": \"Gestor de Prompts\",\n    \"description\": \"Prompt Manager options section label\"\n  },\n  \"hidePromptManager\": {\n    \"message\": \"Ocultar Gestor de Prompts\",\n    \"description\": \"Hide the prompt manager floating ball\"\n  },\n  \"hidePromptManagerHint\": {\n    \"message\": \"Ocultar el botón flotante del Gestor de Prompts en la página\",\n    \"description\": \"Hint for hiding prompt manager feature\"\n  },\n  \"customWebsites\": {\n    \"message\": \"Sitios web personalizados\",\n    \"description\": \"Custom websites for prompt manager\"\n  },\n  \"customWebsitesPlaceholder\": {\n    \"message\": \"Introduce URL del sitio (ej. chatgpt.com)\",\n    \"description\": \"Custom websites input placeholder\"\n  },\n  \"customWebsitesHint\": {\n    \"message\": \"Añade sitios web donde quieras usar el Gestor de Prompts. Solo el Gestor de Prompts se activará en estos sitios.\",\n    \"description\": \"Custom websites hint\"\n  },\n  \"addWebsite\": {\n    \"message\": \"Añadir sitio web\",\n    \"description\": \"Add website button\"\n  },\n  \"removeWebsite\": {\n    \"message\": \"Eliminar\",\n    \"description\": \"Remove website button\"\n  },\n  \"invalidUrl\": {\n    \"message\": \"Formato de URL inválido\",\n    \"description\": \"Invalid URL error message\"\n  },\n  \"websiteAdded\": {\n    \"message\": \"Sitio web añadido\",\n    \"description\": \"Website added success message\"\n  },\n  \"websiteRemoved\": {\n    \"message\": \"Sitio web eliminado\",\n    \"description\": \"Website removed success message\"\n  },\n  \"permissionDenied\": {\n    \"message\": \"Permiso denegado. Por favor permite el acceso a este sitio web.\",\n    \"description\": \"Permission denied error message\"\n  },\n  \"permissionRequestFailed\": {\n    \"message\": \"Solicitud de permiso fallida. Por favor intenta de nuevo o concede acceso en la configuración de la extensión.\",\n    \"description\": \"Permission request failure message\"\n  },\n  \"customWebsitesNote\": {\n    \"message\": \"Consejo: Se te pedirá permitir acceso al añadir un sitio. Después de conceder permiso, recarga ese sitio para que el Gestor de Prompts pueda iniciarse.\",\n    \"description\": \"Custom websites reload/permission note\"\n  },\n  \"starredHistory\": {\n    \"message\": \"Historial Destacado\",\n    \"description\": \"Starred history title\"\n  },\n  \"viewStarredHistory\": {\n    \"message\": \"Ver Historial Destacado\",\n    \"description\": \"View starred history button\"\n  },\n  \"noStarredMessages\": {\n    \"message\": \"No hay mensajes destacados todavía\",\n    \"description\": \"No starred messages placeholder\"\n  },\n  \"removeFromStarred\": {\n    \"message\": \"Quitar de destacados\",\n    \"description\": \"Remove from starred tooltip\"\n  },\n  \"justNow\": {\n    \"message\": \"Justo ahora\",\n    \"description\": \"Just now time label\"\n  },\n  \"hoursAgo\": {\n    \"message\": \"horas atrás\",\n    \"description\": \"Hours ago label\"\n  },\n  \"yesterday\": {\n    \"message\": \"Ayer\",\n    \"description\": \"Yesterday date label\"\n  },\n  \"daysAgo\": {\n    \"message\": \"días atrás\",\n    \"description\": \"Days ago label\"\n  },\n  \"loading\": {\n    \"message\": \"Cargando...\",\n    \"description\": \"Loading message\"\n  },\n  \"keyboardShortcuts\": {\n    \"message\": \"Atajos de Teclado\",\n    \"description\": \"Keyboard shortcuts section title\"\n  },\n  \"enableShortcuts\": {\n    \"message\": \"Habilitar atajos de teclado\",\n    \"description\": \"Enable shortcuts toggle label\"\n  },\n  \"previousNode\": {\n    \"message\": \"Nodo anterior\",\n    \"description\": \"Previous timeline node shortcut label\"\n  },\n  \"nextNode\": {\n    \"message\": \"Nodo siguiente\",\n    \"description\": \"Next timeline node shortcut label\"\n  },\n  \"shortcutKey\": {\n    \"message\": \"Tecla\",\n    \"description\": \"Shortcut key label\"\n  },\n  \"shortcutModifiers\": {\n    \"message\": \"Modificadores\",\n    \"description\": \"Shortcut modifiers label\"\n  },\n  \"resetShortcuts\": {\n    \"message\": \"Restablecer a valores predeterminados\",\n    \"description\": \"Reset shortcuts button\"\n  },\n  \"shortcutsResetSuccess\": {\n    \"message\": \"Atajos restablecidos\",\n    \"description\": \"Shortcuts reset success message\"\n  },\n  \"shortcutsDescription\": {\n    \"message\": \"Navegar nodos de línea de tiempo usando atajos de teclado\",\n    \"description\": \"Shortcuts description\"\n  },\n  \"modifierNone\": {\n    \"message\": \"Ninguno\",\n    \"description\": \"No modifier key\"\n  },\n  \"modifierAlt\": {\n    \"message\": \"Alt\",\n    \"description\": \"Alt modifier key\"\n  },\n  \"modifierCtrl\": {\n    \"message\": \"Ctrl\",\n    \"description\": \"Ctrl modifier key\"\n  },\n  \"modifierShift\": {\n    \"message\": \"Mayús\",\n    \"description\": \"Shift modifier key\"\n  },\n  \"modifierMeta\": {\n    \"message\": \"Cmd/Win\",\n    \"description\": \"Meta/Command/Windows modifier key\"\n  },\n  \"preventAutoScroll\": {\n    \"message\": \"Evitar desplazamiento automático\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"preventAutoScrollHint\": {\n    \"message\": \"Evita que Gemini salte al final cuando presionas Enter mientras lees respuestas anteriores.\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"cloudSync\": {\n    \"message\": \"Sincronización en Nube\",\n    \"description\": \"Cloud sync section title\"\n  },\n  \"cloudSyncDescription\": {\n    \"message\": \"Sincronizar carpetas y prompts con Google Drive\",\n    \"description\": \"Cloud sync description\"\n  },\n  \"syncModeDisabled\": {\n    \"message\": \"Desactivado\",\n    \"description\": \"Sync mode disabled option\"\n  },\n  \"syncModeManual\": {\n    \"message\": \"Manual\",\n    \"description\": \"Sync mode manual option\"\n  },\n  \"syncServerPort\": {\n    \"message\": \"Puerto del servidor\",\n    \"description\": \"Port number for the sync server\"\n  },\n  \"syncNow\": {\n    \"message\": \"Sincronizar\",\n    \"description\": \"Sync now button\"\n  },\n  \"lastSynced\": {\n    \"message\": \"Última sincr.: {time}\",\n    \"description\": \"Last sync timestamp\"\n  },\n  \"neverSynced\": {\n    \"message\": \"Nunca sincronizado\",\n    \"description\": \"Never synced message\"\n  },\n  \"lastUploaded\": {\n    \"message\": \"Subido: {time}\",\n    \"description\": \"Last upload timestamp\"\n  },\n  \"neverUploaded\": {\n    \"message\": \"Nunca subido\",\n    \"description\": \"Never uploaded message\"\n  },\n  \"syncSuccess\": {\n    \"message\": \"✓ Sincronizado correctamente\",\n    \"description\": \"Sync success message\"\n  },\n  \"syncError\": {\n    \"message\": \"✗ Error de sincronización: {error}\",\n    \"description\": \"Sync error message\"\n  },\n  \"syncInProgress\": {\n    \"message\": \"Sincronizando...\",\n    \"description\": \"Sync in progress message\"\n  },\n  \"uploadInProgress\": {\n    \"message\": \"Uploading...\",\n    \"description\": \"Upload in progress message\"\n  },\n  \"uploadSuccess\": {\n    \"message\": \"✓ Uploaded successfully\",\n    \"description\": \"Upload success message\"\n  },\n  \"downloadInProgress\": {\n    \"message\": \"Downloading...\",\n    \"description\": \"Download in progress message\"\n  },\n  \"downloadMergeSuccess\": {\n    \"message\": \"✓ Downloaded and merged\",\n    \"description\": \"Download and merge success message\"\n  },\n  \"syncUpload\": {\n    \"message\": \"Subir\",\n    \"description\": \"Upload button label\"\n  },\n  \"syncMerge\": {\n    \"message\": \"Fusionar\",\n    \"description\": \"Merge button label\"\n  },\n  \"syncMode\": {\n    \"message\": \"Modo sinc.\",\n    \"description\": \"Sync mode label\"\n  },\n  \"minutesAgo\": {\n    \"message\": \"minutos atrás\",\n    \"description\": \"Minutes ago label\"\n  },\n  \"syncNoData\": {\n    \"message\": \"No se encontraron datos de sincronización en Drive\",\n    \"description\": \"Error message when no data is found in Drive\"\n  },\n  \"syncUploadFailed\": {\n    \"message\": \"Error al subir\",\n    \"description\": \"Error message when upload fails\"\n  },\n  \"syncDownloadFailed\": {\n    \"message\": \"Error al descargar\",\n    \"description\": \"Error message when download fails\"\n  },\n  \"syncAuthFailed\": {\n    \"message\": \"Error de autenticación\",\n    \"description\": \"Error message when authentication fails\"\n  },\n  \"signOut\": {\n    \"message\": \"Cerrar sesión\",\n    \"description\": \"Sign out button\"\n  },\n  \"signInWithGoogle\": {\n    \"message\": \"Iniciar sesión con Google\",\n    \"description\": \"Sign in with Google button\"\n  },\n  \"nanobananaOptions\": {\n    \"message\": \"Opciones de NanoBanana\",\n    \"description\": \"NanoBanana options section label\"\n  },\n  \"enableNanobananaWatermarkRemover\": {\n    \"message\": \"Eliminar marca de agua de NanoBanana\",\n    \"description\": \"Enable automatic watermark removal for NanoBanana images\"\n  },\n  \"nanobananaWatermarkRemoverHint\": {\n    \"message\": \"Elimina automáticamente las marcas de agua de Gemini visibles en las imágenes generadas\",\n    \"description\": \"Watermark remover feature hint\"\n  },\n  \"nanobananaDownloadTooltip\": {\n    \"message\": \"Descargar imagen sin marca de agua (NanoBanana)\",\n    \"description\": \"Download button tooltip\"\n  },\n  \"deepResearchDownload\": {\n    \"message\": \"Descargar contenido de pensamiento\",\n    \"description\": \"Deep Research download button text\"\n  },\n  \"deepResearchDownloadTooltip\": {\n    \"message\": \"Descargar contenido de pensamiento como Markdown\",\n    \"description\": \"Deep Research download button tooltip\"\n  },\n  \"folder_uncategorized\": {\n    \"message\": \"Sin categorizar\",\n    \"description\": \"Label for root-level conversations not in any folder\"\n  },\n  \"timelineLevelTitle\": {\n    \"message\": \"Nivel de Nodo\",\n    \"description\": \"Title for timeline node level context menu\"\n  },\n  \"timelineLevel1\": {\n    \"message\": \"Nivel 1\",\n    \"description\": \"Timeline node level 1 option\"\n  },\n  \"timelineLevel2\": {\n    \"message\": \"Nivel 2\",\n    \"description\": \"Timeline node level 2 option\"\n  },\n  \"timelineLevel3\": {\n    \"message\": \"Nivel 3\",\n    \"description\": \"Timeline node level 3 option\"\n  },\n  \"timelineCollapse\": {\n    \"message\": \"Contraer\",\n    \"description\": \"Collapse children\"\n  },\n  \"timelineExpand\": {\n    \"message\": \"Expandir\",\n    \"description\": \"Expand children\"\n  },\n  \"timelinePreviewSearch\": {\n    \"message\": \"Buscar...\",\n    \"description\": \"Timeline preview panel search placeholder\"\n  },\n  \"timelinePreviewNoResults\": {\n    \"message\": \"Sin resultados\",\n    \"description\": \"Timeline preview panel no search results\"\n  },\n  \"timelinePreviewNoMessages\": {\n    \"message\": \"Sin mensajes\",\n    \"description\": \"Timeline preview panel no messages\"\n  },\n  \"quoteReply\": {\n    \"message\": \"Responder cita\",\n    \"description\": \"Quote reply button text\"\n  },\n  \"inputCollapseOptions\": {\n    \"message\": \"Opciones de entrada\",\n    \"description\": \"Input options section label\"\n  },\n  \"enableInputCollapse\": {\n    \"message\": \"Habilitar contracción de entrada\",\n    \"description\": \"Enable input collapse toggle label\"\n  },\n  \"enableInputCollapseHint\": {\n    \"message\": \"Contrae el área de entrada cuando está vacía para ganar más espacio de lectura\",\n    \"description\": \"Input collapse feature hint\"\n  },\n  \"inputCollapseShortcutHint\": {\n    \"message\": \"{modifier}+I para expandir\",\n    \"description\": \"Shortcut hint for expanding collapsed input. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"inputCollapsePlaceholder\": {\n    \"message\": \"Mensaje a Gemini\",\n    \"description\": \"Placeholder text shown in collapsed input\"\n  },\n  \"allowCollapseWhenNotEmpty\": {\n    \"message\": \"Permitir colapso con contenido\",\n    \"description\": \"Allow input to collapse even when it has text or attachments\"\n  },\n  \"allowCollapseWhenNotEmptyHint\": {\n    \"message\": \"Colapsar el area de entrada incluso cuando contiene texto o archivos adjuntos\",\n    \"description\": \"Hint text explaining the collapse when not empty feature\"\n  },\n  \"ctrlEnterSend\": {\n    \"message\": \"{modifier}+Enter para enviar\",\n    \"description\": \"Modifier+Enter to send toggle label. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"ctrlEnterSendHint\": {\n    \"message\": \"Presiona {modifier}+Enter para enviar mensajes, Enter para agregar una nueva línea\",\n    \"description\": \"Modifier+Enter to send feature hint. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"batch_delete_confirm\": {\n    \"message\": \"¿Estás seguro de que quieres eliminar {count} conversación(es)? Esta acción no se puede deshacer.\",\n    \"description\": \"Batch delete confirmation message\"\n  },\n  \"batch_delete_in_progress\": {\n    \"message\": \"Eliminando... ({current}/{total})\",\n    \"description\": \"Batch delete progress message\"\n  },\n  \"batch_delete_success\": {\n    \"message\": \"✓ Eliminadas {count} conversación(es)\",\n    \"description\": \"Batch delete success message\"\n  },\n  \"batch_delete_partial\": {\n    \"message\": \"Eliminación completa: {success} con éxito, {failed} fallidos\",\n    \"description\": \"Batch delete partial success message\"\n  },\n  \"batch_delete_limit_reached\": {\n    \"message\": \"Máximo {max} conversaciones pueden ser seleccionadas a la vez\",\n    \"description\": \"Batch delete limit warning\"\n  },\n  \"batch_delete_button\": {\n    \"message\": \"Eliminar seleccionados\",\n    \"description\": \"Batch delete button tooltip in multi-select mode\"\n  },\n  \"batch_delete_match_patterns\": {\n    \"message\": \"delete,confirm,yes,ok,remove,eliminar,confirmar,si,sí,aceptar,borrar\",\n    \"description\": \"Comma-separated list of keywords to match native delete/confirm buttons (case-insensitive)\"\n  },\n  \"generalOptions\": {\n    \"message\": \"Opciones Generales\",\n    \"description\": \"General options section label\"\n  },\n  \"enableTabTitleUpdate\": {\n    \"message\": \"Sincronizar título de pestaña con conversación\",\n    \"description\": \"Enable tab title sync toggle label\"\n  },\n  \"enableTabTitleUpdateHint\": {\n    \"message\": \"Actualiza automáticamente el título de la pestaña del navegador para que coincida con el título de la conversación actual\",\n    \"description\": \"Tab title sync feature hint\"\n  },\n  \"enableMermaidRendering\": {\n    \"message\": \"Habilitar renderizado de diagramas Mermaid\",\n    \"description\": \"Enable Mermaid diagram rendering toggle label\"\n  },\n  \"enableMermaidRenderingHint\": {\n    \"message\": \"Renderiza automáticamente diagramas Mermaid en bloques de código\",\n    \"description\": \"Mermaid rendering feature hint\"\n  },\n  \"enableQuoteReply\": {\n    \"message\": \"Habilitar citar al responder\",\n    \"description\": \"Enable quote reply toggle label\"\n  },\n  \"enableQuoteReplyHint\": {\n    \"message\": \"Muestra un botón flotante para citar el texto seleccionado en las conversaciones\",\n    \"description\": \"Quote reply feature hint\"\n  },\n  \"contextSync\": {\n    \"message\": \"Sincronización de contexto\",\n    \"description\": \"Context Sync title\"\n  },\n  \"contextSyncDescription\": {\n    \"message\": \"Sincroniza el contexto del chat con tu IDE local\",\n    \"description\": \"Context Sync description\"\n  },\n  \"syncToIDE\": {\n    \"message\": \"Sincronizar con IDE\",\n    \"description\": \"Sync to IDE button\"\n  },\n  \"ideOnline\": {\n    \"message\": \"IDE en línea\",\n    \"description\": \"IDE Online status\"\n  },\n  \"ideOffline\": {\n    \"message\": \"IDE fuera de línea\",\n    \"description\": \"IDE Offline status\"\n  },\n  \"syncing\": {\n    \"message\": \"Sincronizando...\",\n    \"description\": \"Syncing status\"\n  },\n  \"checkServer\": {\n    \"message\": \"Por favor, inicia el servidor AI Sync en VS Code\",\n    \"description\": \"Check server hint\"\n  },\n  \"capturing\": {\n    \"message\": \"Capturando diálogo...\",\n    \"description\": \"Capturing status\"\n  },\n  \"syncedSuccess\": {\n    \"message\": \"¡Sincronizado con éxito!\",\n    \"description\": \"Synced success message\"\n  },\n  \"downloadingOriginal\": {\n    \"message\": \"Descargando imagen original\",\n    \"description\": \"Status when downloading original image\"\n  },\n  \"downloadingOriginalLarge\": {\n    \"message\": \"Descargando imagen original (archivo grande)\",\n    \"description\": \"Status when downloading large original image\"\n  },\n  \"downloadLargeWarning\": {\n    \"message\": \"Advertencia de archivo grande\",\n    \"description\": \"Warning shown for large downloads\"\n  },\n  \"downloadProcessing\": {\n    \"message\": \"Procesando marca de agua\",\n    \"description\": \"Status when processing watermark removal\"\n  },\n  \"downloadSuccess\": {\n    \"message\": \"Descargando...\",\n    \"description\": \"Status when download starts\"\n  },\n  \"downloadError\": {\n    \"message\": \"Error\",\n    \"description\": \"Status prefix when download fails\"\n  },\n  \"recentsHide\": {\n    \"message\": \"Ocultar elementos recientes\",\n    \"description\": \"Tooltip para ocultar la sección de elementos recientes\"\n  },\n  \"recentsShow\": {\n    \"message\": \"Mostrar elementos recientes\",\n    \"description\": \"Tooltip para mostrar la sección de elementos recientes\"\n  },\n  \"gemsHide\": {\n    \"message\": \"Ocultar Gems\",\n    \"description\": \"Tooltip para ocultar la sección de la lista de Gems\"\n  },\n  \"gemsShow\": {\n    \"message\": \"Mostrar Gems\",\n    \"description\": \"Tooltip para mostrar la sección de la lista de Gems\"\n  },\n  \"setAsDefaultModel\": {\n    \"message\": \"Set as default model\",\n    \"description\": \"Tooltip for setting default model\"\n  },\n  \"cancelDefaultModel\": {\n    \"message\": \"Cancel default model\",\n    \"description\": \"Tooltip for cancelling default model\"\n  },\n  \"defaultModelSet\": {\n    \"message\": \"Default model set: $1\",\n    \"description\": \"Toast message when default model is set\"\n  },\n  \"defaultModelCleared\": {\n    \"message\": \"Default model cleared\",\n    \"description\": \"Toast message when default model is cleared\"\n  },\n  \"sidebarAutoHide\": {\n    \"message\": \"Ocultar barra lateral auto\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"sidebarAutoHideHint\": {\n    \"message\": \"Contrae automáticamente la barra lateral cuando el ratón sale, la expande cuando entra\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"sidebarFullHide\": {\n    \"message\": \"Ocultar barra lateral completa\",\n    \"description\": \"Fully hide sidebar toggle label\"\n  },\n  \"sidebarFullHideHint\": {\n    \"message\": \"Ocultar completamente la barra lateral contraída, pasar el ratón por el borde izquierdo para mostrar\",\n    \"description\": \"Fully hide sidebar feature hint\"\n  },\n  \"folderSpacing\": {\n    \"message\": \"Espaciado de carpetas\",\n    \"description\": \"Folder spacing adjustment label\"\n  },\n  \"folderSpacingCompact\": {\n    \"message\": \"Compacto\",\n    \"description\": \"Compact folder spacing label\"\n  },\n  \"folderSpacingSpacious\": {\n    \"message\": \"Espacioso\",\n    \"description\": \"Spacious folder spacing label\"\n  },\n  \"folderTreeIndent\": {\n    \"message\": \"Sangría de subcarpetas\",\n    \"description\": \"Subfolder tree indentation adjustment label\"\n  },\n  \"folderTreeIndentCompact\": {\n    \"message\": \"Estrecha\",\n    \"description\": \"Narrow subfolder indentation label\"\n  },\n  \"folderTreeIndentSpacious\": {\n    \"message\": \"Ancha\",\n    \"description\": \"Wide subfolder indentation label\"\n  },\n  \"export_select_mode_select_all\": {\n    \"message\": \"Seleccionar todo\",\n    \"description\": \"Selection mode select all toggle\"\n  },\n  \"export_select_mode_count\": {\n    \"message\": \"Seleccionados {count}\",\n    \"description\": \"Selection mode selected message count\"\n  },\n  \"export_select_mode_toggle\": {\n    \"message\": \"Select message\",\n    \"description\": \"Selection mode per-message toggle tooltip\"\n  },\n  \"export_select_mode_select_below\": {\n    \"message\": \"Select messages below\",\n    \"description\": \"Selection mode top-left button to select messages below current line\"\n  },\n  \"export_select_mode_empty\": {\n    \"message\": \"Please select at least one message to export.\",\n    \"description\": \"Selection mode validation when nothing is selected\"\n  },\n  \"export_format_image_description\": {\n    \"message\": \"Imagen PNG única, ideal para compartir en móvil.\",\n    \"description\": \"Description for Image export format\"\n  },\n  \"export_image_progress\": {\n    \"message\": \"Generating image...\",\n    \"description\": \"Progress message while generating image export\"\n  },\n  \"deepResearchSaveReport\": {\n    \"message\": \"Save Report\",\n    \"description\": \"Deep Research save report button\"\n  },\n  \"deepResearchSaveReportTooltip\": {\n    \"message\": \"Export this research report\",\n    \"description\": \"Deep Research save report tooltip\"\n  },\n  \"export_fontsize_label\": {\n    \"message\": \"Tamaño de fuente\",\n    \"description\": \"Label for font size slider in export dialog\"\n  },\n  \"export_fontsize_preview\": {\n    \"message\": \"El veloz murciélago hindú comía feliz cardillo y kiwi.\",\n    \"description\": \"Preview text for font size slider in export dialog\"\n  },\n  \"export_error_generic\": {\n    \"message\": \"Error al exportar: {error}\",\n    \"description\": \"Mensaje genérico de error de exportación con detalle\"\n  },\n  \"export_error_refresh_retry\": {\n    \"message\": \"La exportación de imagen falló por problemas de red. Actualiza la página y vuelve a intentarlo.\",\n    \"description\": \"Sugerencia para reintentar cuando falla temporalmente la carga en exportación de imagen\"\n  },\n  \"export_md_include_source_confirm\": {\n    \"message\": \"El contenido exportado contiene imágenes de búsqueda web. ¿Incluir enlaces de origen de las imágenes en el Markdown?\",\n    \"description\": \"Confirm dialog when exporting markdown with web search images\"\n  },\n  \"deepResearch_exportedAt\": {\n    \"message\": \"Exportado el\",\n    \"description\": \"Etiqueta de marca de tiempo de exportación de Deep Research\"\n  },\n  \"deepResearch_totalPhases\": {\n    \"message\": \"Fases totales\",\n    \"description\": \"Etiqueta del total de fases de pensamiento de Deep Research\"\n  },\n  \"deepResearch_thinkingPhase\": {\n    \"message\": \"Fase de pensamiento\",\n    \"description\": \"Encabezado de fase de pensamiento de Deep Research\"\n  },\n  \"deepResearch_researchedWebsites\": {\n    \"message\": \"Sitios web investigados\",\n    \"description\": \"Encabezado de sección de sitios web investigados de Deep Research\"\n  },\n  \"visualEffect\": {\n    \"message\": \"Efectos visuales\",\n    \"description\": \"Visual effect selector label\"\n  },\n  \"visualEffectHint\": {\n    \"message\": \"Agrega ambiente estacional a la pagina\",\n    \"description\": \"Visual effect feature hint\"\n  },\n  \"visualEffectOff\": {\n    \"message\": \"Desactivado\",\n    \"description\": \"Visual effect off option\"\n  },\n  \"visualEffectSnow\": {\n    \"message\": \"Nieve\",\n    \"description\": \"Visual effect snow option\"\n  },\n  \"visualEffectSakura\": {\n    \"message\": \"Sakura\",\n    \"description\": \"Visual effect sakura option\"\n  },\n  \"visualEffectRain\": {\n    \"message\": \"Lluvia\",\n    \"description\": \"Visual effect rain option\"\n  },\n  \"changelog_title\": {\n    \"message\": \"Novedades\",\n    \"description\": \"Changelog modal title\"\n  },\n  \"changelog_close\": {\n    \"message\": \"Entendido\",\n    \"description\": \"Changelog modal close button\"\n  },\n  \"changelog_docs_link\": {\n    \"message\": \"Docs\",\n    \"description\": \"Changelog modal documentation link text\"\n  },\n  \"changelog_recommendation\": {\n    \"message\": \"Si Voyager te ha sido útil, ¡compártelo con amigos o en redes sociales y etiqueta al autor!\",\n    \"description\": \"Changelog modal recommendation message\"\n  },\n  \"changelog_social_xiaohongshu\": {\n    \"message\": \"Xiaohongshu\",\n    \"description\": \"Xiaohongshu platform name for social links\"\n  },\n  \"changelog_social_zhihu\": {\n    \"message\": \"Zhihu\",\n    \"description\": \"Zhihu platform name for social links\"\n  },\n  \"changelog_docs_hint\": {\n    \"message\": \"Detalles en docs\",\n    \"description\": \"Changelog annotation pointing to docs icon\"\n  },\n  \"changelog_rate_chrome\": {\n    \"message\": \"¿Te gusta Voyager? ¡Una valoración en la Chrome Web Store ayuda a otros a descubrirlo!\",\n    \"description\": \"Chrome Web Store rating prompt in changelog modal\"\n  },\n  \"changelog_rate_chrome_cta\": {\n    \"message\": \"Valorar\",\n    \"description\": \"Chrome Web Store rating CTA button text\"\n  },\n  \"changelog_badge_mode\": {\n    \"message\": \"Notificar con una insignia NEW en el botón flotante en lugar de esta ventana\",\n    \"description\": \"Changelog notification mode toggle label\"\n  },\n  \"forkConversation\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork conversation button label\"\n  },\n  \"forkConfirm\": {\n    \"message\": \"Fork conversation from this point?\",\n    \"description\": \"Fork confirmation prompt\"\n  },\n  \"forkConfirmBtn\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork confirmation button\"\n  },\n  \"forkCancel\": {\n    \"message\": \"Cancel\",\n    \"description\": \"Fork cancel button\"\n  },\n  \"forkBranch\": {\n    \"message\": \"Branch\",\n    \"description\": \"Fork branch label\"\n  },\n  \"forkOriginal\": {\n    \"message\": \"Original\",\n    \"description\": \"Original fork branch tag\"\n  },\n  \"forkCurrent\": {\n    \"message\": \"Current\",\n    \"description\": \"Current fork branch tag\"\n  },\n  \"forkPreparing\": {\n    \"message\": \"Preparing fork...\",\n    \"description\": \"Fork preparation status\"\n  },\n  \"forkPasteHint\": {\n    \"message\": \"Press Enter to send and create the fork\",\n    \"description\": \"Fork paste hint text\"\n  },\n  \"forkDeleteData\": {\n    \"message\": \"Eliminar esta rama\",\n    \"description\": \"Delete fork branch metadata button\"\n  },\n  \"forkDeleteDataConfirm\": {\n    \"message\": \"¿Eliminar este enlace de rama? Esto solo elimina metadatos de rama de Voyager y no borra ninguna conversación.\",\n    \"description\": \"Fork metadata delete confirmation prompt\"\n  },\n  \"enableForkFeature\": {\n    \"message\": \"Activar bifurcación de conversación\",\n    \"description\": \"Enable conversation fork feature toggle label\"\n  },\n  \"enableForkFeatureHint\": {\n    \"message\": \"Mostrar el botón Fork y los indicadores de rama en conversaciones de Gemini\",\n    \"description\": \"Enable conversation fork feature toggle hint\"\n  },\n  \"enableAccountIsolation\": {\n    \"message\": \"Enable Hard Isolation\",\n    \"description\": \"Enable hard isolation for account-scoped folder data and cloud sync.\"\n  },\n  \"enableAccountIsolationHint\": {\n    \"message\": \"Strictly isolate folder and cloud sync data by Google account to prevent cross-account mixing.\",\n    \"description\": \"Hint text for account isolation option.\"\n  },\n  \"currentPlatform\": {\n    \"message\": \"Current platform\",\n    \"description\": \"Current active platform label in popup.\"\n  },\n  \"platformGemini\": {\n    \"message\": \"Gemini\",\n    \"description\": \"Gemini platform label.\"\n  },\n  \"platformAIStudio\": {\n    \"message\": \"AI Studio\",\n    \"description\": \"AI Studio platform label.\"\n  },\n  \"enableOnAIStudio\": {\n    \"message\": \"Activar en AI Studio\",\n    \"description\": \"Toggle to enable/disable Voyager on AI Studio\"\n  },\n  \"enableOnAIStudioHint\": {\n    \"message\": \"Desactiva para deshabilitar las funciones de Voyager en AI Studio (Prompt Manager sigue activo)\",\n    \"description\": \"Hint for AI Studio enable toggle\"\n  },\n  \"export_select_mode_only_user\": {\n    \"message\": \"Seleccionar solo usuario\",\n    \"description\": \"Export selection mode: select only user messages\"\n  },\n  \"export_select_mode_only_ai\": {\n    \"message\": \"Seleccionar solo IA\",\n    \"description\": \"Export selection mode: select only AI messages\"\n  },\n  \"aiOrgCopyButton\": {\n    \"message\": \"Organizar con IA\",\n    \"description\": \"Botón para copiar la estructura de carpetas y conversaciones para organización con IA\"\n  },\n  \"aiOrgCopied\": {\n    \"message\": \"¡Copiado!\",\n    \"description\": \"Confirmación después de copiar el prompt de organización con IA\"\n  },\n  \"aiOrgError\": {\n    \"message\": \"Error — abre Gemini primero\",\n    \"description\": \"Error al copiar el prompt de organización con IA\"\n  },\n  \"aiOrgCopyHint\": {\n    \"message\": \"Copia todas las conversaciones y la estructura de carpetas como un prompt. Pégalo en Gemini para obtener un plan de carpetas importable.\",\n    \"description\": \"Texto de ayuda debajo del botón de copia de organización con IA\"\n  },\n  \"aiOrgCurrentFolders\": {\n    \"message\": \"Estructura de carpetas actual\",\n    \"description\": \"Encabezado en el prompt de organización con IA\"\n  },\n  \"aiOrgUnfiledConversations\": {\n    \"message\": \"Conversaciones sin clasificar\",\n    \"description\": \"Encabezado para conversaciones que no están en ninguna carpeta\"\n  },\n  \"aiOrgEmpty\": {\n    \"message\": \"vacía\",\n    \"description\": \"Etiqueta para carpetas vacías en el prompt de organización con IA\"\n  },\n  \"aiOrgInstructions\": {\n    \"message\": \"Instrucciones\",\n    \"description\": \"Encabezado de la sección de instrucciones para IA\"\n  },\n  \"aiOrgInstructionsBody\": {\n    \"message\": \"Basándote en las conversaciones y la estructura de carpetas anterior, reorganízalas en una jerarquía lógica de carpetas. Genera un archivo JSON con el formato exacto de abajo que pueda importarse directamente en la extensión Gemini Voyager (usa la estrategia de importación \\\"Fusionar\\\"). Mantén las carpetas bien organizadas existentes, crea nuevas según sea necesario y mueve las conversaciones sin clasificar a carpetas apropiadas. Cada carpeta necesita un ID único (usa una cadena aleatoria corta) y cada conversación debe mantener su conversationId y url originales.\",\n    \"description\": \"Cuerpo de instrucciones para que la IA genere una estructura de carpetas importable\"\n  },\n  \"showMessageTimestamps\": {\n    \"message\": \"Mostrar marcas de tiempo de mensajes\",\n    \"description\": \"Mostrar marca de tiempo para cada mensaje\"\n  },\n  \"showMessageTimestampsHint\": {\n    \"message\": \"Mostrar cuándo se envió cada mensaje\",\n    \"description\": \"Sugerencia para la función de marcas de tiempo\"\n  },\n  \"moveSectionUp\": {\n    \"message\": \"Subir\",\n    \"description\": \"Tooltip for moving a settings section up in popup\"\n  },\n  \"moveSectionDown\": {\n    \"message\": \"Bajar\",\n    \"description\": \"Tooltip for moving a settings section down in popup\"\n  }\n}\n"
  },
  {
    "path": "src/locales/fr/messages.json",
    "content": "{\n  \"extName\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Extension name\"\n  },\n  \"extDescription\": {\n    \"message\": \"Améliorez votre expérience Gemini™ : chronologie, dossiers, prompts et export.\",\n    \"description\": \"Extension description\"\n  },\n  \"scrollMode\": {\n    \"message\": \"Mode défilement\",\n    \"description\": \"Scroll mode label\"\n  },\n  \"flow\": {\n    \"message\": \"Flux\",\n    \"description\": \"Flow mode\"\n  },\n  \"jump\": {\n    \"message\": \"Saut\",\n    \"description\": \"Jump mode\"\n  },\n  \"hideOuterContainer\": {\n    \"message\": \"Masquer le conteneur externe\",\n    \"description\": \"Hide outer container label\"\n  },\n  \"draggableTimeline\": {\n    \"message\": \"Chronologie déplaçable\",\n    \"description\": \"Draggable Timeline label\"\n  },\n  \"enableMarkerLevel\": {\n    \"message\": \"Activer les niveaux de nœuds\",\n    \"description\": \"Enable timeline node level feature\"\n  },\n  \"enableMarkerLevelHint\": {\n    \"message\": \"Clic droit sur les nœuds pour définir leur niveau et réduire les enfants\",\n    \"description\": \"Node level feature hint\"\n  },\n  \"experimentalLabel\": {\n    \"message\": \"Expérimental\",\n    \"description\": \"Experimental feature label\"\n  },\n  \"resetPosition\": {\n    \"message\": \"Réinitialiser la position\",\n    \"description\": \"Reset Position button\"\n  },\n  \"resetTimelinePosition\": {\n    \"message\": \"Réinitialiser la position de la chronologie\",\n    \"description\": \"Reset timeline position button in timeline options\"\n  },\n  \"language\": {\n    \"message\": \"Langue\",\n    \"description\": \"Language label\"\n  },\n  \"pm_title\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Prompt Manager title\"\n  },\n  \"pm_add\": {\n    \"message\": \"Ajouter\",\n    \"description\": \"Add prompt button\"\n  },\n  \"pm_search_placeholder\": {\n    \"message\": \"Rechercher des prompts ou tags\",\n    \"description\": \"Search placeholder\"\n  },\n  \"pm_import\": {\n    \"message\": \"Importer\",\n    \"description\": \"Import button\"\n  },\n  \"pm_export\": {\n    \"message\": \"Exporter\",\n    \"description\": \"Export button\"\n  },\n  \"pm_prompt_placeholder\": {\n    \"message\": \"Texte du prompt\",\n    \"description\": \"Prompt textarea placeholder\"\n  },\n  \"pm_tags_placeholder\": {\n    \"message\": \"Tags (séparés par des virgules)\",\n    \"description\": \"Tags input placeholder\"\n  },\n  \"pm_save\": {\n    \"message\": \"Enregistrer\",\n    \"description\": \"Save prompt\"\n  },\n  \"pm_cancel\": {\n    \"message\": \"Annuler\",\n    \"description\": \"Cancel\"\n  },\n  \"pm_all_tags\": {\n    \"message\": \"Tous\",\n    \"description\": \"All tags chip\"\n  },\n  \"pm_empty\": {\n    \"message\": \"Aucun prompt pour le moment\",\n    \"description\": \"Empty state\"\n  },\n  \"pm_copy\": {\n    \"message\": \"Copier\",\n    \"description\": \"Copy prompt\"\n  },\n  \"pm_copied\": {\n    \"message\": \"Copié\",\n    \"description\": \"Copied notice\"\n  },\n  \"pm_delete\": {\n    \"message\": \"Supprimer\",\n    \"description\": \"Delete prompt\"\n  },\n  \"pm_delete_confirm\": {\n    \"message\": \"Supprimer ce prompt ?\",\n    \"description\": \"Delete confirm\"\n  },\n  \"pm_lock\": {\n    \"message\": \"Verrouiller la position\",\n    \"description\": \"Lock panel position\"\n  },\n  \"pm_unlock\": {\n    \"message\": \"Déverrouiller la position\",\n    \"description\": \"Unlock panel position\"\n  },\n  \"pm_import_invalid\": {\n    \"message\": \"Format de fichier invalide\",\n    \"description\": \"Import invalid\"\n  },\n  \"pm_import_success\": {\n    \"message\": \"{count} prompts importés\",\n    \"description\": \"Import success\"\n  },\n  \"pm_duplicate\": {\n    \"message\": \"Dupliquer le prompt\",\n    \"description\": \"Duplicate on add\"\n  },\n  \"pm_deleted\": {\n    \"message\": \"Supprimé\",\n    \"description\": \"Deleted notice\"\n  },\n  \"pm_edit\": {\n    \"message\": \"Éditer\",\n    \"description\": \"Edit prompt\"\n  },\n  \"pm_expand\": {\n    \"message\": \"Étendre\",\n    \"description\": \"Expand prompt text\"\n  },\n  \"pm_collapse\": {\n    \"message\": \"Réduire\",\n    \"description\": \"Collapse prompt text\"\n  },\n  \"pm_saved\": {\n    \"message\": \"Enregistré\",\n    \"description\": \"Saved notice\"\n  },\n  \"pm_settings\": {\n    \"message\": \"Paramètres\",\n    \"description\": \"Settings button\"\n  },\n  \"pm_settings_tooltip\": {\n    \"message\": \"Ouvrir les paramètres de l'extension\",\n    \"description\": \"Settings button tooltip\"\n  },\n  \"pm_settings_fallback\": {\n    \"message\": \"Veuillez cliquer sur l'icône de l'extension dans la barre d'outils pour ouvrir les paramètres\",\n    \"description\": \"Settings open failed message\"\n  },\n  \"pm_backup\": {\n    \"message\": \"Sauvegarde locale\",\n    \"description\": \"Local backup button\"\n  },\n  \"pm_backup_tooltip\": {\n    \"message\": \"Sauvegarder les prompts et dossiers dans un dossier horodaté\",\n    \"description\": \"Backup button tooltip\"\n  },\n  \"pm_backup_cancelled\": {\n    \"message\": \"Sauvegarde annulée\",\n    \"description\": \"Backup cancelled hint\"\n  },\n  \"pm_backup_error\": {\n    \"message\": \"✗ Échec de la sauvegarde\",\n    \"description\": \"Backup failed hint\"\n  },\n  \"pm_backup_hint_options\": {\n    \"message\": \"Cette fonctionnalité est intégrée au gestionnaire de prompts sur la page Gemini.\",\n    \"description\": \"Backup hint in options page\"\n  },\n  \"pm_backup_step1\": {\n    \"message\": \"Ouvrez la page Gemini (gemini.google.com)\",\n    \"description\": \"Backup step 1\"\n  },\n  \"pm_backup_step2\": {\n    \"message\": \"Cliquez sur l'icône de l'extension en bas à droite pour ouvrir le gestionnaire\",\n    \"description\": \"Backup step 2\"\n  },\n  \"pm_backup_step3\": {\n    \"message\": \"Cliquez sur le bouton \\\"💾 Sauvegarde locale\\\" et sélectionnez un dossier\",\n    \"description\": \"Backup step 3\"\n  },\n  \"pm_backup_note\": {\n    \"message\": \"Les sauvegardes incluent tous les prompts et dossiers (format : backup-YYYYMMDD-HHMMSS)\",\n    \"description\": \"Backup feature note\"\n  },\n  \"pm_theme_light\": {\n    \"message\": \"Passer en mode clair\",\n    \"description\": \"Theme toggle tooltip - switch to light\"\n  },\n  \"pm_theme_dark\": {\n    \"message\": \"Passer en mode sombre\",\n    \"description\": \"Theme toggle tooltip - switch to dark\"\n  },\n  \"extensionVersion\": {\n    \"message\": \"Version\",\n    \"description\": \"Extension version label\"\n  },\n  \"newVersionAvailable\": {\n    \"message\": \"Nouvelle version disponible\",\n    \"description\": \"Update reminder title\"\n  },\n  \"currentVersionLabel\": {\n    \"message\": \"Actuelle\",\n    \"description\": \"Label for current version number\"\n  },\n  \"latestVersionLabel\": {\n    \"message\": \"Dernière\",\n    \"description\": \"Label for latest version number\"\n  },\n  \"updateNow\": {\n    \"message\": \"Mettre à jour\",\n    \"description\": \"CTA to update to latest version\"\n  },\n  \"safariUpdateNotSynced\": {\n    \"message\": \"La mise à jour n'est pas encore disponible, veuillez réessayer dans quelques heures\",\n    \"description\": \"Shown on Safari when the DMG file is not yet available on GitHub releases\"\n  },\n  \"starProject\": {\n    \"message\": \"Me soutenir ⭐\",\n    \"description\": \"Github callout\"\n  },\n  \"officialDocs\": {\n    \"message\": \"Documentation\",\n    \"description\": \"Official docs link\"\n  },\n  \"exportChatJson\": {\n    \"message\": \"Exporter l'historique de la conversation\",\n    \"description\": \"Tooltip for export button\"\n  },\n  \"folder_title\": {\n    \"message\": \"Dossiers\",\n    \"description\": \"Folder section title\"\n  },\n  \"folder_create\": {\n    \"message\": \"Créer un dossier\",\n    \"description\": \"Create folder button tooltip\"\n  },\n  \"folder_name_prompt\": {\n    \"message\": \"Nom du dossier :\",\n    \"description\": \"Create folder name prompt\"\n  },\n  \"folder_rename_prompt\": {\n    \"message\": \"Nouveau nom :\",\n    \"description\": \"Rename folder prompt\"\n  },\n  \"folder_delete_confirm\": {\n    \"message\": \"Supprimer ce dossier et tout son contenu ?\",\n    \"description\": \"Delete folder confirmation\"\n  },\n  \"folder_create_subfolder\": {\n    \"message\": \"Créer un sous-dossier\",\n    \"description\": \"Create subfolder menu item\"\n  },\n  \"folder_rename\": {\n    \"message\": \"Renommer\",\n    \"description\": \"Rename folder menu item\"\n  },\n  \"folder_change_color\": {\n    \"message\": \"Changer la couleur\",\n    \"description\": \"Change folder color menu item\"\n  },\n  \"folder_delete\": {\n    \"message\": \"Supprimer\",\n    \"description\": \"Delete folder menu item\"\n  },\n  \"folder_color_default\": {\n    \"message\": \"Défaut\",\n    \"description\": \"Default folder color name\"\n  },\n  \"folder_color_red\": {\n    \"message\": \"Rouge\",\n    \"description\": \"Red folder color name\"\n  },\n  \"folder_color_orange\": {\n    \"message\": \"Orange\",\n    \"description\": \"Orange folder color name\"\n  },\n  \"folder_color_yellow\": {\n    \"message\": \"Jaune\",\n    \"description\": \"Yellow folder color name\"\n  },\n  \"folder_color_green\": {\n    \"message\": \"Vert\",\n    \"description\": \"Green folder color name\"\n  },\n  \"folder_color_blue\": {\n    \"message\": \"Bleu\",\n    \"description\": \"Blue folder color name\"\n  },\n  \"folder_color_purple\": {\n    \"message\": \"Violet\",\n    \"description\": \"Purple folder color name\"\n  },\n  \"folder_color_pink\": {\n    \"message\": \"Rose\",\n    \"description\": \"Pink folder color name\"\n  },\n  \"folder_color_custom\": {\n    \"message\": \"Personnalisé\",\n    \"description\": \"Custom folder color name\"\n  },\n  \"folder_empty\": {\n    \"message\": \"Aucun dossier\",\n    \"description\": \"Empty state placeholder when no folders exist\"\n  },\n  \"folder_pin\": {\n    \"message\": \"Épingler le dossier\",\n    \"description\": \"Pin folder menu item\"\n  },\n  \"folder_unpin\": {\n    \"message\": \"Détacher le dossier\",\n    \"description\": \"Unpin folder menu item\"\n  },\n  \"folder_remove_conversation\": {\n    \"message\": \"Retirer du dossier\",\n    \"description\": \"Remove conversation button tooltip\"\n  },\n  \"folder_remove_conversation_confirm\": {\n    \"message\": \"Retirer \\\"{title}\\\" de ce dossier ?\",\n    \"description\": \"Remove conversation confirmation\"\n  },\n  \"conversation_more\": {\n    \"message\": \"Plus d'options\",\n    \"description\": \"Conversation more options button tooltip\"\n  },\n  \"conversation_move_to_folder\": {\n    \"message\": \"Déplacer vers un dossier\",\n    \"description\": \"Move conversation to folder menu item\"\n  },\n  \"conversation_move_to_folder_title\": {\n    \"message\": \"Déplacer vers un dossier\",\n    \"description\": \"Move conversation to folder dialog title\"\n  },\n  \"chatWidth\": {\n    \"message\": \"Largeur du chat\",\n    \"description\": \"Chat width adjustment label\"\n  },\n  \"chatWidthNarrow\": {\n    \"message\": \"Étroit\",\n    \"description\": \"Narrow chat width label\"\n  },\n  \"chatWidthWide\": {\n    \"message\": \"Large\",\n    \"description\": \"Wide chat width label\"\n  },\n  \"sidebarWidth\": {\n    \"message\": \"Largeur barre latérale\",\n    \"description\": \"Sidebar width adjustment label\"\n  },\n  \"sidebarWidthNarrow\": {\n    \"message\": \"Étroite\",\n    \"description\": \"Narrow sidebar width label\"\n  },\n  \"sidebarWidthWide\": {\n    \"message\": \"Large\",\n    \"description\": \"Wide sidebar width label\"\n  },\n  \"formula_copied\": {\n    \"message\": \"✓ Formule copiée\",\n    \"description\": \"Formula copied success message\"\n  },\n  \"formula_copy_failed\": {\n    \"message\": \"✗ Échec de la copie\",\n    \"description\": \"Formula copy failed message\"\n  },\n  \"formulaCopyFormat\": {\n    \"message\": \"Format de copie de formule\",\n    \"description\": \"Formula copy format setting label\"\n  },\n  \"formulaCopyFormatLatex\": {\n    \"message\": \"LaTeX\",\n    \"description\": \"LaTeX format option\"\n  },\n  \"formulaCopyFormatUnicodeMath\": {\n    \"message\": \"MathML (Word)\",\n    \"description\": \"Label for UnicodeMath formula copy format option\"\n  },\n  \"formulaCopyFormatHint\": {\n    \"message\": \"Choisissez le format lors de la copie des formules par clic\",\n    \"description\": \"Formula copy format hint\"\n  },\n  \"formulaCopyFormatNoDollar\": {\n    \"message\": \"LaTeX (Sans dollars)\",\n    \"description\": \"LaTeX format option without dollar signs\"\n  },\n  \"folder_filter_current_user\": {\n    \"message\": \"Isolation de compte\",\n    \"description\": \"Show only conversations from the current Google account\"\n  },\n  \"folder_export\": {\n    \"message\": \"Exporter dossiers\",\n    \"description\": \"Export folders button tooltip\"\n  },\n  \"folder_import_export\": {\n    \"message\": \"Importer/Exporter dossiers\",\n    \"description\": \"Combined import/export button tooltip\"\n  },\n  \"folder_cloud_upload\": {\n    \"message\": \"Téléverser vers le cloud\",\n    \"description\": \"Cloud upload button tooltip\"\n  },\n  \"folder_cloud_sync\": {\n    \"message\": \"Synchroniser depuis le cloud\",\n    \"description\": \"Cloud sync button tooltip\"\n  },\n  \"folder_import\": {\n    \"message\": \"Importer dossiers\",\n    \"description\": \"Import folders button tooltip\"\n  },\n  \"folder_import_title\": {\n    \"message\": \"Importer la configuration\",\n    \"description\": \"Import dialog title\"\n  },\n  \"folder_import_strategy\": {\n    \"message\": \"Stratégie :\",\n    \"description\": \"Import strategy label\"\n  },\n  \"folder_import_merge\": {\n    \"message\": \"Fusionner\",\n    \"description\": \"Merge import strategy\"\n  },\n  \"folder_import_overwrite\": {\n    \"message\": \"Écraser\",\n    \"description\": \"Overwrite import strategy\"\n  },\n  \"folder_import_select_file\": {\n    \"message\": \"Sélectionner un fichier JSON\",\n    \"description\": \"Import file selection prompt\"\n  },\n  \"folder_import_success\": {\n    \"message\": \"✓ {folders} dossiers, {conversations} discussions importés\",\n    \"description\": \"Import success message\"\n  },\n  \"folder_import_success_skipped\": {\n    \"message\": \"✓ {folders} dossiers, {conversations} discussions importés ({skipped} ignorés)\",\n    \"description\": \"Import success with skipped message\"\n  },\n  \"folder_import_error\": {\n    \"message\": \"✗ Échec : {error}\",\n    \"description\": \"Import error message\"\n  },\n  \"folder_export_success\": {\n    \"message\": \"✓ Dossiers exportés avec succès\",\n    \"description\": \"Export success message\"\n  },\n  \"folder_import_invalid_format\": {\n    \"message\": \"Format invalide. Veuillez sélectionner un fichier de configuration valide.\",\n    \"description\": \"Invalid format error\"\n  },\n  \"folder_import_confirm_overwrite\": {\n    \"message\": \"Ceci remplacera tous les dossiers existants. Une sauvegarde sera créée. Continuer ?\",\n    \"description\": \"Overwrite confirmation\"\n  },\n  \"folder_import_paste_json\": {\n    \"message\": \"Ou coller le JSON directement\",\n    \"description\": \"Bouton pour afficher la zone de collage dans le dialogue d'importation\"\n  },\n  \"folder_import_paste_placeholder\": {\n    \"message\": \"Collez votre JSON ici...\",\n    \"description\": \"Texte d'espace réservé pour la zone de collage\"\n  },\n  \"export_dialog_title\": {\n    \"message\": \"Exporter la discussion\",\n    \"description\": \"Export dialog title\"\n  },\n  \"export_dialog_select\": {\n    \"message\": \"Format d'export :\",\n    \"description\": \"Export format selection label\"\n  },\n  \"export_dialog_warning\": {\n    \"message\": \"⚠️ Après avoir cliqué sur exporter, le système sautera automatiquement au premier message pour charger tout le contenu. Veuillez ne pas effectuer d'opérations ; l'exportation continuera automatiquement après le saut.\",\n    \"description\": \"Warning about auto-jump and loading completeness\"\n  },\n  \"export_dialog_safari_cmdp_hint\": {\n    \"message\": \"Astuce Safari : cliquez sur 'Exporter' ci-dessous, attendez un instant, puis appuyez sur ⌘P et choisissez 'Enregistrer au format PDF'.\",\n    \"description\": \"Safari-specific hint for PDF export\"\n  },\n  \"export_toast_safari_pdf_ready\": {\n    \"message\": \"Vous pouvez maintenant appuyer sur Command + P pour exporter le PDF.\",\n    \"description\": \"Safari-specific toast shown after PDF export is prepared\"\n  },\n  \"export_dialog_safari_markdown_hint\": {\n    \"message\": \"Remarque : en raison des limitations de Safari, les images de l'historique du chat ne peuvent pas être extraites. Pour une exportation complète, utilisez l'export PDF.\",\n    \"description\": \"Warning about image references in Safari markdown export\"\n  },\n  \"export_format_json_description\": {\n    \"message\": \"Format lisible par machine pour les développeurs\",\n    \"description\": \"Description for JSON export format\"\n  },\n  \"export_format_markdown_description\": {\n    \"message\": \"Format texte propre et portable (recommandé)\",\n    \"description\": \"Description for Markdown export format\"\n  },\n  \"export_format_pdf_description\": {\n    \"message\": \"Format prêt pour l'impression via Enregistrer au format PDF\",\n    \"description\": \"Description for PDF export format\"\n  },\n  \"editInputWidth\": {\n    \"message\": \"Largeur de l'éditeur\",\n    \"description\": \"Edit input width adjustment label\"\n  },\n  \"editInputWidthNarrow\": {\n    \"message\": \"Étroit\",\n    \"description\": \"Narrow edit input width label\"\n  },\n  \"editInputWidthWide\": {\n    \"message\": \"Large\",\n    \"description\": \"Wide edit input width label\"\n  },\n  \"timelineOptions\": {\n    \"message\": \"Options de la chronologie\",\n    \"description\": \"Timeline options section label\"\n  },\n  \"folderOptions\": {\n    \"message\": \"Options des dossiers\",\n    \"description\": \"Folder options section label\"\n  },\n  \"enableFolderFeature\": {\n    \"message\": \"Activer les dossiers\",\n    \"description\": \"Enable or disable the folder feature\"\n  },\n  \"hideArchivedConversations\": {\n    \"message\": \"Masquer les discussions archivées\",\n    \"description\": \"Hide conversations that are in folders from the main list\"\n  },\n  \"conversation_star\": {\n    \"message\": \"Ajouter aux favoris\",\n    \"description\": \"Star conversation button tooltip\"\n  },\n  \"conversation_unstar\": {\n    \"message\": \"Retirer des favoris\",\n    \"description\": \"Unstar conversation button tooltip\"\n  },\n  \"conversation_untitled\": {\n    \"message\": \"Sans titre\",\n    \"description\": \"Fallback title for conversations without a name\"\n  },\n  \"geminiOnlyNotice\": {\n    \"message\": \"Fonctionne sur Gemini (et Enterprise) & AI Studio par défaut. Ajoutez d'autres sites ci-dessous.\",\n    \"description\": \"Notice that defaults are limited to Gemini/AI Studio\"\n  },\n  \"folderManager_dataLossWarning\": {\n    \"message\": \"⚠️ Attention : Échec du chargement des données. Vos dossiers peuvent être corrompus. Vérifiez la console et essayez de restaurer une sauvegarde.\",\n    \"description\": \"Warning shown when folder data fails to load\"\n  },\n  \"backupOptions\": {\n    \"message\": \"Sauvegarde auto\",\n    \"description\": \"Backup options section label\"\n  },\n  \"backupEnabled\": {\n    \"message\": \"Activer\",\n    \"description\": \"Enable auto backup toggle\"\n  },\n  \"backupNow\": {\n    \"message\": \"Sauvegarder\",\n    \"description\": \"Backup now button\"\n  },\n  \"backupSelectFolder\": {\n    \"message\": \"Choisir dossier\",\n    \"description\": \"Select backup folder button\"\n  },\n  \"backupFolderSelected\": {\n    \"message\": \"Dossier : {folder}\",\n    \"description\": \"Shows selected backup folder\"\n  },\n  \"backupIncludePrompts\": {\n    \"message\": \"Prompts\",\n    \"description\": \"Include prompts in backup toggle\"\n  },\n  \"backupIncludeFolders\": {\n    \"message\": \"Dossiers\",\n    \"description\": \"Include folders in backup toggle\"\n  },\n  \"backupIntervalLabel\": {\n    \"message\": \"Intervalle\",\n    \"description\": \"Backup interval label\"\n  },\n  \"backupIntervalManual\": {\n    \"message\": \"Manuel\",\n    \"description\": \"Manual backup mode\"\n  },\n  \"backupIntervalDaily\": {\n    \"message\": \"Quotidien\",\n    \"description\": \"Daily backup interval\"\n  },\n  \"backupIntervalWeekly\": {\n    \"message\": \"Hebdomadaire\",\n    \"description\": \"Weekly backup interval\"\n  },\n  \"backupLastBackup\": {\n    \"message\": \"Dernière : {time}\",\n    \"description\": \"Last backup timestamp\"\n  },\n  \"backupNever\": {\n    \"message\": \"Jamais\",\n    \"description\": \"Never backed up\"\n  },\n  \"backupSuccess\": {\n    \"message\": \"✓ Sauvegarde : {prompts} prompts, {folders} dossiers\",\n    \"description\": \"Backup success message\"\n  },\n  \"backupError\": {\n    \"message\": \"✗ Échec : {error}\",\n    \"description\": \"Backup error message\"\n  },\n  \"backupNotSupported\": {\n    \"message\": \"⚠️ API File System non supportée\",\n    \"description\": \"Browser not supported message\"\n  },\n  \"backupSelectFolderFirst\": {\n    \"message\": \"Sélectionnez un dossier d'abord\",\n    \"description\": \"No folder selected warning\"\n  },\n  \"backupUserCancelled\": {\n    \"message\": \"Annulé\",\n    \"description\": \"User cancelled folder selection\"\n  },\n  \"backupConfigSaved\": {\n    \"message\": \"✓ Paramètres enregistrés\",\n    \"description\": \"Config saved message\"\n  },\n  \"backupPermissionDenied\": {\n    \"message\": \"⚠️ Accès refusé. Changez d'emplacement.\",\n    \"description\": \"Permission denied for restricted directory\"\n  },\n  \"backupConfigureInOptions\": {\n    \"message\": \"Configurez via la page Options.\",\n    \"description\": \"Backup configure in options page explanation\"\n  },\n  \"openOptionsPage\": {\n    \"message\": \"Ouvrir Options\",\n    \"description\": \"Open options page button\"\n  },\n  \"optionsPageSubtitle\": {\n    \"message\": \"Options\",\n    \"description\": \"Options page subtitle\"\n  },\n  \"optionsComingSoon\": {\n    \"message\": \"Bientôt...\",\n    \"description\": \"Options page placeholder text\"\n  },\n  \"backupDataAccessNotice\": {\n    \"message\": \"Accès limité\",\n    \"description\": \"Backup data access limitation title\"\n  },\n  \"backupDataAccessHint\": {\n    \"message\": \"Sécurité : cette page ne peut accéder aux données. Utilisez la page Gemini.\",\n    \"description\": \"Backup data access limitation explanation\"\n  },\n  \"promptManagerOptions\": {\n    \"message\": \"Prompts\",\n    \"description\": \"Prompt Manager options section label\"\n  },\n  \"hidePromptManager\": {\n    \"message\": \"Masquer\",\n    \"description\": \"Hide the prompt manager floating ball\"\n  },\n  \"hidePromptManagerHint\": {\n    \"message\": \"Masquer le bouton flottant du gestionnaire\",\n    \"description\": \"Hint for hiding prompt manager feature\"\n  },\n  \"customWebsites\": {\n    \"message\": \"Sites personnalisés\",\n    \"description\": \"Custom websites for prompt manager\"\n  },\n  \"customWebsitesPlaceholder\": {\n    \"message\": \"URL (ex: chatgpt.com)\",\n    \"description\": \"Custom websites input placeholder\"\n  },\n  \"customWebsitesHint\": {\n    \"message\": \"Activer le gestionnaire sur ces sites.\",\n    \"description\": \"Custom websites hint\"\n  },\n  \"addWebsite\": {\n    \"message\": \"Ajouter\",\n    \"description\": \"Add website button\"\n  },\n  \"removeWebsite\": {\n    \"message\": \"Retirer\",\n    \"description\": \"Remove website button\"\n  },\n  \"invalidUrl\": {\n    \"message\": \"URL invalide\",\n    \"description\": \"Invalid URL error message\"\n  },\n  \"websiteAdded\": {\n    \"message\": \"Site ajouté\",\n    \"description\": \"Website added success message\"\n  },\n  \"websiteRemoved\": {\n    \"message\": \"Site retiré\",\n    \"description\": \"Website removed success message\"\n  },\n  \"permissionDenied\": {\n    \"message\": \"Accès refusé. Autorisez-le.\",\n    \"description\": \"Permission denied error message\"\n  },\n  \"permissionRequestFailed\": {\n    \"message\": \"Échec demande. Vérifiez les paramètres.\",\n    \"description\": \"Permission request failure message\"\n  },\n  \"customWebsitesNote\": {\n    \"message\": \"Note : rechargez le site après autorisation.\",\n    \"description\": \"Custom websites reload/permission note\"\n  },\n  \"starredHistory\": {\n    \"message\": \"Favoris\",\n    \"description\": \"Starred history title\"\n  },\n  \"viewStarredHistory\": {\n    \"message\": \"Voir les favoris\",\n    \"description\": \"View starred history button\"\n  },\n  \"noStarredMessages\": {\n    \"message\": \"Aucun favori\",\n    \"description\": \"No starred messages placeholder\"\n  },\n  \"removeFromStarred\": {\n    \"message\": \"Retirer\",\n    \"description\": \"Remove from starred tooltip\"\n  },\n  \"justNow\": {\n    \"message\": \"À l'instant\",\n    \"description\": \"Just now time label\"\n  },\n  \"hoursAgo\": {\n    \"message\": \"heures\",\n    \"description\": \"Hours ago label\"\n  },\n  \"yesterday\": {\n    \"message\": \"Hier\",\n    \"description\": \"Yesterday date label\"\n  },\n  \"daysAgo\": {\n    \"message\": \"jours\",\n    \"description\": \"Days ago label\"\n  },\n  \"loading\": {\n    \"message\": \"Chargement...\",\n    \"description\": \"Loading message\"\n  },\n  \"keyboardShortcuts\": {\n    \"message\": \"Raccourcis\",\n    \"description\": \"Keyboard shortcuts section title\"\n  },\n  \"enableShortcuts\": {\n    \"message\": \"Activer les raccourcis\",\n    \"description\": \"Enable shortcuts toggle label\"\n  },\n  \"previousNode\": {\n    \"message\": \"Précédent\",\n    \"description\": \"Previous timeline node shortcut label\"\n  },\n  \"nextNode\": {\n    \"message\": \"Suivant\",\n    \"description\": \"Next timeline node shortcut label\"\n  },\n  \"shortcutKey\": {\n    \"message\": \"Touche\",\n    \"description\": \"Shortcut key label\"\n  },\n  \"shortcutModifiers\": {\n    \"message\": \"Modif.\",\n    \"description\": \"Shortcut modifiers label\"\n  },\n  \"resetShortcuts\": {\n    \"message\": \"Réinitialiser\",\n    \"description\": \"Reset shortcuts button\"\n  },\n  \"shortcutsResetSuccess\": {\n    \"message\": \"Raccourcis réinitialisés\",\n    \"description\": \"Shortcuts reset success message\"\n  },\n  \"shortcutsDescription\": {\n    \"message\": \"Navigation clavier dans la chronologie\",\n    \"description\": \"Shortcuts description\"\n  },\n  \"modifierNone\": {\n    \"message\": \"Aucun\",\n    \"description\": \"No modifier key\"\n  },\n  \"modifierAlt\": {\n    \"message\": \"Alt\",\n    \"description\": \"Alt modifier key\"\n  },\n  \"modifierCtrl\": {\n    \"message\": \"Ctrl\",\n    \"description\": \"Ctrl modifier key\"\n  },\n  \"modifierShift\": {\n    \"message\": \"Maj\",\n    \"description\": \"Shift modifier key\"\n  },\n  \"modifierMeta\": {\n    \"message\": \"Cmd/Win\",\n    \"description\": \"Meta/Command/Windows modifier key\"\n  },\n  \"preventAutoScroll\": {\n    \"message\": \"Empêcher le défilement auto\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"preventAutoScrollHint\": {\n    \"message\": \"Empêche Gemini de sauter en bas lorsque vous appuyez sur Entrée tout en lisant les réponses précédentes.\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"cloudSync\": {\n    \"message\": \"Synchro Cloud\",\n    \"description\": \"Cloud sync section title\"\n  },\n  \"cloudSyncDescription\": {\n    \"message\": \"Synchro dossiers & prompts sur Drive\",\n    \"description\": \"Cloud sync description\"\n  },\n  \"syncModeDisabled\": {\n    \"message\": \"Désactivé\",\n    \"description\": \"Sync mode disabled option\"\n  },\n  \"syncModeManual\": {\n    \"message\": \"Manuel\",\n    \"description\": \"Sync mode manual option\"\n  },\n  \"syncServerPort\": {\n    \"message\": \"Port du serveur\",\n    \"description\": \"Port number for the sync server\"\n  },\n  \"syncNow\": {\n    \"message\": \"Synchro\",\n    \"description\": \"Sync now button\"\n  },\n  \"lastSynced\": {\n    \"message\": \"Synchro : {time}\",\n    \"description\": \"Last sync timestamp\"\n  },\n  \"neverSynced\": {\n    \"message\": \"Jamais\",\n    \"description\": \"Never synced message\"\n  },\n  \"lastUploaded\": {\n    \"message\": \"Upload : {time}\",\n    \"description\": \"Last upload timestamp\"\n  },\n  \"neverUploaded\": {\n    \"message\": \"Jamais\",\n    \"description\": \"Never uploaded message\"\n  },\n  \"syncSuccess\": {\n    \"message\": \"✓ Succès\",\n    \"description\": \"Sync success message\"\n  },\n  \"syncError\": {\n    \"message\": \"✗ Échec : {error}\",\n    \"description\": \"Sync error message\"\n  },\n  \"syncInProgress\": {\n    \"message\": \"Synchro...\",\n    \"description\": \"Sync in progress message\"\n  },\n  \"uploadInProgress\": {\n    \"message\": \"Uploading...\",\n    \"description\": \"Upload in progress message\"\n  },\n  \"uploadSuccess\": {\n    \"message\": \"✓ Uploaded successfully\",\n    \"description\": \"Upload success message\"\n  },\n  \"downloadInProgress\": {\n    \"message\": \"Downloading...\",\n    \"description\": \"Download in progress message\"\n  },\n  \"downloadMergeSuccess\": {\n    \"message\": \"✓ Downloaded and merged\",\n    \"description\": \"Download and merge success message\"\n  },\n  \"syncUpload\": {\n    \"message\": \"Envoyer\",\n    \"description\": \"Upload button label\"\n  },\n  \"syncMerge\": {\n    \"message\": \"Récupérer\",\n    \"description\": \"Merge button label\"\n  },\n  \"syncMode\": {\n    \"message\": \"Mode\",\n    \"description\": \"Sync mode label\"\n  },\n  \"minutesAgo\": {\n    \"message\": \"min.\",\n    \"description\": \"Minutes ago label\"\n  },\n  \"syncNoData\": {\n    \"message\": \"Aucune donnée sur Drive\",\n    \"description\": \"Error message when no data is found in Drive\"\n  },\n  \"syncUploadFailed\": {\n    \"message\": \"Échec upload\",\n    \"description\": \"Error message when upload fails\"\n  },\n  \"syncDownloadFailed\": {\n    \"message\": \"Échec téléch.\",\n    \"description\": \"Error message when download fails\"\n  },\n  \"syncAuthFailed\": {\n    \"message\": \"Échec auth.\",\n    \"description\": \"Error message when authentication fails\"\n  },\n  \"signOut\": {\n    \"message\": \"Déconnexion\",\n    \"description\": \"Sign out button\"\n  },\n  \"signInWithGoogle\": {\n    \"message\": \"Connexion Google\",\n    \"description\": \"Sign in with Google button\"\n  },\n  \"nanobananaOptions\": {\n    \"message\": \"Options NanoBanana\",\n    \"description\": \"NanoBanana options section label\"\n  },\n  \"enableNanobananaWatermarkRemover\": {\n    \"message\": \"Retirer le filigrane NanoBanana\",\n    \"description\": \"Enable automatic watermark removal for NanoBanana images\"\n  },\n  \"nanobananaWatermarkRemoverHint\": {\n    \"message\": \"Retire automatiquement les filigranes visibles de Gemini sur les images\",\n    \"description\": \"Watermark remover feature hint\"\n  },\n  \"nanobananaDownloadTooltip\": {\n    \"message\": \"Télécharger l'image sans filigrane (NanoBanana)\",\n    \"description\": \"Download button tooltip\"\n  },\n  \"deepResearchDownload\": {\n    \"message\": \"Télécharger la réflexion\",\n    \"description\": \"Deep Research download button text\"\n  },\n  \"deepResearchDownloadTooltip\": {\n    \"message\": \"Télécharger le contenu de la réflexion en Markdown\",\n    \"description\": \"Deep Research download button tooltip\"\n  },\n  \"folder_uncategorized\": {\n    \"message\": \"Non classé\",\n    \"description\": \"Label for root-level conversations not in any folder\"\n  },\n  \"timelineLevelTitle\": {\n    \"message\": \"Niveau du nœud\",\n    \"description\": \"Title for timeline node level context menu\"\n  },\n  \"timelineLevel1\": {\n    \"message\": \"Niveau 1\",\n    \"description\": \"Timeline node level 1 option\"\n  },\n  \"timelineLevel2\": {\n    \"message\": \"Niveau 2\",\n    \"description\": \"Timeline node level 2 option\"\n  },\n  \"timelineLevel3\": {\n    \"message\": \"Niveau 3\",\n    \"description\": \"Timeline node level 3 option\"\n  },\n  \"timelineCollapse\": {\n    \"message\": \"Réduire\",\n    \"description\": \"Collapse children\"\n  },\n  \"timelineExpand\": {\n    \"message\": \"Étendre\",\n    \"description\": \"Expand children\"\n  },\n  \"timelinePreviewSearch\": {\n    \"message\": \"Rechercher...\",\n    \"description\": \"Timeline preview panel search placeholder\"\n  },\n  \"timelinePreviewNoResults\": {\n    \"message\": \"Aucun résultat\",\n    \"description\": \"Timeline preview panel no search results\"\n  },\n  \"timelinePreviewNoMessages\": {\n    \"message\": \"Aucun message\",\n    \"description\": \"Timeline preview panel no messages\"\n  },\n  \"quoteReply\": {\n    \"message\": \"Citer et répondre\",\n    \"description\": \"Quote reply button text\"\n  },\n  \"inputCollapseOptions\": {\n    \"message\": \"Options de saisie\",\n    \"description\": \"Input options section label\"\n  },\n  \"enableInputCollapse\": {\n    \"message\": \"Activer la réduction de l'entrée\",\n    \"description\": \"Enable input collapse toggle label\"\n  },\n  \"enableInputCollapseHint\": {\n    \"message\": \"Réduit la zone de saisie quand elle est vide pour gagner de l'espace\",\n    \"description\": \"Input collapse feature hint\"\n  },\n  \"inputCollapseShortcutHint\": {\n    \"message\": \"{modifier}+I pour agrandir\",\n    \"description\": \"Shortcut hint for expanding collapsed input. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"inputCollapsePlaceholder\": {\n    \"message\": \"Message à Gemini\",\n    \"description\": \"Placeholder text shown in collapsed input\"\n  },\n  \"allowCollapseWhenNotEmpty\": {\n    \"message\": \"Autoriser le repli avec contenu\",\n    \"description\": \"Allow input to collapse even when it has text or attachments\"\n  },\n  \"allowCollapseWhenNotEmptyHint\": {\n    \"message\": \"Replier la zone de saisie meme lorsqu'elle contient du texte ou des pieces jointes\",\n    \"description\": \"Hint text explaining the collapse when not empty feature\"\n  },\n  \"ctrlEnterSend\": {\n    \"message\": \"{modifier}+Entrée pour envoyer\",\n    \"description\": \"Modifier+Enter to send toggle label. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"ctrlEnterSendHint\": {\n    \"message\": \"Appuyez sur {modifier}+Entrée pour envoyer, Entrée pour un saut de ligne\",\n    \"description\": \"Modifier+Enter to send feature hint. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"batch_delete_confirm\": {\n    \"message\": \"Voulez-vous vraiment supprimer {count} discussion(s) ? Cette action est irréversible.\",\n    \"description\": \"Batch delete confirmation message\"\n  },\n  \"batch_delete_in_progress\": {\n    \"message\": \"Suppression... ({current}/{total})\",\n    \"description\": \"Batch delete progress message\"\n  },\n  \"batch_delete_success\": {\n    \"message\": \"✓ {count} discussion(s) supprimée(s)\",\n    \"description\": \"Batch delete success message\"\n  },\n  \"batch_delete_partial\": {\n    \"message\": \"Suppression terminée : {success} succès, {failed} échecs\",\n    \"description\": \"Batch delete partial success message\"\n  },\n  \"batch_delete_limit_reached\": {\n    \"message\": \"Maximum {max} discussions sélectionnables à la fois\",\n    \"description\": \"Batch delete limit warning\"\n  },\n  \"batch_delete_button\": {\n    \"message\": \"Supprimer la sélection\",\n    \"description\": \"Batch delete button tooltip in multi-select mode\"\n  },\n  \"batch_delete_match_patterns\": {\n    \"message\": \"delete,confirm,yes,ok,remove,supprimer,confirmer,oui,retirer,effacer\",\n    \"description\": \"Comma-separated list of keywords to match native delete/confirm buttons (case-insensitive)\"\n  },\n  \"generalOptions\": {\n    \"message\": \"Options générales\",\n    \"description\": \"General options section label\"\n  },\n  \"enableTabTitleUpdate\": {\n    \"message\": \"Synchroniser le titre de l'onglet\",\n    \"description\": \"Enable tab title sync toggle label\"\n  },\n  \"enableTabTitleUpdateHint\": {\n    \"message\": \"Met à jour automatiquement le titre de l'onglet avec celui de la discussion\",\n    \"description\": \"Tab title sync feature hint\"\n  },\n  \"enableMermaidRendering\": {\n    \"message\": \"Activer le rendu Mermaid\",\n    \"description\": \"Enable Mermaid diagram rendering toggle label\"\n  },\n  \"enableMermaidRenderingHint\": {\n    \"message\": \"Affiche automatiquement les diagrammes Mermaid dans les blocs de code\",\n    \"description\": \"Mermaid rendering feature hint\"\n  },\n  \"enableQuoteReply\": {\n    \"message\": \"Activer la citation\",\n    \"description\": \"Enable quote reply toggle label\"\n  },\n  \"enableQuoteReplyHint\": {\n    \"message\": \"Affiche un bouton flottant pour citer le texte sélectionné dans les conversations\",\n    \"description\": \"Quote reply feature hint\"\n  },\n  \"contextSync\": {\n    \"message\": \"Synchro du contexte\",\n    \"description\": \"Context Sync title\"\n  },\n  \"contextSyncDescription\": {\n    \"message\": \"Synchronisez le contexte du chat avec votre IDE local\",\n    \"description\": \"Context Sync description\"\n  },\n  \"syncToIDE\": {\n    \"message\": \"Synchroniser vers l'IDE\",\n    \"description\": \"Sync to IDE button\"\n  },\n  \"ideOnline\": {\n    \"message\": \"IDE en ligne\",\n    \"description\": \"IDE Online status\"\n  },\n  \"ideOffline\": {\n    \"message\": \"IDE hors ligne\",\n    \"description\": \"IDE Offline status\"\n  },\n  \"syncing\": {\n    \"message\": \"Synchronisation...\",\n    \"description\": \"Syncing status\"\n  },\n  \"checkServer\": {\n    \"message\": \"Veuillez démarrer le serveur AI Sync dans VS Code\",\n    \"description\": \"Check server hint\"\n  },\n  \"capturing\": {\n    \"message\": \"Capture du dialogue...\",\n    \"description\": \"Capturing status\"\n  },\n  \"syncedSuccess\": {\n    \"message\": \"Synchronisé avec succès !\",\n    \"description\": \"Synced success message\"\n  },\n  \"downloadingOriginal\": {\n    \"message\": \"Téléchargement de l'image originale\",\n    \"description\": \"Status when downloading original image\"\n  },\n  \"downloadingOriginalLarge\": {\n    \"message\": \"Téléchargement de l'image originale (fichier volumineux)\",\n    \"description\": \"Status when downloading large original image\"\n  },\n  \"downloadLargeWarning\": {\n    \"message\": \"Avertissement fichier volumineux\",\n    \"description\": \"Warning shown for large downloads\"\n  },\n  \"downloadProcessing\": {\n    \"message\": \"Traitement du filigrane\",\n    \"description\": \"Status when processing watermark removal\"\n  },\n  \"downloadSuccess\": {\n    \"message\": \"Téléchargement...\",\n    \"description\": \"Status when download starts\"\n  },\n  \"downloadError\": {\n    \"message\": \"Échec\",\n    \"description\": \"Status prefix when download fails\"\n  },\n  \"recentsHide\": {\n    \"message\": \"Masquer les éléments récents\",\n    \"description\": \"Info-bulle pour masquer la section des éléments récents\"\n  },\n  \"recentsShow\": {\n    \"message\": \"Afficher les éléments récents\",\n    \"description\": \"Info-bulle pour afficher la section des éléments récents\"\n  },\n  \"gemsHide\": {\n    \"message\": \"Masquer les Gems\",\n    \"description\": \"Info-bulle pour masquer la section de la liste des Gems\"\n  },\n  \"gemsShow\": {\n    \"message\": \"Afficher les Gems\",\n    \"description\": \"Info-bulle pour afficher la section de la liste des Gems\"\n  },\n  \"setAsDefaultModel\": {\n    \"message\": \"Set as default model\",\n    \"description\": \"Tooltip for setting default model\"\n  },\n  \"cancelDefaultModel\": {\n    \"message\": \"Cancel default model\",\n    \"description\": \"Tooltip for cancelling default model\"\n  },\n  \"defaultModelSet\": {\n    \"message\": \"Default model set: $1\",\n    \"description\": \"Toast message when default model is set\"\n  },\n  \"defaultModelCleared\": {\n    \"message\": \"Default model cleared\",\n    \"description\": \"Toast message when default model is cleared\"\n  },\n  \"sidebarAutoHide\": {\n    \"message\": \"Masquer auto barre latérale\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"sidebarAutoHideHint\": {\n    \"message\": \"Réduit automatiquement la barre latérale quand la souris la quitte, l'étend quand elle y entre\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"sidebarFullHide\": {\n    \"message\": \"Masquer complètement la barre latérale\",\n    \"description\": \"Fully hide sidebar toggle label\"\n  },\n  \"sidebarFullHideHint\": {\n    \"message\": \"Masquer complètement la barre latérale réduite, survoler le bord gauche pour afficher\",\n    \"description\": \"Fully hide sidebar feature hint\"\n  },\n  \"folderSpacing\": {\n    \"message\": \"Espacement des dossiers\",\n    \"description\": \"Folder spacing adjustment label\"\n  },\n  \"folderSpacingCompact\": {\n    \"message\": \"Compact\",\n    \"description\": \"Compact folder spacing label\"\n  },\n  \"folderSpacingSpacious\": {\n    \"message\": \"Spacieux\",\n    \"description\": \"Spacious folder spacing label\"\n  },\n  \"folderTreeIndent\": {\n    \"message\": \"Retrait des sous-dossiers\",\n    \"description\": \"Subfolder tree indentation adjustment label\"\n  },\n  \"folderTreeIndentCompact\": {\n    \"message\": \"Étroit\",\n    \"description\": \"Narrow subfolder indentation label\"\n  },\n  \"folderTreeIndentSpacious\": {\n    \"message\": \"Large\",\n    \"description\": \"Wide subfolder indentation label\"\n  },\n  \"export_select_mode_select_all\": {\n    \"message\": \"Tout sélectionner\",\n    \"description\": \"Selection mode select all toggle\"\n  },\n  \"export_select_mode_count\": {\n    \"message\": \"{count} sélectionné(s)\",\n    \"description\": \"Selection mode selected message count\"\n  },\n  \"export_select_mode_toggle\": {\n    \"message\": \"Select message\",\n    \"description\": \"Selection mode per-message toggle tooltip\"\n  },\n  \"export_select_mode_select_below\": {\n    \"message\": \"Select messages below\",\n    \"description\": \"Selection mode top-left button to select messages below current line\"\n  },\n  \"export_select_mode_empty\": {\n    \"message\": \"Please select at least one message to export.\",\n    \"description\": \"Selection mode validation when nothing is selected\"\n  },\n  \"export_format_image_description\": {\n    \"message\": \"Image PNG unique, pratique pour le partage mobile.\",\n    \"description\": \"Description for Image export format\"\n  },\n  \"export_image_progress\": {\n    \"message\": \"Generating image...\",\n    \"description\": \"Progress message while generating image export\"\n  },\n  \"deepResearchSaveReport\": {\n    \"message\": \"Save Report\",\n    \"description\": \"Deep Research save report button\"\n  },\n  \"deepResearchSaveReportTooltip\": {\n    \"message\": \"Export this research report\",\n    \"description\": \"Deep Research save report tooltip\"\n  },\n  \"export_fontsize_label\": {\n    \"message\": \"Taille de police\",\n    \"description\": \"Label for font size slider in export dialog\"\n  },\n  \"export_fontsize_preview\": {\n    \"message\": \"Le vif renard brun saute par-dessus le chien paresseux.\",\n    \"description\": \"Preview text for font size slider in export dialog\"\n  },\n  \"export_error_generic\": {\n    \"message\": \"Échec de l'exportation : {error}\",\n    \"description\": \"Message d'échec d'export avec raison détaillée\"\n  },\n  \"export_error_refresh_retry\": {\n    \"message\": \"Échec de l'exportation d'image en raison d'un problème réseau. Veuillez actualiser la page puis réessayer.\",\n    \"description\": \"Conseil affiché en cas d'échec temporaire du chargement lors de l'export image\"\n  },\n  \"export_md_include_source_confirm\": {\n    \"message\": \"Le contenu exporté contient des images de recherche web. Inclure les liens source des images dans le Markdown ?\",\n    \"description\": \"Confirm dialog when exporting markdown with web search images\"\n  },\n  \"deepResearch_exportedAt\": {\n    \"message\": \"Exporté le\",\n    \"description\": \"Étiquette d'horodatage de l'exportation de Deep Research\"\n  },\n  \"deepResearch_totalPhases\": {\n    \"message\": \"Phases totales\",\n    \"description\": \"Étiquette du nombre total de phases de réflexion de Deep Research\"\n  },\n  \"deepResearch_thinkingPhase\": {\n    \"message\": \"Phase de réflexion\",\n    \"description\": \"En-tête de phase de réflexion de Deep Research\"\n  },\n  \"deepResearch_researchedWebsites\": {\n    \"message\": \"Sites web consultés\",\n    \"description\": \"En-tête de section des sites web recherchés de Deep Research\"\n  },\n  \"visualEffect\": {\n    \"message\": \"Effets visuels\",\n    \"description\": \"Visual effect selector label\"\n  },\n  \"visualEffectHint\": {\n    \"message\": \"Ajoutez une ambiance saisonniere a la page\",\n    \"description\": \"Visual effect feature hint\"\n  },\n  \"visualEffectOff\": {\n    \"message\": \"Desactive\",\n    \"description\": \"Visual effect off option\"\n  },\n  \"visualEffectSnow\": {\n    \"message\": \"Neige\",\n    \"description\": \"Visual effect snow option\"\n  },\n  \"visualEffectSakura\": {\n    \"message\": \"Sakura\",\n    \"description\": \"Visual effect sakura option\"\n  },\n  \"visualEffectRain\": {\n    \"message\": \"Pluie\",\n    \"description\": \"Visual effect rain option\"\n  },\n  \"changelog_title\": {\n    \"message\": \"Nouveautés\",\n    \"description\": \"Changelog modal title\"\n  },\n  \"changelog_close\": {\n    \"message\": \"Compris\",\n    \"description\": \"Changelog modal close button\"\n  },\n  \"changelog_docs_link\": {\n    \"message\": \"Docs\",\n    \"description\": \"Changelog modal documentation link text\"\n  },\n  \"changelog_recommendation\": {\n    \"message\": \"Si Voyager vous a été utile, partagez-le avec vos amis ou sur les réseaux sociaux — n'hésitez pas à mentionner l'auteur !\",\n    \"description\": \"Changelog modal recommendation message\"\n  },\n  \"changelog_social_xiaohongshu\": {\n    \"message\": \"Xiaohongshu\",\n    \"description\": \"Xiaohongshu platform name for social links\"\n  },\n  \"changelog_social_zhihu\": {\n    \"message\": \"Zhihu\",\n    \"description\": \"Zhihu platform name for social links\"\n  },\n  \"changelog_docs_hint\": {\n    \"message\": \"Détails dans les docs\",\n    \"description\": \"Changelog annotation pointing to docs icon\"\n  },\n  \"changelog_rate_chrome\": {\n    \"message\": \"Vous aimez Voyager ? Une note sur le Chrome Web Store aide d'autres utilisateurs à le découvrir !\",\n    \"description\": \"Chrome Web Store rating prompt in changelog modal\"\n  },\n  \"changelog_rate_chrome_cta\": {\n    \"message\": \"Évaluer\",\n    \"description\": \"Chrome Web Store rating CTA button text\"\n  },\n  \"changelog_badge_mode\": {\n    \"message\": \"Notifier via un badge NEW sur le bouton flottant au lieu de cette fenêtre\",\n    \"description\": \"Changelog notification mode toggle label\"\n  },\n  \"forkConversation\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork conversation button label\"\n  },\n  \"forkConfirm\": {\n    \"message\": \"Fork conversation from this point?\",\n    \"description\": \"Fork confirmation prompt\"\n  },\n  \"forkConfirmBtn\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork confirmation button\"\n  },\n  \"forkCancel\": {\n    \"message\": \"Cancel\",\n    \"description\": \"Fork cancel button\"\n  },\n  \"forkBranch\": {\n    \"message\": \"Branch\",\n    \"description\": \"Fork branch label\"\n  },\n  \"forkOriginal\": {\n    \"message\": \"Original\",\n    \"description\": \"Original fork branch tag\"\n  },\n  \"forkCurrent\": {\n    \"message\": \"Current\",\n    \"description\": \"Current fork branch tag\"\n  },\n  \"forkPreparing\": {\n    \"message\": \"Preparing fork...\",\n    \"description\": \"Fork preparation status\"\n  },\n  \"forkPasteHint\": {\n    \"message\": \"Press Enter to send and create the fork\",\n    \"description\": \"Fork paste hint text\"\n  },\n  \"forkDeleteData\": {\n    \"message\": \"Supprimer cette branche\",\n    \"description\": \"Delete fork branch metadata button\"\n  },\n  \"forkDeleteDataConfirm\": {\n    \"message\": \"Supprimer ce lien de branche ? Cela supprime uniquement les métadonnées de branche Voyager et ne supprime aucune conversation.\",\n    \"description\": \"Fork metadata delete confirmation prompt\"\n  },\n  \"enableForkFeature\": {\n    \"message\": \"Activer le fork de conversation\",\n    \"description\": \"Enable conversation fork feature toggle label\"\n  },\n  \"enableForkFeatureHint\": {\n    \"message\": \"Afficher le bouton Fork et les indicateurs de branche dans les conversations Gemini\",\n    \"description\": \"Enable conversation fork feature toggle hint\"\n  },\n  \"enableAccountIsolation\": {\n    \"message\": \"Enable Hard Isolation\",\n    \"description\": \"Enable hard isolation for account-scoped folder data and cloud sync.\"\n  },\n  \"enableAccountIsolationHint\": {\n    \"message\": \"Strictly isolate folder and cloud sync data by Google account to prevent cross-account mixing.\",\n    \"description\": \"Hint text for account isolation option.\"\n  },\n  \"currentPlatform\": {\n    \"message\": \"Current platform\",\n    \"description\": \"Current active platform label in popup.\"\n  },\n  \"platformGemini\": {\n    \"message\": \"Gemini\",\n    \"description\": \"Gemini platform label.\"\n  },\n  \"platformAIStudio\": {\n    \"message\": \"AI Studio\",\n    \"description\": \"AI Studio platform label.\"\n  },\n  \"enableOnAIStudio\": {\n    \"message\": \"Activer sur AI Studio\",\n    \"description\": \"Toggle to enable/disable Voyager on AI Studio\"\n  },\n  \"enableOnAIStudioHint\": {\n    \"message\": \"Désactiver pour désactiver les fonctions Voyager sur AI Studio (Prompt Manager reste actif)\",\n    \"description\": \"Hint for AI Studio enable toggle\"\n  },\n  \"export_select_mode_only_user\": {\n    \"message\": \"Sélectionner uniquement l'utilisateur\",\n    \"description\": \"Export selection mode: select only user messages\"\n  },\n  \"export_select_mode_only_ai\": {\n    \"message\": \"Sélectionner uniquement l'IA\",\n    \"description\": \"Export selection mode: select only AI messages\"\n  },\n  \"aiOrgCopyButton\": {\n    \"message\": \"Organiser par IA\",\n    \"description\": \"Bouton pour copier la structure des dossiers et conversations pour organisation IA\"\n  },\n  \"aiOrgCopied\": {\n    \"message\": \"Copié !\",\n    \"description\": \"Confirmation après la copie du prompt d'organisation IA\"\n  },\n  \"aiOrgError\": {\n    \"message\": \"Échec — ouvrez Gemini d'abord\",\n    \"description\": \"Erreur lors de la copie du prompt d'organisation IA\"\n  },\n  \"aiOrgCopyHint\": {\n    \"message\": \"Copie toutes les conversations et la structure des dossiers comme un prompt. Collez dans Gemini pour obtenir un plan de dossiers importable.\",\n    \"description\": \"Texte d'aide sous le bouton de copie d'organisation IA\"\n  },\n  \"aiOrgCurrentFolders\": {\n    \"message\": \"Structure des dossiers actuelle\",\n    \"description\": \"En-tête dans le prompt d'organisation IA\"\n  },\n  \"aiOrgUnfiledConversations\": {\n    \"message\": \"Conversations non classées\",\n    \"description\": \"En-tête pour les conversations qui ne sont dans aucun dossier\"\n  },\n  \"aiOrgEmpty\": {\n    \"message\": \"vide\",\n    \"description\": \"Étiquette pour les dossiers vides dans le prompt d'organisation IA\"\n  },\n  \"aiOrgInstructions\": {\n    \"message\": \"Instructions\",\n    \"description\": \"En-tête de la section d'instructions IA\"\n  },\n  \"aiOrgInstructionsBody\": {\n    \"message\": \"Sur la base des conversations et de la structure des dossiers ci-dessus, réorganisez-les en une hiérarchie logique de dossiers. Générez un fichier JSON au format exact ci-dessous qui peut être directement importé dans l'extension Gemini Voyager (utilisez la stratégie d'importation \\\"Fusionner\\\"). Conservez les dossiers bien organisés existants, créez-en de nouveaux si nécessaire et déplacez les conversations non classées dans des dossiers appropriés. Chaque dossier a besoin d'un ID unique (utilisez une chaîne aléatoire courte) et chaque conversation doit conserver son conversationId et son url d'origine.\",\n    \"description\": \"Corps des instructions pour que l'IA génère une structure de dossiers importable\"\n  },\n  \"showMessageTimestamps\": {\n    \"message\": \"Afficher les horodatages des messages\",\n    \"description\": \"Afficher l'horodatage de chaque message\"\n  },\n  \"showMessageTimestampsHint\": {\n    \"message\": \"Afficher quand chaque message a été envoyé\",\n    \"description\": \"Indice pour la fonctionnalité d'horodatage\"\n  },\n  \"moveSectionUp\": {\n    \"message\": \"Déplacer vers le haut\",\n    \"description\": \"Tooltip for moving a settings section up in popup\"\n  },\n  \"moveSectionDown\": {\n    \"message\": \"Déplacer vers le bas\",\n    \"description\": \"Tooltip for moving a settings section down in popup\"\n  }\n}\n"
  },
  {
    "path": "src/locales/ja/messages.json",
    "content": "{\n  \"extName\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Extension name\"\n  },\n  \"extDescription\": {\n    \"message\": \"タイムラインナビゲーション、フォルダ整理、プロンプトヴォルト、チャットのエクスポートなどで、Gemini™ の体験を強化します。\",\n    \"description\": \"Extension description\"\n  },\n  \"scrollMode\": {\n    \"message\": \"スクロールモード\",\n    \"description\": \"Scroll mode label\"\n  },\n  \"flow\": {\n    \"message\": \"フロー\",\n    \"description\": \"Flow mode\"\n  },\n  \"jump\": {\n    \"message\": \"ジャンプ\",\n    \"description\": \"Jump mode\"\n  },\n  \"hideOuterContainer\": {\n    \"message\": \"外側のコンテナを隠す\",\n    \"description\": \"Hide outer container label\"\n  },\n  \"draggableTimeline\": {\n    \"message\": \"ドラッグ可能なタイムライン\",\n    \"description\": \"Draggable Timeline label\"\n  },\n  \"enableMarkerLevel\": {\n    \"message\": \"ノード階層を有効化\",\n    \"description\": \"Enable timeline node level feature\"\n  },\n  \"enableMarkerLevelHint\": {\n    \"message\": \"タイムラインノードを右クリックして階層を設定し、子ノードを折りたたみます\",\n    \"description\": \"Node level feature hint\"\n  },\n  \"experimentalLabel\": {\n    \"message\": \"実験的\",\n    \"description\": \"Experimental feature label\"\n  },\n  \"resetPosition\": {\n    \"message\": \"位置をリセット\",\n    \"description\": \"Reset Position button\"\n  },\n  \"resetTimelinePosition\": {\n    \"message\": \"タイムラインの位置をリセット\",\n    \"description\": \"Reset timeline position button in timeline options\"\n  },\n  \"language\": {\n    \"message\": \"言語\",\n    \"description\": \"Language label\"\n  },\n  \"pm_title\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Prompt Manager title\"\n  },\n  \"pm_add\": {\n    \"message\": \"追加\",\n    \"description\": \"Add prompt button\"\n  },\n  \"pm_search_placeholder\": {\n    \"message\": \"プロンプトまたはタグを検索\",\n    \"description\": \"Search placeholder\"\n  },\n  \"pm_import\": {\n    \"message\": \"インポート\",\n    \"description\": \"Import button\"\n  },\n  \"pm_export\": {\n    \"message\": \"エクスポート\",\n    \"description\": \"Export button\"\n  },\n  \"pm_prompt_placeholder\": {\n    \"message\": \"プロンプトのテキスト\",\n    \"description\": \"Prompt textarea placeholder\"\n  },\n  \"pm_tags_placeholder\": {\n    \"message\": \"タグ (カンマ区切り)\",\n    \"description\": \"Tags input placeholder\"\n  },\n  \"pm_save\": {\n    \"message\": \"保存\",\n    \"description\": \"Save prompt\"\n  },\n  \"pm_cancel\": {\n    \"message\": \"キャンセル\",\n    \"description\": \"Cancel\"\n  },\n  \"pm_all_tags\": {\n    \"message\": \"すべて\",\n    \"description\": \"All tags chip\"\n  },\n  \"pm_empty\": {\n    \"message\": \"まだプロンプトがありません\",\n    \"description\": \"Empty state\"\n  },\n  \"pm_copy\": {\n    \"message\": \"コピー\",\n    \"description\": \"Copy prompt\"\n  },\n  \"pm_copied\": {\n    \"message\": \"コピーしました\",\n    \"description\": \"Copied notice\"\n  },\n  \"pm_delete\": {\n    \"message\": \"削除\",\n    \"description\": \"Delete prompt\"\n  },\n  \"pm_delete_confirm\": {\n    \"message\": \"このプロンプトを削除しますか？\",\n    \"description\": \"Delete confirm\"\n  },\n  \"pm_lock\": {\n    \"message\": \"位置を固定\",\n    \"description\": \"Lock panel position\"\n  },\n  \"pm_unlock\": {\n    \"message\": \"位置固定を解除\",\n    \"description\": \"Unlock panel position\"\n  },\n  \"pm_import_invalid\": {\n    \"message\": \"無効なファイル形式です\",\n    \"description\": \"Import invalid\"\n  },\n  \"pm_import_success\": {\n    \"message\": \"{count} 件のプロンプトをインポートしました\",\n    \"description\": \"Import success\"\n  },\n  \"pm_duplicate\": {\n    \"message\": \"プロンプトを複製\",\n    \"description\": \"Duplicate on add\"\n  },\n  \"pm_deleted\": {\n    \"message\": \"削除しました\",\n    \"description\": \"Deleted notice\"\n  },\n  \"pm_edit\": {\n    \"message\": \"編集\",\n    \"description\": \"Edit prompt\"\n  },\n  \"pm_expand\": {\n    \"message\": \"展開\",\n    \"description\": \"Expand prompt text\"\n  },\n  \"pm_collapse\": {\n    \"message\": \"折りたたむ\",\n    \"description\": \"Collapse prompt text\"\n  },\n  \"pm_saved\": {\n    \"message\": \"保存しました\",\n    \"description\": \"Saved notice\"\n  },\n  \"pm_settings\": {\n    \"message\": \"設定\",\n    \"description\": \"Settings button\"\n  },\n  \"pm_settings_tooltip\": {\n    \"message\": \"拡張機能の設定を開く\",\n    \"description\": \"Settings button tooltip\"\n  },\n  \"pm_settings_fallback\": {\n    \"message\": \"ブラウザツールバーの拡張機能アイコンをクリックして設定を開いてください\",\n    \"description\": \"Settings open failed message\"\n  },\n  \"pm_backup\": {\n    \"message\": \"ローカルバックアップ\",\n    \"description\": \"Local backup button\"\n  },\n  \"pm_backup_tooltip\": {\n    \"message\": \"プロンプトとフォルダをタイムスタンプ付きフォルダにバックアップ\",\n    \"description\": \"Backup button tooltip\"\n  },\n  \"pm_backup_cancelled\": {\n    \"message\": \"バックアップをキャンセルしました\",\n    \"description\": \"Backup cancelled hint\"\n  },\n  \"pm_backup_error\": {\n    \"message\": \"✗ バックアップに失敗しました\",\n    \"description\": \"Backup failed hint\"\n  },\n  \"pm_backup_hint_options\": {\n    \"message\": \"この機能は Gemini ページのプロンプトマネージャーに統合されています。\",\n    \"description\": \"Backup hint in options page\"\n  },\n  \"pm_backup_step1\": {\n    \"message\": \"Gemini のページ (gemini.google.com) を開きます\",\n    \"description\": \"Backup step 1\"\n  },\n  \"pm_backup_step2\": {\n    \"message\": \"右下の拡張機能アイコンをクリックしてプロンプトマネージャーを開きます\",\n    \"description\": \"Backup step 2\"\n  },\n  \"pm_backup_step3\": {\n    \"message\": \"「💾 ローカルバックアップ」ボタンをクリックし、バックアップ先フォルダを選択します\",\n    \"description\": \"Backup step 3\"\n  },\n  \"pm_backup_note\": {\n    \"message\": \"すべてのプロンプトとフォルダが含まれ、タイムスタンプ付きのフォルダに保存されます (形式：backup-YYYYMMDD-HHMMSS)\",\n    \"description\": \"Backup feature note\"\n  },\n  \"pm_theme_light\": {\n    \"message\": \"ライトモードに切替\",\n    \"description\": \"Theme toggle tooltip - switch to light\"\n  },\n  \"pm_theme_dark\": {\n    \"message\": \"ダークモードに切替\",\n    \"description\": \"Theme toggle tooltip - switch to dark\"\n  },\n  \"extensionVersion\": {\n    \"message\": \"バージョン\",\n    \"description\": \"Extension version label\"\n  },\n  \"newVersionAvailable\": {\n    \"message\": \"新しいバージョンが利用可能です\",\n    \"description\": \"Update reminder title\"\n  },\n  \"currentVersionLabel\": {\n    \"message\": \"現在のバージョン\",\n    \"description\": \"Label for current version number\"\n  },\n  \"latestVersionLabel\": {\n    \"message\": \"最新バージョン\",\n    \"description\": \"Label for latest version number\"\n  },\n  \"updateNow\": {\n    \"message\": \"更新する\",\n    \"description\": \"CTA to update to latest version\"\n  },\n  \"safariUpdateNotSynced\": {\n    \"message\": \"更新がまだ同期されていません。数時間後にもう一度お試しください\",\n    \"description\": \"Shown on Safari when the DMG file is not yet available on GitHub releases\"\n  },\n  \"starProject\": {\n    \"message\": \"応援する ⭐\",\n    \"description\": \"Github callout\"\n  },\n  \"officialDocs\": {\n    \"message\": \"公式ドキュメント\",\n    \"description\": \"Official docs link\"\n  },\n  \"exportChatJson\": {\n    \"message\": \"会話履歴をエクスポート\",\n    \"description\": \"Tooltip for export button\"\n  },\n  \"folder_title\": {\n    \"message\": \"フォルダ\",\n    \"description\": \"Folder section title\"\n  },\n  \"folder_create\": {\n    \"message\": \"フォルダを作成\",\n    \"description\": \"Create folder button tooltip\"\n  },\n  \"folder_name_prompt\": {\n    \"message\": \"フォルダ名を入力：\",\n    \"description\": \"Create folder name prompt\"\n  },\n  \"folder_rename_prompt\": {\n    \"message\": \"新しい名前を入力：\",\n    \"description\": \"Rename folder prompt\"\n  },\n  \"folder_delete_confirm\": {\n    \"message\": \"このフォルダとその中身をすべて削除しますか？\",\n    \"description\": \"Delete folder confirmation\"\n  },\n  \"folder_create_subfolder\": {\n    \"message\": \"サブフォルダを作成\",\n    \"description\": \"Create subfolder menu item\"\n  },\n  \"folder_rename\": {\n    \"message\": \"名前を変更\",\n    \"description\": \"Rename folder menu item\"\n  },\n  \"folder_change_color\": {\n    \"message\": \"色を変更\",\n    \"description\": \"Change folder color menu item\"\n  },\n  \"folder_delete\": {\n    \"message\": \"削除\",\n    \"description\": \"Delete folder menu item\"\n  },\n  \"folder_color_default\": {\n    \"message\": \"デフォルト\",\n    \"description\": \"Default folder color name\"\n  },\n  \"folder_color_red\": {\n    \"message\": \"赤\",\n    \"description\": \"Red folder color name\"\n  },\n  \"folder_color_orange\": {\n    \"message\": \"オレンジ\",\n    \"description\": \"Orange folder color name\"\n  },\n  \"folder_color_yellow\": {\n    \"message\": \"黄色\",\n    \"description\": \"Yellow folder color name\"\n  },\n  \"folder_color_green\": {\n    \"message\": \"緑\",\n    \"description\": \"Green folder color name\"\n  },\n  \"folder_color_blue\": {\n    \"message\": \"青\",\n    \"description\": \"Blue folder color name\"\n  },\n  \"folder_color_purple\": {\n    \"message\": \"紫\",\n    \"description\": \"Purple folder color name\"\n  },\n  \"folder_color_pink\": {\n    \"message\": \"ピンク\",\n    \"description\": \"Pink folder color name\"\n  },\n  \"folder_color_custom\": {\n    \"message\": \"カスタム\",\n    \"description\": \"Custom folder color name\"\n  },\n  \"folder_empty\": {\n    \"message\": \"フォルダがありません\",\n    \"description\": \"Empty state placeholder when no folders exist\"\n  },\n  \"folder_pin\": {\n    \"message\": \"フォルダをピン留め\",\n    \"description\": \"Pin folder menu item\"\n  },\n  \"folder_unpin\": {\n    \"message\": \"ピン留めを解除\",\n    \"description\": \"Unpin folder menu item\"\n  },\n  \"folder_remove_conversation\": {\n    \"message\": \"フォルダから削除\",\n    \"description\": \"Remove conversation button tooltip\"\n  },\n  \"folder_remove_conversation_confirm\": {\n    \"message\": \"「{title}」をこのフォルダから削除しますか？\",\n    \"description\": \"Remove conversation confirmation\"\n  },\n  \"conversation_more\": {\n    \"message\": \"その他のオプション\",\n    \"description\": \"Conversation more options button tooltip\"\n  },\n  \"conversation_move_to_folder\": {\n    \"message\": \"フォルダへ移動\",\n    \"description\": \"Move conversation to folder menu item\"\n  },\n  \"conversation_move_to_folder_title\": {\n    \"message\": \"フォルダへ移動\",\n    \"description\": \"Move conversation to folder dialog title\"\n  },\n  \"chatWidth\": {\n    \"message\": \"チャットの幅\",\n    \"description\": \"Chat width adjustment label\"\n  },\n  \"chatWidthNarrow\": {\n    \"message\": \"狭い\",\n    \"description\": \"Narrow chat width label\"\n  },\n  \"chatWidthWide\": {\n    \"message\": \"広い\",\n    \"description\": \"Wide chat width label\"\n  },\n  \"sidebarWidth\": {\n    \"message\": \"サイドバーの幅\",\n    \"description\": \"Sidebar width adjustment label\"\n  },\n  \"sidebarWidthNarrow\": {\n    \"message\": \"狭い\",\n    \"description\": \"Narrow sidebar width label\"\n  },\n  \"sidebarWidthWide\": {\n    \"message\": \"広い\",\n    \"description\": \"Wide sidebar width label\"\n  },\n  \"formula_copied\": {\n    \"message\": \"✓ 数式をコピーしました\",\n    \"description\": \"Formula copied success message\"\n  },\n  \"formula_copy_failed\": {\n    \"message\": \"✗ コピーに失敗しました\",\n    \"description\": \"Formula copy failed message\"\n  },\n  \"formulaCopyFormat\": {\n    \"message\": \"数式コピー形式\",\n    \"description\": \"Formula copy format setting label\"\n  },\n  \"formulaCopyFormatLatex\": {\n    \"message\": \"LaTeX\",\n    \"description\": \"LaTeX format option\"\n  },\n  \"formulaCopyFormatUnicodeMath\": {\n    \"message\": \"MathML (Word)\",\n    \"description\": \"Label for UnicodeMath formula copy format option\"\n  },\n  \"formulaCopyFormatHint\": {\n    \"message\": \"数式をクリックしてコピーする際の形式を選択します\",\n    \"description\": \"Formula copy format hint\"\n  },\n  \"formulaCopyFormatNoDollar\": {\n    \"message\": \"LaTeX (ドル記号なし)\",\n    \"description\": \"LaTeX format option without dollar signs\"\n  },\n  \"folder_filter_current_user\": {\n    \"message\": \"アカウント隔離\",\n    \"description\": \"Show only conversations from the current Google account\"\n  },\n  \"folder_export\": {\n    \"message\": \"フォルダをエクスポート\",\n    \"description\": \"Export folders button tooltip\"\n  },\n  \"folder_import_export\": {\n    \"message\": \"フォルダのインポート/エクスポート\",\n    \"description\": \"Combined import/export button tooltip\"\n  },\n  \"folder_cloud_upload\": {\n    \"message\": \"クラウドにアップロード\",\n    \"description\": \"Cloud upload button tooltip\"\n  },\n  \"folder_cloud_sync\": {\n    \"message\": \"クラウドから同期\",\n    \"description\": \"Cloud sync button tooltip\"\n  },\n  \"folder_import\": {\n    \"message\": \"フォルダをインポート\",\n    \"description\": \"Import folders button tooltip\"\n  },\n  \"folder_import_title\": {\n    \"message\": \"フォルダ設定のインポート\",\n    \"description\": \"Import dialog title\"\n  },\n  \"folder_import_strategy\": {\n    \"message\": \"インポート方法：\",\n    \"description\": \"Import strategy label\"\n  },\n  \"folder_import_merge\": {\n    \"message\": \"既存のフォルダと統合する\",\n    \"description\": \"Merge import strategy\"\n  },\n  \"folder_import_overwrite\": {\n    \"message\": \"既存のフォルダを上書きする\",\n    \"description\": \"Overwrite import strategy\"\n  },\n  \"folder_import_select_file\": {\n    \"message\": \"インポートする JSON ファイルを選択\",\n    \"description\": \"Import file selection prompt\"\n  },\n  \"folder_import_success\": {\n    \"message\": \"✓ フォルダ {folders} 件、会話 {conversations} 件をインポートしました\",\n    \"description\": \"Import success message\"\n  },\n  \"folder_import_success_skipped\": {\n    \"message\": \"✓ フォルダ {folders} 件、会話 {conversations} 件をインポートしました (重複 {skipped} 件をスキップ)\",\n    \"description\": \"Import success with skipped message\"\n  },\n  \"folder_import_error\": {\n    \"message\": \"✗ インポート失敗：{error}\",\n    \"description\": \"Import error message\"\n  },\n  \"folder_export_success\": {\n    \"message\": \"✓ フォルダをエクスポートしました\",\n    \"description\": \"Export success message\"\n  },\n  \"folder_import_invalid_format\": {\n    \"message\": \"無効なファイル形式です。正しいフォルダ設定ファイルを選択してください。\",\n    \"description\": \"Invalid format error\"\n  },\n  \"folder_import_confirm_overwrite\": {\n    \"message\": \"既存のフォルダ構造がすべて置換されます。バックアップが作成されます。続行しますか？\",\n    \"description\": \"Overwrite confirmation\"\n  },\n  \"folder_import_paste_json\": {\n    \"message\": \"または JSON を直接貼り付け\",\n    \"description\": \"インポートダイアログで貼り付けテキストエリアを表示するトグルボタン\"\n  },\n  \"folder_import_paste_placeholder\": {\n    \"message\": \"ここに JSON を貼り付け...\",\n    \"description\": \"貼り付けテキストエリアのプレースホルダー\"\n  },\n  \"export_dialog_title\": {\n    \"message\": \"会話のエクスポート\",\n    \"description\": \"Export dialog title\"\n  },\n  \"export_dialog_select\": {\n    \"message\": \"エクスポート形式を選択：\",\n    \"description\": \"Export format selection label\"\n  },\n  \"export_dialog_warning\": {\n    \"message\": \"⚠️ エクスポート開始後、全内容を読み込むために最初のメッセージへ自動移動します。移動中は何もしないでください。完了後、エクスポートが自動的に再開されます。\",\n    \"description\": \"Warning about auto-jump and loading completeness\"\n  },\n  \"export_dialog_safari_cmdp_hint\": {\n    \"message\": \"Safari のヒント：下の「エクスポート」を選択し、少し待ってから ⌘P を押して「PDF として保存」を選択してください。\",\n    \"description\": \"Safari-specific hint for PDF export\"\n  },\n  \"export_toast_safari_pdf_ready\": {\n    \"message\": \"今すぐ Command + P を押して PDF を書き出せます。\",\n    \"description\": \"Safari-specific toast shown after PDF export is prepared\"\n  },\n  \"export_dialog_safari_markdown_hint\": {\n    \"message\": \"注意：Safari の制限により、チャット履歴内の画像は抽出できません。完全にエクスポートするには、PDF エクスポートを使用してください。\",\n    \"description\": \"Warning about image references in Safari markdown export\"\n  },\n  \"export_format_json_description\": {\n    \"message\": \"開発者向けの機械可読形式\",\n    \"description\": \"Description for JSON export format\"\n  },\n  \"export_format_markdown_description\": {\n    \"message\": \"クリーンで汎用性の高いテキスト形式（推奨）\",\n    \"description\": \"Description for Markdown export format\"\n  },\n  \"export_format_pdf_description\": {\n    \"message\": \"印刷に適した形式（PDF として保存）\",\n    \"description\": \"Description for PDF export format\"\n  },\n  \"editInputWidth\": {\n    \"message\": \"入力欄の幅\",\n    \"description\": \"Edit input width adjustment label\"\n  },\n  \"editInputWidthNarrow\": {\n    \"message\": \"狭い\",\n    \"description\": \"Narrow edit input width label\"\n  },\n  \"editInputWidthWide\": {\n    \"message\": \"広い\",\n    \"description\": \"Wide edit input width label\"\n  },\n  \"timelineOptions\": {\n    \"message\": \"タイムラインオプション\",\n    \"description\": \"Timeline options section label\"\n  },\n  \"folderOptions\": {\n    \"message\": \"フォルダオプション\",\n    \"description\": \"Folder options section label\"\n  },\n  \"enableFolderFeature\": {\n    \"message\": \"フォルダ機能を有効化\",\n    \"description\": \"Enable or disable the folder feature\"\n  },\n  \"hideArchivedConversations\": {\n    \"message\": \"フォルダに入れた会話をメインリストから隠す\",\n    \"description\": \"Hide conversations that are in folders from the main list\"\n  },\n  \"conversation_star\": {\n    \"message\": \"会話にスターを付ける\",\n    \"description\": \"Star conversation button tooltip\"\n  },\n  \"conversation_unstar\": {\n    \"message\": \"スターを外す\",\n    \"description\": \"Unstar conversation button tooltip\"\n  },\n  \"conversation_untitled\": {\n    \"message\": \"無題\",\n    \"description\": \"Fallback title for conversations without a name\"\n  },\n  \"geminiOnlyNotice\": {\n    \"message\": \"デフォルトでは Gemini（Enterprise 版含む）と AI Studio で動作します。他のサイトでプロンプトマネージャーを使用するには、以下で追加してください。\",\n    \"description\": \"Notice that defaults are limited to Gemini/AI Studio\"\n  },\n  \"folderManager_dataLossWarning\": {\n    \"message\": \"⚠️ 警告：フォルダデータの読み込みに失敗しました。データが破損している可能性があります。ブラウザのコンソールで詳細を確認し、可能であればバックアップから復元してください。\",\n    \"description\": \"Warning shown when folder data fails to load\"\n  },\n  \"backupOptions\": {\n    \"message\": \"自動バックアップ\",\n    \"description\": \"Backup options section label\"\n  },\n  \"backupEnabled\": {\n    \"message\": \"自動バックアップを有効化\",\n    \"description\": \"Enable auto backup toggle\"\n  },\n  \"backupNow\": {\n    \"message\": \"今すぐバックアップ\",\n    \"description\": \"Backup now button\"\n  },\n  \"backupSelectFolder\": {\n    \"message\": \"バックアップ先を選択\",\n    \"description\": \"Select backup folder button\"\n  },\n  \"backupFolderSelected\": {\n    \"message\": \"バックアップフォルダ：{folder}\",\n    \"description\": \"Shows selected backup folder\"\n  },\n  \"backupIncludePrompts\": {\n    \"message\": \"プロンプトを含める\",\n    \"description\": \"Include prompts in backup toggle\"\n  },\n  \"backupIncludeFolders\": {\n    \"message\": \"フォルダを含める\",\n    \"description\": \"Include folders in backup toggle\"\n  },\n  \"backupIntervalLabel\": {\n    \"message\": \"バックアップ間隔\",\n    \"description\": \"Backup interval label\"\n  },\n  \"backupIntervalManual\": {\n    \"message\": \"手動のみ\",\n    \"description\": \"Manual backup mode\"\n  },\n  \"backupIntervalDaily\": {\n    \"message\": \"毎日\",\n    \"description\": \"Daily backup interval\"\n  },\n  \"backupIntervalWeekly\": {\n    \"message\": \"毎週 (7 日ごと)\",\n    \"description\": \"Weekly backup interval\"\n  },\n  \"backupLastBackup\": {\n    \"message\": \"前回のバックアップ：{time}\",\n    \"description\": \"Last backup timestamp\"\n  },\n  \"backupNever\": {\n    \"message\": \"なし\",\n    \"description\": \"Never backed up\"\n  },\n  \"backupSuccess\": {\n    \"message\": \"✓ バックアップ完了：プロンプト {prompts} 件，フォルダ {folders} 件，会話 {conversations} 件\",\n    \"description\": \"Backup success message\"\n  },\n  \"backupError\": {\n    \"message\": \"✗ バックアップ失敗：{error}\",\n    \"description\": \"Backup error message\"\n  },\n  \"backupNotSupported\": {\n    \"message\": \"⚠️ 自動バックアップには File System Access API に対応した最新のブラウザが必要です\",\n    \"description\": \"Browser not supported message\"\n  },\n  \"backupSelectFolderFirst\": {\n    \"message\": \"先にバックアップフォルダを選択してください\",\n    \"description\": \"No folder selected warning\"\n  },\n  \"backupUserCancelled\": {\n    \"message\": \"バックアップをキャンセルしました\",\n    \"description\": \"User cancelled folder selection\"\n  },\n  \"backupConfigSaved\": {\n    \"message\": \"✓ バックアップ設定を保存しました\",\n    \"description\": \"Config saved message\"\n  },\n  \"backupPermissionDenied\": {\n    \"message\": \"⚠️ このディレクトリにはアクセスできません。別の場所（ドキュメント、ダウンロード、デスクトップなど）を選択してください。\",\n    \"description\": \"Permission denied for restricted directory\"\n  },\n  \"backupConfigureInOptions\": {\n    \"message\": \"バックアップの設定はオプションページで行う必要があります。下のボタンをクリックして開いてください（拡張機能アイコン横の 3 点リーダ → オプション）。\",\n    \"description\": \"Backup configure in options page explanation\"\n  },\n  \"openOptionsPage\": {\n    \"message\": \"オプションページを開く\",\n    \"description\": \"Open options page button\"\n  },\n  \"optionsPageSubtitle\": {\n    \"message\": \"拡張機能のオプション\",\n    \"description\": \"Options page subtitle\"\n  },\n  \"optionsComingSoon\": {\n    \"message\": \"さらに多くのオプションを近日追加予定です...\",\n    \"description\": \"Options page placeholder text\"\n  },\n  \"backupDataAccessNotice\": {\n    \"message\": \"データアクセスの制限\",\n    \"description\": \"Backup data access limitation title\"\n  },\n  \"backupDataAccessHint\": {\n    \"message\": \"ブラウザのセキュリティ制限により、このオプションページからは Gemini ページ上のプロンプトやフォルダデータに直接アクセスできません。手動バックアップには、Gemini ページ上のプロンプトマネージャーとフォルダエクスポート機能をご利用ください。\",\n    \"description\": \"Backup data access limitation explanation\"\n  },\n  \"promptManagerOptions\": {\n    \"message\": \"プロンプトマネージャー\",\n    \"description\": \"Prompt Manager options section label\"\n  },\n  \"hidePromptManager\": {\n    \"message\": \"プロンプトマネージャーを隠す\",\n    \"description\": \"Hide the prompt manager floating ball\"\n  },\n  \"hidePromptManagerHint\": {\n    \"message\": \"ページ上のプロンプトマネージャーのフローティングアイコンを非表示にします\",\n    \"description\": \"Hint for hiding prompt manager feature\"\n  },\n  \"customWebsites\": {\n    \"message\": \"カスタムウェブサイト\",\n    \"description\": \"Custom websites for prompt manager\"\n  },\n  \"customWebsitesPlaceholder\": {\n    \"message\": \"ウェブサイトの URL を入力 (例：chatgpt.com)\",\n    \"description\": \"Custom websites input placeholder\"\n  },\n  \"customWebsitesHint\": {\n    \"message\": \"プロンプトマネージャーを使用したいウェブサイトを追加します。これらのサイトではプロンプトマネージャーのみが有効になります。\",\n    \"description\": \"Custom websites hint\"\n  },\n  \"addWebsite\": {\n    \"message\": \"ウェブサイトを追加\",\n    \"description\": \"Add website button\"\n  },\n  \"removeWebsite\": {\n    \"message\": \"削除\",\n    \"description\": \"Remove website button\"\n  },\n  \"invalidUrl\": {\n    \"message\": \"無効な URL 形式です\",\n    \"description\": \"Invalid URL error message\"\n  },\n  \"websiteAdded\": {\n    \"message\": \"ウェブサイトを追加しました\",\n    \"description\": \"Website added success message\"\n  },\n  \"websiteRemoved\": {\n    \"message\": \"ウェブサイトを削除しました\",\n    \"description\": \"Website removed success message\"\n  },\n  \"permissionDenied\": {\n    \"message\": \"権限が拒否されました。このウェブサイトへのアクセスを許可してください。\",\n    \"description\": \"Permission denied error message\"\n  },\n  \"permissionRequestFailed\": {\n    \"message\": \"権限のリクエストに失敗しました。もう一度試すか、拡張機能の設定でアクセスを許可してください。\",\n    \"description\": \"Permission request failure message\"\n  },\n  \"customWebsitesNote\": {\n    \"message\": \"ヒント：サイトを追加するとアクセスの許可を求められます。許可した後、プロンプトマネージャーを開始するにはそのサイトを再読み込みしてください。\",\n    \"description\": \"Custom websites reload/permission note\"\n  },\n  \"starredHistory\": {\n    \"message\": \"スター付き履歴\",\n    \"description\": \"Starred history title\"\n  },\n  \"viewStarredHistory\": {\n    \"message\": \"スター付き履歴を表示\",\n    \"description\": \"View starred history button\"\n  },\n  \"noStarredMessages\": {\n    \"message\": \"スター付きメッセージはまだありません\",\n    \"description\": \"No starred messages placeholder\"\n  },\n  \"removeFromStarred\": {\n    \"message\": \"スターを外す\",\n    \"description\": \"Remove from starred tooltip\"\n  },\n  \"justNow\": {\n    \"message\": \"たった今\",\n    \"description\": \"Just now time label\"\n  },\n  \"hoursAgo\": {\n    \"message\": \"時間前\",\n    \"description\": \"Hours ago label\"\n  },\n  \"yesterday\": {\n    \"message\": \"昨日\",\n    \"description\": \"Yesterday date label\"\n  },\n  \"daysAgo\": {\n    \"message\": \"日前\",\n    \"description\": \"Days ago label\"\n  },\n  \"loading\": {\n    \"message\": \"読み込み中...\",\n    \"description\": \"Loading message\"\n  },\n  \"keyboardShortcuts\": {\n    \"message\": \"キーボードショートカット\",\n    \"description\": \"Keyboard shortcuts section title\"\n  },\n  \"enableShortcuts\": {\n    \"message\": \"キーボードショートカットを有効化\",\n    \"description\": \"Enable shortcuts toggle label\"\n  },\n  \"previousNode\": {\n    \"message\": \"前のノードへ\",\n    \"description\": \"Previous timeline node shortcut label\"\n  },\n  \"nextNode\": {\n    \"message\": \"次のノードへ\",\n    \"description\": \"Next timeline node shortcut label\"\n  },\n  \"shortcutKey\": {\n    \"message\": \"キー\",\n    \"description\": \"Shortcut key label\"\n  },\n  \"shortcutModifiers\": {\n    \"message\": \"修飾キー\",\n    \"description\": \"Shortcut modifiers label\"\n  },\n  \"resetShortcuts\": {\n    \"message\": \"デフォルトに戻す\",\n    \"description\": \"Reset shortcuts button\"\n  },\n  \"shortcutsResetSuccess\": {\n    \"message\": \"ショートカットをデフォルトに戻しました\",\n    \"description\": \"Shortcuts reset success message\"\n  },\n  \"shortcutsDescription\": {\n    \"message\": \"キーボードショートカットを使ってタイムラインを移動します\",\n    \"description\": \"Shortcuts description\"\n  },\n  \"modifierNone\": {\n    \"message\": \"なし\",\n    \"description\": \"No modifier key\"\n  },\n  \"modifierAlt\": {\n    \"message\": \"Alt\",\n    \"description\": \"Alt modifier key\"\n  },\n  \"modifierCtrl\": {\n    \"message\": \"Ctrl\",\n    \"description\": \"Ctrl modifier key\"\n  },\n  \"modifierShift\": {\n    \"message\": \"Shift\",\n    \"description\": \"Shift modifier key\"\n  },\n  \"modifierMeta\": {\n    \"message\": \"Cmd/Win\",\n    \"description\": \"Meta/Command/Windows modifier key\"\n  },\n  \"preventAutoScroll\": {\n    \"message\": \"自動スクロール防止\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"preventAutoScrollHint\": {\n    \"message\": \"過去の回答を読んでいるときに Enter キーを押すと、一気に一番下までスクロールしてしまうのを防ぎます。\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"cloudSync\": {\n    \"message\": \"クラウド同期\",\n    \"description\": \"Cloud sync section title\"\n  },\n  \"cloudSyncDescription\": {\n    \"message\": \"フォルダとプロンプトを Google ドライブに同期します\",\n    \"description\": \"Cloud sync description\"\n  },\n  \"syncModeDisabled\": {\n    \"message\": \"無効\",\n    \"description\": \"Sync mode disabled option\"\n  },\n  \"syncModeManual\": {\n    \"message\": \"手動\",\n    \"description\": \"Sync mode manual option\"\n  },\n  \"syncServerPort\": {\n    \"message\": \"サーバーポート\",\n    \"description\": \"Port number for the sync server\"\n  },\n  \"syncNow\": {\n    \"message\": \"今すぐ同期\",\n    \"description\": \"Sync now button\"\n  },\n  \"lastSynced\": {\n    \"message\": \"最終同期：{time}\",\n    \"description\": \"Last sync timestamp\"\n  },\n  \"neverSynced\": {\n    \"message\": \"未同期\",\n    \"description\": \"Never synced message\"\n  },\n  \"lastUploaded\": {\n    \"message\": \"アップロード：{time}\",\n    \"description\": \"Last upload timestamp\"\n  },\n  \"neverUploaded\": {\n    \"message\": \"未アップロード\",\n    \"description\": \"Never uploaded message\"\n  },\n  \"syncSuccess\": {\n    \"message\": \"✓ 同期に成功しました\",\n    \"description\": \"Sync success message\"\n  },\n  \"syncError\": {\n    \"message\": \"✗ 同期に失敗しました：{error}\",\n    \"description\": \"Sync error message\"\n  },\n  \"syncInProgress\": {\n    \"message\": \"同期中...\",\n    \"description\": \"Sync in progress message\"\n  },\n  \"uploadInProgress\": {\n    \"message\": \"Uploading...\",\n    \"description\": \"Upload in progress message\"\n  },\n  \"uploadSuccess\": {\n    \"message\": \"✓ Uploaded successfully\",\n    \"description\": \"Upload success message\"\n  },\n  \"downloadInProgress\": {\n    \"message\": \"Downloading...\",\n    \"description\": \"Download in progress message\"\n  },\n  \"downloadMergeSuccess\": {\n    \"message\": \"✓ Downloaded and merged\",\n    \"description\": \"Download and merge success message\"\n  },\n  \"syncUpload\": {\n    \"message\": \"ドライブへ保存\",\n    \"description\": \"Upload button label\"\n  },\n  \"syncMerge\": {\n    \"message\": \"ドライブからマージ\",\n    \"description\": \"Merge button label\"\n  },\n  \"syncMode\": {\n    \"message\": \"同期モード\",\n    \"description\": \"Sync mode label\"\n  },\n  \"minutesAgo\": {\n    \"message\": \"分前\",\n    \"description\": \"Minutes ago label\"\n  },\n  \"syncNoData\": {\n    \"message\": \"ドライブに同期データが見つかりません\",\n    \"description\": \"Error message when no data is found in Drive\"\n  },\n  \"syncUploadFailed\": {\n    \"message\": \"アップロードに失敗しました\",\n    \"description\": \"Error message when upload fails\"\n  },\n  \"syncDownloadFailed\": {\n    \"message\": \"ダウンロードに失敗しました\",\n    \"description\": \"Error message when download fails\"\n  },\n  \"syncAuthFailed\": {\n    \"message\": \"認証に失敗しました\",\n    \"description\": \"Error message when authentication fails\"\n  },\n  \"signOut\": {\n    \"message\": \"サインアウト\",\n    \"description\": \"Sign out button\"\n  },\n  \"signInWithGoogle\": {\n    \"message\": \"Google でサインイン\",\n    \"description\": \"Sign in with Google button\"\n  },\n  \"nanobananaOptions\": {\n    \"message\": \"NanoBanana オプション\",\n    \"description\": \"NanoBanana options section label\"\n  },\n  \"enableNanobananaWatermarkRemover\": {\n    \"message\": \"NanoBanana 透かし除去\",\n    \"description\": \"Enable automatic watermark removal for NanoBanana images\"\n  },\n  \"nanobananaWatermarkRemoverHint\": {\n    \"message\": \"生成された画像から目に見える Gemini の透かしを自動的に除去します\",\n    \"description\": \"Watermark remover feature hint\"\n  },\n  \"nanobananaDownloadTooltip\": {\n    \"message\": \"透かしなし画像をダウンロード (NanoBanana)\",\n    \"description\": \"Download button tooltip\"\n  },\n  \"deepResearchDownload\": {\n    \"message\": \"思考内容をダウンロード\",\n    \"description\": \"Deep Research download button text\"\n  },\n  \"deepResearchDownloadTooltip\": {\n    \"message\": \"思考内容を Markdown 形式でダウンロード\",\n    \"description\": \"Deep Research download button tooltip\"\n  },\n  \"folder_uncategorized\": {\n    \"message\": \"未分類\",\n    \"description\": \"Label for root-level conversations not in any folder\"\n  },\n  \"timelineLevelTitle\": {\n    \"message\": \"ノード階層\",\n    \"description\": \"Title for timeline node level context menu\"\n  },\n  \"timelineLevel1\": {\n    \"message\": \"レベル 1\",\n    \"description\": \"Timeline node level 1 option\"\n  },\n  \"timelineLevel2\": {\n    \"message\": \"レベル 2\",\n    \"description\": \"Timeline node level 2 option\"\n  },\n  \"timelineLevel3\": {\n    \"message\": \"レベル 3\",\n    \"description\": \"Timeline node level 3 option\"\n  },\n  \"timelineCollapse\": {\n    \"message\": \"折りたたむ\",\n    \"description\": \"Collapse children\"\n  },\n  \"timelineExpand\": {\n    \"message\": \"展開\",\n    \"description\": \"Expand children\"\n  },\n  \"timelinePreviewSearch\": {\n    \"message\": \"検索...\",\n    \"description\": \"Timeline preview panel search placeholder\"\n  },\n  \"timelinePreviewNoResults\": {\n    \"message\": \"結果なし\",\n    \"description\": \"Timeline preview panel no search results\"\n  },\n  \"timelinePreviewNoMessages\": {\n    \"message\": \"メッセージなし\",\n    \"description\": \"Timeline preview panel no messages\"\n  },\n  \"quoteReply\": {\n    \"message\": \"引用返信\",\n    \"description\": \"Quote reply button text\"\n  },\n  \"inputCollapseOptions\": {\n    \"message\": \"入力オプション\",\n    \"description\": \"Input options section label\"\n  },\n  \"enableInputCollapse\": {\n    \"message\": \"入力欄の自動非表示を有効化\",\n    \"description\": \"Enable input collapse toggle label\"\n  },\n  \"enableInputCollapseHint\": {\n    \"message\": \"空の時に入力欄を折りたたみ、広い読書スペースを確保します\",\n    \"description\": \"Input collapse feature hint\"\n  },\n  \"inputCollapseShortcutHint\": {\n    \"message\": \"{modifier}+I で展開\",\n    \"description\": \"Shortcut hint for expanding collapsed input. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"inputCollapsePlaceholder\": {\n    \"message\": \"Gemini にメッセージを送信\",\n    \"description\": \"Placeholder text shown in collapsed input\"\n  },\n  \"allowCollapseWhenNotEmpty\": {\n    \"message\": \"内容ありでも折りたたみを許可\",\n    \"description\": \"Allow input to collapse even when it has text or attachments\"\n  },\n  \"allowCollapseWhenNotEmptyHint\": {\n    \"message\": \"テキストや添付ファイルがある場合でも入力欄を折りたたみます\",\n    \"description\": \"Hint text explaining the collapse when not empty feature\"\n  },\n  \"ctrlEnterSend\": {\n    \"message\": \"{modifier}+Enter で送信\",\n    \"description\": \"Modifier+Enter to send toggle label. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"ctrlEnterSendHint\": {\n    \"message\": \"{modifier}+Enter でメッセージを送信、Enter で改行\",\n    \"description\": \"Modifier+Enter to send feature hint. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"batch_delete_confirm\": {\n    \"message\": \"{count} 件の会話を削除してもよろしいですか？この操作は取り消せません。\",\n    \"description\": \"Batch delete confirmation message\"\n  },\n  \"batch_delete_in_progress\": {\n    \"message\": \"削除中... ({current}/{total})\",\n    \"description\": \"Batch delete progress message\"\n  },\n  \"batch_delete_success\": {\n    \"message\": \"✓ {count} 件の会話を削除しました\",\n    \"description\": \"Batch delete success message\"\n  },\n  \"batch_delete_partial\": {\n    \"message\": \"削除完了：成功 {success} 件，失敗 {failed} 件\",\n    \"description\": \"Batch delete partial success message\"\n  },\n  \"batch_delete_limit_reached\": {\n    \"message\": \"一度に選択できる会話は最大 {max} 件です\",\n    \"description\": \"Batch delete limit warning\"\n  },\n  \"batch_delete_button\": {\n    \"message\": \"選択した項目を削除\",\n    \"description\": \"Batch delete button tooltip in multi-select mode\"\n  },\n  \"batch_delete_match_patterns\": {\n    \"message\": \"delete,confirm,yes,ok,remove，削除，確認，確定，はい\",\n    \"description\": \"Comma-separated list of keywords to match native delete/confirm buttons (case-insensitive)\"\n  },\n  \"generalOptions\": {\n    \"message\": \"一般オプション\",\n    \"description\": \"General options section label\"\n  },\n  \"enableTabTitleUpdate\": {\n    \"message\": \"タブタイトルの同期\",\n    \"description\": \"Enable tab title sync toggle label\"\n  },\n  \"enableTabTitleUpdateHint\": {\n    \"message\": \"ブラウザのタブタイトルを、現在の会話タイトルと自動的に同期します\",\n    \"description\": \"Tab title sync feature hint\"\n  },\n  \"enableMermaidRendering\": {\n    \"message\": \"Mermaid 図表のレンダリング\",\n    \"description\": \"Enable Mermaid diagram rendering toggle label\"\n  },\n  \"enableMermaidRenderingHint\": {\n    \"message\": \"コードブロック内の Mermaid ダイアグラムを自動的にレンダリングします\",\n    \"description\": \"Mermaid rendering feature hint\"\n  },\n  \"enableQuoteReply\": {\n    \"message\": \"引用返信を有効化\",\n    \"description\": \"Enable quote reply toggle label\"\n  },\n  \"enableQuoteReplyHint\": {\n    \"message\": \"会話でテキストを選択したときに、選択したテキストを引用するフローティングボタンを表示します\",\n    \"description\": \"Quote reply feature hint\"\n  },\n  \"contextSync\": {\n    \"message\": \"コンテキスト同期\",\n    \"description\": \"Context Sync title\"\n  },\n  \"contextSyncDescription\": {\n    \"message\": \"チャットのコンテキストをローカル IDE に同期します\",\n    \"description\": \"Context Sync description\"\n  },\n  \"syncToIDE\": {\n    \"message\": \"IDE に同期\",\n    \"description\": \"Sync to IDE button\"\n  },\n  \"ideOnline\": {\n    \"message\": \"IDE オンライン\",\n    \"description\": \"IDE Online status\"\n  },\n  \"ideOffline\": {\n    \"message\": \"IDE オフライン\",\n    \"description\": \"IDE Offline status\"\n  },\n  \"syncing\": {\n    \"message\": \"同期中...\",\n    \"description\": \"Syncing status\"\n  },\n  \"checkServer\": {\n    \"message\": \"VS Code で AI Sync サーバーを起動してください\",\n    \"description\": \"Check server hint\"\n  },\n  \"capturing\": {\n    \"message\": \"対話をキャプチャ中...\",\n    \"description\": \"Capturing status\"\n  },\n  \"syncedSuccess\": {\n    \"message\": \"同期に成功しました！\",\n    \"description\": \"Synced success message\"\n  },\n  \"downloadingOriginal\": {\n    \"message\": \"元画像をダウンロード中\",\n    \"description\": \"Status when downloading original image\"\n  },\n  \"downloadingOriginalLarge\": {\n    \"message\": \"元画像をダウンロード中（大きいファイル）\",\n    \"description\": \"Status when downloading large original image\"\n  },\n  \"downloadLargeWarning\": {\n    \"message\": \"大容量ファイル警告\",\n    \"description\": \"Warning shown for large downloads\"\n  },\n  \"downloadProcessing\": {\n    \"message\": \"透かしを処理中\",\n    \"description\": \"Status when processing watermark removal\"\n  },\n  \"downloadSuccess\": {\n    \"message\": \"ダウンロード中...\",\n    \"description\": \"Status when download starts\"\n  },\n  \"downloadError\": {\n    \"message\": \"失敗\",\n    \"description\": \"Status prefix when download fails\"\n  },\n  \"recentsHide\": {\n    \"message\": \"最近の項目を非表示\",\n    \"description\": \"最近のプレビューセクションを非表示にするツールチップ\"\n  },\n  \"recentsShow\": {\n    \"message\": \"最近の項目を表示\",\n    \"description\": \"最近のプレビューセクションを表示するツールチップ\"\n  },\n  \"gemsHide\": {\n    \"message\": \"Gems を非表示\",\n    \"description\": \"Gems リストセクションを非表示にするツールチップ\"\n  },\n  \"gemsShow\": {\n    \"message\": \"Gems を表示\",\n    \"description\": \"Gems リストセクションを表示するツールチップ\"\n  },\n  \"setAsDefaultModel\": {\n    \"message\": \"Set as default model\",\n    \"description\": \"Tooltip for setting default model\"\n  },\n  \"cancelDefaultModel\": {\n    \"message\": \"Cancel default model\",\n    \"description\": \"Tooltip for cancelling default model\"\n  },\n  \"defaultModelSet\": {\n    \"message\": \"Default model set: $1\",\n    \"description\": \"Toast message when default model is set\"\n  },\n  \"defaultModelCleared\": {\n    \"message\": \"Default model cleared\",\n    \"description\": \"Toast message when default model is cleared\"\n  },\n  \"sidebarAutoHide\": {\n    \"message\": \"サイドバー自動非表示\",\n    \"description\": \"サイドバー自動非表示トグルラベル\"\n  },\n  \"sidebarAutoHideHint\": {\n    \"message\": \"マウスが離れると自動でサイドバーを折りたたみ、マウスが入ると展開します\",\n    \"description\": \"サイドバー自動非表示機能のヒント\"\n  },\n  \"sidebarFullHide\": {\n    \"message\": \"サイドバー完全非表示\",\n    \"description\": \"サイドバー完全非表示の切り替えラベル\"\n  },\n  \"sidebarFullHideHint\": {\n    \"message\": \"折りたたみ時にサイドバーを完全に非表示にし、左端にマウスを移動して表示\",\n    \"description\": \"サイドバー完全非表示機能のヒント\"\n  },\n  \"folderSpacing\": {\n    \"message\": \"フォルダ間隔\",\n    \"description\": \"フォルダ間隔調整ラベル\"\n  },\n  \"folderSpacingCompact\": {\n    \"message\": \"コンパクト\",\n    \"description\": \"コンパクトフォルダ間隔ラベル\"\n  },\n  \"folderSpacingSpacious\": {\n    \"message\": \"ゆったり\",\n    \"description\": \"ゆったりフォルダ間隔ラベル\"\n  },\n  \"folderTreeIndent\": {\n    \"message\": \"サブフォルダのインデント\",\n    \"description\": \"サブフォルダ階層のインデント調整ラベル\"\n  },\n  \"folderTreeIndentCompact\": {\n    \"message\": \"狭い\",\n    \"description\": \"狭いサブフォルダインデントラベル\"\n  },\n  \"folderTreeIndentSpacious\": {\n    \"message\": \"広い\",\n    \"description\": \"広いサブフォルダインデントラベル\"\n  },\n  \"export_select_mode_select_all\": {\n    \"message\": \"すべて選択\",\n    \"description\": \"Selection mode select all toggle\"\n  },\n  \"export_select_mode_count\": {\n    \"message\": \"{count}件選択中\",\n    \"description\": \"Selection mode selected message count\"\n  },\n  \"export_select_mode_toggle\": {\n    \"message\": \"メッセージを選択\",\n    \"description\": \"Selection mode per-message toggle tooltip\"\n  },\n  \"export_select_mode_select_below\": {\n    \"message\": \"以下のメッセージを選択\",\n    \"description\": \"Selection mode top-left button to select messages below current line\"\n  },\n  \"export_select_mode_empty\": {\n    \"message\": \"エクスポートするメッセージを 1 つ以上選択してください\",\n    \"description\": \"Selection mode validation when nothing is selected\"\n  },\n  \"export_format_image_description\": {\n    \"message\": \"モバイル共有に便利な単一の PNG 画像。\",\n    \"description\": \"Description for Image export format\"\n  },\n  \"export_image_progress\": {\n    \"message\": \"画像を生成中...\",\n    \"description\": \"Progress message while generating image export\"\n  },\n  \"deepResearchSaveReport\": {\n    \"message\": \"レポートを保存\",\n    \"description\": \"Deep Research save report button\"\n  },\n  \"deepResearchSaveReportTooltip\": {\n    \"message\": \"この調査レポートをエクスポート\",\n    \"description\": \"Deep Research save report tooltip\"\n  },\n  \"export_fontsize_label\": {\n    \"message\": \"フォントサイズ\",\n    \"description\": \"Label for font size slider in export dialog\"\n  },\n  \"export_fontsize_preview\": {\n    \"message\": \"吾輩は猫である。名前はまだない。\",\n    \"description\": \"Preview text for font size slider in export dialog\"\n  },\n  \"export_error_generic\": {\n    \"message\": \"エクスポートに失敗しました：{error}\",\n    \"description\": \"詳細理由付きのエクスポート失敗メッセージ\"\n  },\n  \"export_error_refresh_retry\": {\n    \"message\": \"ネットワークの問題により画像のエクスポートに失敗しました。ページを再読み込みして再試行してください。\",\n    \"description\": \"画像エクスポートの一時的な読み込み失敗時に表示する再試行案内\"\n  },\n  \"export_md_include_source_confirm\": {\n    \"message\": \"エクスポートするコンテンツにウェブ検索画像が含まれています。Markdown に画像のソースリンクを含めますか？\",\n    \"description\": \"Confirm dialog when exporting markdown with web search images\"\n  },\n  \"deepResearch_exportedAt\": {\n    \"message\": \"エクスポート日時\",\n    \"description\": \"深層研究エクスポートタイムスタンプラベル\"\n  },\n  \"deepResearch_totalPhases\": {\n    \"message\": \"総思考フェーズ\",\n    \"description\": \"深層研究の総思考フェーズラベル\"\n  },\n  \"deepResearch_thinkingPhase\": {\n    \"message\": \"思考フェーズ\",\n    \"description\": \"深層研究思考フェーズヘッダー\"\n  },\n  \"deepResearch_researchedWebsites\": {\n    \"message\": \"調査済みサイト\",\n    \"description\": \"深層研究の調査済みウェブサイトセクションヘッダー\"\n  },\n  \"visualEffect\": {\n    \"message\": \"ビジュアルエフェクト\",\n    \"description\": \"Visual effect selector label\"\n  },\n  \"visualEffectHint\": {\n    \"message\": \"ページに季節の雰囲気を演出\",\n    \"description\": \"Visual effect feature hint\"\n  },\n  \"visualEffectOff\": {\n    \"message\": \"オフ\",\n    \"description\": \"Visual effect off option\"\n  },\n  \"visualEffectSnow\": {\n    \"message\": \"雪\",\n    \"description\": \"Visual effect snow option\"\n  },\n  \"visualEffectSakura\": {\n    \"message\": \"桜\",\n    \"description\": \"Visual effect sakura option\"\n  },\n  \"visualEffectRain\": {\n    \"message\": \"雨\",\n    \"description\": \"Visual effect rain option\"\n  },\n  \"changelog_title\": {\n    \"message\": \"新機能\",\n    \"description\": \"Changelog modal title\"\n  },\n  \"changelog_close\": {\n    \"message\": \"了解\",\n    \"description\": \"Changelog modal close button\"\n  },\n  \"changelog_docs_link\": {\n    \"message\": \"ドキュメント\",\n    \"description\": \"Changelog modal documentation link text\"\n  },\n  \"changelog_recommendation\": {\n    \"message\": \"Voyager がお役に立てたら、友達や SNS でシェアしていただけると嬉しいです！作者をタグ付けしてくれると励みになります！\",\n    \"description\": \"Changelog modal recommendation message\"\n  },\n  \"changelog_social_xiaohongshu\": {\n    \"message\": \"小紅書\",\n    \"description\": \"Xiaohongshu platform name for social links\"\n  },\n  \"changelog_social_zhihu\": {\n    \"message\": \"知乎\",\n    \"description\": \"Zhihu platform name for social links\"\n  },\n  \"changelog_docs_hint\": {\n    \"message\": \"詳細はドキュメントへ\",\n    \"description\": \"Changelog annotation pointing to docs icon\"\n  },\n  \"changelog_rate_chrome\": {\n    \"message\": \"Voyager を気に入っていただけましたか？Chrome ウェブストアでの評価が大きな励みになります！\",\n    \"description\": \"Chrome Web Store rating prompt in changelog modal\"\n  },\n  \"changelog_rate_chrome_cta\": {\n    \"message\": \"評価する\",\n    \"description\": \"Chrome Web Store rating CTA button text\"\n  },\n  \"changelog_badge_mode\": {\n    \"message\": \"このポップアップの代わりに、フローティングボタンの NEW バッジで通知する\",\n    \"description\": \"Changelog notification mode toggle label\"\n  },\n  \"forkConversation\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork conversation button label\"\n  },\n  \"forkConfirm\": {\n    \"message\": \"Fork conversation from this point?\",\n    \"description\": \"Fork confirmation prompt\"\n  },\n  \"forkConfirmBtn\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork confirmation button\"\n  },\n  \"forkCancel\": {\n    \"message\": \"Cancel\",\n    \"description\": \"Fork cancel button\"\n  },\n  \"forkBranch\": {\n    \"message\": \"Branch\",\n    \"description\": \"Fork branch label\"\n  },\n  \"forkOriginal\": {\n    \"message\": \"Original\",\n    \"description\": \"Original fork branch tag\"\n  },\n  \"forkCurrent\": {\n    \"message\": \"Current\",\n    \"description\": \"Current fork branch tag\"\n  },\n  \"forkPreparing\": {\n    \"message\": \"Preparing fork...\",\n    \"description\": \"Fork preparation status\"\n  },\n  \"forkPasteHint\": {\n    \"message\": \"Press Enter to send and create the fork\",\n    \"description\": \"Fork paste hint text\"\n  },\n  \"forkDeleteData\": {\n    \"message\": \"この分岐を削除\",\n    \"description\": \"Delete fork branch metadata button\"\n  },\n  \"forkDeleteDataConfirm\": {\n    \"message\": \"この分岐リンクを削除しますか？Voyager の分岐メタデータのみ削除され、会話自体は削除されません。\",\n    \"description\": \"Fork metadata delete confirmation prompt\"\n  },\n  \"enableForkFeature\": {\n    \"message\": \"会話分岐を有効化\",\n    \"description\": \"Enable conversation fork feature toggle label\"\n  },\n  \"enableForkFeatureHint\": {\n    \"message\": \"Gemini の会話で Fork ボタンと分岐番号を表示します\",\n    \"description\": \"Enable conversation fork feature toggle hint\"\n  },\n  \"enableAccountIsolation\": {\n    \"message\": \"Enable Hard Isolation\",\n    \"description\": \"Enable hard isolation for account-scoped folder data and cloud sync.\"\n  },\n  \"enableAccountIsolationHint\": {\n    \"message\": \"Strictly isolate folder and cloud sync data by Google account to prevent cross-account mixing.\",\n    \"description\": \"Hint text for account isolation option.\"\n  },\n  \"currentPlatform\": {\n    \"message\": \"Current platform\",\n    \"description\": \"Current active platform label in popup.\"\n  },\n  \"platformGemini\": {\n    \"message\": \"Gemini\",\n    \"description\": \"Gemini platform label.\"\n  },\n  \"platformAIStudio\": {\n    \"message\": \"AI Studio\",\n    \"description\": \"AI Studio platform label.\"\n  },\n  \"enableOnAIStudio\": {\n    \"message\": \"AI Studio で有効化\",\n    \"description\": \"Toggle to enable/disable Voyager on AI Studio\"\n  },\n  \"enableOnAIStudioHint\": {\n    \"message\": \"オフにすると AI Studio での Voyager 機能が無効になります（Prompt Manager は除く）\",\n    \"description\": \"Hint for AI Studio enable toggle\"\n  },\n  \"export_select_mode_only_user\": {\n    \"message\": \"ユーザーのみ選択\",\n    \"description\": \"Export selection mode: select only user messages\"\n  },\n  \"export_select_mode_only_ai\": {\n    \"message\": \"AIのみ選択\",\n    \"description\": \"Export selection mode: select only AI messages\"\n  },\n  \"aiOrgCopyButton\": {\n    \"message\": \"AI 整理\",\n    \"description\": \"AI整理のためにフォルダ構造と会話をコピーするボタン\"\n  },\n  \"aiOrgCopied\": {\n    \"message\": \"コピーしました！\",\n    \"description\": \"AI整理プロンプトのコピー後の確認\"\n  },\n  \"aiOrgError\": {\n    \"message\": \"失敗 — 先に Gemini を開いてください\",\n    \"description\": \"AI整理プロンプトのコピー失敗時のエラー\"\n  },\n  \"aiOrgCopyHint\": {\n    \"message\": \"すべての会話とフォルダ構造をプロンプトとしてコピーします。Gemini に貼り付けて、インポート可能なフォルダプランを取得できます。\",\n    \"description\": \"AI整理コピーボタンの下のヒントテキスト\"\n  },\n  \"aiOrgCurrentFolders\": {\n    \"message\": \"現在のフォルダ構造\",\n    \"description\": \"AI整理プロンプトの見出し\"\n  },\n  \"aiOrgUnfiledConversations\": {\n    \"message\": \"未分類の会話\",\n    \"description\": \"フォルダに入っていない会話の見出し\"\n  },\n  \"aiOrgEmpty\": { \"message\": \"空\", \"description\": \"AI整理プロンプトの空フォルダのラベル\" },\n  \"aiOrgInstructions\": { \"message\": \"指示\", \"description\": \"AI指示セクションの見出し\" },\n  \"aiOrgInstructionsBody\": {\n    \"message\": \"上記の会話とフォルダ構造に基づいて、論理的なフォルダ階層に再整理してください。以下の形式の JSON ファイルを出力し、Gemini Voyager 拡張機能に直接インポートできるようにしてください（「マージ」インポート戦略を使用）。既存の整理済みフォルダはそのまま維持し、必要に応じて新しいフォルダを作成し、未分類の会話を適切なフォルダに移動してください。各フォルダには一意の ID（短いランダム文字列）が必要で、各会話は元の conversationId と url を保持する必要があります。\",\n    \"description\": \"AI がインポート可能なフォルダ構造を生成するための指示本文\"\n  },\n  \"showMessageTimestamps\": {\n    \"message\": \"メッセージのタイムスタンプを表示\",\n    \"description\": \"各メッセージのタイムスタンプを表示\"\n  },\n  \"showMessageTimestampsHint\": {\n    \"message\": \"各メッセージの送信時刻を表示します\",\n    \"description\": \"メッセージタイムスタンプ機能のヒント\"\n  },\n  \"moveSectionUp\": {\n    \"message\": \"上に移動\",\n    \"description\": \"Tooltip for moving a settings section up in popup\"\n  },\n  \"moveSectionDown\": {\n    \"message\": \"下に移動\",\n    \"description\": \"Tooltip for moving a settings section down in popup\"\n  }\n}\n"
  },
  {
    "path": "src/locales/ko/messages.json",
    "content": "{\n  \"extName\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Extension name\"\n  },\n  \"extDescription\": {\n    \"message\": \"타임라인 탐색, 폴더 관리, 프롬프트 저장소 및 채팅 내보내기 기능을 통해 Gemini™ 환경을 강화하세요.\",\n    \"description\": \"Extension description\"\n  },\n  \"scrollMode\": {\n    \"message\": \"스크롤 모드\",\n    \"description\": \"Scroll mode label\"\n  },\n  \"flow\": {\n    \"message\": \"플로우\",\n    \"description\": \"Flow mode\"\n  },\n  \"jump\": {\n    \"message\": \"점프\",\n    \"description\": \"Jump mode\"\n  },\n  \"hideOuterContainer\": {\n    \"message\": \"외부 컨테이너 숨기기\",\n    \"description\": \"Hide outer container label\"\n  },\n  \"draggableTimeline\": {\n    \"message\": \"드래그 가능한 타임라인\",\n    \"description\": \"Draggable Timeline label\"\n  },\n  \"enableMarkerLevel\": {\n    \"message\": \"노드 레벨 활성화\",\n    \"description\": \"Enable timeline node level feature\"\n  },\n  \"enableMarkerLevelHint\": {\n    \"message\": \"타임라인 노드를 우클릭하여 레벨을 설정하고 하위 노드를 접을 수 있습니다\",\n    \"description\": \"Node level feature hint\"\n  },\n  \"experimentalLabel\": {\n    \"message\": \"실험적\",\n    \"description\": \"Experimental feature label\"\n  },\n  \"resetPosition\": {\n    \"message\": \"위치 초기화\",\n    \"description\": \"Reset Position button\"\n  },\n  \"resetTimelinePosition\": {\n    \"message\": \"타임라인 위치 초기화\",\n    \"description\": \"Reset timeline position button in timeline options\"\n  },\n  \"language\": {\n    \"message\": \"언어\",\n    \"description\": \"Language label\"\n  },\n  \"pm_title\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Prompt Manager title\"\n  },\n  \"pm_add\": {\n    \"message\": \"추가\",\n    \"description\": \"Add prompt button\"\n  },\n  \"pm_search_placeholder\": {\n    \"message\": \"프롬프트 또는 태그 검색\",\n    \"description\": \"Search placeholder\"\n  },\n  \"pm_import\": {\n    \"message\": \"가져오기\",\n    \"description\": \"Import button\"\n  },\n  \"pm_export\": {\n    \"message\": \"내보내기\",\n    \"description\": \"Export button\"\n  },\n  \"pm_prompt_placeholder\": {\n    \"message\": \"프롬프트 텍스트\",\n    \"description\": \"Prompt textarea placeholder\"\n  },\n  \"pm_tags_placeholder\": {\n    \"message\": \"태그 (쉼표로 구분)\",\n    \"description\": \"Tags input placeholder\"\n  },\n  \"pm_save\": {\n    \"message\": \"저장\",\n    \"description\": \"Save prompt\"\n  },\n  \"pm_cancel\": {\n    \"message\": \"취소\",\n    \"description\": \"Cancel\"\n  },\n  \"pm_all_tags\": {\n    \"message\": \"모두\",\n    \"description\": \"All tags chip\"\n  },\n  \"pm_empty\": {\n    \"message\": \"프롬프트가 없습니다\",\n    \"description\": \"Empty state\"\n  },\n  \"pm_copy\": {\n    \"message\": \"복사\",\n    \"description\": \"Copy prompt\"\n  },\n  \"pm_copied\": {\n    \"message\": \"복사됨\",\n    \"description\": \"Copied notice\"\n  },\n  \"pm_delete\": {\n    \"message\": \"삭제\",\n    \"description\": \"Delete prompt\"\n  },\n  \"pm_delete_confirm\": {\n    \"message\": \"이 프롬프트를 삭제하시겠습니까?\",\n    \"description\": \"Delete confirm\"\n  },\n  \"pm_lock\": {\n    \"message\": \"위치 고정\",\n    \"description\": \"Lock panel position\"\n  },\n  \"pm_unlock\": {\n    \"message\": \"위치 고정 해제\",\n    \"description\": \"Unlock panel position\"\n  },\n  \"pm_import_invalid\": {\n    \"message\": \"유효하지 않은 파일 형식\",\n    \"description\": \"Import invalid\"\n  },\n  \"pm_import_success\": {\n    \"message\": \"{count}개의 프롬프트를 가져왔습니다\",\n    \"description\": \"Import success\"\n  },\n  \"pm_duplicate\": {\n    \"message\": \"중복 프롬프트\",\n    \"description\": \"Duplicate on add\"\n  },\n  \"pm_deleted\": {\n    \"message\": \"삭제됨\",\n    \"description\": \"Deleted notice\"\n  },\n  \"pm_edit\": {\n    \"message\": \"편집\",\n    \"description\": \"Edit prompt\"\n  },\n  \"pm_expand\": {\n    \"message\": \"펼치기\",\n    \"description\": \"Expand prompt text\"\n  },\n  \"pm_collapse\": {\n    \"message\": \"접기\",\n    \"description\": \"Collapse prompt text\"\n  },\n  \"pm_saved\": {\n    \"message\": \"저장됨\",\n    \"description\": \"Saved notice\"\n  },\n  \"pm_settings\": {\n    \"message\": \"설정\",\n    \"description\": \"Settings button\"\n  },\n  \"pm_settings_tooltip\": {\n    \"message\": \"확장 프로그램 설정 열기\",\n    \"description\": \"Settings button tooltip\"\n  },\n  \"pm_settings_fallback\": {\n    \"message\": \"브라우저 툴바의 확장 프로그램 아이콘을 클릭하여 설정을 열어주세요\",\n    \"description\": \"Settings open failed message\"\n  },\n  \"pm_backup\": {\n    \"message\": \"로컬 백업\",\n    \"description\": \"Local backup button\"\n  },\n  \"pm_backup_tooltip\": {\n    \"message\": \"프롬프트와 폴더를 타임스탬프가 포함된 폴더로 백업합니다\",\n    \"description\": \"Backup button tooltip\"\n  },\n  \"pm_backup_cancelled\": {\n    \"message\": \"백업 취소됨\",\n    \"description\": \"Backup cancelled hint\"\n  },\n  \"pm_backup_error\": {\n    \"message\": \"✗ 백업 실패\",\n    \"description\": \"Backup failed hint\"\n  },\n  \"pm_backup_hint_options\": {\n    \"message\": \"이 기능은 Gemini 페이지의 프롬프트 관리자에 통합되어 있습니다.\",\n    \"description\": \"Backup hint in options page\"\n  },\n  \"pm_backup_step1\": {\n    \"message\": \"Gemini 페이지를 엽니다 (gemini.google.com)\",\n    \"description\": \"Backup step 1\"\n  },\n  \"pm_backup_step2\": {\n    \"message\": \"우측 하단의 확장 프로그램 아이콘을 클릭하여 프롬프트 관리자를 엽니다\",\n    \"description\": \"Backup step 2\"\n  },\n  \"pm_backup_step3\": {\n    \"message\": \"\\\"💾 로컬 백업\\\" 버튼을 클릭하고 백업 폴더를 선택합니다\",\n    \"description\": \"Backup step 3\"\n  },\n  \"pm_backup_note\": {\n    \"message\": \"백업에는 모든 프롬프트와 폴더가 포함되며, 타임스탬프 폴더 형식 (backup-YYYYMMDD-HHMMSS) 으로 저장됩니다\",\n    \"description\": \"Backup feature note\"\n  },\n  \"pm_theme_light\": {\n    \"message\": \"라이트 모드로 전환\",\n    \"description\": \"Theme toggle tooltip - switch to light\"\n  },\n  \"pm_theme_dark\": {\n    \"message\": \"다크 모드로 전환\",\n    \"description\": \"Theme toggle tooltip - switch to dark\"\n  },\n  \"extensionVersion\": {\n    \"message\": \"버전\",\n    \"description\": \"Extension version label\"\n  },\n  \"newVersionAvailable\": {\n    \"message\": \"새 버전 사용 가능\",\n    \"description\": \"Update reminder title\"\n  },\n  \"currentVersionLabel\": {\n    \"message\": \"현재\",\n    \"description\": \"Label for current version number\"\n  },\n  \"latestVersionLabel\": {\n    \"message\": \"최신\",\n    \"description\": \"Label for latest version number\"\n  },\n  \"updateNow\": {\n    \"message\": \"업데이트\",\n    \"description\": \"CTA to update to latest version\"\n  },\n  \"safariUpdateNotSynced\": {\n    \"message\": \"업데이트 소스가 아직 동기화되지 않았습니다. 몇 시간 후에 다시 시도해 주세요\",\n    \"description\": \"Shown on Safari when the DMG file is not yet available on GitHub releases\"\n  },\n  \"starProject\": {\n    \"message\": \"지원하기 ⭐\",\n    \"description\": \"Github callout\"\n  },\n  \"officialDocs\": {\n    \"message\": \"공식 문서\",\n    \"description\": \"Official docs link\"\n  },\n  \"exportChatJson\": {\n    \"message\": \"대화 기록 내보내기\",\n    \"description\": \"Tooltip for export button\"\n  },\n  \"folder_title\": {\n    \"message\": \"폴더\",\n    \"description\": \"Folder section title\"\n  },\n  \"folder_create\": {\n    \"message\": \"폴더 생성\",\n    \"description\": \"Create folder button tooltip\"\n  },\n  \"folder_name_prompt\": {\n    \"message\": \"폴더 이름을 입력하세요:\",\n    \"description\": \"Create folder name prompt\"\n  },\n  \"folder_rename_prompt\": {\n    \"message\": \"새 이름을 입력하세요:\",\n    \"description\": \"Rename folder prompt\"\n  },\n  \"folder_delete_confirm\": {\n    \"message\": \"이 폴더와 모든 내용을 삭제하시겠습니까?\",\n    \"description\": \"Delete folder confirmation\"\n  },\n  \"folder_create_subfolder\": {\n    \"message\": \"하위 폴더 생성\",\n    \"description\": \"Create subfolder menu item\"\n  },\n  \"folder_rename\": {\n    \"message\": \"이름 바꾸기\",\n    \"description\": \"Rename folder menu item\"\n  },\n  \"folder_change_color\": {\n    \"message\": \"색상 변경\",\n    \"description\": \"Change folder color menu item\"\n  },\n  \"folder_delete\": {\n    \"message\": \"삭제\",\n    \"description\": \"Delete folder menu item\"\n  },\n  \"folder_color_default\": {\n    \"message\": \"기본\",\n    \"description\": \"Default folder color name\"\n  },\n  \"folder_color_red\": {\n    \"message\": \"빨간색\",\n    \"description\": \"Red folder color name\"\n  },\n  \"folder_color_orange\": {\n    \"message\": \"오렌지색\",\n    \"description\": \"Orange folder color name\"\n  },\n  \"folder_color_yellow\": {\n    \"message\": \"노란색\",\n    \"description\": \"Yellow folder color name\"\n  },\n  \"folder_color_green\": {\n    \"message\": \"녹색\",\n    \"description\": \"Green folder color name\"\n  },\n  \"folder_color_blue\": {\n    \"message\": \"파란색\",\n    \"description\": \"Blue folder color name\"\n  },\n  \"folder_color_purple\": {\n    \"message\": \"보라색\",\n    \"description\": \"Purple folder color name\"\n  },\n  \"folder_color_pink\": {\n    \"message\": \"분홍색\",\n    \"description\": \"Pink folder color name\"\n  },\n  \"folder_color_custom\": {\n    \"message\": \"사용자 지정\",\n    \"description\": \"Custom folder color name\"\n  },\n  \"folder_empty\": {\n    \"message\": \"폴더가 없습니다\",\n    \"description\": \"Empty state placeholder when no folders exist\"\n  },\n  \"folder_pin\": {\n    \"message\": \"폴더 고정\",\n    \"description\": \"Pin folder menu item\"\n  },\n  \"folder_unpin\": {\n    \"message\": \"폴더 고정 해제\",\n    \"description\": \"Unpin folder menu item\"\n  },\n  \"folder_remove_conversation\": {\n    \"message\": \"폴더에서 제거\",\n    \"description\": \"Remove conversation button tooltip\"\n  },\n  \"folder_remove_conversation_confirm\": {\n    \"message\": \"이 폴더에서 \\\"{title}\\\"을 (를) 제거하시겠습니까?\",\n    \"description\": \"Remove conversation confirmation\"\n  },\n  \"conversation_more\": {\n    \"message\": \"추가 옵션\",\n    \"description\": \"Conversation more options button tooltip\"\n  },\n  \"conversation_move_to_folder\": {\n    \"message\": \"폴더로 이동\",\n    \"description\": \"Move conversation to folder menu item\"\n  },\n  \"conversation_move_to_folder_title\": {\n    \"message\": \"폴더로 이동\",\n    \"description\": \"Move conversation to folder dialog title\"\n  },\n  \"chatWidth\": {\n    \"message\": \"대화 너비\",\n    \"description\": \"Chat width adjustment label\"\n  },\n  \"chatWidthNarrow\": {\n    \"message\": \"좁게\",\n    \"description\": \"Narrow chat width label\"\n  },\n  \"chatWidthWide\": {\n    \"message\": \"넓게\",\n    \"description\": \"Wide chat width label\"\n  },\n  \"sidebarWidth\": {\n    \"message\": \"사이드바 너비\",\n    \"description\": \"Sidebar width adjustment label\"\n  },\n  \"sidebarWidthNarrow\": {\n    \"message\": \"좁게\",\n    \"description\": \"Narrow sidebar width label\"\n  },\n  \"sidebarWidthWide\": {\n    \"message\": \"넓게\",\n    \"description\": \"Wide sidebar width label\"\n  },\n  \"formula_copied\": {\n    \"message\": \"✓ 수식 복사됨\",\n    \"description\": \"Formula copied success message\"\n  },\n  \"formula_copy_failed\": {\n    \"message\": \"✗ 복사 실패\",\n    \"description\": \"Formula copy failed message\"\n  },\n  \"formulaCopyFormat\": {\n    \"message\": \"수식 복사 형식\",\n    \"description\": \"Formula copy format setting label\"\n  },\n  \"formulaCopyFormatLatex\": {\n    \"message\": \"LaTeX\",\n    \"description\": \"LaTeX format option\"\n  },\n  \"formulaCopyFormatUnicodeMath\": {\n    \"message\": \"MathML (Word)\",\n    \"description\": \"Label for MathML formula copy format option (formerly UnicodeMath)\"\n  },\n  \"formulaCopyFormatHint\": {\n    \"message\": \"수식을 클릭하여 복사할 때 형식을 선택하세요\",\n    \"description\": \"Formula copy format hint\"\n  },\n  \"formulaCopyFormatNoDollar\": {\n    \"message\": \"LaTeX (달러 기호 없음)\",\n    \"description\": \"LaTeX format option without dollar signs\"\n  },\n  \"folder_filter_current_user\": {\n    \"message\": \"계정 분리\",\n    \"description\": \"Show only conversations from the current Google account\"\n  },\n  \"folder_export\": {\n    \"message\": \"폴더 내보내기\",\n    \"description\": \"Export folders button tooltip\"\n  },\n  \"folder_import_export\": {\n    \"message\": \"폴더 가져오기/내보내기\",\n    \"description\": \"Combined import/export button tooltip\"\n  },\n  \"folder_cloud_upload\": {\n    \"message\": \"클라우드에 업로드\",\n    \"description\": \"Cloud upload button tooltip\"\n  },\n  \"folder_cloud_sync\": {\n    \"message\": \"클라우드에서 동기화\",\n    \"description\": \"Cloud sync button tooltip\"\n  },\n  \"folder_import\": {\n    \"message\": \"폴더 가져오기\",\n    \"description\": \"Import folders button tooltip\"\n  },\n  \"folder_import_title\": {\n    \"message\": \"폴더 구성 가져오기\",\n    \"description\": \"Import dialog title\"\n  },\n  \"folder_import_strategy\": {\n    \"message\": \"가져오기 전략:\",\n    \"description\": \"Import strategy label\"\n  },\n  \"folder_import_merge\": {\n    \"message\": \"기존 폴더와 병합\",\n    \"description\": \"Merge import strategy\"\n  },\n  \"folder_import_overwrite\": {\n    \"message\": \"기존 폴더 덮어쓰기\",\n    \"description\": \"Overwrite import strategy\"\n  },\n  \"folder_import_select_file\": {\n    \"message\": \"가져올 JSON 파일을 선택하세요\",\n    \"description\": \"Import file selection prompt\"\n  },\n  \"folder_import_success\": {\n    \"message\": \"✓ {folders}개의 폴더, {conversations}개의 대화를 가져왔습니다\",\n    \"description\": \"Import success message\"\n  },\n  \"folder_import_success_skipped\": {\n    \"message\": \"✓ {folders}개의 폴더, {conversations}개의 대화를 가져왔습니다 ({skipped}개의 중복 항목 건너뜀)\",\n    \"description\": \"Import success with skipped message\"\n  },\n  \"folder_import_error\": {\n    \"message\": \"✗ 가져오기 실패: {error}\",\n    \"description\": \"Import error message\"\n  },\n  \"folder_export_success\": {\n    \"message\": \"✓ 폴더를 성공적으로 내보냈습니다\",\n    \"description\": \"Export success message\"\n  },\n  \"folder_import_invalid_format\": {\n    \"message\": \"유효하지 않은 파일 형식입니다. 올바른 폴더 구성 파일을 선택하세요.\",\n    \"description\": \"Invalid format error\"\n  },\n  \"folder_import_confirm_overwrite\": {\n    \"message\": \"모든 기존 폴더가 대체됩니다. 백업이 생성됩니다. 계속하시겠습니까?\",\n    \"description\": \"Overwrite confirmation\"\n  },\n  \"folder_import_paste_json\": {\n    \"message\": \"또는 JSON 직접 붙여넣기\",\n    \"description\": \"가져오기 대화상자에서 붙여넣기 텍스트 영역을 표시하는 토글 버튼\"\n  },\n  \"folder_import_paste_placeholder\": {\n    \"message\": \"여기에 JSON을 붙여넣으세요...\",\n    \"description\": \"붙여넣기 텍스트 영역의 플레이스홀더\"\n  },\n  \"export_dialog_title\": {\n    \"message\": \"대화 내보내기\",\n    \"description\": \"Export dialog title\"\n  },\n  \"export_dialog_select\": {\n    \"message\": \"내보내기 형식 선택:\",\n    \"description\": \"Export format selection label\"\n  },\n  \"export_dialog_warning\": {\n    \"message\": \"⚠️ 내보내기를 클릭하면 모든 내용을 불러오기 위해 첫 번째 메시지로 자동 이동합니다. 이동 중에는 조작하지 마십시오. 이동이 완료되면 내보내기가 자동으로 계속됩니다.\",\n    \"description\": \"Warning about auto-jump and loading completeness\"\n  },\n  \"export_dialog_safari_cmdp_hint\": {\n    \"message\": \"Safari 팁: 아래 '내보내기'를 클릭하고 잠시 기다린 다음, ⌘P 를 누르고 'PDF 로 저장'을 선택하세요.\",\n    \"description\": \"Safari-specific hint for PDF export\"\n  },\n  \"export_toast_safari_pdf_ready\": {\n    \"message\": \"이제 Command + P 를 눌러 PDF 를 내보낼 수 있습니다.\",\n    \"description\": \"Safari-specific toast shown after PDF export is prepared\"\n  },\n  \"export_dialog_safari_markdown_hint\": {\n    \"message\": \"안내: Safari 제한으로 인해 채팅 기록의 이미지를 추출할 수 없습니다. 전체 내보내기가 필요하면 PDF 내보내기를 사용하세요.\",\n    \"description\": \"Warning about image references in Safari markdown export\"\n  },\n  \"export_format_json_description\": {\n    \"message\": \"개발자를 위한 기계 판독 가능 형식\",\n    \"description\": \"Description for JSON export format\"\n  },\n  \"export_format_markdown_description\": {\n    \"message\": \"깨끗하고 이식 가능한 텍스트 형식 (권장)\",\n    \"description\": \"Description for Markdown export format\"\n  },\n  \"export_format_pdf_description\": {\n    \"message\": \"PDF 로 저장을 통한 인쇄 친화적 형식\",\n    \"description\": \"Description for PDF export format\"\n  },\n  \"editInputWidth\": {\n    \"message\": \"편집 입력창 너비\",\n    \"description\": \"Edit input width adjustment label\"\n  },\n  \"editInputWidthNarrow\": {\n    \"message\": \"좁게\",\n    \"description\": \"Narrow edit input width label\"\n  },\n  \"editInputWidthWide\": {\n    \"message\": \"넓게\",\n    \"description\": \"Wide edit input width label\"\n  },\n  \"timelineOptions\": {\n    \"message\": \"타임라인 옵션\",\n    \"description\": \"Timeline options section label\"\n  },\n  \"folderOptions\": {\n    \"message\": \"폴더 옵션\",\n    \"description\": \"Folder options section label\"\n  },\n  \"enableFolderFeature\": {\n    \"message\": \"폴더 기능 활성화\",\n    \"description\": \"Enable or disable the folder feature\"\n  },\n  \"hideArchivedConversations\": {\n    \"message\": \"보관된 대화 숨기기\",\n    \"description\": \"Hide conversations that are in folders from the main list\"\n  },\n  \"conversation_star\": {\n    \"message\": \"대화 별표 표시\",\n    \"description\": \"Star conversation button tooltip\"\n  },\n  \"conversation_unstar\": {\n    \"message\": \"대화 별표 해제\",\n    \"description\": \"Unstar conversation button tooltip\"\n  },\n  \"conversation_untitled\": {\n    \"message\": \"제목 없음\",\n    \"description\": \"Fallback title for conversations without a name\"\n  },\n  \"geminiOnlyNotice\": {\n    \"message\": \"기본적으로 Gemini (Enterprise 포함) 및 AI Studio 에서 실행됩니다. 다른 사이트에서 프롬프트 관리자를 활성화하려면 아래에 추가하세요.\",\n    \"description\": \"Notice that defaults are limited to Gemini/AI Studio\"\n  },\n  \"folderManager_dataLossWarning\": {\n    \"message\": \"⚠️ 경고: 폴더 데이터를 로드하지 못했습니다. 폴더가 손상되었을 수 있습니다. 브라우저 콘솔에서 자세한 내용을 확인하고 가능한 경우 백업에서 복원을 시도하십시오.\",\n    \"description\": \"Warning shown when folder data fails to load\"\n  },\n  \"backupOptions\": {\n    \"message\": \"자동 백업\",\n    \"description\": \"Backup options section label\"\n  },\n  \"backupEnabled\": {\n    \"message\": \"자동 백업 활성화\",\n    \"description\": \"Enable auto backup toggle\"\n  },\n  \"backupNow\": {\n    \"message\": \"지금 백업\",\n    \"description\": \"Backup now button\"\n  },\n  \"backupSelectFolder\": {\n    \"message\": \"백업 폴더 선택\",\n    \"description\": \"Select backup folder button\"\n  },\n  \"backupFolderSelected\": {\n    \"message\": \"백업 폴더: {folder}\",\n    \"description\": \"Shows selected backup folder\"\n  },\n  \"backupIncludePrompts\": {\n    \"message\": \"프롬프트 포함\",\n    \"description\": \"Include prompts in backup toggle\"\n  },\n  \"backupIncludeFolders\": {\n    \"message\": \"폴더 포함\",\n    \"description\": \"Include folders in backup toggle\"\n  },\n  \"backupIntervalLabel\": {\n    \"message\": \"백업 간격\",\n    \"description\": \"Backup interval label\"\n  },\n  \"backupIntervalManual\": {\n    \"message\": \"수동 전용\",\n    \"description\": \"Manual backup mode\"\n  },\n  \"backupIntervalDaily\": {\n    \"message\": \"매일\",\n    \"description\": \"Daily backup interval\"\n  },\n  \"backupIntervalWeekly\": {\n    \"message\": \"매주 (7 일)\",\n    \"description\": \"Weekly backup interval\"\n  },\n  \"backupLastBackup\": {\n    \"message\": \"마지막 백업: {time}\",\n    \"description\": \"Last backup timestamp\"\n  },\n  \"backupNever\": {\n    \"message\": \"안 함\",\n    \"description\": \"Never backed up\"\n  },\n  \"backupSuccess\": {\n    \"message\": \"✓ 백업 생성됨: {prompts}개의 프롬프트, {folders}개의 폴더, {conversations}개의 대화\",\n    \"description\": \"Backup success message\"\n  },\n  \"backupError\": {\n    \"message\": \"✗ 백업 실패: {error}\",\n    \"description\": \"Backup error message\"\n  },\n  \"backupNotSupported\": {\n    \"message\": \"⚠️ 자동 백업에는 File System Access API 를 지원하는 최신 브라우저가 필요합니다\",\n    \"description\": \"Browser not supported message\"\n  },\n  \"backupSelectFolderFirst\": {\n    \"message\": \"먼저 백업 폴더를 선택하세요\",\n    \"description\": \"No folder selected warning\"\n  },\n  \"backupUserCancelled\": {\n    \"message\": \"백업 취소됨\",\n    \"description\": \"User cancelled folder selection\"\n  },\n  \"backupConfigSaved\": {\n    \"message\": \"✓ 백업 설정 저장됨\",\n    \"description\": \"Config saved message\"\n  },\n  \"backupPermissionDenied\": {\n    \"message\": \"⚠️ 이 디렉토리에 액세스할 수 없습니다. 다른 위치를 선택하세요 (예: 문서, 다운로드 또는 바탕 화면의 폴더)\",\n    \"description\": \"Permission denied for restricted directory\"\n  },\n  \"backupConfigureInOptions\": {\n    \"message\": \"백업 구성에는 옵션 페이지가 필요합니다. 아래 버튼을 클릭하여 여세요 (확장 프로그램 아이콘 옆의 세 개의 점 클릭 → 옵션).\",\n    \"description\": \"Backup configure in options page explanation\"\n  },\n  \"openOptionsPage\": {\n    \"message\": \"옵션 페이지 열기\",\n    \"description\": \"Open options page button\"\n  },\n  \"optionsPageSubtitle\": {\n    \"message\": \"확장 프로그램 옵션\",\n    \"description\": \"Options page subtitle\"\n  },\n  \"optionsComingSoon\": {\n    \"message\": \"더 많은 옵션이 곧 추가될 예정입니다...\",\n    \"description\": \"Options page placeholder text\"\n  },\n  \"backupDataAccessNotice\": {\n    \"message\": \"데이터 액세스 제한\",\n    \"description\": \"Backup data access limitation title\"\n  },\n  \"backupDataAccessHint\": {\n    \"message\": \"브라우저 보안 제한으로 인해, 이 옵션 페이지는 Gemini 페이지의 프롬프트 및 폴더 데이터에 직접 액세스할 수 없습니다. 수동 백업은 Gemini 페이지의 프롬프트 관리자 및 폴더 내보내기 기능을 사용하십시오.\",\n    \"description\": \"Backup data access limitation explanation\"\n  },\n  \"promptManagerOptions\": {\n    \"message\": \"프롬프트 관리자\",\n    \"description\": \"Prompt Manager options section label\"\n  },\n  \"hidePromptManager\": {\n    \"message\": \"프롬프트 관리자 숨기기\",\n    \"description\": \"Hide the prompt manager floating ball\"\n  },\n  \"hidePromptManagerHint\": {\n    \"message\": \"페이지의 프롬프트 관리자 플로팅 볼을 숨깁니다\",\n    \"description\": \"Hint for hiding prompt manager feature\"\n  },\n  \"customWebsites\": {\n    \"message\": \"사용자 지정 웹사이트\",\n    \"description\": \"Custom websites for prompt manager\"\n  },\n  \"customWebsitesPlaceholder\": {\n    \"message\": \"웹사이트 URL 입력 (예: chatgpt.com)\",\n    \"description\": \"Custom websites input placeholder\"\n  },\n  \"customWebsitesHint\": {\n    \"message\": \"프롬프트 관리자를 사용할 웹사이트를 추가하세요. 이 사이트들에서는 프롬프트 관리자만 활성화됩니다.\",\n    \"description\": \"Custom websites hint\"\n  },\n  \"addWebsite\": {\n    \"message\": \"웹사이트 추가\",\n    \"description\": \"Add website button\"\n  },\n  \"removeWebsite\": {\n    \"message\": \"제거\",\n    \"description\": \"Remove website button\"\n  },\n  \"invalidUrl\": {\n    \"message\": \"유효하지 않은 URL 형식\",\n    \"description\": \"Invalid URL error message\"\n  },\n  \"websiteAdded\": {\n    \"message\": \"웹사이트 추가됨\",\n    \"description\": \"Website added success message\"\n  },\n  \"websiteRemoved\": {\n    \"message\": \"웹사이트 제거됨\",\n    \"description\": \"Website removed success message\"\n  },\n  \"permissionDenied\": {\n    \"message\": \"권한이 거부되었습니다. 이 웹사이트에 대한 액세스를 허용하세요.\",\n    \"description\": \"Permission denied error message\"\n  },\n  \"permissionRequestFailed\": {\n    \"message\": \"권한 요청에 실패했습니다. 다시 시도하거나 확장 프로그램 설정에서 액세스 권한을 부여하세요.\",\n    \"description\": \"Permission request failure message\"\n  },\n  \"customWebsitesNote\": {\n    \"message\": \"팁: 사이트를 추가할 때 액세스 허용 요청을 받게 됩니다. 권한 부여 후 사이트를 새로 고침하면 프롬프트 관리자가 시작됩니다.\",\n    \"description\": \"Custom websites reload/permission note\"\n  },\n  \"starredHistory\": {\n    \"message\": \"별표 표시된 기록\",\n    \"description\": \"Starred history title\"\n  },\n  \"viewStarredHistory\": {\n    \"message\": \"별표 표시된 기록 보기\",\n    \"description\": \"View starred history button\"\n  },\n  \"noStarredMessages\": {\n    \"message\": \"별표 표시된 메시지가 없습니다\",\n    \"description\": \"No starred messages placeholder\"\n  },\n  \"removeFromStarred\": {\n    \"message\": \"별표 제거\",\n    \"description\": \"Remove from starred tooltip\"\n  },\n  \"justNow\": {\n    \"message\": \"방금 전\",\n    \"description\": \"Just now time label\"\n  },\n  \"hoursAgo\": {\n    \"message\": \"시간 전\",\n    \"description\": \"Hours ago label\"\n  },\n  \"yesterday\": {\n    \"message\": \"어제\",\n    \"description\": \"Yesterday date label\"\n  },\n  \"daysAgo\": {\n    \"message\": \"일 전\",\n    \"description\": \"Days ago label\"\n  },\n  \"loading\": {\n    \"message\": \"로드 중...\",\n    \"description\": \"Loading message\"\n  },\n  \"keyboardShortcuts\": {\n    \"message\": \"키보드 단축키\",\n    \"description\": \"Keyboard shortcuts section title\"\n  },\n  \"enableShortcuts\": {\n    \"message\": \"키보드 단축키 활성화\",\n    \"description\": \"Enable shortcuts toggle label\"\n  },\n  \"previousNode\": {\n    \"message\": \"이전 노드\",\n    \"description\": \"Previous timeline node shortcut label\"\n  },\n  \"nextNode\": {\n    \"message\": \"다음 노드\",\n    \"description\": \"Next timeline node shortcut label\"\n  },\n  \"shortcutKey\": {\n    \"message\": \"키\",\n    \"description\": \"Shortcut key label\"\n  },\n  \"shortcutModifiers\": {\n    \"message\": \"수정 키\",\n    \"description\": \"Shortcut modifiers label\"\n  },\n  \"resetShortcuts\": {\n    \"message\": \"기본값으로 초기화\",\n    \"description\": \"Reset shortcuts button\"\n  },\n  \"shortcutsResetSuccess\": {\n    \"message\": \"단축키가 기본값으로 초기화되었습니다\",\n    \"description\": \"Shortcuts reset success message\"\n  },\n  \"shortcutsDescription\": {\n    \"message\": \"키보드 단축키를 사용하여 타임라인 노드를 탐색합니다\",\n    \"description\": \"Shortcuts description\"\n  },\n  \"modifierNone\": {\n    \"message\": \"없음\",\n    \"description\": \"No modifier key\"\n  },\n  \"modifierAlt\": {\n    \"message\": \"Alt\",\n    \"description\": \"Alt modifier key\"\n  },\n  \"modifierCtrl\": {\n    \"message\": \"Ctrl\",\n    \"description\": \"Ctrl modifier key\"\n  },\n  \"modifierShift\": {\n    \"message\": \"Shift\",\n    \"description\": \"Shift modifier key\"\n  },\n  \"modifierMeta\": {\n    \"message\": \"Cmd/Win\",\n    \"description\": \"Meta/Command/Windows modifier key\"\n  },\n  \"preventAutoScroll\": {\n    \"message\": \"자동 스크롤 방지\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"preventAutoScrollHint\": {\n    \"message\": \"이전 응답을 읽는 동안 Enter 키를 누를 때 Gemini 가 맨 아래로 점프하는 것을 방지합니다.\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"cloudSync\": {\n    \"message\": \"클라우드 동기화\",\n    \"description\": \"Cloud sync section title\"\n  },\n  \"cloudSyncDescription\": {\n    \"message\": \"폴더와 프롬프트를 Google Drive 에 동기화합니다\",\n    \"description\": \"Cloud sync description\"\n  },\n  \"syncModeDisabled\": {\n    \"message\": \"비활성화\",\n    \"description\": \"Sync mode disabled option\"\n  },\n  \"syncModeManual\": {\n    \"message\": \"수동\",\n    \"description\": \"Sync mode manual option\"\n  },\n  \"syncServerPort\": {\n    \"message\": \"서버 포트\",\n    \"description\": \"Port number for the sync server\"\n  },\n  \"syncNow\": {\n    \"message\": \"지금 동기화\",\n    \"description\": \"Sync now button\"\n  },\n  \"lastSynced\": {\n    \"message\": \"마지막 동기화: {time}\",\n    \"description\": \"Last sync timestamp\"\n  },\n  \"neverSynced\": {\n    \"message\": \"동기화된 적 없음\",\n    \"description\": \"Never synced message\"\n  },\n  \"lastUploaded\": {\n    \"message\": \"업로드됨: {time}\",\n    \"description\": \"Last upload timestamp\"\n  },\n  \"neverUploaded\": {\n    \"message\": \"업로드된 적 없음\",\n    \"description\": \"Never uploaded message\"\n  },\n  \"syncSuccess\": {\n    \"message\": \"✓ 성공적으로 동기화되었습니다\",\n    \"description\": \"Sync success message\"\n  },\n  \"syncError\": {\n    \"message\": \"✗ 동기화 실패: {error}\",\n    \"description\": \"Sync error message\"\n  },\n  \"syncInProgress\": {\n    \"message\": \"동기화 중...\",\n    \"description\": \"Sync in progress message\"\n  },\n  \"uploadInProgress\": {\n    \"message\": \"Uploading...\",\n    \"description\": \"Upload in progress message\"\n  },\n  \"uploadSuccess\": {\n    \"message\": \"✓ Uploaded successfully\",\n    \"description\": \"Upload success message\"\n  },\n  \"downloadInProgress\": {\n    \"message\": \"Downloading...\",\n    \"description\": \"Download in progress message\"\n  },\n  \"downloadMergeSuccess\": {\n    \"message\": \"✓ Downloaded and merged\",\n    \"description\": \"Download and merge success message\"\n  },\n  \"syncUpload\": {\n    \"message\": \"업로드\",\n    \"description\": \"Upload button label\"\n  },\n  \"syncMerge\": {\n    \"message\": \"다운로드 및 병합\",\n    \"description\": \"Merge button label\"\n  },\n  \"syncMode\": {\n    \"message\": \"동기화 모드\",\n    \"description\": \"Sync mode label\"\n  },\n  \"minutesAgo\": {\n    \"message\": \"분 전\",\n    \"description\": \"Minutes ago label\"\n  },\n  \"syncNoData\": {\n    \"message\": \"Drive 에서 동기화 데이터를 찾을 수 없습니다\",\n    \"description\": \"Error message when no data is found in Drive\"\n  },\n  \"syncUploadFailed\": {\n    \"message\": \"업로드 실패\",\n    \"description\": \"Error message when upload fails\"\n  },\n  \"syncDownloadFailed\": {\n    \"message\": \"다운로드 실패\",\n    \"description\": \"Error message when download fails\"\n  },\n  \"syncAuthFailed\": {\n    \"message\": \"인증 실패\",\n    \"description\": \"Error message when authentication fails\"\n  },\n  \"signOut\": {\n    \"message\": \"로그아웃\",\n    \"description\": \"Sign out button\"\n  },\n  \"signInWithGoogle\": {\n    \"message\": \"Google 계정으로 로그인\",\n    \"description\": \"Sign in with Google button\"\n  },\n  \"nanobananaOptions\": {\n    \"message\": \"NanoBanana 옵션\",\n    \"description\": \"NanoBanana options section label\"\n  },\n  \"enableNanobananaWatermarkRemover\": {\n    \"message\": \"NanoBanana 워터마크 제거\",\n    \"description\": \"Enable automatic watermark removal for NanoBanana images\"\n  },\n  \"nanobananaWatermarkRemoverHint\": {\n    \"message\": \"생성된 이미지에서 가시적인 Gemini 워터마크를 자동으로 제거합니다\",\n    \"description\": \"Watermark remover feature hint\"\n  },\n  \"nanobananaDownloadTooltip\": {\n    \"message\": \"워터마크 없는 이미지 다운로드 (NanoBanana)\",\n    \"description\": \"Download button tooltip\"\n  },\n  \"deepResearchDownload\": {\n    \"message\": \"생각 내용 다운로드\",\n    \"description\": \"Deep Research download button text\"\n  },\n  \"deepResearchDownloadTooltip\": {\n    \"message\": \"생각 내용을 Markdown 으로 다운로드\",\n    \"description\": \"Deep Research download button tooltip\"\n  },\n  \"folder_uncategorized\": {\n    \"message\": \"미분류\",\n    \"description\": \"Label for root-level conversations not in any folder\"\n  },\n  \"timelineLevelTitle\": {\n    \"message\": \"노드 레벨\",\n    \"description\": \"Title for timeline node level context menu\"\n  },\n  \"timelineLevel1\": {\n    \"message\": \"레벨 1\",\n    \"description\": \"Timeline node level 1 option\"\n  },\n  \"timelineLevel2\": {\n    \"message\": \"레벨 2\",\n    \"description\": \"Timeline node level 2 option\"\n  },\n  \"timelineLevel3\": {\n    \"message\": \"레벨 3\",\n    \"description\": \"Timeline node level 3 option\"\n  },\n  \"timelineCollapse\": {\n    \"message\": \"접기\",\n    \"description\": \"Collapse children\"\n  },\n  \"timelineExpand\": {\n    \"message\": \"펼치기\",\n    \"description\": \"Expand children\"\n  },\n  \"timelinePreviewSearch\": {\n    \"message\": \"검색...\",\n    \"description\": \"Timeline preview panel search placeholder\"\n  },\n  \"timelinePreviewNoResults\": {\n    \"message\": \"결과 없음\",\n    \"description\": \"Timeline preview panel no search results\"\n  },\n  \"timelinePreviewNoMessages\": {\n    \"message\": \"메시지 없음\",\n    \"description\": \"Timeline preview panel no messages\"\n  },\n  \"quoteReply\": {\n    \"message\": \"인용 답장\",\n    \"description\": \"Quote reply button text\"\n  },\n  \"inputCollapseOptions\": {\n    \"message\": \"입력 옵션\",\n    \"description\": \"Input options section label\"\n  },\n  \"enableInputCollapse\": {\n    \"message\": \"입력창 접기 활성화\",\n    \"description\": \"Enable input collapse toggle label\"\n  },\n  \"enableInputCollapseHint\": {\n    \"message\": \"입력창이 비어 있을 때 접어서 더 많은 읽기 공간을 확보합니다\",\n    \"description\": \"Input collapse feature hint\"\n  },\n  \"inputCollapseShortcutHint\": {\n    \"message\": \"{modifier}+I 로 펼치기\",\n    \"description\": \"Shortcut hint for expanding collapsed input. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"inputCollapsePlaceholder\": {\n    \"message\": \"Gemini 에게 메시지 보내기\",\n    \"description\": \"Placeholder text shown in collapsed input\"\n  },\n  \"allowCollapseWhenNotEmpty\": {\n    \"message\": \"내용이 있어도 접기 허용\",\n    \"description\": \"Allow input to collapse even when it has text or attachments\"\n  },\n  \"allowCollapseWhenNotEmptyHint\": {\n    \"message\": \"텍스트나 첨부 파일이 있어도 입력 영역을 접을 수 있습니다\",\n    \"description\": \"Hint text explaining the collapse when not empty feature\"\n  },\n  \"ctrlEnterSend\": {\n    \"message\": \"{modifier}+Enter 로 전송\",\n    \"description\": \"Modifier+Enter to send toggle label. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"ctrlEnterSendHint\": {\n    \"message\": \"{modifier}+Enter 를 눌러 메시지를 전송하고, Enter 로 줄바꿈을 합니다\",\n    \"description\": \"Modifier+Enter to send feature hint. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"batch_delete_confirm\": {\n    \"message\": \"{count}개의 대화를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.\",\n    \"description\": \"Batch delete confirmation message\"\n  },\n  \"batch_delete_in_progress\": {\n    \"message\": \"삭제 중... ({current}/{total})\",\n    \"description\": \"Batch delete progress message\"\n  },\n  \"batch_delete_success\": {\n    \"message\": \"✓ {count}개의 대화를 삭제했습니다\",\n    \"description\": \"Batch delete success message\"\n  },\n  \"batch_delete_partial\": {\n    \"message\": \"삭제 완료: {success}개 성공, {failed}개 실패\",\n    \"description\": \"Batch delete partial success message\"\n  },\n  \"batch_delete_limit_reached\": {\n    \"message\": \"한 번에 최대 {max}개의 대화만 선택할 수 있습니다\",\n    \"description\": \"Batch delete limit warning\"\n  },\n  \"batch_delete_button\": {\n    \"message\": \"선택한 항목 삭제\",\n    \"description\": \"Batch delete button tooltip in multi-select mode\"\n  },\n  \"batch_delete_match_patterns\": {\n    \"message\": \"delete,confirm,yes,ok,remove,삭제,확인,예,확인,제거\",\n    \"description\": \"Comma-separated list of keywords to match native delete/confirm buttons (case-insensitive)\"\n  },\n  \"generalOptions\": {\n    \"message\": \"일반 옵션\",\n    \"description\": \"General options section label\"\n  },\n  \"enableTabTitleUpdate\": {\n    \"message\": \"탭 제목을 대화와 동기화\",\n    \"description\": \"Enable tab title sync toggle label\"\n  },\n  \"enableTabTitleUpdateHint\": {\n    \"message\": \"브라우저 탭 제목을 현재 대화 제목과 일치하도록 자동으로 업데이트합니다\",\n    \"description\": \"Tab title sync feature hint\"\n  },\n  \"enableMermaidRendering\": {\n    \"message\": \"Mermaid 다이어그램 렌더링 활성화\",\n    \"description\": \"Enable Mermaid diagram rendering toggle label\"\n  },\n  \"enableMermaidRenderingHint\": {\n    \"message\": \"코드 블록에서 Mermaid 다이어그램을 자동으로 렌더링합니다\",\n    \"description\": \"Mermaid rendering feature hint\"\n  },\n  \"enableQuoteReply\": {\n    \"message\": \"인용 답장 활성화\",\n    \"description\": \"Enable quote reply toggle label\"\n  },\n  \"enableQuoteReplyHint\": {\n    \"message\": \"대화에서 텍스트를 선택할 때 선택한 텍스트를 인용할 수 있는 플로팅 버튼을 표시합니다\",\n    \"description\": \"Quote reply feature hint\"\n  },\n  \"contextSync\": {\n    \"message\": \"컨텍스트 동기화\",\n    \"description\": \"Context Sync title\"\n  },\n  \"contextSyncDescription\": {\n    \"message\": \"대화 컨텍스트를 로컬 IDE 와 동기화합니다\",\n    \"description\": \"Context Sync description\"\n  },\n  \"syncToIDE\": {\n    \"message\": \"IDE 로 동기화\",\n    \"description\": \"Sync to IDE button\"\n  },\n  \"ideOnline\": {\n    \"message\": \"IDE 온라인\",\n    \"description\": \"IDE Online status\"\n  },\n  \"ideOffline\": {\n    \"message\": \"IDE 오프라인\",\n    \"description\": \"IDE Offline status\"\n  },\n  \"syncing\": {\n    \"message\": \"동기화 중...\",\n    \"description\": \"Syncing status\"\n  },\n  \"checkServer\": {\n    \"message\": \"VS Code 에서 AI Sync 서버를 시작해 주세요\",\n    \"description\": \"Check server hint\"\n  },\n  \"capturing\": {\n    \"message\": \"대화 캡처 중...\",\n    \"description\": \"Capturing status\"\n  },\n  \"syncedSuccess\": {\n    \"message\": \"성공적으로 동기화되었습니다!\",\n    \"description\": \"Synced success message\"\n  },\n  \"downloadingOriginal\": {\n    \"message\": \"원본 이미지 다운로드 중\",\n    \"description\": \"Status when downloading original image\"\n  },\n  \"downloadingOriginalLarge\": {\n    \"message\": \"원본 이미지 다운로드 중 (대용량 파일)\",\n    \"description\": \"Status when downloading large original image\"\n  },\n  \"downloadLargeWarning\": {\n    \"message\": \"대용량 파일 경고\",\n    \"description\": \"Warning shown for large downloads\"\n  },\n  \"downloadProcessing\": {\n    \"message\": \"워터마크 처리 중\",\n    \"description\": \"Status when processing watermark removal\"\n  },\n  \"downloadSuccess\": {\n    \"message\": \"다운로드 중...\",\n    \"description\": \"Status when download starts\"\n  },\n  \"downloadError\": {\n    \"message\": \"실패\",\n    \"description\": \"Status prefix when download fails\"\n  },\n  \"recentsHide\": {\n    \"message\": \"최근 항목 숨기기\",\n    \"description\": \"Tooltip for hiding recents preview section\"\n  },\n  \"recentsShow\": {\n    \"message\": \"최근 항목 표시\",\n    \"description\": \"Tooltip for showing recents preview section\"\n  },\n  \"gemsHide\": {\n    \"message\": \"Gem 숨기기\",\n    \"description\": \"Tooltip for hiding Gems list section\"\n  },\n  \"gemsShow\": {\n    \"message\": \"Gem 표시\",\n    \"description\": \"Tooltip for showing Gems list section\"\n  },\n  \"setAsDefaultModel\": {\n    \"message\": \"기본 모델로 설정\",\n    \"description\": \"Tooltip for setting default model\"\n  },\n  \"cancelDefaultModel\": {\n    \"message\": \"기본 모델 취소\",\n    \"description\": \"Tooltip for cancelling default model\"\n  },\n  \"defaultModelSet\": {\n    \"message\": \"기본 모델 설정됨: $1\",\n    \"description\": \"Toast message when default model is set\"\n  },\n  \"defaultModelCleared\": {\n    \"message\": \"기본 모델 해제됨\",\n    \"description\": \"Toast message when default model is cleared\"\n  },\n  \"sidebarAutoHide\": {\n    \"message\": \"사이드바 자동 숨김\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"sidebarAutoHideHint\": {\n    \"message\": \"마우스가 벗어나면 사이드바를 자동으로 접고, 마우스가 들어오면 펼칩니다\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"sidebarFullHide\": {\n    \"message\": \"사이드바 완전 숨김\",\n    \"description\": \"Fully hide sidebar toggle label\"\n  },\n  \"sidebarFullHideHint\": {\n    \"message\": \"접힌 사이드바를 완전히 숨기고, 왼쪽 가장자리에 마우스를 올려 표시\",\n    \"description\": \"Fully hide sidebar feature hint\"\n  },\n  \"folderSpacing\": {\n    \"message\": \"폴더 간격\",\n    \"description\": \"Folder spacing adjustment label\"\n  },\n  \"folderSpacingCompact\": {\n    \"message\": \"좁게\",\n    \"description\": \"Compact folder spacing label\"\n  },\n  \"folderSpacingSpacious\": {\n    \"message\": \"넓게\",\n    \"description\": \"Spacious folder spacing label\"\n  },\n  \"folderTreeIndent\": {\n    \"message\": \"하위 폴더 들여쓰기\",\n    \"description\": \"Subfolder tree indentation adjustment label\"\n  },\n  \"folderTreeIndentCompact\": {\n    \"message\": \"좁게\",\n    \"description\": \"Narrow subfolder indentation label\"\n  },\n  \"folderTreeIndentSpacious\": {\n    \"message\": \"넓게\",\n    \"description\": \"Wide subfolder indentation label\"\n  },\n  \"export_select_mode_select_all\": {\n    \"message\": \"전체 선택\",\n    \"description\": \"Selection mode select all toggle\"\n  },\n  \"export_select_mode_count\": {\n    \"message\": \"{count}개 선택됨\",\n    \"description\": \"Selection mode selected message count\"\n  },\n  \"export_select_mode_toggle\": {\n    \"message\": \"메시지 선택\",\n    \"description\": \"Selection mode per-message toggle tooltip\"\n  },\n  \"export_select_mode_select_below\": {\n    \"message\": \"아래 메시지 선택\",\n    \"description\": \"Selection mode top-left button to select messages below current line\"\n  },\n  \"export_select_mode_empty\": {\n    \"message\": \"내보낼 메시지를 하나 이상 선택하세요\",\n    \"description\": \"Selection mode validation when nothing is selected\"\n  },\n  \"export_format_image_description\": {\n    \"message\": \"모바일 공유에 적합한 단일 PNG 이미지.\",\n    \"description\": \"Description for Image export format\"\n  },\n  \"export_image_progress\": {\n    \"message\": \"이미지 생성 중...\",\n    \"description\": \"Progress message while generating image export\"\n  },\n  \"deepResearchSaveReport\": {\n    \"message\": \"보고서 저장\",\n    \"description\": \"Deep Research save report button\"\n  },\n  \"deepResearchSaveReportTooltip\": {\n    \"message\": \"이 연구 보고서 내보내기\",\n    \"description\": \"Deep Research save report tooltip\"\n  },\n  \"export_fontsize_label\": {\n    \"message\": \"글꼴 크기\",\n    \"description\": \"Label for font size slider in export dialog\"\n  },\n  \"export_fontsize_preview\": {\n    \"message\": \"다람쥐 헌 쳇바퀴에 타고파.\",\n    \"description\": \"Preview text for font size slider in export dialog\"\n  },\n  \"export_error_generic\": {\n    \"message\": \"내보내기 실패: {error}\",\n    \"description\": \"상세 원인을 포함한 일반 내보내기 실패 메시지\"\n  },\n  \"export_error_refresh_retry\": {\n    \"message\": \"네트워크 문제로 이미지 내보내기에 실패했습니다. 페이지를 새로고침한 뒤 다시 시도해 주세요.\",\n    \"description\": \"이미지 내보내기에서 일시적 로딩 실패가 발생했을 때 표시되는 안내\"\n  },\n  \"export_md_include_source_confirm\": {\n    \"message\": \"내보내기 내용에 웹 검색 이미지가 포함되어 있습니다. Markdown 에 이미지 출처 링크를 포함하시겠습니까？\",\n    \"description\": \"Confirm dialog when exporting markdown with web search images\"\n  },\n  \"deepResearch_exportedAt\": {\n    \"message\": \"내보내기 일시\",\n    \"description\": \"Deep Research 내보내기 타임스탬프 레이블\"\n  },\n  \"deepResearch_totalPhases\": {\n    \"message\": \"총 단계 수\",\n    \"description\": \"Deep Research 총 사고 단계 레이블\"\n  },\n  \"deepResearch_thinkingPhase\": {\n    \"message\": \"사고 단계\",\n    \"description\": \"Deep Research 사고 단계 헤더\"\n  },\n  \"deepResearch_researchedWebsites\": {\n    \"message\": \"조사한 웹사이트\",\n    \"description\": \"Deep Research 조사한 웹사이트 섹션 헤더\"\n  },\n  \"visualEffect\": {\n    \"message\": \"시각 효과\",\n    \"description\": \"Visual effect selector label\"\n  },\n  \"visualEffectHint\": {\n    \"message\": \"페이지에 계절 분위기 연출\",\n    \"description\": \"Visual effect feature hint\"\n  },\n  \"visualEffectOff\": {\n    \"message\": \"끄기\",\n    \"description\": \"Visual effect off option\"\n  },\n  \"visualEffectSnow\": {\n    \"message\": \"눈\",\n    \"description\": \"Visual effect snow option\"\n  },\n  \"visualEffectSakura\": {\n    \"message\": \"벚꽃\",\n    \"description\": \"Visual effect sakura option\"\n  },\n  \"visualEffectRain\": {\n    \"message\": \"비\",\n    \"description\": \"Visual effect rain option\"\n  },\n  \"changelog_title\": {\n    \"message\": \"새로운 기능\",\n    \"description\": \"Changelog modal title\"\n  },\n  \"changelog_close\": {\n    \"message\": \"확인\",\n    \"description\": \"Changelog modal close button\"\n  },\n  \"changelog_docs_link\": {\n    \"message\": \"문서\",\n    \"description\": \"Changelog modal documentation link text\"\n  },\n  \"changelog_recommendation\": {\n    \"message\": \"Voyager 가 도움이 되셨다면, 친구나 SNS 에서 공유해 주세요 — 개발자를 태그해 주시면 큰 힘이 됩니다!\",\n    \"description\": \"Changelog modal recommendation message\"\n  },\n  \"changelog_social_xiaohongshu\": {\n    \"message\": \"Xiaohongshu\",\n    \"description\": \"Xiaohongshu platform name for social links\"\n  },\n  \"changelog_social_zhihu\": {\n    \"message\": \"Zhihu\",\n    \"description\": \"Zhihu platform name for social links\"\n  },\n  \"changelog_docs_hint\": {\n    \"message\": \"자세한 내용은 문서 참조\",\n    \"description\": \"Changelog annotation pointing to docs icon\"\n  },\n  \"changelog_rate_chrome\": {\n    \"message\": \"Voyager가 마음에 드시나요? Chrome 웹 스토어에 평점을 남겨 더 많은 분들이 발견할 수 있게 해주세요!\",\n    \"description\": \"Chrome Web Store rating prompt in changelog modal\"\n  },\n  \"changelog_rate_chrome_cta\": {\n    \"message\": \"평점 주기\",\n    \"description\": \"Chrome Web Store rating CTA button text\"\n  },\n  \"changelog_badge_mode\": {\n    \"message\": \"이 팝업 대신 플로팅 버튼의 NEW 배지로 알림\",\n    \"description\": \"Changelog notification mode toggle label\"\n  },\n  \"forkConversation\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork conversation button label\"\n  },\n  \"forkConfirm\": {\n    \"message\": \"Fork conversation from this point?\",\n    \"description\": \"Fork confirmation prompt\"\n  },\n  \"forkConfirmBtn\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork confirmation button\"\n  },\n  \"forkCancel\": {\n    \"message\": \"Cancel\",\n    \"description\": \"Fork cancel button\"\n  },\n  \"forkBranch\": {\n    \"message\": \"Branch\",\n    \"description\": \"Fork branch label\"\n  },\n  \"forkOriginal\": {\n    \"message\": \"Original\",\n    \"description\": \"Original fork branch tag\"\n  },\n  \"forkCurrent\": {\n    \"message\": \"Current\",\n    \"description\": \"Current fork branch tag\"\n  },\n  \"forkPreparing\": {\n    \"message\": \"Preparing fork...\",\n    \"description\": \"Fork preparation status\"\n  },\n  \"forkPasteHint\": {\n    \"message\": \"Press Enter to send and create the fork\",\n    \"description\": \"Fork paste hint text\"\n  },\n  \"forkDeleteData\": {\n    \"message\": \"이 분기 삭제\",\n    \"description\": \"Delete fork branch metadata button\"\n  },\n  \"forkDeleteDataConfirm\": {\n    \"message\": \"이 분기 연결을 삭제할까요? Voyager 분기 메타데이터만 삭제되며 대화 자체는 삭제되지 않습니다.\",\n    \"description\": \"Fork metadata delete confirmation prompt\"\n  },\n  \"enableForkFeature\": {\n    \"message\": \"대화 분기 활성화\",\n    \"description\": \"Enable conversation fork feature toggle label\"\n  },\n  \"enableForkFeatureHint\": {\n    \"message\": \"Gemini 대화에 Fork 버튼과 분기 번호를 표시합니다\",\n    \"description\": \"Enable conversation fork feature toggle hint\"\n  },\n  \"enableAccountIsolation\": {\n    \"message\": \"Enable Hard Isolation\",\n    \"description\": \"Enable hard isolation for account-scoped folder data and cloud sync.\"\n  },\n  \"enableAccountIsolationHint\": {\n    \"message\": \"Strictly isolate folder and cloud sync data by Google account to prevent cross-account mixing.\",\n    \"description\": \"Hint text for account isolation option.\"\n  },\n  \"currentPlatform\": {\n    \"message\": \"Current platform\",\n    \"description\": \"Current active platform label in popup.\"\n  },\n  \"platformGemini\": {\n    \"message\": \"Gemini\",\n    \"description\": \"Gemini platform label.\"\n  },\n  \"platformAIStudio\": {\n    \"message\": \"AI Studio\",\n    \"description\": \"AI Studio platform label.\"\n  },\n  \"enableOnAIStudio\": {\n    \"message\": \"AI Studio에서 활성화\",\n    \"description\": \"Toggle to enable/disable Voyager on AI Studio\"\n  },\n  \"enableOnAIStudioHint\": {\n    \"message\": \"끄면 AI Studio의 Voyager 기능이 비활성화됩니다 (Prompt Manager 제외)\",\n    \"description\": \"Hint for AI Studio enable toggle\"\n  },\n  \"export_select_mode_only_user\": {\n    \"message\": \"사용자만 선택\",\n    \"description\": \"Export selection mode: select only user messages\"\n  },\n  \"export_select_mode_only_ai\": {\n    \"message\": \"AI만 선택\",\n    \"description\": \"Export selection mode: select only AI messages\"\n  },\n  \"aiOrgCopyButton\": {\n    \"message\": \"AI 정리\",\n    \"description\": \"AI 정리를 위해 폴더 구조와 대화를 복사하는 버튼\"\n  },\n  \"aiOrgCopied\": { \"message\": \"복사됨!\", \"description\": \"AI 정리 프롬프트 복사 후 확인\" },\n  \"aiOrgError\": {\n    \"message\": \"실패 — 먼저 Gemini를 열어주세요\",\n    \"description\": \"AI 정리 프롬프트 복사 실패 시 에러\"\n  },\n  \"aiOrgCopyHint\": {\n    \"message\": \"모든 대화와 폴더 구조를 프롬프트로 복사합니다. Gemini에 붙여넣어 가져올 수 있는 폴더 계획을 얻으세요.\",\n    \"description\": \"AI 정리 복사 버튼 아래 힌트 텍스트\"\n  },\n  \"aiOrgCurrentFolders\": { \"message\": \"현재 폴더 구조\", \"description\": \"AI 정리 프롬프트의 제목\" },\n  \"aiOrgUnfiledConversations\": {\n    \"message\": \"분류되지 않은 대화\",\n    \"description\": \"폴더에 없는 대화 제목\"\n  },\n  \"aiOrgEmpty\": { \"message\": \"비어 있음\", \"description\": \"AI 정리 프롬프트에서 빈 폴더 라벨\" },\n  \"aiOrgInstructions\": { \"message\": \"지시사항\", \"description\": \"AI 지시사항 섹션 제목\" },\n  \"aiOrgInstructionsBody\": {\n    \"message\": \"위의 대화와 폴더 구조를 기반으로 논리적인 폴더 계층으로 재정리해주세요. 아래 형식의 JSON 파일을 출력하여 Gemini Voyager 확장 프로그램에 직접 가져올 수 있도록 하세요(\\\"병합\\\" 가져오기 전략 사용). 기존의 잘 정리된 폴더는 유지하고, 필요에 따라 새 폴더를 만들고, 분류되지 않은 대화를 적절한 폴더로 이동하세요. 각 폴더에는 고유한 ID(짧은 랜덤 문자열)가 필요하며, 각 대화는 원래의 conversationId와 url을 유지해야 합니다.\",\n    \"description\": \"AI가 가져올 수 있는 폴더 구조를 생성하기 위한 지시사항 본문\"\n  },\n  \"showMessageTimestamps\": {\n    \"message\": \"메시지 타임스탬프 표시\",\n    \"description\": \"각 메시지의 타임스탬프 표시\"\n  },\n  \"showMessageTimestampsHint\": {\n    \"message\": \"각 메시지가 전송된 시간을 표시합니다\",\n    \"description\": \"메시지 타임스탬프 기능 힌트\"\n  },\n  \"moveSectionUp\": {\n    \"message\": \"위로 이동\",\n    \"description\": \"Tooltip for moving a settings section up in popup\"\n  },\n  \"moveSectionDown\": {\n    \"message\": \"아래로 이동\",\n    \"description\": \"Tooltip for moving a settings section down in popup\"\n  }\n}\n"
  },
  {
    "path": "src/locales/pt/messages.json",
    "content": "{\n  \"extName\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Extension name\"\n  },\n  \"extDescription\": {\n    \"message\": \"Melhore sua experiência Gemini™: linha do tempo, pastas, prompts e exportação.\",\n    \"description\": \"Extension description\"\n  },\n  \"scrollMode\": {\n    \"message\": \"Modo de rolagem\",\n    \"description\": \"Scroll mode label\"\n  },\n  \"flow\": {\n    \"message\": \"Fluxo\",\n    \"description\": \"Flow mode\"\n  },\n  \"jump\": {\n    \"message\": \"Pulo\",\n    \"description\": \"Jump mode\"\n  },\n  \"hideOuterContainer\": {\n    \"message\": \"Ocultar contêiner externo\",\n    \"description\": \"Hide outer container label\"\n  },\n  \"draggableTimeline\": {\n    \"message\": \"Linha do tempo arrastável\",\n    \"description\": \"Draggable Timeline label\"\n  },\n  \"enableMarkerLevel\": {\n    \"message\": \"Habilitar níveis de nó\",\n    \"description\": \"Enable timeline node level feature\"\n  },\n  \"enableMarkerLevelHint\": {\n    \"message\": \"Clique com o botão direito nos nós da linha do tempo para definir o nível e recolher filhos\",\n    \"description\": \"Node level feature hint\"\n  },\n  \"experimentalLabel\": {\n    \"message\": \"Experimental\",\n    \"description\": \"Experimental feature label\"\n  },\n  \"resetPosition\": {\n    \"message\": \"Redefinir Posição\",\n    \"description\": \"Reset Position button\"\n  },\n  \"resetTimelinePosition\": {\n    \"message\": \"Redefinir Posição da Linha do Tempo\",\n    \"description\": \"Reset timeline position button in timeline options\"\n  },\n  \"language\": {\n    \"message\": \"Idioma\",\n    \"description\": \"Language label\"\n  },\n  \"pm_title\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Prompt Manager title\"\n  },\n  \"pm_add\": {\n    \"message\": \"Adicionar\",\n    \"description\": \"Add prompt button\"\n  },\n  \"pm_search_placeholder\": {\n    \"message\": \"Pesquisar prompts ou tags\",\n    \"description\": \"Search placeholder\"\n  },\n  \"pm_import\": {\n    \"message\": \"Importar\",\n    \"description\": \"Import button\"\n  },\n  \"pm_export\": {\n    \"message\": \"Exportar\",\n    \"description\": \"Export button\"\n  },\n  \"pm_prompt_placeholder\": {\n    \"message\": \"Texto do prompt\",\n    \"description\": \"Prompt textarea placeholder\"\n  },\n  \"pm_tags_placeholder\": {\n    \"message\": \"Tags (separadas por vírgula)\",\n    \"description\": \"Tags input placeholder\"\n  },\n  \"pm_save\": {\n    \"message\": \"Salvar\",\n    \"description\": \"Save prompt\"\n  },\n  \"pm_cancel\": {\n    \"message\": \"Cancelar\",\n    \"description\": \"Cancel\"\n  },\n  \"pm_all_tags\": {\n    \"message\": \"Todos\",\n    \"description\": \"All tags chip\"\n  },\n  \"pm_empty\": {\n    \"message\": \"Nenhum prompt ainda\",\n    \"description\": \"Empty state\"\n  },\n  \"pm_copy\": {\n    \"message\": \"Copiar\",\n    \"description\": \"Copy prompt\"\n  },\n  \"pm_copied\": {\n    \"message\": \"Copiado\",\n    \"description\": \"Copied notice\"\n  },\n  \"pm_delete\": {\n    \"message\": \"Excluir\",\n    \"description\": \"Delete prompt\"\n  },\n  \"pm_delete_confirm\": {\n    \"message\": \"Excluir este prompt?\",\n    \"description\": \"Delete confirm\"\n  },\n  \"pm_lock\": {\n    \"message\": \"Bloquear posição\",\n    \"description\": \"Lock panel position\"\n  },\n  \"pm_unlock\": {\n    \"message\": \"Desbloquear posição\",\n    \"description\": \"Unlock panel position\"\n  },\n  \"pm_import_invalid\": {\n    \"message\": \"Formato de arquivo inválido\",\n    \"description\": \"Import invalid\"\n  },\n  \"pm_import_success\": {\n    \"message\": \"Importado(s) {count} prompt(s)\",\n    \"description\": \"Import success\"\n  },\n  \"pm_duplicate\": {\n    \"message\": \"Duplicar prompt\",\n    \"description\": \"Duplicate on add\"\n  },\n  \"pm_deleted\": {\n    \"message\": \"Excluído\",\n    \"description\": \"Deleted notice\"\n  },\n  \"pm_edit\": {\n    \"message\": \"Editar\",\n    \"description\": \"Edit prompt\"\n  },\n  \"pm_expand\": {\n    \"message\": \"Expandir\",\n    \"description\": \"Expand prompt text\"\n  },\n  \"pm_collapse\": {\n    \"message\": \"Recolher\",\n    \"description\": \"Collapse prompt text\"\n  },\n  \"pm_saved\": {\n    \"message\": \"Salvo\",\n    \"description\": \"Saved notice\"\n  },\n  \"pm_settings\": {\n    \"message\": \"Configurações\",\n    \"description\": \"Settings button\"\n  },\n  \"pm_settings_tooltip\": {\n    \"message\": \"Abrir configurações da extensão\",\n    \"description\": \"Settings button tooltip\"\n  },\n  \"pm_settings_fallback\": {\n    \"message\": \"Clique no ícone da extensão na barra do navegador para abrir as configurações\",\n    \"description\": \"Settings open failed message\"\n  },\n  \"pm_backup\": {\n    \"message\": \"Backup Local\",\n    \"description\": \"Local backup button\"\n  },\n  \"pm_backup_tooltip\": {\n    \"message\": \"Fazer backup de prompts e pastas em uma pasta com carimbo de data/hora\",\n    \"description\": \"Backup button tooltip\"\n  },\n  \"pm_backup_cancelled\": {\n    \"message\": \"Backup cancelado\",\n    \"description\": \"Backup cancelled hint\"\n  },\n  \"pm_backup_error\": {\n    \"message\": \"✗ Falha no backup\",\n    \"description\": \"Backup failed hint\"\n  },\n  \"pm_backup_hint_options\": {\n    \"message\": \"O recurso está integrado ao Gerenciador de Prompts na página do Gemini.\",\n    \"description\": \"Backup hint in options page\"\n  },\n  \"pm_backup_step1\": {\n    \"message\": \"Abra a página do Gemini (gemini.google.com)\",\n    \"description\": \"Backup step 1\"\n  },\n  \"pm_backup_step2\": {\n    \"message\": \"Clique no ícone da extensão no canto inferior direito para abrir o Gerenciador de Prompts\",\n    \"description\": \"Backup step 2\"\n  },\n  \"pm_backup_step3\": {\n    \"message\": \"Clique no botão \\\"💾 Backup Local\\\" e selecione uma pasta de backup\",\n    \"description\": \"Backup step 3\"\n  },\n  \"pm_backup_note\": {\n    \"message\": \"Os backups incluem todos os prompts e pastas, salvos em uma pasta com carimbo de data/hora (formato: backup-YYYYMMDD-HHMMSS)\",\n    \"description\": \"Backup feature note\"\n  },\n  \"pm_theme_light\": {\n    \"message\": \"Mudar para modo claro\",\n    \"description\": \"Theme toggle tooltip - switch to light\"\n  },\n  \"pm_theme_dark\": {\n    \"message\": \"Mudar para modo escuro\",\n    \"description\": \"Theme toggle tooltip - switch to dark\"\n  },\n  \"extensionVersion\": {\n    \"message\": \"Versão\",\n    \"description\": \"Extension version label\"\n  },\n  \"newVersionAvailable\": {\n    \"message\": \"Nova versão disponível\",\n    \"description\": \"Update reminder title\"\n  },\n  \"currentVersionLabel\": {\n    \"message\": \"Atual\",\n    \"description\": \"Label for current version number\"\n  },\n  \"latestVersionLabel\": {\n    \"message\": \"Mais recente\",\n    \"description\": \"Label for latest version number\"\n  },\n  \"updateNow\": {\n    \"message\": \"Atualizar\",\n    \"description\": \"CTA to update to latest version\"\n  },\n  \"safariUpdateNotSynced\": {\n    \"message\": \"A atualização ainda não está sincronizada, tente novamente em algumas horas\",\n    \"description\": \"Shown on Safari when the DMG file is not yet available on GitHub releases\"\n  },\n  \"starProject\": {\n    \"message\": \"Apoie-me ⭐\",\n    \"description\": \"Github callout\"\n  },\n  \"officialDocs\": {\n    \"message\": \"Documentação Oficial\",\n    \"description\": \"Official docs link\"\n  },\n  \"exportChatJson\": {\n    \"message\": \"Exportar histórico da conversa\",\n    \"description\": \"Tooltip for export button\"\n  },\n  \"folder_title\": {\n    \"message\": \"Pastas\",\n    \"description\": \"Folder section title\"\n  },\n  \"folder_create\": {\n    \"message\": \"Criar pasta\",\n    \"description\": \"Create folder button tooltip\"\n  },\n  \"folder_name_prompt\": {\n    \"message\": \"Digite o nome da pasta:\",\n    \"description\": \"Create folder name prompt\"\n  },\n  \"folder_rename_prompt\": {\n    \"message\": \"Digite o novo nome:\",\n    \"description\": \"Rename folder prompt\"\n  },\n  \"folder_delete_confirm\": {\n    \"message\": \"Excluir esta pasta e todo o seu conteúdo?\",\n    \"description\": \"Delete folder confirmation\"\n  },\n  \"folder_create_subfolder\": {\n    \"message\": \"Criar subpasta\",\n    \"description\": \"Create subfolder menu item\"\n  },\n  \"folder_rename\": {\n    \"message\": \"Renomear\",\n    \"description\": \"Rename folder menu item\"\n  },\n  \"folder_change_color\": {\n    \"message\": \"Alterar cor\",\n    \"description\": \"Change folder color menu item\"\n  },\n  \"folder_delete\": {\n    \"message\": \"Excluir\",\n    \"description\": \"Delete folder menu item\"\n  },\n  \"folder_color_default\": {\n    \"message\": \"Padrão\",\n    \"description\": \"Default folder color name\"\n  },\n  \"folder_color_red\": {\n    \"message\": \"Vermelho\",\n    \"description\": \"Red folder color name\"\n  },\n  \"folder_color_orange\": {\n    \"message\": \"Laranja\",\n    \"description\": \"Orange folder color name\"\n  },\n  \"folder_color_yellow\": {\n    \"message\": \"Amarelo\",\n    \"description\": \"Yellow folder color name\"\n  },\n  \"folder_color_green\": {\n    \"message\": \"Verde\",\n    \"description\": \"Green folder color name\"\n  },\n  \"folder_color_blue\": {\n    \"message\": \"Azul\",\n    \"description\": \"Blue folder color name\"\n  },\n  \"folder_color_purple\": {\n    \"message\": \"Roxo\",\n    \"description\": \"Purple folder color name\"\n  },\n  \"folder_color_pink\": {\n    \"message\": \"Rosa\",\n    \"description\": \"Pink folder color name\"\n  },\n  \"folder_color_custom\": {\n    \"message\": \"Personalizado\",\n    \"description\": \"Custom folder color name\"\n  },\n  \"folder_empty\": {\n    \"message\": \"Nenhuma pasta ainda\",\n    \"description\": \"Empty state placeholder when no folders exist\"\n  },\n  \"folder_pin\": {\n    \"message\": \"Fixar pasta\",\n    \"description\": \"Pin folder menu item\"\n  },\n  \"folder_unpin\": {\n    \"message\": \"Desafixar pasta\",\n    \"description\": \"Unpin folder menu item\"\n  },\n  \"folder_remove_conversation\": {\n    \"message\": \"Remover da pasta\",\n    \"description\": \"Remove conversation button tooltip\"\n  },\n  \"folder_remove_conversation_confirm\": {\n    \"message\": \"Remover \\\"{title}\\\" desta pasta?\",\n    \"description\": \"Remove conversation confirmation\"\n  },\n  \"conversation_more\": {\n    \"message\": \"Mais opções\",\n    \"description\": \"Conversation more options button tooltip\"\n  },\n  \"conversation_move_to_folder\": {\n    \"message\": \"Mover para pasta\",\n    \"description\": \"Move conversation to folder menu item\"\n  },\n  \"conversation_move_to_folder_title\": {\n    \"message\": \"Mover para pasta\",\n    \"description\": \"Move conversation to folder dialog title\"\n  },\n  \"chatWidth\": {\n    \"message\": \"Largura do chat\",\n    \"description\": \"Chat width adjustment label\"\n  },\n  \"chatWidthNarrow\": {\n    \"message\": \"Estreita\",\n    \"description\": \"Narrow chat width label\"\n  },\n  \"chatWidthWide\": {\n    \"message\": \"Larga\",\n    \"description\": \"Wide chat width label\"\n  },\n  \"sidebarWidth\": {\n    \"message\": \"Largura da barra lateral\",\n    \"description\": \"Sidebar width adjustment label\"\n  },\n  \"sidebarWidthNarrow\": {\n    \"message\": \"Estreita\",\n    \"description\": \"Narrow sidebar width label\"\n  },\n  \"sidebarWidthWide\": {\n    \"message\": \"Larga\",\n    \"description\": \"Wide sidebar width label\"\n  },\n  \"formula_copied\": {\n    \"message\": \"✓ Fórmula copiada\",\n    \"description\": \"Formula copied success message\"\n  },\n  \"formula_copy_failed\": {\n    \"message\": \"✗ Falha ao copiar\",\n    \"description\": \"Formula copy failed message\"\n  },\n  \"formulaCopyFormat\": {\n    \"message\": \"Formato de cópia de fórmula\",\n    \"description\": \"Formula copy format setting label\"\n  },\n  \"formulaCopyFormatLatex\": {\n    \"message\": \"LaTeX\",\n    \"description\": \"LaTeX format option\"\n  },\n  \"formulaCopyFormatUnicodeMath\": {\n    \"message\": \"MathML (Word)\",\n    \"description\": \"Label for UnicodeMath formula copy format option\"\n  },\n  \"formulaCopyFormatHint\": {\n    \"message\": \"Escolha o formato ao copiar fórmulas clicando\",\n    \"description\": \"Formula copy format hint\"\n  },\n  \"formulaCopyFormatNoDollar\": {\n    \"message\": \"LaTeX (Sem cifrão)\",\n    \"description\": \"LaTeX format option without dollar signs\"\n  },\n  \"folder_filter_current_user\": {\n    \"message\": \"Isolamento de conta\",\n    \"description\": \"Show only conversations from the current Google account\"\n  },\n  \"folder_export\": {\n    \"message\": \"Exportar pastas\",\n    \"description\": \"Export folders button tooltip\"\n  },\n  \"folder_import_export\": {\n    \"message\": \"Importar/Exportar pastas\",\n    \"description\": \"Combined import/export button tooltip\"\n  },\n  \"folder_cloud_upload\": {\n    \"message\": \"Enviar para nuvem\",\n    \"description\": \"Cloud upload button tooltip\"\n  },\n  \"folder_cloud_sync\": {\n    \"message\": \"Sincronizar da nuvem\",\n    \"description\": \"Cloud sync button tooltip\"\n  },\n  \"folder_import\": {\n    \"message\": \"Importar pastas\",\n    \"description\": \"Import folders button tooltip\"\n  },\n  \"folder_import_title\": {\n    \"message\": \"Importar Configuração de Pasta\",\n    \"description\": \"Import dialog title\"\n  },\n  \"folder_import_strategy\": {\n    \"message\": \"Estratégia de importação:\",\n    \"description\": \"Import strategy label\"\n  },\n  \"folder_import_merge\": {\n    \"message\": \"Mesclar com pastas existentes\",\n    \"description\": \"Merge import strategy\"\n  },\n  \"folder_import_overwrite\": {\n    \"message\": \"Substituir pastas existentes\",\n    \"description\": \"Overwrite import strategy\"\n  },\n  \"folder_import_select_file\": {\n    \"message\": \"Selecione um arquivo JSON para importar\",\n    \"description\": \"Import file selection prompt\"\n  },\n  \"folder_import_success\": {\n    \"message\": \"✓ Importado(s) {folders} pasta(s), {conversations} conversa(s)\",\n    \"description\": \"Import success message\"\n  },\n  \"folder_import_success_skipped\": {\n    \"message\": \"✓ Importado(s) {folders} pasta(s), {conversations} conversa(s) ({skipped} duplicatas ignoradas)\",\n    \"description\": \"Import success with skipped message\"\n  },\n  \"folder_import_error\": {\n    \"message\": \"✗ Falha na importação: {error}\",\n    \"description\": \"Import error message\"\n  },\n  \"folder_export_success\": {\n    \"message\": \"✓ Pastas exportadas com sucesso\",\n    \"description\": \"Export success message\"\n  },\n  \"folder_import_invalid_format\": {\n    \"message\": \"Formato de arquivo inválido. Por favor, selecione um arquivo de configuração de pasta válido.\",\n    \"description\": \"Invalid format error\"\n  },\n  \"folder_import_confirm_overwrite\": {\n    \"message\": \"Isso substituirá todas as pastas existentes. Um backup será criado. Continuar?\",\n    \"description\": \"Overwrite confirmation\"\n  },\n  \"folder_import_paste_json\": {\n    \"message\": \"Ou colar JSON diretamente\",\n    \"description\": \"Botão para mostrar a área de colagem no diálogo de importação\"\n  },\n  \"folder_import_paste_placeholder\": {\n    \"message\": \"Cole seu JSON aqui...\",\n    \"description\": \"Texto de espaço reservado para a área de colagem\"\n  },\n  \"export_dialog_title\": {\n    \"message\": \"Exportar Conversa\",\n    \"description\": \"Export dialog title\"\n  },\n  \"export_dialog_select\": {\n    \"message\": \"Escolha o formato de exportação:\",\n    \"description\": \"Export format selection label\"\n  },\n  \"export_dialog_warning\": {\n    \"message\": \"⚠️ Após clicar em exportar, o sistema saltará automaticamente para a primeira mensagem para carregar todo o conteúdo. Não realize nenhuma operação; a exportação continuará automaticamente após o salto.\",\n    \"description\": \"Warning about auto-jump and loading completeness\"\n  },\n  \"export_dialog_safari_cmdp_hint\": {\n    \"message\": \"Dica Safari: Clique em 'Exportar' abaixo, aguarde um momento, depois pressione ⌘P e escolha 'Salvar como PDF'.\",\n    \"description\": \"Safari-specific hint for PDF export\"\n  },\n  \"export_toast_safari_pdf_ready\": {\n    \"message\": \"Agora você pode pressionar Command + P para exportar o PDF.\",\n    \"description\": \"Safari-specific toast shown after PDF export is prepared\"\n  },\n  \"export_dialog_safari_markdown_hint\": {\n    \"message\": \"Aviso: devido às limitações do Safari, não é possível extrair as imagens do histórico de conversa. Para uma exportação completa, use a exportação em PDF.\",\n    \"description\": \"Warning about image references in Safari markdown export\"\n  },\n  \"export_format_json_description\": {\n    \"message\": \"Formato legível por máquina para desenvolvedores\",\n    \"description\": \"Description for JSON export format\"\n  },\n  \"export_format_markdown_description\": {\n    \"message\": \"Formato de texto limpo e portátil (recomendado)\",\n    \"description\": \"Description for Markdown export format\"\n  },\n  \"export_format_pdf_description\": {\n    \"message\": \"Formato amigável para impressão via Salvar como PDF\",\n    \"description\": \"Description for PDF export format\"\n  },\n  \"editInputWidth\": {\n    \"message\": \"Largura da entrada de edição\",\n    \"description\": \"Edit input width adjustment label\"\n  },\n  \"editInputWidthNarrow\": {\n    \"message\": \"Estreita\",\n    \"description\": \"Narrow edit input width label\"\n  },\n  \"editInputWidthWide\": {\n    \"message\": \"Larga\",\n    \"description\": \"Wide edit input width label\"\n  },\n  \"timelineOptions\": {\n    \"message\": \"Opções da Linha do Tempo\",\n    \"description\": \"Timeline options section label\"\n  },\n  \"folderOptions\": {\n    \"message\": \"Opções de Pasta\",\n    \"description\": \"Folder options section label\"\n  },\n  \"enableFolderFeature\": {\n    \"message\": \"Habilitar recurso de pasta\",\n    \"description\": \"Enable or disable the folder feature\"\n  },\n  \"hideArchivedConversations\": {\n    \"message\": \"Ocultar conversas arquivadas\",\n    \"description\": \"Hide conversations that are in folders from the main list\"\n  },\n  \"conversation_star\": {\n    \"message\": \"Favoritar conversa\",\n    \"description\": \"Star conversation button tooltip\"\n  },\n  \"conversation_unstar\": {\n    \"message\": \"Desfavoritar conversa\",\n    \"description\": \"Unstar conversation button tooltip\"\n  },\n  \"conversation_untitled\": {\n    \"message\": \"Sem título\",\n    \"description\": \"Fallback title for conversations without a name\"\n  },\n  \"geminiOnlyNotice\": {\n    \"message\": \"Funciona no Gemini (incluindo Enterprise) e AI Studio por padrão. Adicione outros sites abaixo para habilitar o Gerenciador de Prompts lá.\",\n    \"description\": \"Notice that defaults are limited to Gemini/AI Studio\"\n  },\n  \"folderManager_dataLossWarning\": {\n    \"message\": \"⚠️ Aviso: Falha ao carregar dados da pasta. Suas pastas podem ter sido corrompidas. Verifique o console do navegador para detalhes e tente restaurar do backup, se disponível.\",\n    \"description\": \"Warning shown when folder data fails to load\"\n  },\n  \"backupOptions\": {\n    \"message\": \"Backup Automático\",\n    \"description\": \"Backup options section label\"\n  },\n  \"backupEnabled\": {\n    \"message\": \"Habilitar backup automático\",\n    \"description\": \"Enable auto backup toggle\"\n  },\n  \"backupNow\": {\n    \"message\": \"Fazer Backup Agora\",\n    \"description\": \"Backup now button\"\n  },\n  \"backupSelectFolder\": {\n    \"message\": \"Selecionar Pasta de Backup\",\n    \"description\": \"Select backup folder button\"\n  },\n  \"backupFolderSelected\": {\n    \"message\": \"Pasta de backup: {folder}\",\n    \"description\": \"Shows selected backup folder\"\n  },\n  \"backupIncludePrompts\": {\n    \"message\": \"Incluir prompts\",\n    \"description\": \"Include prompts in backup toggle\"\n  },\n  \"backupIncludeFolders\": {\n    \"message\": \"Incluir pastas\",\n    \"description\": \"Include folders in backup toggle\"\n  },\n  \"backupIntervalLabel\": {\n    \"message\": \"Intervalo de backup\",\n    \"description\": \"Backup interval label\"\n  },\n  \"backupIntervalManual\": {\n    \"message\": \"Apenas manual\",\n    \"description\": \"Manual backup mode\"\n  },\n  \"backupIntervalDaily\": {\n    \"message\": \"Diário\",\n    \"description\": \"Daily backup interval\"\n  },\n  \"backupIntervalWeekly\": {\n    \"message\": \"Semanal (7 dias)\",\n    \"description\": \"Weekly backup interval\"\n  },\n  \"backupLastBackup\": {\n    \"message\": \"Último backup: {time}\",\n    \"description\": \"Last backup timestamp\"\n  },\n  \"backupNever\": {\n    \"message\": \"Nunca\",\n    \"description\": \"Never backed up\"\n  },\n  \"backupSuccess\": {\n    \"message\": \"✓ Backup criado: {prompts} prompts, {folders} pastas, {conversations} conversas\",\n    \"description\": \"Backup success message\"\n  },\n  \"backupError\": {\n    \"message\": \"✗ Falha no backup: {error}\",\n    \"description\": \"Backup error message\"\n  },\n  \"backupNotSupported\": {\n    \"message\": \"⚠️ O backup automático requer um navegador moderno com suporte à API File System Access\",\n    \"description\": \"Browser not supported message\"\n  },\n  \"backupSelectFolderFirst\": {\n    \"message\": \"Selecione uma pasta de backup primeiro\",\n    \"description\": \"No folder selected warning\"\n  },\n  \"backupUserCancelled\": {\n    \"message\": \"Backup cancelado\",\n    \"description\": \"User cancelled folder selection\"\n  },\n  \"backupConfigSaved\": {\n    \"message\": \"✓ Configurações de backup salvas\",\n    \"description\": \"Config saved message\"\n  },\n  \"backupPermissionDenied\": {\n    \"message\": \"⚠️ Não é possível acessar este diretório. Escolha um local diferente (por exemplo, Documentos, Downloads ou uma pasta na Área de Trabalho)\",\n    \"description\": \"Permission denied for restricted directory\"\n  },\n  \"backupConfigureInOptions\": {\n    \"message\": \"A configuração de backup requer a página de Opções. Clique no botão abaixo para abri-la (clique nos três pontos ao lado do ícone da extensão → Opções).\",\n    \"description\": \"Backup configure in options page explanation\"\n  },\n  \"openOptionsPage\": {\n    \"message\": \"Abrir Página de Opções\",\n    \"description\": \"Open options page button\"\n  },\n  \"optionsPageSubtitle\": {\n    \"message\": \"Opções da Extensão\",\n    \"description\": \"Options page subtitle\"\n  },\n  \"optionsComingSoon\": {\n    \"message\": \"Mais opções em breve...\",\n    \"description\": \"Options page placeholder text\"\n  },\n  \"backupDataAccessNotice\": {\n    \"message\": \"Limitação de Acesso a Dados\",\n    \"description\": \"Backup data access limitation title\"\n  },\n  \"backupDataAccessHint\": {\n    \"message\": \"Devido a restrições de segurança do navegador, esta página de Opções não pode acessar diretamente os prompts e dados de pasta das páginas do Gemini. Use o Gerenciador de Prompts e os recursos de Exportação de Pasta na página do Gemini para backups manuais.\",\n    \"description\": \"Backup data access limitation explanation\"\n  },\n  \"promptManagerOptions\": {\n    \"message\": \"Gerenciador de Prompts\",\n    \"description\": \"Prompt Manager options section label\"\n  },\n  \"hidePromptManager\": {\n    \"message\": \"Ocultar Gerenciador de Prompts\",\n    \"description\": \"Hide the prompt manager floating ball\"\n  },\n  \"hidePromptManagerHint\": {\n    \"message\": \"Ocultar o botão flutuante do Gerenciador de Prompts na página\",\n    \"description\": \"Hint for hiding prompt manager feature\"\n  },\n  \"customWebsites\": {\n    \"message\": \"Sites personalizados\",\n    \"description\": \"Custom websites for prompt manager\"\n  },\n  \"customWebsitesPlaceholder\": {\n    \"message\": \"Digite a URL do site (ex: chatgpt.com)\",\n    \"description\": \"Custom websites input placeholder\"\n  },\n  \"customWebsitesHint\": {\n    \"message\": \"Adicione sites onde você deseja usar o Gerenciador de Prompts. Apenas o Gerenciador de Prompts será ativado nesses sites.\",\n    \"description\": \"Custom websites hint\"\n  },\n  \"addWebsite\": {\n    \"message\": \"Adicionar site\",\n    \"description\": \"Add website button\"\n  },\n  \"removeWebsite\": {\n    \"message\": \"Remover\",\n    \"description\": \"Remove website button\"\n  },\n  \"invalidUrl\": {\n    \"message\": \"Formato de URL inválido\",\n    \"description\": \"Invalid URL error message\"\n  },\n  \"websiteAdded\": {\n    \"message\": \"Site adicionado\",\n    \"description\": \"Website added success message\"\n  },\n  \"websiteRemoved\": {\n    \"message\": \"Site removido\",\n    \"description\": \"Website removed success message\"\n  },\n  \"permissionDenied\": {\n    \"message\": \"Permissão negada. Por favor, permita o acesso a este site.\",\n    \"description\": \"Permission denied error message\"\n  },\n  \"permissionRequestFailed\": {\n    \"message\": \"Solicitação de permissão falhou. Tente novamente ou conceda acesso nas configurações da extensão.\",\n    \"description\": \"Permission request failure message\"\n  },\n  \"customWebsitesNote\": {\n    \"message\": \"Dica: Você será solicitado a permitir o acesso ao adicionar um site. Após conceder permissão, recarregue esse site para que o Gerenciador de Prompts possa iniciar.\",\n    \"description\": \"Custom websites reload/permission note\"\n  },\n  \"starredHistory\": {\n    \"message\": \"Histórico Favorito\",\n    \"description\": \"Starred history title\"\n  },\n  \"viewStarredHistory\": {\n    \"message\": \"Ver Histórico Favorito\",\n    \"description\": \"View starred history button\"\n  },\n  \"noStarredMessages\": {\n    \"message\": \"Nenhuma mensagem favorita ainda\",\n    \"description\": \"No starred messages placeholder\"\n  },\n  \"removeFromStarred\": {\n    \"message\": \"Remover dos favoritos\",\n    \"description\": \"Remove from starred tooltip\"\n  },\n  \"justNow\": {\n    \"message\": \"Agora mesmo\",\n    \"description\": \"Just now time label\"\n  },\n  \"hoursAgo\": {\n    \"message\": \"horas atrás\",\n    \"description\": \"Hours ago label\"\n  },\n  \"yesterday\": {\n    \"message\": \"Ontem\",\n    \"description\": \"Yesterday date label\"\n  },\n  \"daysAgo\": {\n    \"message\": \"dias atrás\",\n    \"description\": \"Days ago label\"\n  },\n  \"loading\": {\n    \"message\": \"Carregando...\",\n    \"description\": \"Loading message\"\n  },\n  \"keyboardShortcuts\": {\n    \"message\": \"Atalhos de Teclado\",\n    \"description\": \"Keyboard shortcuts section title\"\n  },\n  \"enableShortcuts\": {\n    \"message\": \"Habilitar atalhos de teclado\",\n    \"description\": \"Enable shortcuts toggle label\"\n  },\n  \"previousNode\": {\n    \"message\": \"Nó anterior\",\n    \"description\": \"Previous timeline node shortcut label\"\n  },\n  \"nextNode\": {\n    \"message\": \"Próximo nó\",\n    \"description\": \"Next timeline node shortcut label\"\n  },\n  \"shortcutKey\": {\n    \"message\": \"Tecla\",\n    \"description\": \"Shortcut key label\"\n  },\n  \"shortcutModifiers\": {\n    \"message\": \"Modificadores\",\n    \"description\": \"Shortcut modifiers label\"\n  },\n  \"resetShortcuts\": {\n    \"message\": \"Redefinir para padrões\",\n    \"description\": \"Reset shortcuts button\"\n  },\n  \"shortcutsResetSuccess\": {\n    \"message\": \"Atalhos redefinidos\",\n    \"description\": \"Shortcuts reset success message\"\n  },\n  \"shortcutsDescription\": {\n    \"message\": \"Navegar pelos nós da linha do tempo usando atalhos de teclado\",\n    \"description\": \"Shortcuts description\"\n  },\n  \"modifierNone\": {\n    \"message\": \"Nenhum\",\n    \"description\": \"No modifier key\"\n  },\n  \"modifierAlt\": {\n    \"message\": \"Alt\",\n    \"description\": \"Alt modifier key\"\n  },\n  \"modifierCtrl\": {\n    \"message\": \"Ctrl\",\n    \"description\": \"Ctrl modifier key\"\n  },\n  \"modifierShift\": {\n    \"message\": \"Shift\",\n    \"description\": \"Shift modifier key\"\n  },\n  \"modifierMeta\": {\n    \"message\": \"Cmd/Win\",\n    \"description\": \"Meta/Command/Windows modifier key\"\n  },\n  \"preventAutoScroll\": {\n    \"message\": \"Prevenir rolamento automático\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"preventAutoScrollHint\": {\n    \"message\": \"Impede o Gemini de pular para o final quando você aperta Enter enquanto lê respostas anteriores.\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"cloudSync\": {\n    \"message\": \"Sincronização na Nuvem\",\n    \"description\": \"Cloud sync section title\"\n  },\n  \"cloudSyncDescription\": {\n    \"message\": \"Sincronizar pastas e prompts com o Google Drive\",\n    \"description\": \"Cloud sync description\"\n  },\n  \"syncModeDisabled\": {\n    \"message\": \"Desativado\",\n    \"description\": \"Sync mode disabled option\"\n  },\n  \"syncModeManual\": {\n    \"message\": \"Manual\",\n    \"description\": \"Sync mode manual option\"\n  },\n  \"syncServerPort\": {\n    \"message\": \"Porta do servidor\",\n    \"description\": \"Port number for the sync server\"\n  },\n  \"syncNow\": {\n    \"message\": \"Sincronizar Agora\",\n    \"description\": \"Sync now button\"\n  },\n  \"lastSynced\": {\n    \"message\": \"Última sincr.: {time}\",\n    \"description\": \"Last sync timestamp\"\n  },\n  \"neverSynced\": {\n    \"message\": \"Nunca sincronizado\",\n    \"description\": \"Never synced message\"\n  },\n  \"lastUploaded\": {\n    \"message\": \"Enviado: {time}\",\n    \"description\": \"Last upload timestamp\"\n  },\n  \"neverUploaded\": {\n    \"message\": \"Nunca enviado\",\n    \"description\": \"Never uploaded message\"\n  },\n  \"syncSuccess\": {\n    \"message\": \"✓ Sincronizado com sucesso\",\n    \"description\": \"Sync success message\"\n  },\n  \"syncError\": {\n    \"message\": \"✗ Falha na sincronização: {error}\",\n    \"description\": \"Sync error message\"\n  },\n  \"syncInProgress\": {\n    \"message\": \"Sincronizando...\",\n    \"description\": \"Sync in progress message\"\n  },\n  \"uploadInProgress\": {\n    \"message\": \"Uploading...\",\n    \"description\": \"Upload in progress message\"\n  },\n  \"uploadSuccess\": {\n    \"message\": \"✓ Uploaded successfully\",\n    \"description\": \"Upload success message\"\n  },\n  \"downloadInProgress\": {\n    \"message\": \"Downloading...\",\n    \"description\": \"Download in progress message\"\n  },\n  \"downloadMergeSuccess\": {\n    \"message\": \"✓ Downloaded and merged\",\n    \"description\": \"Download and merge success message\"\n  },\n  \"syncUpload\": {\n    \"message\": \"Enviar\",\n    \"description\": \"Upload button label\"\n  },\n  \"syncMerge\": {\n    \"message\": \"Mesclar\",\n    \"description\": \"Merge button label\"\n  },\n  \"syncMode\": {\n    \"message\": \"Modo Sincr.\",\n    \"description\": \"Sync mode label\"\n  },\n  \"minutesAgo\": {\n    \"message\": \"minutos atrás\",\n    \"description\": \"Minutes ago label\"\n  },\n  \"syncNoData\": {\n    \"message\": \"Nenhum dado de sincronização encontrado no Drive\",\n    \"description\": \"Error message when no data is found in Drive\"\n  },\n  \"syncUploadFailed\": {\n    \"message\": \"Falha no envio\",\n    \"description\": \"Error message when upload fails\"\n  },\n  \"syncDownloadFailed\": {\n    \"message\": \"Falha no download\",\n    \"description\": \"Error message when download fails\"\n  },\n  \"syncAuthFailed\": {\n    \"message\": \"Falha na autenticação\",\n    \"description\": \"Error message when authentication fails\"\n  },\n  \"signOut\": {\n    \"message\": \"Sair\",\n    \"description\": \"Sign out button\"\n  },\n  \"signInWithGoogle\": {\n    \"message\": \"Entrar com Google\",\n    \"description\": \"Sign in with Google button\"\n  },\n  \"nanobananaOptions\": {\n    \"message\": \"Opções NanoBanana\",\n    \"description\": \"NanoBanana options section label\"\n  },\n  \"enableNanobananaWatermarkRemover\": {\n    \"message\": \"Remover marca d'água NanoBanana\",\n    \"description\": \"Enable automatic watermark removal for NanoBanana images\"\n  },\n  \"nanobananaWatermarkRemoverHint\": {\n    \"message\": \"Remove automaticamente marcas d'água do Gemini visíveis em imagens geradas\",\n    \"description\": \"Watermark remover feature hint\"\n  },\n  \"nanobananaDownloadTooltip\": {\n    \"message\": \"Baixar imagem sem marca d'água (NanoBanana)\",\n    \"description\": \"Download button tooltip\"\n  },\n  \"deepResearchDownload\": {\n    \"message\": \"Baixar conteúdo de pensamento\",\n    \"description\": \"Deep Research download button text\"\n  },\n  \"deepResearchDownloadTooltip\": {\n    \"message\": \"Baixar conteúdo de pensamento como Markdown\",\n    \"description\": \"Deep Research download button tooltip\"\n  },\n  \"folder_uncategorized\": {\n    \"message\": \"Sem categoria\",\n    \"description\": \"Label for root-level conversations not in any folder\"\n  },\n  \"timelineLevelTitle\": {\n    \"message\": \"Nível de Nó\",\n    \"description\": \"Title for timeline node level context menu\"\n  },\n  \"timelineLevel1\": {\n    \"message\": \"Nível 1\",\n    \"description\": \"Timeline node level 1 option\"\n  },\n  \"timelineLevel2\": {\n    \"message\": \"Nível 2\",\n    \"description\": \"Timeline node level 2 option\"\n  },\n  \"timelineLevel3\": {\n    \"message\": \"Nível 3\",\n    \"description\": \"Timeline node level 3 option\"\n  },\n  \"timelineCollapse\": {\n    \"message\": \"Recolher\",\n    \"description\": \"Collapse children\"\n  },\n  \"timelineExpand\": {\n    \"message\": \"Expandir\",\n    \"description\": \"Expand children\"\n  },\n  \"timelinePreviewSearch\": {\n    \"message\": \"Pesquisar...\",\n    \"description\": \"Timeline preview panel search placeholder\"\n  },\n  \"timelinePreviewNoResults\": {\n    \"message\": \"Sem resultados\",\n    \"description\": \"Timeline preview panel no search results\"\n  },\n  \"timelinePreviewNoMessages\": {\n    \"message\": \"Sem mensagens\",\n    \"description\": \"Timeline preview panel no messages\"\n  },\n  \"quoteReply\": {\n    \"message\": \"Responder Citação\",\n    \"description\": \"Quote reply button text\"\n  },\n  \"inputCollapseOptions\": {\n    \"message\": \"Opções de entrada\",\n    \"description\": \"Input options section label\"\n  },\n  \"enableInputCollapse\": {\n    \"message\": \"Habilitar contração de entrada\",\n    \"description\": \"Enable input collapse toggle label\"\n  },\n  \"enableInputCollapseHint\": {\n    \"message\": \"Contrai a área de entrada quando vazia para ganhar mais espaço de leitura\",\n    \"description\": \"Input collapse feature hint\"\n  },\n  \"inputCollapseShortcutHint\": {\n    \"message\": \"{modifier}+I para expandir\",\n    \"description\": \"Shortcut hint for expanding collapsed input. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"inputCollapsePlaceholder\": {\n    \"message\": \"Mensagem para o Gemini\",\n    \"description\": \"Placeholder text shown in collapsed input\"\n  },\n  \"allowCollapseWhenNotEmpty\": {\n    \"message\": \"Permitir recolher com conteudo\",\n    \"description\": \"Allow input to collapse even when it has text or attachments\"\n  },\n  \"allowCollapseWhenNotEmptyHint\": {\n    \"message\": \"Recolher a area de entrada mesmo quando contem texto ou anexos\",\n    \"description\": \"Hint text explaining the collapse when not empty feature\"\n  },\n  \"ctrlEnterSend\": {\n    \"message\": \"{modifier}+Enter para enviar\",\n    \"description\": \"Modifier+Enter to send toggle label. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"ctrlEnterSendHint\": {\n    \"message\": \"Pressione {modifier}+Enter para enviar mensagens, Enter para nova linha\",\n    \"description\": \"Modifier+Enter to send feature hint. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"batch_delete_confirm\": {\n    \"message\": \"Tem certeza de que deseja excluir {count} conversa(s)? Esta ação não pode ser desfeita.\",\n    \"description\": \"Batch delete confirmation message\"\n  },\n  \"batch_delete_in_progress\": {\n    \"message\": \"Excluindo... ({current}/{total})\",\n    \"description\": \"Batch delete progress message\"\n  },\n  \"batch_delete_success\": {\n    \"message\": \"✓ {count} conversa(s) excluída(s)\",\n    \"description\": \"Batch delete success message\"\n  },\n  \"batch_delete_partial\": {\n    \"message\": \"Exclusão concluída: {success} com sucesso, {failed} falhas\",\n    \"description\": \"Batch delete partial success message\"\n  },\n  \"batch_delete_limit_reached\": {\n    \"message\": \"No máximo {max} conversas podem ser selecionadas de uma vez\",\n    \"description\": \"Batch delete limit warning\"\n  },\n  \"batch_delete_button\": {\n    \"message\": \"Excluir selecionados\",\n    \"description\": \"Batch delete button tooltip in multi-select mode\"\n  },\n  \"batch_delete_match_patterns\": {\n    \"message\": \"delete,confirm,yes,ok,remove,excluir,confirmar,sim,aceitar,apagar\",\n    \"description\": \"Comma-separated list of keywords to match native delete/confirm buttons (case-insensitive)\"\n  },\n  \"generalOptions\": {\n    \"message\": \"Opções Gerais\",\n    \"description\": \"General options section label\"\n  },\n  \"enableTabTitleUpdate\": {\n    \"message\": \"Sincronizar título da guia com conversa\",\n    \"description\": \"Enable tab title sync toggle label\"\n  },\n  \"enableTabTitleUpdateHint\": {\n    \"message\": \"Atualiza automaticamente o título da guia do navegador para corresponder ao título da conversa atual\",\n    \"description\": \"Tab title sync feature hint\"\n  },\n  \"enableMermaidRendering\": {\n    \"message\": \"Habilitar renderização de diagramas Mermaid\",\n    \"description\": \"Enable Mermaid diagram rendering toggle label\"\n  },\n  \"enableMermaidRenderingHint\": {\n    \"message\": \"Renderiza automaticamente diagramas Mermaid em blocos de código\",\n    \"description\": \"Mermaid rendering feature hint\"\n  },\n  \"enableQuoteReply\": {\n    \"message\": \"Habilitar resposta com citação\",\n    \"description\": \"Enable quote reply toggle label\"\n  },\n  \"enableQuoteReplyHint\": {\n    \"message\": \"Mostra um botão flutuante para citar o texto selecionado em conversas\",\n    \"description\": \"Quote reply feature hint\"\n  },\n  \"contextSync\": {\n    \"message\": \"Sincronização de Contexto\",\n    \"description\": \"Context Sync title\"\n  },\n  \"contextSyncDescription\": {\n    \"message\": \"Sincronize o contexto do chat com seu IDE local\",\n    \"description\": \"Context Sync description\"\n  },\n  \"syncToIDE\": {\n    \"message\": \"Sincronizar com IDE\",\n    \"description\": \"Sync to IDE button\"\n  },\n  \"ideOnline\": {\n    \"message\": \"IDE Online\",\n    \"description\": \"IDE Online status\"\n  },\n  \"ideOffline\": {\n    \"message\": \"IDE Offline\",\n    \"description\": \"IDE Offline status\"\n  },\n  \"syncing\": {\n    \"message\": \"Sincronizando...\",\n    \"description\": \"Syncing status\"\n  },\n  \"checkServer\": {\n    \"message\": \"Por favor, inicie o servidor AI Sync no VS Code\",\n    \"description\": \"Check server hint\"\n  },\n  \"capturing\": {\n    \"message\": \"Capturando diálogo...\",\n    \"description\": \"Capturing status\"\n  },\n  \"syncedSuccess\": {\n    \"message\": \"Sincronizado com sucesso!\",\n    \"description\": \"Synced success message\"\n  },\n  \"downloadingOriginal\": {\n    \"message\": \"Baixando imagem original\",\n    \"description\": \"Status when downloading original image\"\n  },\n  \"downloadingOriginalLarge\": {\n    \"message\": \"Baixando imagem original (arquivo grande)\",\n    \"description\": \"Status when downloading large original image\"\n  },\n  \"downloadLargeWarning\": {\n    \"message\": \"Aviso de arquivo grande\",\n    \"description\": \"Warning shown for large downloads\"\n  },\n  \"downloadProcessing\": {\n    \"message\": \"Processando marca d'água\",\n    \"description\": \"Status when processing watermark removal\"\n  },\n  \"downloadSuccess\": {\n    \"message\": \"Baixando...\",\n    \"description\": \"Status when download starts\"\n  },\n  \"downloadError\": {\n    \"message\": \"Falha\",\n    \"description\": \"Status prefix when download fails\"\n  },\n  \"recentsHide\": {\n    \"message\": \"Ocultar itens recentes\",\n    \"description\": \"Dica para ocultar a seção de itens recentes\"\n  },\n  \"recentsShow\": {\n    \"message\": \"Mostrar itens recentes\",\n    \"description\": \"Dica para mostrar a seção de itens recentes\"\n  },\n  \"gemsHide\": {\n    \"message\": \"Ocultar Gems\",\n    \"description\": \"Dica para ocultar a seção da lista de Gems\"\n  },\n  \"gemsShow\": {\n    \"message\": \"Mostrar Gems\",\n    \"description\": \"Dica para mostrar a seção da lista de Gems\"\n  },\n  \"setAsDefaultModel\": {\n    \"message\": \"Set as default model\",\n    \"description\": \"Tooltip for setting default model\"\n  },\n  \"cancelDefaultModel\": {\n    \"message\": \"Cancel default model\",\n    \"description\": \"Tooltip for cancelling default model\"\n  },\n  \"defaultModelSet\": {\n    \"message\": \"Default model set: $1\",\n    \"description\": \"Toast message when default model is set\"\n  },\n  \"defaultModelCleared\": {\n    \"message\": \"Default model cleared\",\n    \"description\": \"Toast message when default model is cleared\"\n  },\n  \"sidebarAutoHide\": {\n    \"message\": \"Ocultar barra lateral auto\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"sidebarAutoHideHint\": {\n    \"message\": \"Contrai automaticamente a barra lateral quando o mouse sai, expande quando entra\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"sidebarFullHide\": {\n    \"message\": \"Ocultar barra lateral completa\",\n    \"description\": \"Fully hide sidebar toggle label\"\n  },\n  \"sidebarFullHideHint\": {\n    \"message\": \"Ocultar completamente a barra lateral recolhida, passar o mouse na borda esquerda para mostrar\",\n    \"description\": \"Fully hide sidebar feature hint\"\n  },\n  \"folderSpacing\": {\n    \"message\": \"Espaçamento de pastas\",\n    \"description\": \"Folder spacing adjustment label\"\n  },\n  \"folderSpacingCompact\": {\n    \"message\": \"Compacto\",\n    \"description\": \"Compact folder spacing label\"\n  },\n  \"folderSpacingSpacious\": {\n    \"message\": \"Espaçoso\",\n    \"description\": \"Spacious folder spacing label\"\n  },\n  \"folderTreeIndent\": {\n    \"message\": \"Recuo de subpastas\",\n    \"description\": \"Subfolder tree indentation adjustment label\"\n  },\n  \"folderTreeIndentCompact\": {\n    \"message\": \"Estreito\",\n    \"description\": \"Narrow subfolder indentation label\"\n  },\n  \"folderTreeIndentSpacious\": {\n    \"message\": \"Largo\",\n    \"description\": \"Wide subfolder indentation label\"\n  },\n  \"export_select_mode_select_all\": {\n    \"message\": \"Selecionar tudo\",\n    \"description\": \"Selection mode select all toggle\"\n  },\n  \"export_select_mode_count\": {\n    \"message\": \"Selecionados {count}\",\n    \"description\": \"Selection mode selected message count\"\n  },\n  \"export_select_mode_toggle\": {\n    \"message\": \"Select message\",\n    \"description\": \"Selection mode per-message toggle tooltip\"\n  },\n  \"export_select_mode_select_below\": {\n    \"message\": \"Select messages below\",\n    \"description\": \"Selection mode top-left button to select messages below current line\"\n  },\n  \"export_select_mode_empty\": {\n    \"message\": \"Please select at least one message to export.\",\n    \"description\": \"Selection mode validation when nothing is selected\"\n  },\n  \"export_format_image_description\": {\n    \"message\": \"Imagem PNG única, ideal para compartilhamento no celular.\",\n    \"description\": \"Description for Image export format\"\n  },\n  \"export_image_progress\": {\n    \"message\": \"Generating image...\",\n    \"description\": \"Progress message while generating image export\"\n  },\n  \"deepResearchSaveReport\": {\n    \"message\": \"Save Report\",\n    \"description\": \"Deep Research save report button\"\n  },\n  \"deepResearchSaveReportTooltip\": {\n    \"message\": \"Export this research report\",\n    \"description\": \"Deep Research save report tooltip\"\n  },\n  \"export_fontsize_label\": {\n    \"message\": \"Tamanho da fonte\",\n    \"description\": \"Label for font size slider in export dialog\"\n  },\n  \"export_fontsize_preview\": {\n    \"message\": \"A rápida raposa marrom salta sobre o cão preguiçoso.\",\n    \"description\": \"Preview text for font size slider in export dialog\"\n  },\n  \"export_error_generic\": {\n    \"message\": \"Falha na exportação: {error}\",\n    \"description\": \"Mensagem genérica de falha na exportação com detalhe\"\n  },\n  \"export_error_refresh_retry\": {\n    \"message\": \"A exportação da imagem falhou devido a problemas de rede. Atualize a página e tente novamente.\",\n    \"description\": \"Orientação exibida quando a exportação de imagem falha por carregamento temporário\"\n  },\n  \"export_md_include_source_confirm\": {\n    \"message\": \"O conteúdo exportado contém imagens de pesquisa na web. Incluir links de origem das imagens no Markdown?\",\n    \"description\": \"Confirm dialog when exporting markdown with web search images\"\n  },\n  \"deepResearch_exportedAt\": {\n    \"message\": \"Exportado em\",\n    \"description\": \"Etiqueta de carimbo de data/hora de exportação de Deep Research\"\n  },\n  \"deepResearch_totalPhases\": {\n    \"message\": \"Fases totais\",\n    \"description\": \"Etiqueta do total de fases de pensamento de Deep Research\"\n  },\n  \"deepResearch_thinkingPhase\": {\n    \"message\": \"Fase de pensamento\",\n    \"description\": \"Cabeçalho de fase de pensamento de Deep Research\"\n  },\n  \"deepResearch_researchedWebsites\": {\n    \"message\": \"Sites pesquisados\",\n    \"description\": \"Cabeçalho de secção de sites pesquisados de Deep Research\"\n  },\n  \"visualEffect\": {\n    \"message\": \"Efeitos visuais\",\n    \"description\": \"Visual effect selector label\"\n  },\n  \"visualEffectHint\": {\n    \"message\": \"Adicione um clima sazonal a pagina\",\n    \"description\": \"Visual effect feature hint\"\n  },\n  \"visualEffectOff\": {\n    \"message\": \"Desligado\",\n    \"description\": \"Visual effect off option\"\n  },\n  \"visualEffectSnow\": {\n    \"message\": \"Neve\",\n    \"description\": \"Visual effect snow option\"\n  },\n  \"visualEffectSakura\": {\n    \"message\": \"Sakura\",\n    \"description\": \"Visual effect sakura option\"\n  },\n  \"visualEffectRain\": {\n    \"message\": \"Chuva\",\n    \"description\": \"Visual effect rain option\"\n  },\n  \"changelog_title\": {\n    \"message\": \"Novidades\",\n    \"description\": \"Changelog modal title\"\n  },\n  \"changelog_close\": {\n    \"message\": \"Entendi\",\n    \"description\": \"Changelog modal close button\"\n  },\n  \"changelog_docs_link\": {\n    \"message\": \"Docs\",\n    \"description\": \"Changelog modal documentation link text\"\n  },\n  \"changelog_recommendation\": {\n    \"message\": \"Se o Voyager foi útil, compartilhe com amigos ou nas redes sociais — marque o autor!\",\n    \"description\": \"Changelog modal recommendation message\"\n  },\n  \"changelog_social_xiaohongshu\": {\n    \"message\": \"Xiaohongshu\",\n    \"description\": \"Xiaohongshu platform name for social links\"\n  },\n  \"changelog_social_zhihu\": {\n    \"message\": \"Zhihu\",\n    \"description\": \"Zhihu platform name for social links\"\n  },\n  \"changelog_docs_hint\": {\n    \"message\": \"Detalhes nos docs\",\n    \"description\": \"Changelog annotation pointing to docs icon\"\n  },\n  \"changelog_rate_chrome\": {\n    \"message\": \"Gosta do Voyager? Uma avaliação na Chrome Web Store ajuda outros a descobri-lo!\",\n    \"description\": \"Chrome Web Store rating prompt in changelog modal\"\n  },\n  \"changelog_rate_chrome_cta\": {\n    \"message\": \"Avaliar\",\n    \"description\": \"Chrome Web Store rating CTA button text\"\n  },\n  \"changelog_badge_mode\": {\n    \"message\": \"Notificar com um selo NEW no botão flutuante em vez desta janela\",\n    \"description\": \"Changelog notification mode toggle label\"\n  },\n  \"forkConversation\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork conversation button label\"\n  },\n  \"forkConfirm\": {\n    \"message\": \"Fork conversation from this point?\",\n    \"description\": \"Fork confirmation prompt\"\n  },\n  \"forkConfirmBtn\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork confirmation button\"\n  },\n  \"forkCancel\": {\n    \"message\": \"Cancel\",\n    \"description\": \"Fork cancel button\"\n  },\n  \"forkBranch\": {\n    \"message\": \"Branch\",\n    \"description\": \"Fork branch label\"\n  },\n  \"forkOriginal\": {\n    \"message\": \"Original\",\n    \"description\": \"Original fork branch tag\"\n  },\n  \"forkCurrent\": {\n    \"message\": \"Current\",\n    \"description\": \"Current fork branch tag\"\n  },\n  \"forkPreparing\": {\n    \"message\": \"Preparing fork...\",\n    \"description\": \"Fork preparation status\"\n  },\n  \"forkPasteHint\": {\n    \"message\": \"Press Enter to send and create the fork\",\n    \"description\": \"Fork paste hint text\"\n  },\n  \"forkDeleteData\": {\n    \"message\": \"Excluir esta ramificação\",\n    \"description\": \"Delete fork branch metadata button\"\n  },\n  \"forkDeleteDataConfirm\": {\n    \"message\": \"Excluir este vínculo de ramificação? Isso remove apenas os metadados de ramificação do Voyager e não exclui nenhuma conversa.\",\n    \"description\": \"Fork metadata delete confirmation prompt\"\n  },\n  \"enableForkFeature\": {\n    \"message\": \"Ativar ramificação de conversa\",\n    \"description\": \"Enable conversation fork feature toggle label\"\n  },\n  \"enableForkFeatureHint\": {\n    \"message\": \"Mostrar botão Fork e indicadores de ramificação nas conversas do Gemini\",\n    \"description\": \"Enable conversation fork feature toggle hint\"\n  },\n  \"enableAccountIsolation\": {\n    \"message\": \"Enable Hard Isolation\",\n    \"description\": \"Enable hard isolation for account-scoped folder data and cloud sync.\"\n  },\n  \"enableAccountIsolationHint\": {\n    \"message\": \"Strictly isolate folder and cloud sync data by Google account to prevent cross-account mixing.\",\n    \"description\": \"Hint text for account isolation option.\"\n  },\n  \"currentPlatform\": {\n    \"message\": \"Current platform\",\n    \"description\": \"Current active platform label in popup.\"\n  },\n  \"platformGemini\": {\n    \"message\": \"Gemini\",\n    \"description\": \"Gemini platform label.\"\n  },\n  \"platformAIStudio\": {\n    \"message\": \"AI Studio\",\n    \"description\": \"AI Studio platform label.\"\n  },\n  \"enableOnAIStudio\": {\n    \"message\": \"Ativar no AI Studio\",\n    \"description\": \"Toggle to enable/disable Voyager on AI Studio\"\n  },\n  \"enableOnAIStudioHint\": {\n    \"message\": \"Desative para desabilitar as funcionalidades do Voyager no AI Studio (Prompt Manager permanece ativo)\",\n    \"description\": \"Hint for AI Studio enable toggle\"\n  },\n  \"export_select_mode_only_user\": {\n    \"message\": \"Selecionar apenas usuário\",\n    \"description\": \"Export selection mode: select only user messages\"\n  },\n  \"export_select_mode_only_ai\": {\n    \"message\": \"Selecionar apenas IA\",\n    \"description\": \"Export selection mode: select only AI messages\"\n  },\n  \"aiOrgCopyButton\": {\n    \"message\": \"Organizar com IA\",\n    \"description\": \"Botão para copiar a estrutura de pastas e conversas para organização com IA\"\n  },\n  \"aiOrgCopied\": {\n    \"message\": \"Copiado!\",\n    \"description\": \"Confirmação após copiar o prompt de organização com IA\"\n  },\n  \"aiOrgError\": {\n    \"message\": \"Falha — abra o Gemini primeiro\",\n    \"description\": \"Erro ao copiar o prompt de organização com IA\"\n  },\n  \"aiOrgCopyHint\": {\n    \"message\": \"Copia todas as conversas e a estrutura de pastas como um prompt. Cole no Gemini para obter um plano de pastas importável.\",\n    \"description\": \"Texto de dica abaixo do botão de cópia de organização com IA\"\n  },\n  \"aiOrgCurrentFolders\": {\n    \"message\": \"Estrutura de pastas atual\",\n    \"description\": \"Cabeçalho no prompt de organização com IA\"\n  },\n  \"aiOrgUnfiledConversations\": {\n    \"message\": \"Conversas não classificadas\",\n    \"description\": \"Cabeçalho para conversas que não estão em nenhuma pasta\"\n  },\n  \"aiOrgEmpty\": {\n    \"message\": \"vazia\",\n    \"description\": \"Rótulo para pastas vazias no prompt de organização com IA\"\n  },\n  \"aiOrgInstructions\": {\n    \"message\": \"Instruções\",\n    \"description\": \"Cabeçalho da seção de instruções para IA\"\n  },\n  \"aiOrgInstructionsBody\": {\n    \"message\": \"Com base nas conversas e na estrutura de pastas acima, reorganize-as em uma hierarquia lógica de pastas. Gere um arquivo JSON no formato exato abaixo que possa ser importado diretamente na extensão Gemini Voyager (use a estratégia de importação \\\"Mesclar\\\"). Mantenha as pastas bem organizadas existentes, crie novas conforme necessário e mova as conversas não classificadas para pastas apropriadas. Cada pasta precisa de um ID único (use uma string aleatória curta) e cada conversa deve manter seu conversationId e url originais.\",\n    \"description\": \"Corpo das instruções para a IA gerar uma estrutura de pastas importável\"\n  },\n  \"showMessageTimestamps\": {\n    \"message\": \"Mostrar carimbos de data/hora das mensagens\",\n    \"description\": \"Exibir carimbo de data/hora para cada mensagem\"\n  },\n  \"showMessageTimestampsHint\": {\n    \"message\": \"Mostrar quando cada mensagem foi enviada\",\n    \"description\": \"Dica para o recurso de carimbos de data/hora\"\n  },\n  \"moveSectionUp\": {\n    \"message\": \"Mover para cima\",\n    \"description\": \"Tooltip for moving a settings section up in popup\"\n  },\n  \"moveSectionDown\": {\n    \"message\": \"Mover para baixo\",\n    \"description\": \"Tooltip for moving a settings section down in popup\"\n  }\n}\n"
  },
  {
    "path": "src/locales/ru/messages.json",
    "content": "{\n  \"extName\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Extension name\"\n  },\n  \"extDescription\": {\n    \"message\": \"Улучшите опыт Gemini™: таймлайн, папки, промпты и экспорт чатов.\",\n    \"description\": \"Extension description\"\n  },\n  \"scrollMode\": {\n    \"message\": \"Режим прокрутки\",\n    \"description\": \"Scroll mode label\"\n  },\n  \"flow\": {\n    \"message\": \"Поток\",\n    \"description\": \"Flow mode\"\n  },\n  \"jump\": {\n    \"message\": \"Прыжок\",\n    \"description\": \"Jump mode\"\n  },\n  \"hideOuterContainer\": {\n    \"message\": \"Скрыть внешний контейнер\",\n    \"description\": \"Hide outer container label\"\n  },\n  \"draggableTimeline\": {\n    \"message\": \"Перетаскиваемый таймлайн\",\n    \"description\": \"Draggable Timeline label\"\n  },\n  \"enableMarkerLevel\": {\n    \"message\": \"Включить уровни узлов\",\n    \"description\": \"Enable timeline node level feature\"\n  },\n  \"enableMarkerLevelHint\": {\n    \"message\": \"Щелкните правой кнопкой мыши по узлам таймлайна, чтобы установить их уровень и свернуть дочерние элементы\",\n    \"description\": \"Node level feature hint\"\n  },\n  \"experimentalLabel\": {\n    \"message\": \"Экспериментально\",\n    \"description\": \"Experimental feature label\"\n  },\n  \"resetPosition\": {\n    \"message\": \"Сбросить позицию\",\n    \"description\": \"Reset Position button\"\n  },\n  \"resetTimelinePosition\": {\n    \"message\": \"Сбросить позицию таймлайна\",\n    \"description\": \"Reset timeline position button in timeline options\"\n  },\n  \"language\": {\n    \"message\": \"Язык\",\n    \"description\": \"Language label\"\n  },\n  \"pm_title\": {\n    \"message\": \"Voyager\",\n    \"description\": \"Prompt Manager title\"\n  },\n  \"pm_add\": {\n    \"message\": \"Добавить\",\n    \"description\": \"Add prompt button\"\n  },\n  \"pm_search_placeholder\": {\n    \"message\": \"Поиск промптов или тегов\",\n    \"description\": \"Search placeholder\"\n  },\n  \"pm_import\": {\n    \"message\": \"Импорт\",\n    \"description\": \"Import button\"\n  },\n  \"pm_export\": {\n    \"message\": \"Экспорт\",\n    \"description\": \"Export button\"\n  },\n  \"pm_prompt_placeholder\": {\n    \"message\": \"Текст промпта\",\n    \"description\": \"Prompt textarea placeholder\"\n  },\n  \"pm_tags_placeholder\": {\n    \"message\": \"Теги (через запятую)\",\n    \"description\": \"Tags input placeholder\"\n  },\n  \"pm_save\": {\n    \"message\": \"Сохранить\",\n    \"description\": \"Save prompt\"\n  },\n  \"pm_cancel\": {\n    \"message\": \"Отмена\",\n    \"description\": \"Cancel\"\n  },\n  \"pm_all_tags\": {\n    \"message\": \"Все\",\n    \"description\": \"All tags chip\"\n  },\n  \"pm_empty\": {\n    \"message\": \"Промптов пока нет\",\n    \"description\": \"Empty state\"\n  },\n  \"pm_copy\": {\n    \"message\": \"Копировать\",\n    \"description\": \"Copy prompt\"\n  },\n  \"pm_copied\": {\n    \"message\": \"Скопировано\",\n    \"description\": \"Copied notice\"\n  },\n  \"pm_delete\": {\n    \"message\": \"Удалить\",\n    \"description\": \"Delete prompt\"\n  },\n  \"pm_delete_confirm\": {\n    \"message\": \"Удалить этот промпт?\",\n    \"description\": \"Delete confirm\"\n  },\n  \"pm_lock\": {\n    \"message\": \"Заблокировать позицию\",\n    \"description\": \"Lock panel position\"\n  },\n  \"pm_unlock\": {\n    \"message\": \"Разблокировать позицию\",\n    \"description\": \"Unlock panel position\"\n  },\n  \"pm_import_invalid\": {\n    \"message\": \"Неверный формат файла\",\n    \"description\": \"Import invalid\"\n  },\n  \"pm_import_success\": {\n    \"message\": \"Импортировано {count} промптов\",\n    \"description\": \"Import success\"\n  },\n  \"pm_duplicate\": {\n    \"message\": \"Дублировать промпт\",\n    \"description\": \"Duplicate on add\"\n  },\n  \"pm_deleted\": {\n    \"message\": \"Удалено\",\n    \"description\": \"Deleted notice\"\n  },\n  \"pm_edit\": {\n    \"message\": \"Редактировать\",\n    \"description\": \"Edit prompt\"\n  },\n  \"pm_expand\": {\n    \"message\": \"Развернуть\",\n    \"description\": \"Expand prompt text\"\n  },\n  \"pm_collapse\": {\n    \"message\": \"Свернуть\",\n    \"description\": \"Collapse prompt text\"\n  },\n  \"pm_saved\": {\n    \"message\": \"Сохранено\",\n    \"description\": \"Saved notice\"\n  },\n  \"pm_settings\": {\n    \"message\": \"Настройки\",\n    \"description\": \"Settings button\"\n  },\n  \"pm_settings_tooltip\": {\n    \"message\": \"Открыть настройки расширения\",\n    \"description\": \"Settings button tooltip\"\n  },\n  \"pm_settings_fallback\": {\n    \"message\": \"Пожалуйста, нажмите на значок расширения на панели инструментов браузера, чтобы открыть настройки\",\n    \"description\": \"Settings open failed message\"\n  },\n  \"pm_backup\": {\n    \"message\": \"Локальное резервное копирование\",\n    \"description\": \"Local backup button\"\n  },\n  \"pm_backup_tooltip\": {\n    \"message\": \"Резервное копирование промптов и папок в папку с отметкой времени\",\n    \"description\": \"Backup button tooltip\"\n  },\n  \"pm_backup_cancelled\": {\n    \"message\": \"Резервное копирование отменено\",\n    \"description\": \"Backup cancelled hint\"\n  },\n  \"pm_backup_error\": {\n    \"message\": \"✗ Ошибка резервного копирования\",\n    \"description\": \"Backup failed hint\"\n  },\n  \"pm_backup_hint_options\": {\n    \"message\": \"Эта функция интегрирована в Менеджер промптов на странице Gemini.\",\n    \"description\": \"Backup hint in options page\"\n  },\n  \"pm_backup_step1\": {\n    \"message\": \"Откройте страницу Gemini (gemini.google.com)\",\n    \"description\": \"Backup step 1\"\n  },\n  \"pm_backup_step2\": {\n    \"message\": \"Нажмите на значок расширения в правом нижнем углу, чтобы открыть Менеджер промптов\",\n    \"description\": \"Backup step 2\"\n  },\n  \"pm_backup_step3\": {\n    \"message\": \"Нажмите кнопку \\\"💾 Локальное резервное копирование\\\" и выберите папку для резервного копирования\",\n    \"description\": \"Backup step 3\"\n  },\n  \"pm_backup_note\": {\n    \"message\": \"Резервные копии включают все промпты и папки, сохраненные в папке с отметкой времени (формат: backup-YYYYMMDD-HHMMSS)\",\n    \"description\": \"Backup feature note\"\n  },\n  \"pm_theme_light\": {\n    \"message\": \"Переключить на светлую тему\",\n    \"description\": \"Theme toggle tooltip - switch to light\"\n  },\n  \"pm_theme_dark\": {\n    \"message\": \"Переключить на тёмную тему\",\n    \"description\": \"Theme toggle tooltip - switch to dark\"\n  },\n  \"extensionVersion\": {\n    \"message\": \"Версия\",\n    \"description\": \"Extension version label\"\n  },\n  \"newVersionAvailable\": {\n    \"message\": \"Доступна новая версия\",\n    \"description\": \"Update reminder title\"\n  },\n  \"currentVersionLabel\": {\n    \"message\": \"Текущая\",\n    \"description\": \"Label for current version number\"\n  },\n  \"latestVersionLabel\": {\n    \"message\": \"Последняя\",\n    \"description\": \"Label for latest version number\"\n  },\n  \"updateNow\": {\n    \"message\": \"Обновить\",\n    \"description\": \"CTA to update to latest version\"\n  },\n  \"safariUpdateNotSynced\": {\n    \"message\": \"Источник обновления ещё не синхронизирован, попробуйте через несколько часов\",\n    \"description\": \"Shown on Safari when the DMG file is not yet available on GitHub releases\"\n  },\n  \"starProject\": {\n    \"message\": \"Поддержать меня ⭐\",\n    \"description\": \"Github callout\"\n  },\n  \"officialDocs\": {\n    \"message\": \"Официальная документация\",\n    \"description\": \"Official docs link\"\n  },\n  \"exportChatJson\": {\n    \"message\": \"Экспорт истории диалога\",\n    \"description\": \"Tooltip for export button\"\n  },\n  \"folder_title\": {\n    \"message\": \"Папки\",\n    \"description\": \"Folder section title\"\n  },\n  \"folder_create\": {\n    \"message\": \"Создать папку\",\n    \"description\": \"Create folder button tooltip\"\n  },\n  \"folder_name_prompt\": {\n    \"message\": \"Введите имя папки:\",\n    \"description\": \"Create folder name prompt\"\n  },\n  \"folder_rename_prompt\": {\n    \"message\": \"Введите новое имя:\",\n    \"description\": \"Rename folder prompt\"\n  },\n  \"folder_delete_confirm\": {\n    \"message\": \"Удалить эту папку и все её содержимое?\",\n    \"description\": \"Delete folder confirmation\"\n  },\n  \"folder_create_subfolder\": {\n    \"message\": \"Создать подпапку\",\n    \"description\": \"Create subfolder menu item\"\n  },\n  \"folder_rename\": {\n    \"message\": \"Переименовать\",\n    \"description\": \"Rename folder menu item\"\n  },\n  \"folder_change_color\": {\n    \"message\": \"Изменить цвет\",\n    \"description\": \"Change folder color menu item\"\n  },\n  \"folder_delete\": {\n    \"message\": \"Удалить\",\n    \"description\": \"Delete folder menu item\"\n  },\n  \"folder_color_default\": {\n    \"message\": \"По умолчанию\",\n    \"description\": \"Default folder color name\"\n  },\n  \"folder_color_red\": {\n    \"message\": \"Красный\",\n    \"description\": \"Red folder color name\"\n  },\n  \"folder_color_orange\": {\n    \"message\": \"Оранжевый\",\n    \"description\": \"Orange folder color name\"\n  },\n  \"folder_color_yellow\": {\n    \"message\": \"Желтый\",\n    \"description\": \"Yellow folder color name\"\n  },\n  \"folder_color_green\": {\n    \"message\": \"Зеленый\",\n    \"description\": \"Green folder color name\"\n  },\n  \"folder_color_blue\": {\n    \"message\": \"Синий\",\n    \"description\": \"Blue folder color name\"\n  },\n  \"folder_color_purple\": {\n    \"message\": \"Фиолетовый\",\n    \"description\": \"Purple folder color name\"\n  },\n  \"folder_color_pink\": {\n    \"message\": \"Розовый\",\n    \"description\": \"Pink folder color name\"\n  },\n  \"folder_color_custom\": {\n    \"message\": \"Пользовательский\",\n    \"description\": \"Custom folder color name\"\n  },\n  \"folder_empty\": {\n    \"message\": \"Папок пока нет\",\n    \"description\": \"Empty state placeholder when no folders exist\"\n  },\n  \"folder_pin\": {\n    \"message\": \"Закрепить папку\",\n    \"description\": \"Pin folder menu item\"\n  },\n  \"folder_unpin\": {\n    \"message\": \"Открепить папку\",\n    \"description\": \"Unpin folder menu item\"\n  },\n  \"folder_remove_conversation\": {\n    \"message\": \"Убрать из папки\",\n    \"description\": \"Remove conversation button tooltip\"\n  },\n  \"folder_remove_conversation_confirm\": {\n    \"message\": \"Убрать \\\"{title}\\\" из этой папки?\",\n    \"description\": \"Remove conversation confirmation\"\n  },\n  \"conversation_more\": {\n    \"message\": \"Больше опций\",\n    \"description\": \"Conversation more options button tooltip\"\n  },\n  \"conversation_move_to_folder\": {\n    \"message\": \"Переместить в папку\",\n    \"description\": \"Move conversation to folder menu item\"\n  },\n  \"conversation_move_to_folder_title\": {\n    \"message\": \"Переместить в папку\",\n    \"description\": \"Move conversation to folder dialog title\"\n  },\n  \"chatWidth\": {\n    \"message\": \"Ширина чата\",\n    \"description\": \"Chat width adjustment label\"\n  },\n  \"chatWidthNarrow\": {\n    \"message\": \"Узкая\",\n    \"description\": \"Narrow chat width label\"\n  },\n  \"chatWidthWide\": {\n    \"message\": \"Широкая\",\n    \"description\": \"Wide chat width label\"\n  },\n  \"sidebarWidth\": {\n    \"message\": \"Ширина боковой панели\",\n    \"description\": \"Sidebar width adjustment label\"\n  },\n  \"sidebarWidthNarrow\": {\n    \"message\": \"Узкая\",\n    \"description\": \"Narrow sidebar width label\"\n  },\n  \"sidebarWidthWide\": {\n    \"message\": \"Широкая\",\n    \"description\": \"Wide sidebar width label\"\n  },\n  \"formula_copied\": {\n    \"message\": \"✓ Формула скопирована\",\n    \"description\": \"Formula copied success message\"\n  },\n  \"formula_copy_failed\": {\n    \"message\": \"✗ Не удалось скопировать\",\n    \"description\": \"Formula copy failed message\"\n  },\n  \"formulaCopyFormat\": {\n    \"message\": \"Формат копирования формулы\",\n    \"description\": \"Formula copy format setting label\"\n  },\n  \"formulaCopyFormatLatex\": {\n    \"message\": \"LaTeX\",\n    \"description\": \"LaTeX format option\"\n  },\n  \"formulaCopyFormatUnicodeMath\": {\n    \"message\": \"MathML (Word)\",\n    \"description\": \"Label for MathML formula copy format option (formerly UnicodeMath)\"\n  },\n  \"formulaCopyFormatHint\": {\n    \"message\": \"Выберите формат при копировании формул щелчком мыши\",\n    \"description\": \"Formula copy format hint\"\n  },\n  \"formulaCopyFormatNoDollar\": {\n    \"message\": \"LaTeX (без знака доллара)\",\n    \"description\": \"LaTeX format option without dollar signs\"\n  },\n  \"folder_filter_current_user\": {\n    \"message\": \"Изоляция аккаунтов\",\n    \"description\": \"Show only conversations from the current Google account\"\n  },\n  \"folder_export\": {\n    \"message\": \"Экспорт папок\",\n    \"description\": \"Export folders button tooltip\"\n  },\n  \"folder_import_export\": {\n    \"message\": \"Импорт/Экспорт папок\",\n    \"description\": \"Combined import/export button tooltip\"\n  },\n  \"folder_cloud_upload\": {\n    \"message\": \"Загрузить в облако\",\n    \"description\": \"Cloud upload button tooltip\"\n  },\n  \"folder_cloud_sync\": {\n    \"message\": \"Синхронизировать из облака\",\n    \"description\": \"Cloud sync button tooltip\"\n  },\n  \"folder_import\": {\n    \"message\": \"Импорт папок\",\n    \"description\": \"Import folders button tooltip\"\n  },\n  \"folder_import_title\": {\n    \"message\": \"Импорт конфигурации папок\",\n    \"description\": \"Import dialog title\"\n  },\n  \"folder_import_strategy\": {\n    \"message\": \"Стратегия импорта:\",\n    \"description\": \"Import strategy label\"\n  },\n  \"folder_import_merge\": {\n    \"message\": \"Объединить с существующими папками\",\n    \"description\": \"Merge import strategy\"\n  },\n  \"folder_import_overwrite\": {\n    \"message\": \"Перезаписать существующие папки\",\n    \"description\": \"Overwrite import strategy\"\n  },\n  \"folder_import_select_file\": {\n    \"message\": \"Выберите файл JSON для импорта\",\n    \"description\": \"Import file selection prompt\"\n  },\n  \"folder_import_success\": {\n    \"message\": \"✓ Импортировано папок: {folders}, разговоров: {conversations}\",\n    \"description\": \"Import success message\"\n  },\n  \"folder_import_success_skipped\": {\n    \"message\": \"✓ Импортировано папок: {folders}, разговоров: {conversations} ({skipped} дубликатов пропущено)\",\n    \"description\": \"Import success with skipped message\"\n  },\n  \"folder_import_error\": {\n    \"message\": \"✗ Ошибка импорта: {error}\",\n    \"description\": \"Import error message\"\n  },\n  \"folder_export_success\": {\n    \"message\": \"✓ Папки успешно экспортированы\",\n    \"description\": \"Export success message\"\n  },\n  \"folder_import_invalid_format\": {\n    \"message\": \"Неверный формат файла. Пожалуйста, выберите допустимый файл конфигурации папок.\",\n    \"description\": \"Invalid format error\"\n  },\n  \"folder_import_confirm_overwrite\": {\n    \"message\": \"Это заменит все существующие папки. Будет создана резервная копия. Продолжить?\",\n    \"description\": \"Overwrite confirmation\"\n  },\n  \"folder_import_paste_json\": {\n    \"message\": \"Или вставить JSON напрямую\",\n    \"description\": \"Кнопка для отображения области вставки текста в диалоге импорта\"\n  },\n  \"folder_import_paste_placeholder\": {\n    \"message\": \"Вставьте JSON сюда...\",\n    \"description\": \"Текст-заполнитель для области вставки\"\n  },\n  \"export_dialog_title\": {\n    \"message\": \"Экспорт разговора\",\n    \"description\": \"Export dialog title\"\n  },\n  \"export_dialog_select\": {\n    \"message\": \"Выберите формат экспорта:\",\n    \"description\": \"Export format selection label\"\n  },\n  \"export_dialog_warning\": {\n    \"message\": \"⚠️ После нажатия на экспорт система автоматически перейдет к первому сообщению для загрузки всего контента. Пожалуйста, не совершайте никаких действий; экспорт продолжится автоматически после перехода.\",\n    \"description\": \"Warning about auto-jump and loading completeness\"\n  },\n  \"export_dialog_safari_cmdp_hint\": {\n    \"message\": \"Совет для Safari: нажмите «Экспорт» ниже, подождите немного, затем нажмите ⌘P и выберите «Сохранить как PDF».\",\n    \"description\": \"Safari-specific hint for PDF export\"\n  },\n  \"export_toast_safari_pdf_ready\": {\n    \"message\": \"Теперь можно нажать Command + P, чтобы экспортировать PDF.\",\n    \"description\": \"Safari-specific toast shown after PDF export is prepared\"\n  },\n  \"export_dialog_safari_markdown_hint\": {\n    \"message\": \"Примечание: из-за ограничений Safari изображения из истории чата извлечь нельзя. Для полного экспорта используйте экспорт в PDF.\",\n    \"description\": \"Warning about image references in Safari markdown export\"\n  },\n  \"export_format_json_description\": {\n    \"message\": \"Машиночитаемый формат для разработчиков\",\n    \"description\": \"Description for JSON export format\"\n  },\n  \"export_format_markdown_description\": {\n    \"message\": \"Чистый текстовый формат (рекомендуется)\",\n    \"description\": \"Description for Markdown export format\"\n  },\n  \"export_format_pdf_description\": {\n    \"message\": \"Формат для печати (через Сохранить как PDF)\",\n    \"description\": \"Description for PDF export format\"\n  },\n  \"editInputWidth\": {\n    \"message\": \"Ширина поля ввода\",\n    \"description\": \"Edit input width adjustment label\"\n  },\n  \"editInputWidthNarrow\": {\n    \"message\": \"Узкая\",\n    \"description\": \"Narrow edit input width label\"\n  },\n  \"editInputWidthWide\": {\n    \"message\": \"Широкая\",\n    \"description\": \"Wide edit input width label\"\n  },\n  \"timelineOptions\": {\n    \"message\": \"Настройки таймлайна\",\n    \"description\": \"Timeline options section label\"\n  },\n  \"folderOptions\": {\n    \"message\": \"Настройки папок\",\n    \"description\": \"Folder options section label\"\n  },\n  \"enableFolderFeature\": {\n    \"message\": \"Включить функцию папок\",\n    \"description\": \"Enable or disable the folder feature\"\n  },\n  \"hideArchivedConversations\": {\n    \"message\": \"Скрыть архивные разговоры\",\n    \"description\": \"Hide conversations that are in folders from the main list\"\n  },\n  \"conversation_star\": {\n    \"message\": \"Пометить разговор\",\n    \"description\": \"Star conversation button tooltip\"\n  },\n  \"conversation_unstar\": {\n    \"message\": \"Снять пометку\",\n    \"description\": \"Unstar conversation button tooltip\"\n  },\n  \"conversation_untitled\": {\n    \"message\": \"Без названия\",\n    \"description\": \"Fallback title for conversations without a name\"\n  },\n  \"geminiOnlyNotice\": {\n    \"message\": \"Работает по умолчанию в Gemini (включая Enterprise) и AI Studio. Добавьте другие сайты ниже, чтобы включить там Менеджер промптов.\",\n    \"description\": \"Notice that defaults are limited to Gemini/AI Studio\"\n  },\n  \"folderManager_dataLossWarning\": {\n    \"message\": \"⚠️ Внимание: не удалось загрузить данные папок. Ваши папки могут быть повреждены. Пожалуйста, проверьте консоль браузера для получения подробной информации и попробуйте восстановить из резервной копии, если она доступна.\",\n    \"description\": \"Warning shown when folder data fails to load\"\n  },\n  \"backupOptions\": {\n    \"message\": \"Авто-резервное копирование\",\n    \"description\": \"Backup options section label\"\n  },\n  \"backupEnabled\": {\n    \"message\": \"Включить авто-бэкап\",\n    \"description\": \"Enable auto backup toggle\"\n  },\n  \"backupNow\": {\n    \"message\": \"Создать бэкап сейчас\",\n    \"description\": \"Backup now button\"\n  },\n  \"backupSelectFolder\": {\n    \"message\": \"Выбрать папку для бэкапа\",\n    \"description\": \"Select backup folder button\"\n  },\n  \"backupFolderSelected\": {\n    \"message\": \"Папка для бэкапа: {folder}\",\n    \"description\": \"Shows selected backup folder\"\n  },\n  \"backupIncludePrompts\": {\n    \"message\": \"Включить промпты\",\n    \"description\": \"Include prompts in backup toggle\"\n  },\n  \"backupIncludeFolders\": {\n    \"message\": \"Включить папки\",\n    \"description\": \"Include folders in backup toggle\"\n  },\n  \"backupIntervalLabel\": {\n    \"message\": \"Интервал бэкапа\",\n    \"description\": \"Backup interval label\"\n  },\n  \"backupIntervalManual\": {\n    \"message\": \"Только вручную\",\n    \"description\": \"Manual backup mode\"\n  },\n  \"backupIntervalDaily\": {\n    \"message\": \"Ежедневно\",\n    \"description\": \"Daily backup interval\"\n  },\n  \"backupIntervalWeekly\": {\n    \"message\": \"Еженедельно (7 дней)\",\n    \"description\": \"Weekly backup interval\"\n  },\n  \"backupLastBackup\": {\n    \"message\": \"Последний бэкап: {time}\",\n    \"description\": \"Last backup timestamp\"\n  },\n  \"backupNever\": {\n    \"message\": \"Никогда\",\n    \"description\": \"Never backed up\"\n  },\n  \"backupSuccess\": {\n    \"message\": \"✓ Бэкап создан: {prompts} промптов, {folders} папок, {conversations} разговоров\",\n    \"description\": \"Backup success message\"\n  },\n  \"backupError\": {\n    \"message\": \"✗ Ошибка бэкапа: {error}\",\n    \"description\": \"Backup error message\"\n  },\n  \"backupNotSupported\": {\n    \"message\": \"⚠️ Авто-бэкап требует современный браузер с поддержкой File System Access API\",\n    \"description\": \"Browser not supported message\"\n  },\n  \"backupSelectFolderFirst\": {\n    \"message\": \"Пожалуйста, сначала выберите папку для бэкапа\",\n    \"description\": \"No folder selected warning\"\n  },\n  \"backupUserCancelled\": {\n    \"message\": \"Бэкап отменен\",\n    \"description\": \"User cancelled folder selection\"\n  },\n  \"backupConfigSaved\": {\n    \"message\": \"✓ Настройки бэкапа сохранены\",\n    \"description\": \"Config saved message\"\n  },\n  \"backupPermissionDenied\": {\n    \"message\": \"⚠️ Нет доступа к этой директории. Пожалуйста, выберите другое место (например, Документы, Загрузки или папку на Рабочем столе)\",\n    \"description\": \"Permission denied for restricted directory\"\n  },\n  \"backupConfigureInOptions\": {\n    \"message\": \"Настройка бэкапа требует страницы Опций. Нажмите кнопку ниже, чтобы открыть её (нажмите три точки рядом со значком расширения → Опции).\",\n    \"description\": \"Backup configure in options page explanation\"\n  },\n  \"openOptionsPage\": {\n    \"message\": \"Открыть страницу Опций\",\n    \"description\": \"Open options page button\"\n  },\n  \"optionsPageSubtitle\": {\n    \"message\": \"Настройки расширения\",\n    \"description\": \"Options page subtitle\"\n  },\n  \"optionsComingSoon\": {\n    \"message\": \"Больше опций скоро...\",\n    \"description\": \"Options page placeholder text\"\n  },\n  \"backupDataAccessNotice\": {\n    \"message\": \"Ограничение доступа к данным\",\n    \"description\": \"Backup data access limitation title\"\n  },\n  \"backupDataAccessHint\": {\n    \"message\": \"Из-за ограничений безопасности браузера эта страница Опций не может получить прямой доступ к промптам и папкам со страниц Gemini. Пожалуйста, используйте функции Менеджера промптов и Экспорта папок на странице Gemini для ручного резервного копирования.\",\n    \"description\": \"Backup data access limitation explanation\"\n  },\n  \"promptManagerOptions\": {\n    \"message\": \"Менеджер промптов\",\n    \"description\": \"Prompt Manager options section label\"\n  },\n  \"hidePromptManager\": {\n    \"message\": \"Скрыть Менеджер промптов\",\n    \"description\": \"Hide the prompt manager floating ball\"\n  },\n  \"hidePromptManagerHint\": {\n    \"message\": \"Скрыть плавающую кнопку Менеджера промптов на странице\",\n    \"description\": \"Hint for hiding prompt manager feature\"\n  },\n  \"customWebsites\": {\n    \"message\": \"Пользовательские сайты\",\n    \"description\": \"Custom websites for prompt manager\"\n  },\n  \"customWebsitesPlaceholder\": {\n    \"message\": \"Введите URL сайта (например, chatgpt.com)\",\n    \"description\": \"Custom websites input placeholder\"\n  },\n  \"customWebsitesHint\": {\n    \"message\": \"Добавьте сайты, на которых вы хотите использовать Менеджер промптов. Только Менеджер промптов будет активирован на этих сайтах.\",\n    \"description\": \"Custom websites hint\"\n  },\n  \"addWebsite\": {\n    \"message\": \"Добавить сайт\",\n    \"description\": \"Add website button\"\n  },\n  \"removeWebsite\": {\n    \"message\": \"Удалить\",\n    \"description\": \"Remove website button\"\n  },\n  \"invalidUrl\": {\n    \"message\": \"Неверный формат URL\",\n    \"description\": \"Invalid URL error message\"\n  },\n  \"websiteAdded\": {\n    \"message\": \"Сайт добавлен\",\n    \"description\": \"Website added success message\"\n  },\n  \"websiteRemoved\": {\n    \"message\": \"Сайт удален\",\n    \"description\": \"Website removed success message\"\n  },\n  \"permissionDenied\": {\n    \"message\": \"В доступе отказано. Пожалуйста, разрешите доступ к этому сайту.\",\n    \"description\": \"Permission denied error message\"\n  },\n  \"permissionRequestFailed\": {\n    \"message\": \"Не удалось запросить разрешение. Пожалуйста, попробуйте снова или предоставьте доступ в настройках расширения.\",\n    \"description\": \"Permission request failure message\"\n  },\n  \"customWebsitesNote\": {\n    \"message\": \"Совет: Вас попросят разрешить доступ при добавлении сайта. После предоставления разрешения перезагрузите этот сайт, чтобы Менеджер промптов мог запуститься.\",\n    \"description\": \"Custom websites reload/permission note\"\n  },\n  \"starredHistory\": {\n    \"message\": \"Избранная история\",\n    \"description\": \"Starred history title\"\n  },\n  \"viewStarredHistory\": {\n    \"message\": \"Просмотр избранной истории\",\n    \"description\": \"View starred history button\"\n  },\n  \"noStarredMessages\": {\n    \"message\": \"Нет избранных сообщений\",\n    \"description\": \"No starred messages placeholder\"\n  },\n  \"removeFromStarred\": {\n    \"message\": \"Убрать из избранного\",\n    \"description\": \"Remove from starred tooltip\"\n  },\n  \"justNow\": {\n    \"message\": \"Только что\",\n    \"description\": \"Just now time label\"\n  },\n  \"hoursAgo\": {\n    \"message\": \"ч. назад\",\n    \"description\": \"Hours ago label\"\n  },\n  \"yesterday\": {\n    \"message\": \"Вчера\",\n    \"description\": \"Yesterday date label\"\n  },\n  \"daysAgo\": {\n    \"message\": \"дн. назад\",\n    \"description\": \"Days ago label\"\n  },\n  \"loading\": {\n    \"message\": \"Загрузка...\",\n    \"description\": \"Loading message\"\n  },\n  \"keyboardShortcuts\": {\n    \"message\": \"Горячие клавиши\",\n    \"description\": \"Keyboard shortcuts section title\"\n  },\n  \"enableShortcuts\": {\n    \"message\": \"Включить горячие клавиши\",\n    \"description\": \"Enable shortcuts toggle label\"\n  },\n  \"previousNode\": {\n    \"message\": \"Предыдущий узел\",\n    \"description\": \"Previous timeline node shortcut label\"\n  },\n  \"nextNode\": {\n    \"message\": \"Следующий узел\",\n    \"description\": \"Next timeline node shortcut label\"\n  },\n  \"shortcutKey\": {\n    \"message\": \"Клавиша\",\n    \"description\": \"Shortcut key label\"\n  },\n  \"shortcutModifiers\": {\n    \"message\": \"Модификаторы\",\n    \"description\": \"Shortcut modifiers label\"\n  },\n  \"resetShortcuts\": {\n    \"message\": \"Сбросить по умолчанию\",\n    \"description\": \"Reset shortcuts button\"\n  },\n  \"shortcutsResetSuccess\": {\n    \"message\": \"Горячие клавиши сброшены\",\n    \"description\": \"Shortcuts reset success message\"\n  },\n  \"shortcutsDescription\": {\n    \"message\": \"Навигация по узлам таймлайна с помощью клавиатуры\",\n    \"description\": \"Shortcuts description\"\n  },\n  \"modifierNone\": {\n    \"message\": \"Нет\",\n    \"description\": \"No modifier key\"\n  },\n  \"modifierAlt\": {\n    \"message\": \"Alt\",\n    \"description\": \"Alt modifier key\"\n  },\n  \"modifierCtrl\": {\n    \"message\": \"Ctrl\",\n    \"description\": \"Ctrl modifier key\"\n  },\n  \"modifierShift\": {\n    \"message\": \"Shift\",\n    \"description\": \"Shift modifier key\"\n  },\n  \"modifierMeta\": {\n    \"message\": \"Cmd/Win\",\n    \"description\": \"Meta/Command/Windows modifier key\"\n  },\n  \"preventAutoScroll\": {\n    \"message\": \"Предотвращение автопрокрутки\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"preventAutoScrollHint\": {\n    \"message\": \"Предотвращает прыжок Gemini вниз при нажатии Enter во время чтения предыдущих ответов.\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"cloudSync\": {\n    \"message\": \"Облачная синхронизация\",\n    \"description\": \"Cloud sync section title\"\n  },\n  \"cloudSyncDescription\": {\n    \"message\": \"Синхронизация папок и промптов с Google Drive\",\n    \"description\": \"Cloud sync description\"\n  },\n  \"syncModeDisabled\": {\n    \"message\": \"Отключено\",\n    \"description\": \"Sync mode disabled option\"\n  },\n  \"syncModeManual\": {\n    \"message\": \"Вручную\",\n    \"description\": \"Sync mode manual option\"\n  },\n  \"syncServerPort\": {\n    \"message\": \"Порт сервера\",\n    \"description\": \"Port number for the sync server\"\n  },\n  \"syncNow\": {\n    \"message\": \"Синхронизировать сейчас\",\n    \"description\": \"Sync now button\"\n  },\n  \"lastSynced\": {\n    \"message\": \"Последняя синхронизация: {time}\",\n    \"description\": \"Last sync timestamp\"\n  },\n  \"neverSynced\": {\n    \"message\": \"Никогда не синхронизировалось\",\n    \"description\": \"Never synced message\"\n  },\n  \"lastUploaded\": {\n    \"message\": \"Загружено: {time}\",\n    \"description\": \"Last upload timestamp\"\n  },\n  \"neverUploaded\": {\n    \"message\": \"Никогда не загружалось\",\n    \"description\": \"Never uploaded message\"\n  },\n  \"syncSuccess\": {\n    \"message\": \"✓ Синхронизация успешна\",\n    \"description\": \"Sync success message\"\n  },\n  \"syncError\": {\n    \"message\": \"✗ Ошибка синхронизации: {error}\",\n    \"description\": \"Sync error message\"\n  },\n  \"syncInProgress\": {\n    \"message\": \"Синхронизация...\",\n    \"description\": \"Sync in progress message\"\n  },\n  \"uploadInProgress\": {\n    \"message\": \"Uploading...\",\n    \"description\": \"Upload in progress message\"\n  },\n  \"uploadSuccess\": {\n    \"message\": \"✓ Uploaded successfully\",\n    \"description\": \"Upload success message\"\n  },\n  \"downloadInProgress\": {\n    \"message\": \"Downloading...\",\n    \"description\": \"Download in progress message\"\n  },\n  \"downloadMergeSuccess\": {\n    \"message\": \"✓ Downloaded and merged\",\n    \"description\": \"Download and merge success message\"\n  },\n  \"syncUpload\": {\n    \"message\": \"Загрузить\",\n    \"description\": \"Upload button label\"\n  },\n  \"syncMerge\": {\n    \"message\": \"Синхронизация\",\n    \"description\": \"Merge button label\"\n  },\n  \"syncMode\": {\n    \"message\": \"Режим синхронизации\",\n    \"description\": \"Sync mode label\"\n  },\n  \"minutesAgo\": {\n    \"message\": \"мин. назад\",\n    \"description\": \"Minutes ago label\"\n  },\n  \"syncNoData\": {\n    \"message\": \"Данные синхронизации не найдены в Drive\",\n    \"description\": \"Error message when no data is found in Drive\"\n  },\n  \"syncUploadFailed\": {\n    \"message\": \"Загрузка не удалась\",\n    \"description\": \"Error message when upload fails\"\n  },\n  \"syncDownloadFailed\": {\n    \"message\": \"Скачивание не удалось\",\n    \"description\": \"Error message when download fails\"\n  },\n  \"syncAuthFailed\": {\n    \"message\": \"Ошибка аутентификации\",\n    \"description\": \"Error message when authentication fails\"\n  },\n  \"signOut\": {\n    \"message\": \"Выйти\",\n    \"description\": \"Sign out button\"\n  },\n  \"signInWithGoogle\": {\n    \"message\": \"Войти через Google\",\n    \"description\": \"Sign in with Google button\"\n  },\n  \"nanobananaOptions\": {\n    \"message\": \"Настройки NanoBanana\",\n    \"description\": \"NanoBanana options section label\"\n  },\n  \"enableNanobananaWatermarkRemover\": {\n    \"message\": \"Удалить водяной знак NanoBanana\",\n    \"description\": \"Enable automatic watermark removal for NanoBanana images\"\n  },\n  \"nanobananaWatermarkRemoverHint\": {\n    \"message\": \"Автоматически удаляет видимые водяные знаки Gemini с сгенерированных изображений\",\n    \"description\": \"Watermark remover feature hint\"\n  },\n  \"nanobananaDownloadTooltip\": {\n    \"message\": \"Скачать изображение без водяного знака (NanoBanana)\",\n    \"description\": \"Download button tooltip\"\n  },\n  \"deepResearchDownload\": {\n    \"message\": \"Скачать содержимое мыслей\",\n    \"description\": \"Deep Research download button text\"\n  },\n  \"deepResearchDownloadTooltip\": {\n    \"message\": \"Скачать содержимое мыслей как Markdown\",\n    \"description\": \"Deep Research download button tooltip\"\n  },\n  \"folder_uncategorized\": {\n    \"message\": \"Без категории\",\n    \"description\": \"Label for root-level conversations not in any folder\"\n  },\n  \"timelineLevelTitle\": {\n    \"message\": \"Уровень узла\",\n    \"description\": \"Title for timeline node level context menu\"\n  },\n  \"timelineLevel1\": {\n    \"message\": \"Уровень 1\",\n    \"description\": \"Timeline node level 1 option\"\n  },\n  \"timelineLevel2\": {\n    \"message\": \"Уровень 2\",\n    \"description\": \"Timeline node level 2 option\"\n  },\n  \"timelineLevel3\": {\n    \"message\": \"Уровень 3\",\n    \"description\": \"Timeline node level 3 option\"\n  },\n  \"timelineCollapse\": {\n    \"message\": \"Свернуть\",\n    \"description\": \"Collapse children\"\n  },\n  \"timelineExpand\": {\n    \"message\": \"Развернуть\",\n    \"description\": \"Expand children\"\n  },\n  \"timelinePreviewSearch\": {\n    \"message\": \"Поиск...\",\n    \"description\": \"Timeline preview panel search placeholder\"\n  },\n  \"timelinePreviewNoResults\": {\n    \"message\": \"Нет результатов\",\n    \"description\": \"Timeline preview panel no search results\"\n  },\n  \"timelinePreviewNoMessages\": {\n    \"message\": \"Нет сообщений\",\n    \"description\": \"Timeline preview panel no messages\"\n  },\n  \"quoteReply\": {\n    \"message\": \"Цитировать\",\n    \"description\": \"Quote reply button text\"\n  },\n  \"inputCollapseOptions\": {\n    \"message\": \"Параметры ввода\",\n    \"description\": \"Input options section label\"\n  },\n  \"enableInputCollapse\": {\n    \"message\": \"Включить сворачивание ввода\",\n    \"description\": \"Enable input collapse toggle label\"\n  },\n  \"enableInputCollapseHint\": {\n    \"message\": \"Сворачивать поле ввода, когда оно пустое, для увеличения пространства для чтения\",\n    \"description\": \"Input collapse feature hint\"\n  },\n  \"inputCollapseShortcutHint\": {\n    \"message\": \"{modifier}+I для раскрытия\",\n    \"description\": \"Shortcut hint for expanding collapsed input. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"inputCollapsePlaceholder\": {\n    \"message\": \"Сообщение Gemini\",\n    \"description\": \"Placeholder text shown in collapsed input\"\n  },\n  \"allowCollapseWhenNotEmpty\": {\n    \"message\": \"Позволить сворачивать с содержимым\",\n    \"description\": \"Allow input to collapse even when it has text or attachments\"\n  },\n  \"allowCollapseWhenNotEmptyHint\": {\n    \"message\": \"Сворачивать область ввода, даже если она содержит текст или вложения\",\n    \"description\": \"Hint text explaining the collapse when not empty feature\"\n  },\n  \"ctrlEnterSend\": {\n    \"message\": \"{modifier}+Enter для отправки\",\n    \"description\": \"Modifier+Enter to send toggle label. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"ctrlEnterSendHint\": {\n    \"message\": \"Нажмите {modifier}+Enter для отправки сообщений, Enter для новой строки\",\n    \"description\": \"Modifier+Enter to send feature hint. {modifier} is replaced with ⌘ on macOS and Ctrl on other platforms\"\n  },\n  \"batch_delete_confirm\": {\n    \"message\": \"Вы уверены, что хотите удалить {count} разговор(ов)? Это действие нельзя отменить.\",\n    \"description\": \"Batch delete confirmation message\"\n  },\n  \"batch_delete_in_progress\": {\n    \"message\": \"Удаление... ({current}/{total})\",\n    \"description\": \"Batch delete progress message\"\n  },\n  \"batch_delete_success\": {\n    \"message\": \"✓ Удалено {count} разговор(ов)\",\n    \"description\": \"Batch delete success message\"\n  },\n  \"batch_delete_partial\": {\n    \"message\": \"Удаление завершено: {success} успешно, {failed} не удалось\",\n    \"description\": \"Batch delete partial success message\"\n  },\n  \"batch_delete_limit_reached\": {\n    \"message\": \"Максимум {max} разговоров может быть выбрано за раз\",\n    \"description\": \"Batch delete limit warning\"\n  },\n  \"batch_delete_button\": {\n    \"message\": \"Удалить выбранные\",\n    \"description\": \"Batch delete button tooltip in multi-select mode\"\n  },\n  \"batch_delete_match_patterns\": {\n    \"message\": \"delete,confirm,yes,ok,remove,удалить,подтвердить,да,ок,убрать\",\n    \"description\": \"Comma-separated list of keywords to match native delete/confirm buttons (case-insensitive)\"\n  },\n  \"generalOptions\": {\n    \"message\": \"Общие настройки\",\n    \"description\": \"General options section label\"\n  },\n  \"enableTabTitleUpdate\": {\n    \"message\": \"Синхронизировать заголовок вкладки с разговором\",\n    \"description\": \"Enable tab title sync toggle label\"\n  },\n  \"enableTabTitleUpdateHint\": {\n    \"message\": \"Автоматически обновлять заголовок вкладки браузера в соответствии с текущим заголовком разговора\",\n    \"description\": \"Tab title sync feature hint\"\n  },\n  \"enableMermaidRendering\": {\n    \"message\": \"Включить рендеринг диаграмм Mermaid\",\n    \"description\": \"Enable Mermaid diagram rendering toggle label\"\n  },\n  \"enableMermaidRenderingHint\": {\n    \"message\": \"Автоматически рендерить диаграммы Mermaid в блоках кода\",\n    \"description\": \"Mermaid rendering feature hint\"\n  },\n  \"enableQuoteReply\": {\n    \"message\": \"Включить ответ с цитированием\",\n    \"description\": \"Enable quote reply toggle label\"\n  },\n  \"enableQuoteReplyHint\": {\n    \"message\": \"Показывать плавающую кнопку для цитирования выделенного текста при выделении текста в разговорах\",\n    \"description\": \"Quote reply feature hint\"\n  },\n  \"contextSync\": {\n    \"message\": \"Синхронизация контекста\",\n    \"description\": \"Context Sync title\"\n  },\n  \"contextSyncDescription\": {\n    \"message\": \"Синхронизируйте контекст чата с вашей локальной IDE\",\n    \"description\": \"Context Sync description\"\n  },\n  \"syncToIDE\": {\n    \"message\": \"Синхронизировать с IDE\",\n    \"description\": \"Sync to IDE button\"\n  },\n  \"ideOnline\": {\n    \"message\": \"IDE Онлайн\",\n    \"description\": \"IDE Online status\"\n  },\n  \"ideOffline\": {\n    \"message\": \"IDE Оффлайн\",\n    \"description\": \"IDE Offline status\"\n  },\n  \"syncing\": {\n    \"message\": \"Синхронизация...\",\n    \"description\": \"Syncing status\"\n  },\n  \"checkServer\": {\n    \"message\": \"Пожалуйста, запустите сервер AI Sync в VS Code\",\n    \"description\": \"Check server hint\"\n  },\n  \"capturing\": {\n    \"message\": \"Захват диалога...\",\n    \"description\": \"Capturing status\"\n  },\n  \"syncedSuccess\": {\n    \"message\": \"Синхронизировано успешно!\",\n    \"description\": \"Synced success message\"\n  },\n  \"downloadingOriginal\": {\n    \"message\": \"Скачивание оригинального изображения\",\n    \"description\": \"Status when downloading original image\"\n  },\n  \"downloadingOriginalLarge\": {\n    \"message\": \"Скачивание оригинального изображения (большой файл)\",\n    \"description\": \"Status when downloading large original image\"\n  },\n  \"downloadLargeWarning\": {\n    \"message\": \"Предупреждение о большом файле\",\n    \"description\": \"Warning shown for large downloads\"\n  },\n  \"downloadProcessing\": {\n    \"message\": \"Обработка водяного знака\",\n    \"description\": \"Status when processing watermark removal\"\n  },\n  \"downloadSuccess\": {\n    \"message\": \"Скачивание...\",\n    \"description\": \"Status when download starts\"\n  },\n  \"downloadError\": {\n    \"message\": \"Ошибка\",\n    \"description\": \"Status prefix when download fails\"\n  },\n  \"recentsHide\": {\n    \"message\": \"Скрыть недавние элементы\",\n    \"description\": \"Подсказка для скрытия раздела недавних элементов\"\n  },\n  \"recentsShow\": {\n    \"message\": \"Показать недавние элементы\",\n    \"description\": \"Подсказка для отображения раздела недавних элементов\"\n  },\n  \"gemsHide\": {\n    \"message\": \"Скрыть Gems\",\n    \"description\": \"Подсказка для скрытия раздела списка Gems\"\n  },\n  \"gemsShow\": {\n    \"message\": \"Показать Gems\",\n    \"description\": \"Подсказка для отображения раздела списка Gems\"\n  },\n  \"setAsDefaultModel\": {\n    \"message\": \"Set as default model\",\n    \"description\": \"Tooltip for setting default model\"\n  },\n  \"cancelDefaultModel\": {\n    \"message\": \"Cancel default model\",\n    \"description\": \"Tooltip for cancelling default model\"\n  },\n  \"defaultModelSet\": {\n    \"message\": \"Default model set: $1\",\n    \"description\": \"Toast message when default model is set\"\n  },\n  \"defaultModelCleared\": {\n    \"message\": \"Default model cleared\",\n    \"description\": \"Toast message when default model is cleared\"\n  },\n  \"sidebarAutoHide\": {\n    \"message\": \"Авто-скрытие боковой панели\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"sidebarAutoHideHint\": {\n    \"message\": \"Автоматически сворачивать боковую панель при уходе мыши, разворачивать при наведении\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"sidebarFullHide\": {\n    \"message\": \"Полностью скрыть боковую панель\",\n    \"description\": \"Fully hide sidebar toggle label\"\n  },\n  \"sidebarFullHideHint\": {\n    \"message\": \"Полностью скрыть свёрнутую боковую панель, наведите на левый край для показа\",\n    \"description\": \"Fully hide sidebar feature hint\"\n  },\n  \"folderSpacing\": {\n    \"message\": \"Расстояние между папками\",\n    \"description\": \"Folder spacing adjustment label\"\n  },\n  \"folderSpacingCompact\": {\n    \"message\": \"Компактно\",\n    \"description\": \"Compact folder spacing label\"\n  },\n  \"folderSpacingSpacious\": {\n    \"message\": \"Просторно\",\n    \"description\": \"Spacious folder spacing label\"\n  },\n  \"folderTreeIndent\": {\n    \"message\": \"Отступ подпапок\",\n    \"description\": \"Subfolder tree indentation adjustment label\"\n  },\n  \"folderTreeIndentCompact\": {\n    \"message\": \"Узкий\",\n    \"description\": \"Narrow subfolder indentation label\"\n  },\n  \"folderTreeIndentSpacious\": {\n    \"message\": \"Широкий\",\n    \"description\": \"Wide subfolder indentation label\"\n  },\n  \"export_select_mode_select_all\": {\n    \"message\": \"Выбрать все\",\n    \"description\": \"Selection mode select all toggle\"\n  },\n  \"export_select_mode_count\": {\n    \"message\": \"Выбрано {count}\",\n    \"description\": \"Selection mode selected message count\"\n  },\n  \"export_select_mode_toggle\": {\n    \"message\": \"Select message\",\n    \"description\": \"Selection mode per-message toggle tooltip\"\n  },\n  \"export_select_mode_select_below\": {\n    \"message\": \"Select messages below\",\n    \"description\": \"Selection mode top-left button to select messages below current line\"\n  },\n  \"export_select_mode_empty\": {\n    \"message\": \"Please select at least one message to export.\",\n    \"description\": \"Selection mode validation when nothing is selected\"\n  },\n  \"export_format_image_description\": {\n    \"message\": \"Один PNG-файл, удобно для отправки с мобильного.\",\n    \"description\": \"Description for Image export format\"\n  },\n  \"export_image_progress\": {\n    \"message\": \"Generating image...\",\n    \"description\": \"Progress message while generating image export\"\n  },\n  \"deepResearchSaveReport\": {\n    \"message\": \"Save Report\",\n    \"description\": \"Deep Research save report button\"\n  },\n  \"deepResearchSaveReportTooltip\": {\n    \"message\": \"Export this research report\",\n    \"description\": \"Deep Research save report tooltip\"\n  },\n  \"export_fontsize_label\": {\n    \"message\": \"Размер шрифта\",\n    \"description\": \"Label for font size slider in export dialog\"\n  },\n  \"export_fontsize_preview\": {\n    \"message\": \"Съешь ещё этих мягких французских булок, да выпей чаю.\",\n    \"description\": \"Preview text for font size slider in export dialog\"\n  },\n  \"export_error_generic\": {\n    \"message\": \"Не удалось экспортировать: {error}\",\n    \"description\": \"Общее сообщение об ошибке экспорта с подробной причиной\"\n  },\n  \"export_error_refresh_retry\": {\n    \"message\": \"Экспорт изображения не удался из-за проблем с сетью. Обновите страницу и попробуйте снова.\",\n    \"description\": \"Подсказка при временной ошибке загрузки во время экспорта изображения\"\n  },\n  \"export_md_include_source_confirm\": {\n    \"message\": \"Экспортируемый контент содержит изображения из веб-поиска. Включить ссылки на источники изображений в Markdown?\",\n    \"description\": \"Confirm dialog when exporting markdown with web search images\"\n  },\n  \"deepResearch_exportedAt\": {\n    \"message\": \"Экспортировано\",\n    \"description\": \"Метка временной метки экспорта Deep Research\"\n  },\n  \"deepResearch_totalPhases\": {\n    \"message\": \"Всего фаз\",\n    \"description\": \"Метка общего количества фаз размышления Deep Research\"\n  },\n  \"deepResearch_thinkingPhase\": {\n    \"message\": \"Фаза размышления\",\n    \"description\": \"Заголовок фазы размышления Deep Research\"\n  },\n  \"deepResearch_researchedWebsites\": {\n    \"message\": \"Исследованные сайты\",\n    \"description\": \"Заголовок раздела исследованных сайтов Deep Research\"\n  },\n  \"visualEffect\": {\n    \"message\": \"Визуальные эффекты\",\n    \"description\": \"Visual effect selector label\"\n  },\n  \"visualEffectHint\": {\n    \"message\": \"Добавьте сезонную атмосферу на страницу\",\n    \"description\": \"Visual effect feature hint\"\n  },\n  \"visualEffectOff\": {\n    \"message\": \"Выкл\",\n    \"description\": \"Visual effect off option\"\n  },\n  \"visualEffectSnow\": {\n    \"message\": \"Снег\",\n    \"description\": \"Visual effect snow option\"\n  },\n  \"visualEffectSakura\": {\n    \"message\": \"Сакура\",\n    \"description\": \"Visual effect sakura option\"\n  },\n  \"visualEffectRain\": {\n    \"message\": \"Дождь\",\n    \"description\": \"Visual effect rain option\"\n  },\n  \"changelog_title\": {\n    \"message\": \"Что нового\",\n    \"description\": \"Changelog modal title\"\n  },\n  \"changelog_close\": {\n    \"message\": \"Понятно\",\n    \"description\": \"Changelog modal close button\"\n  },\n  \"changelog_docs_link\": {\n    \"message\": \"Документация\",\n    \"description\": \"Changelog modal documentation link text\"\n  },\n  \"changelog_recommendation\": {\n    \"message\": \"Если Voyager был полезен, поделитесь им с друзьями или в соцсетях — не забудьте отметить автора!\",\n    \"description\": \"Changelog modal recommendation message\"\n  },\n  \"changelog_social_xiaohongshu\": {\n    \"message\": \"Xiaohongshu\",\n    \"description\": \"Xiaohongshu platform name for social links\"\n  },\n  \"changelog_social_zhihu\": {\n    \"message\": \"Zhihu\",\n    \"description\": \"Zhihu platform name for social links\"\n  },\n  \"changelog_docs_hint\": {\n    \"message\": \"Подробности в документации\",\n    \"description\": \"Changelog annotation pointing to docs icon\"\n  },\n  \"changelog_rate_chrome\": {\n    \"message\": \"Нравится Voyager? Оценка в Chrome Web Store помогает другим пользователям найти расширение!\",\n    \"description\": \"Chrome Web Store rating prompt in changelog modal\"\n  },\n  \"changelog_rate_chrome_cta\": {\n    \"message\": \"Оценить\",\n    \"description\": \"Chrome Web Store rating CTA button text\"\n  },\n  \"changelog_badge_mode\": {\n    \"message\": \"Уведомлять значком NEW на плавающей кнопке вместо этого окна\",\n    \"description\": \"Changelog notification mode toggle label\"\n  },\n  \"forkConversation\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork conversation button label\"\n  },\n  \"forkConfirm\": {\n    \"message\": \"Fork conversation from this point?\",\n    \"description\": \"Fork confirmation prompt\"\n  },\n  \"forkConfirmBtn\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork confirmation button\"\n  },\n  \"forkCancel\": {\n    \"message\": \"Cancel\",\n    \"description\": \"Fork cancel button\"\n  },\n  \"forkBranch\": {\n    \"message\": \"Branch\",\n    \"description\": \"Fork branch label\"\n  },\n  \"forkOriginal\": {\n    \"message\": \"Original\",\n    \"description\": \"Original fork branch tag\"\n  },\n  \"forkCurrent\": {\n    \"message\": \"Current\",\n    \"description\": \"Current fork branch tag\"\n  },\n  \"forkPreparing\": {\n    \"message\": \"Preparing fork...\",\n    \"description\": \"Fork preparation status\"\n  },\n  \"forkPasteHint\": {\n    \"message\": \"Press Enter to send and create the fork\",\n    \"description\": \"Fork paste hint text\"\n  },\n  \"forkDeleteData\": {\n    \"message\": \"Удалить эту ветку\",\n    \"description\": \"Delete fork branch metadata button\"\n  },\n  \"forkDeleteDataConfirm\": {\n    \"message\": \"Удалить эту связь ветвления? Будут удалены только метаданные ветвления Voyager, сами диалоги не удаляются.\",\n    \"description\": \"Fork metadata delete confirmation prompt\"\n  },\n  \"enableForkFeature\": {\n    \"message\": \"Включить ветвление диалога\",\n    \"description\": \"Enable conversation fork feature toggle label\"\n  },\n  \"enableForkFeatureHint\": {\n    \"message\": \"Показывать кнопку Fork и индикаторы веток в диалогах Gemini\",\n    \"description\": \"Enable conversation fork feature toggle hint\"\n  },\n  \"enableAccountIsolation\": {\n    \"message\": \"Enable Hard Isolation\",\n    \"description\": \"Enable hard isolation for account-scoped folder data and cloud sync.\"\n  },\n  \"enableAccountIsolationHint\": {\n    \"message\": \"Strictly isolate folder and cloud sync data by Google account to prevent cross-account mixing.\",\n    \"description\": \"Hint text for account isolation option.\"\n  },\n  \"currentPlatform\": {\n    \"message\": \"Current platform\",\n    \"description\": \"Current active platform label in popup.\"\n  },\n  \"platformGemini\": {\n    \"message\": \"Gemini\",\n    \"description\": \"Gemini platform label.\"\n  },\n  \"platformAIStudio\": {\n    \"message\": \"AI Studio\",\n    \"description\": \"AI Studio platform label.\"\n  },\n  \"enableOnAIStudio\": {\n    \"message\": \"Включить в AI Studio\",\n    \"description\": \"Toggle to enable/disable Voyager on AI Studio\"\n  },\n  \"enableOnAIStudioHint\": {\n    \"message\": \"Отключите, чтобы деактивировать функции Voyager в AI Studio (Prompt Manager остаётся активным)\",\n    \"description\": \"Hint for AI Studio enable toggle\"\n  },\n  \"export_select_mode_only_user\": {\n    \"message\": \"Выбрать только пользователя\",\n    \"description\": \"Export selection mode: select only user messages\"\n  },\n  \"export_select_mode_only_ai\": {\n    \"message\": \"Выбрать только ИИ\",\n    \"description\": \"Export selection mode: select only AI messages\"\n  },\n  \"aiOrgCopyButton\": {\n    \"message\": \"ИИ-организация\",\n    \"description\": \"Кнопка для копирования структуры папок и разговоров для организации ИИ\"\n  },\n  \"aiOrgCopied\": {\n    \"message\": \"Скопировано!\",\n    \"description\": \"Подтверждение после копирования промпта организации ИИ\"\n  },\n  \"aiOrgError\": {\n    \"message\": \"Ошибка — сначала откройте Gemini\",\n    \"description\": \"Ошибка при копировании промпта организации ИИ\"\n  },\n  \"aiOrgCopyHint\": {\n    \"message\": \"Копирует все разговоры и структуру папок в виде промпта. Вставьте в Gemini, чтобы получить импортируемый план папок.\",\n    \"description\": \"Текст подсказки под кнопкой копирования организации ИИ\"\n  },\n  \"aiOrgCurrentFolders\": {\n    \"message\": \"Текущая структура папок\",\n    \"description\": \"Заголовок в промпте организации ИИ\"\n  },\n  \"aiOrgUnfiledConversations\": {\n    \"message\": \"Неклассифицированные разговоры\",\n    \"description\": \"Заголовок для разговоров, не находящихся ни в одной папке\"\n  },\n  \"aiOrgEmpty\": {\n    \"message\": \"пусто\",\n    \"description\": \"Метка для пустых папок в промпте организации ИИ\"\n  },\n  \"aiOrgInstructions\": {\n    \"message\": \"Инструкции\",\n    \"description\": \"Заголовок раздела инструкций ИИ\"\n  },\n  \"aiOrgInstructionsBody\": {\n    \"message\": \"На основе разговоров и структуры папок выше, пожалуйста, реорганизуйте их в логическую иерархию папок. Выведите JSON-файл в точном формате ниже, который можно напрямую импортировать в расширение Gemini Voyager (используйте стратегию импорта \\\"Слияние\\\"). Сохраните существующие хорошо организованные папки, создайте новые по мере необходимости и переместите неклассифицированные разговоры в подходящие папки. Каждой папке нужен уникальный ID (используйте короткую случайную строку), и каждый разговор должен сохранить свои оригинальные conversationId и url.\",\n    \"description\": \"Текст инструкций для ИИ по генерации импортируемой структуры папок\"\n  },\n  \"showMessageTimestamps\": {\n    \"message\": \"Показывать временные метки сообщений\",\n    \"description\": \"Отображать временную метку для каждого сообщения\"\n  },\n  \"showMessageTimestampsHint\": {\n    \"message\": \"Показывать, когда было отправлено каждое сообщение\",\n    \"description\": \"Подсказка для функции временных меток\"\n  },\n  \"moveSectionUp\": {\n    \"message\": \"Переместить вверх\",\n    \"description\": \"Tooltip for moving a settings section up in popup\"\n  },\n  \"moveSectionDown\": {\n    \"message\": \"Переместить вниз\",\n    \"description\": \"Tooltip for moving a settings section down in popup\"\n  }\n}\n"
  },
  {
    "path": "src/locales/zh/messages.json",
    "content": "{\n  \"extName\": {\n    \"message\": \"Voyager\",\n    \"description\": \"扩展程序名称\"\n  },\n  \"extDescription\": {\n    \"message\": \"通过时间线导航、文件夹组织、指令宝库和对话导出功能，全面提升您的 Gemini™ 使用体验。\",\n    \"description\": \"扩展程序描述\"\n  },\n  \"scrollMode\": {\n    \"message\": \"滚动模式\",\n    \"description\": \"滚动模式标签\"\n  },\n  \"flow\": {\n    \"message\": \"流动\",\n    \"description\": \"流动模式\"\n  },\n  \"jump\": {\n    \"message\": \"跳跃\",\n    \"description\": \"跳跃模式\"\n  },\n  \"hideOuterContainer\": {\n    \"message\": \"隐藏外部容器\",\n    \"description\": \"隐藏外部容器标签\"\n  },\n  \"draggableTimeline\": {\n    \"message\": \"可拖拽时间线\",\n    \"description\": \"可拖拽时间线标签\"\n  },\n  \"enableMarkerLevel\": {\n    \"message\": \"启用节点层级\",\n    \"description\": \"启用时间线节点层级功能\"\n  },\n  \"enableMarkerLevelHint\": {\n    \"message\": \"右键点击时间线节点可设置其层级和折叠子节点\",\n    \"description\": \"节点层级功能提示\"\n  },\n  \"experimentalLabel\": {\n    \"message\": \"实验性\",\n    \"description\": \"实验性功能标签\"\n  },\n  \"resetPosition\": {\n    \"message\": \"重置位置\",\n    \"description\": \"重置位置按钮\"\n  },\n  \"resetTimelinePosition\": {\n    \"message\": \"重置时间线位置\",\n    \"description\": \"重置时间线位置按钮（在时间线选项中）\"\n  },\n  \"language\": {\n    \"message\": \"语言\",\n    \"description\": \"语言标签\"\n  },\n  \"pm_title\": {\n    \"message\": \"Voyager\",\n    \"description\": \"面板标题\"\n  },\n  \"pm_add\": {\n    \"message\": \"新增\",\n    \"description\": \"新增 prompt 按钮\"\n  },\n  \"pm_search_placeholder\": {\n    \"message\": \"搜索 prompt 或标签\",\n    \"description\": \"搜索占位符\"\n  },\n  \"pm_import\": {\n    \"message\": \"导入\",\n    \"description\": \"导入按钮\"\n  },\n  \"pm_export\": {\n    \"message\": \"导出\",\n    \"description\": \"导出按钮\"\n  },\n  \"pm_prompt_placeholder\": {\n    \"message\": \"Prompt 文本\",\n    \"description\": \"Prompt 输入占位符\"\n  },\n  \"pm_tags_placeholder\": {\n    \"message\": \"标签（逗号分隔）\",\n    \"description\": \"标签输入占位符\"\n  },\n  \"pm_save\": {\n    \"message\": \"保存\",\n    \"description\": \"保存\"\n  },\n  \"pm_cancel\": {\n    \"message\": \"取消\",\n    \"description\": \"取消\"\n  },\n  \"pm_all_tags\": {\n    \"message\": \"全部\",\n    \"description\": \"全部标签\"\n  },\n  \"pm_empty\": {\n    \"message\": \"暂无 prompt\",\n    \"description\": \"空状态\"\n  },\n  \"pm_copy\": {\n    \"message\": \"复制\",\n    \"description\": \"复制 prompt\"\n  },\n  \"pm_copied\": {\n    \"message\": \"已复制\",\n    \"description\": \"复制成功\"\n  },\n  \"pm_delete\": {\n    \"message\": \"删除\",\n    \"description\": \"删除 prompt\"\n  },\n  \"pm_delete_confirm\": {\n    \"message\": \"确定删除该 prompt 吗？\",\n    \"description\": \"删除确认\"\n  },\n  \"pm_lock\": {\n    \"message\": \"锁定位置\",\n    \"description\": \"锁定面板位置\"\n  },\n  \"pm_unlock\": {\n    \"message\": \"取消锁定\",\n    \"description\": \"取消锁定面板位置\"\n  },\n  \"pm_import_invalid\": {\n    \"message\": \"文件格式不正确\",\n    \"description\": \"导入失败\"\n  },\n  \"pm_import_success\": {\n    \"message\": \"已导入 {count} 条\",\n    \"description\": \"导入成功\"\n  },\n  \"pm_duplicate\": {\n    \"message\": \"重复的 prompt\",\n    \"description\": \"新增重复\"\n  },\n  \"pm_deleted\": {\n    \"message\": \"已删除\",\n    \"description\": \"删除提示\"\n  },\n  \"pm_edit\": {\n    \"message\": \"编辑\",\n    \"description\": \"编辑 prompt\"\n  },\n  \"pm_expand\": {\n    \"message\": \"展开\",\n    \"description\": \"展开提示词文本\"\n  },\n  \"pm_collapse\": {\n    \"message\": \"收起\",\n    \"description\": \"收起提示词文本\"\n  },\n  \"pm_saved\": {\n    \"message\": \"已保存\",\n    \"description\": \"保存提示\"\n  },\n  \"pm_settings\": {\n    \"message\": \"设置\",\n    \"description\": \"设置按钮\"\n  },\n  \"pm_settings_tooltip\": {\n    \"message\": \"调整扩展设置\",\n    \"description\": \"设置按钮提示\"\n  },\n  \"pm_settings_fallback\": {\n    \"message\": \"请点击浏览器工具栏中的扩展图标打开设置\",\n    \"description\": \"设置打开失败提示\"\n  },\n  \"pm_backup\": {\n    \"message\": \"本地备份\",\n    \"description\": \"本地备份按钮\"\n  },\n  \"pm_backup_tooltip\": {\n    \"message\": \"将提示词和文件夹备份到带时间戳的文件夹\",\n    \"description\": \"备份按钮提示\"\n  },\n  \"pm_backup_cancelled\": {\n    \"message\": \"备份已取消\",\n    \"description\": \"备份取消提示\"\n  },\n  \"pm_backup_error\": {\n    \"message\": \"✗ 备份失败\",\n    \"description\": \"备份失败提示\"\n  },\n  \"pm_backup_hint_options\": {\n    \"message\": \"功能已集成在 Gemini 页面的提示词管理器中。\",\n    \"description\": \"选项页面的备份说明\"\n  },\n  \"pm_backup_step1\": {\n    \"message\": \"打开 Gemini 页面（gemini.google.com）\",\n    \"description\": \"备份步骤 1\"\n  },\n  \"pm_backup_step2\": {\n    \"message\": \"点击右下角的扩展图标打开提示词管理器\",\n    \"description\": \"备份步骤 2\"\n  },\n  \"pm_backup_step3\": {\n    \"message\": \"点击 \\\"💾 本地备份\\\" 按钮，选择备份文件夹\",\n    \"description\": \"备份步骤 3\"\n  },\n  \"pm_backup_note\": {\n    \"message\": \"备份将包含所有提示词和文件夹，并保存在带时间戳的文件夹中（格式：backup-YYYYMMDD-HHMMSS）\",\n    \"description\": \"备份功能说明\"\n  },\n  \"pm_theme_light\": {\n    \"message\": \"切换到浅色模式\",\n    \"description\": \"Theme toggle tooltip - switch to light\"\n  },\n  \"pm_theme_dark\": {\n    \"message\": \"切换到深色模式\",\n    \"description\": \"Theme toggle tooltip - switch to dark\"\n  },\n  \"extensionVersion\": {\n    \"message\": \"版本\",\n    \"description\": \"扩展版本标签\"\n  },\n  \"newVersionAvailable\": {\n    \"message\": \"发现新版本\",\n    \"description\": \"更新提醒标题\"\n  },\n  \"currentVersionLabel\": {\n    \"message\": \"当前\",\n    \"description\": \"当前版本号标签\"\n  },\n  \"latestVersionLabel\": {\n    \"message\": \"最新\",\n    \"description\": \"最新版本号标签\"\n  },\n  \"updateNow\": {\n    \"message\": \"立即更新\",\n    \"description\": \"跳转更新 CTA\"\n  },\n  \"safariUpdateNotSynced\": {\n    \"message\": \"更新源尚未同步，请几小时后再试\",\n    \"description\": \"Safari 上 DMG 文件尚未上传到 GitHub releases 时显示\"\n  },\n  \"starProject\": {\n    \"message\": \"为项目点亮 ⭐️\",\n    \"description\": \"Github 引导\"\n  },\n  \"officialDocs\": {\n    \"message\": \"官方文档\",\n    \"description\": \"Official docs link\"\n  },\n  \"exportChatJson\": {\n    \"message\": \"导出对话记录\",\n    \"description\": \"导出按钮提示\"\n  },\n  \"folder_title\": {\n    \"message\": \"文件夹\",\n    \"description\": \"文件夹区域标题\"\n  },\n  \"folder_create\": {\n    \"message\": \"创建文件夹\",\n    \"description\": \"创建文件夹按钮提示\"\n  },\n  \"folder_name_prompt\": {\n    \"message\": \"输入文件夹名称：\",\n    \"description\": \"创建文件夹名称提示\"\n  },\n  \"folder_rename_prompt\": {\n    \"message\": \"输入新名称：\",\n    \"description\": \"重命名文件夹提示\"\n  },\n  \"folder_delete_confirm\": {\n    \"message\": \"确定删除此文件夹及其所有内容吗？\",\n    \"description\": \"删除文件夹确认\"\n  },\n  \"folder_create_subfolder\": {\n    \"message\": \"创建子文件夹\",\n    \"description\": \"创建子文件夹菜单项\"\n  },\n  \"folder_rename\": {\n    \"message\": \"重命名\",\n    \"description\": \"重命名文件夹菜单项\"\n  },\n  \"folder_change_color\": {\n    \"message\": \"更改颜色\",\n    \"description\": \"更改文件夹颜色菜单项\"\n  },\n  \"folder_delete\": {\n    \"message\": \"删除\",\n    \"description\": \"删除文件夹菜单项\"\n  },\n  \"folder_color_default\": {\n    \"message\": \"默认\",\n    \"description\": \"默认文件夹颜色名称\"\n  },\n  \"folder_color_red\": {\n    \"message\": \"红色（紧急）\",\n    \"description\": \"红色文件夹颜色名称\"\n  },\n  \"folder_color_orange\": {\n    \"message\": \"橙色（高优先级）\",\n    \"description\": \"橙色文件夹颜色名称\"\n  },\n  \"folder_color_yellow\": {\n    \"message\": \"黄色（需注意）\",\n    \"description\": \"黄色文件夹颜色名称\"\n  },\n  \"folder_color_green\": {\n    \"message\": \"绿色（已完成）\",\n    \"description\": \"绿色文件夹颜色名称\"\n  },\n  \"folder_color_blue\": {\n    \"message\": \"蓝色（参考）\",\n    \"description\": \"蓝色文件夹颜色名称\"\n  },\n  \"folder_color_purple\": {\n    \"message\": \"紫色（创意）\",\n    \"description\": \"紫色文件夹颜色名称\"\n  },\n  \"folder_color_pink\": {\n    \"message\": \"粉色（个人）\",\n    \"description\": \"粉色文件夹颜色名称\"\n  },\n  \"folder_color_custom\": {\n    \"message\": \"自定义\",\n    \"description\": \"自定义文件夹颜色名称\"\n  },\n  \"folder_empty\": {\n    \"message\": \"暂无文件夹\",\n    \"description\": \"没有文件夹时显示的占位提示\"\n  },\n  \"folder_pin\": {\n    \"message\": \"置顶文件夹\",\n    \"description\": \"置顶文件夹菜单项\"\n  },\n  \"folder_unpin\": {\n    \"message\": \"取消置顶\",\n    \"description\": \"取消置顶文件夹菜单项\"\n  },\n  \"folder_remove_conversation\": {\n    \"message\": \"从文件夹中移除\",\n    \"description\": \"移除对话按钮提示\"\n  },\n  \"folder_remove_conversation_confirm\": {\n    \"message\": \"从此文件夹中移除「{title}」？\",\n    \"description\": \"移除对话确认\"\n  },\n  \"conversation_more\": {\n    \"message\": \"更多选项\",\n    \"description\": \"对话更多选项按钮提示\"\n  },\n  \"conversation_move_to_folder\": {\n    \"message\": \"移动到文件夹\",\n    \"description\": \"移动对话到文件夹菜单项\"\n  },\n  \"conversation_move_to_folder_title\": {\n    \"message\": \"移动到文件夹\",\n    \"description\": \"移动对话到文件夹对话框标题\"\n  },\n  \"chatWidth\": {\n    \"message\": \"对话区域宽度\",\n    \"description\": \"对话区域宽度调整标签\"\n  },\n  \"chatWidthNarrow\": {\n    \"message\": \"窄\",\n    \"description\": \"窄对话区域宽度标签\"\n  },\n  \"chatWidthWide\": {\n    \"message\": \"宽\",\n    \"description\": \"宽对话区域宽度标签\"\n  },\n  \"sidebarWidth\": {\n    \"message\": \"侧边栏宽度\",\n    \"description\": \"侧边栏宽度调节标签\"\n  },\n  \"sidebarWidthNarrow\": {\n    \"message\": \"窄\",\n    \"description\": \"侧边栏变窄标签\"\n  },\n  \"sidebarWidthWide\": {\n    \"message\": \"宽\",\n    \"description\": \"侧边栏变宽标签\"\n  },\n  \"formula_copied\": {\n    \"message\": \"✓ 公式已复制\",\n    \"description\": \"公式复制成功消息\"\n  },\n  \"formula_copy_failed\": {\n    \"message\": \"✗ 复制失败\",\n    \"description\": \"公式复制失败消息\"\n  },\n  \"formulaCopyFormat\": {\n    \"message\": \"公式复制格式\",\n    \"description\": \"公式复制格式设置标签\"\n  },\n  \"formulaCopyFormatLatex\": {\n    \"message\": \"LaTeX\",\n    \"description\": \"LaTeX 格式选项\"\n  },\n  \"formulaCopyFormatUnicodeMath\": {\n    \"message\": \"MathML (Word)\",\n    \"description\": \"Label for UnicodeMath formula copy format option\"\n  },\n  \"formulaCopyFormatHint\": {\n    \"message\": \"选择点击公式时复制的格式\",\n    \"description\": \"公式复制格式提示\"\n  },\n  \"formulaCopyFormatNoDollar\": {\n    \"message\": \"LaTeX (纯文本，无 $ 符号)\",\n    \"description\": \"LaTeX 格式选项，无美元符号\"\n  },\n  \"folder_filter_current_user\": {\n    \"message\": \"账号隔离模式\",\n    \"description\": \"Show only conversations from the current Google account\"\n  },\n  \"folder_export\": {\n    \"message\": \"导出文件夹\",\n    \"description\": \"导出文件夹按钮提示\"\n  },\n  \"folder_import_export\": {\n    \"message\": \"导入/导出文件夹\",\n    \"description\": \"合并的导入导出按钮提示\"\n  },\n  \"folder_cloud_upload\": {\n    \"message\": \"上传到云端\",\n    \"description\": \"云上传按钮提示\"\n  },\n  \"folder_cloud_sync\": {\n    \"message\": \"从云端同步\",\n    \"description\": \"云同步按钮提示\"\n  },\n  \"folder_import\": {\n    \"message\": \"导入文件夹\",\n    \"description\": \"导入文件夹按钮提示\"\n  },\n  \"folder_import_title\": {\n    \"message\": \"导入文件夹配置\",\n    \"description\": \"导入对话框标题\"\n  },\n  \"folder_import_strategy\": {\n    \"message\": \"导入策略：\",\n    \"description\": \"导入策略标签\"\n  },\n  \"folder_import_merge\": {\n    \"message\": \"与现有文件夹合并\",\n    \"description\": \"合并导入策略\"\n  },\n  \"folder_import_overwrite\": {\n    \"message\": \"覆盖现有文件夹\",\n    \"description\": \"覆盖导入策略\"\n  },\n  \"folder_import_select_file\": {\n    \"message\": \"选择要导入的 JSON 文件\",\n    \"description\": \"导入文件选择提示\"\n  },\n  \"folder_import_success\": {\n    \"message\": \"✓ 已导入 {folders} 个文件夹，{conversations} 个对话\",\n    \"description\": \"导入成功消息\"\n  },\n  \"folder_import_success_skipped\": {\n    \"message\": \"✓ 已导入 {folders} 个文件夹，{conversations} 个对话（跳过 {skipped} 个重复项）\",\n    \"description\": \"导入成功并跳过重复项消息\"\n  },\n  \"folder_import_error\": {\n    \"message\": \"✗ 导入失败：{error}\",\n    \"description\": \"导入错误消息\"\n  },\n  \"folder_export_success\": {\n    \"message\": \"✓ 文件夹导出成功\",\n    \"description\": \"导出成功消息\"\n  },\n  \"folder_import_invalid_format\": {\n    \"message\": \"文件格式无效。请选择有效的文件夹配置文件。\",\n    \"description\": \"无效格式错误\"\n  },\n  \"folder_import_confirm_overwrite\": {\n    \"message\": \"这将替换所有现有文件夹。系统将创建备份。是否继续？\",\n    \"description\": \"覆盖确认\"\n  },\n  \"folder_import_paste_json\": {\n    \"message\": \"或直接粘贴 JSON\",\n    \"description\": \"导入对话框中显示粘贴文本框的切换按钮\"\n  },\n  \"folder_import_paste_placeholder\": {\n    \"message\": \"在此粘贴 JSON...\",\n    \"description\": \"粘贴文本框的占位文字\"\n  },\n  \"export_dialog_title\": {\n    \"message\": \"导出对话\",\n    \"description\": \"导出对话框标题\"\n  },\n  \"export_dialog_select\": {\n    \"message\": \"选择导出格式：\",\n    \"description\": \"导出格式选择标签\"\n  },\n  \"export_dialog_warning\": {\n    \"message\": \"⚠️ 点击导出后，系统将自动跳转至首条消息以加载完整内容。在此期间请勿操作，跳转完成后将自动继续导出。\",\n    \"description\": \"关于内容完整性和自动跳转的提示\"\n  },\n  \"export_dialog_safari_cmdp_hint\": {\n    \"message\": \"Safari 提示：请先点击下方“导出”按钮，稍等片刻，再按 ⌘P 选择“保存为 PDF”。\",\n    \"description\": \"Safari 下 PDF 导出的提示\"\n  },\n  \"export_toast_safari_pdf_ready\": {\n    \"message\": \"现在可以按 Command + P 导出 PDF。\",\n    \"description\": \"Safari 中完成导出准备后的提示\"\n  },\n  \"export_dialog_safari_markdown_hint\": {\n    \"message\": \"提醒：由于 Safari 限制，无法提取聊天记录中的图片，如需完整导出，建议使用 PDF 导出\",\n    \"description\": \"关于 Safari Markdown 导出无法提取图片的警告\"\n  },\n  \"export_format_json_description\": {\n    \"message\": \"开发者使用的机器可读格式\",\n    \"description\": \"JSON 导出格式描述\"\n  },\n  \"export_format_markdown_description\": {\n    \"message\": \"清晰便携的文本格式（推荐）\",\n    \"description\": \"Markdown 导出格式描述\"\n  },\n  \"export_format_pdf_description\": {\n    \"message\": \"适合打印的格式（通过保存为 PDF）\",\n    \"description\": \"PDF 导出格式描述\"\n  },\n  \"editInputWidth\": {\n    \"message\": \"编辑输入框宽度\",\n    \"description\": \"编辑输入框宽度调整标签\"\n  },\n  \"editInputWidthNarrow\": {\n    \"message\": \"窄\",\n    \"description\": \"窄编辑输入框宽度标签\"\n  },\n  \"editInputWidthWide\": {\n    \"message\": \"宽\",\n    \"description\": \"宽编辑输入框宽度标签\"\n  },\n  \"timelineOptions\": {\n    \"message\": \"时间线选项\",\n    \"description\": \"时间线选项区域标签\"\n  },\n  \"folderOptions\": {\n    \"message\": \"文件夹选项\",\n    \"description\": \"文件夹选项区域标签\"\n  },\n  \"enableFolderFeature\": {\n    \"message\": \"启用文件夹功能\",\n    \"description\": \"启用或禁用文件夹功能\"\n  },\n  \"hideArchivedConversations\": {\n    \"message\": \"隐藏已归档对话\",\n    \"description\": \"从主列表隐藏已添加到文件夹的对话\"\n  },\n  \"conversation_star\": {\n    \"message\": \"星标对话\",\n    \"description\": \"星标对话按钮提示\"\n  },\n  \"conversation_unstar\": {\n    \"message\": \"取消星标\",\n    \"description\": \"取消星标对话按钮提示\"\n  },\n  \"conversation_untitled\": {\n    \"message\": \"未命名\",\n    \"description\": \"无标题对话的回退标题\"\n  },\n  \"geminiOnlyNotice\": {\n    \"message\": \"默认仅在 Gemini（含企业版）和 AI Studio 生效。想在其他网站用提示词管理器，请在下方添加并授权。\",\n    \"description\": \"提示默认范围为 Gemini/AI Studio\"\n  },\n  \"folderManager_dataLossWarning\": {\n    \"message\": \"⚠️ 警告：无法加载文件夹数据。您的文件夹可能已损坏。请检查浏览器控制台以获取详细信息，并在可用时尝试从备份恢复。\",\n    \"description\": \"文件夹数据加载失败时显示的警告\"\n  },\n  \"backupOptions\": {\n    \"message\": \"自动备份\",\n    \"description\": \"备份选项区域标签\"\n  },\n  \"backupEnabled\": {\n    \"message\": \"启用自动备份\",\n    \"description\": \"启用自动备份开关\"\n  },\n  \"backupNow\": {\n    \"message\": \"立即备份\",\n    \"description\": \"立即备份按钮\"\n  },\n  \"backupSelectFolder\": {\n    \"message\": \"选择备份文件夹\",\n    \"description\": \"选择备份文件夹按钮\"\n  },\n  \"backupFolderSelected\": {\n    \"message\": \"备份文件夹：{folder}\",\n    \"description\": \"显示已选择的备份文件夹\"\n  },\n  \"backupIncludePrompts\": {\n    \"message\": \"包含提示词\",\n    \"description\": \"备份中包含提示词开关\"\n  },\n  \"backupIncludeFolders\": {\n    \"message\": \"包含文件夹\",\n    \"description\": \"备份中包含文件夹开关\"\n  },\n  \"backupIntervalLabel\": {\n    \"message\": \"备份间隔\",\n    \"description\": \"备份间隔标签\"\n  },\n  \"backupIntervalManual\": {\n    \"message\": \"仅手动\",\n    \"description\": \"手动备份模式\"\n  },\n  \"backupIntervalDaily\": {\n    \"message\": \"每天\",\n    \"description\": \"每天备份间隔\"\n  },\n  \"backupIntervalWeekly\": {\n    \"message\": \"每周（7 天）\",\n    \"description\": \"每周备份间隔\"\n  },\n  \"backupLastBackup\": {\n    \"message\": \"上次备份：{time}\",\n    \"description\": \"上次备份时间戳\"\n  },\n  \"backupNever\": {\n    \"message\": \"从未\",\n    \"description\": \"从未备份过\"\n  },\n  \"backupSuccess\": {\n    \"message\": \"✓ 备份创建成功：{prompts} 个提示词、{folders} 个文件夹、{conversations} 个对话\",\n    \"description\": \"备份成功消息\"\n  },\n  \"backupError\": {\n    \"message\": \"✗ 备份失败：{error}\",\n    \"description\": \"备份错误消息\"\n  },\n  \"backupNotSupported\": {\n    \"message\": \"⚠️ 自动备份需要支持文件系统访问 API 的现代浏览器\",\n    \"description\": \"浏览器不支持消息\"\n  },\n  \"backupSelectFolderFirst\": {\n    \"message\": \"请先选择备份文件夹\",\n    \"description\": \"未选择文件夹警告\"\n  },\n  \"backupUserCancelled\": {\n    \"message\": \"备份已取消\",\n    \"description\": \"用户取消文件夹选择\"\n  },\n  \"backupConfigSaved\": {\n    \"message\": \"✓ 备份设置已保存\",\n    \"description\": \"配置保存消息\"\n  },\n  \"backupPermissionDenied\": {\n    \"message\": \"⚠️ 无法访问此目录。请选择其他位置（例如：文档、下载，或桌面中的自定义文件夹）\",\n    \"description\": \"受限目录权限被拒绝\"\n  },\n  \"backupConfigureInOptions\": {\n    \"message\": \"备份功能需要在选项页面中配置。点击下方按钮打开选项页面（点击扩展图标旁的三个点 → 选项）。\",\n    \"description\": \"备份配置在选项页面的说明\"\n  },\n  \"openOptionsPage\": {\n    \"message\": \"打开选项页面\",\n    \"description\": \"打开选项页面按钮\"\n  },\n  \"optionsPageSubtitle\": {\n    \"message\": \"扩展选项\",\n    \"description\": \"选项页副标题\"\n  },\n  \"optionsComingSoon\": {\n    \"message\": \"更多选项即将上线...\",\n    \"description\": \"选项页占位提示\"\n  },\n  \"backupDataAccessNotice\": {\n    \"message\": \"数据访问限制\",\n    \"description\": \"备份数据访问限制标题\"\n  },\n  \"backupDataAccessHint\": {\n    \"message\": \"由于浏览器安全限制，选项页面无法直接读取 Gemini 页面的提示词和文件夹数据。建议使用 Gemini 页面上的提示词管理器和文件夹导出功能进行手动备份。\",\n    \"description\": \"备份数据访问限制说明\"\n  },\n  \"promptManagerOptions\": {\n    \"message\": \"提示词管理器\",\n    \"description\": \"提示词管理器选项区域标签\"\n  },\n  \"hidePromptManager\": {\n    \"message\": \"隐藏提示词管理器\",\n    \"description\": \"隐藏提示词管理器悬浮球\"\n  },\n  \"hidePromptManagerHint\": {\n    \"message\": \"隐藏页面上的提示词管理器悬浮球\",\n    \"description\": \"隐藏提示词管理器功能提示\"\n  },\n  \"customWebsites\": {\n    \"message\": \"自定义网站\",\n    \"description\": \"提示词管理器的自定义网站\"\n  },\n  \"customWebsitesPlaceholder\": {\n    \"message\": \"输入网站 URL（例如：chatgpt.com）\",\n    \"description\": \"自定义网站输入占位符\"\n  },\n  \"customWebsitesHint\": {\n    \"message\": \"添加您想要使用提示词管理器的网站。这些网站上只会激活提示词管理器功能。\",\n    \"description\": \"自定义网站提示\"\n  },\n  \"addWebsite\": {\n    \"message\": \"添加网站\",\n    \"description\": \"添加网站按钮\"\n  },\n  \"removeWebsite\": {\n    \"message\": \"移除\",\n    \"description\": \"移除网站按钮\"\n  },\n  \"invalidUrl\": {\n    \"message\": \"URL 格式无效\",\n    \"description\": \"无效 URL 错误消息\"\n  },\n  \"websiteAdded\": {\n    \"message\": \"网站已添加\",\n    \"description\": \"网站添加成功消息\"\n  },\n  \"websiteRemoved\": {\n    \"message\": \"网站已移除\",\n    \"description\": \"网站移除成功消息\"\n  },\n  \"permissionDenied\": {\n    \"message\": \"权限被拒绝。请允许访问此网站。\",\n    \"description\": \"权限被拒绝错误消息\"\n  },\n  \"permissionRequestFailed\": {\n    \"message\": \"请求权限失败，请重试或在扩展设置中手动授权。\",\n    \"description\": \"请求权限失败消息\"\n  },\n  \"customWebsitesNote\": {\n    \"message\": \"提示：添加网站时会弹出授权，请先允许，再刷新该站点即可启用提示词管理器。\",\n    \"description\": \"自定义网站重新加载/权限提示\"\n  },\n  \"starredHistory\": {\n    \"message\": \"星标历史\",\n    \"description\": \"星标历史标题\"\n  },\n  \"viewStarredHistory\": {\n    \"message\": \"查看星标历史\",\n    \"description\": \"查看星标历史按钮\"\n  },\n  \"noStarredMessages\": {\n    \"message\": \"暂无星标消息\",\n    \"description\": \"无星标消息占位符\"\n  },\n  \"removeFromStarred\": {\n    \"message\": \"取消星标\",\n    \"description\": \"取消星标提示\"\n  },\n  \"justNow\": {\n    \"message\": \"刚刚\",\n    \"description\": \"刚刚时间标签\"\n  },\n  \"hoursAgo\": {\n    \"message\": \"小时前\",\n    \"description\": \"小时前标签\"\n  },\n  \"yesterday\": {\n    \"message\": \"昨天\",\n    \"description\": \"昨天日期标签\"\n  },\n  \"daysAgo\": {\n    \"message\": \"天前\",\n    \"description\": \"天前标签\"\n  },\n  \"loading\": {\n    \"message\": \"加载中...\",\n    \"description\": \"加载消息\"\n  },\n  \"keyboardShortcuts\": {\n    \"message\": \"键盘快捷键\",\n    \"description\": \"键盘快捷键部分标题\"\n  },\n  \"enableShortcuts\": {\n    \"message\": \"启用键盘快捷键\",\n    \"description\": \"启用快捷键开关标签\"\n  },\n  \"previousNode\": {\n    \"message\": \"上一个节点\",\n    \"description\": \"上一个时间线节点快捷键标签\"\n  },\n  \"nextNode\": {\n    \"message\": \"下一个节点\",\n    \"description\": \"下一个时间线节点快捷键标签\"\n  },\n  \"shortcutKey\": {\n    \"message\": \"按键\",\n    \"description\": \"快捷键按键标签\"\n  },\n  \"shortcutModifiers\": {\n    \"message\": \"修饰键\",\n    \"description\": \"快捷键修饰键标签\"\n  },\n  \"resetShortcuts\": {\n    \"message\": \"恢复默认\",\n    \"description\": \"重置快捷键按钮\"\n  },\n  \"shortcutsResetSuccess\": {\n    \"message\": \"快捷键已恢复默认设置\",\n    \"description\": \"快捷键重置成功消息\"\n  },\n  \"shortcutsDescription\": {\n    \"message\": \"使用键盘快捷键导航时间线节点\",\n    \"description\": \"快捷键描述\"\n  },\n  \"modifierNone\": {\n    \"message\": \"无\",\n    \"description\": \"无修饰键\"\n  },\n  \"modifierAlt\": {\n    \"message\": \"Alt\",\n    \"description\": \"Alt 修饰键\"\n  },\n  \"modifierCtrl\": {\n    \"message\": \"Ctrl\",\n    \"description\": \"Ctrl 修饰键\"\n  },\n  \"modifierShift\": {\n    \"message\": \"Shift\",\n    \"description\": \"Shift 修饰键\"\n  },\n  \"modifierMeta\": {\n    \"message\": \"Cmd/Win\",\n    \"description\": \"Meta/Command/Windows 修饰键\"\n  },\n  \"cloudSync\": {\n    \"message\": \"云同步\",\n    \"description\": \"云同步区域标题\"\n  },\n  \"cloudSyncDescription\": {\n    \"message\": \"将文件夹和提示词同步到 Google Drive\",\n    \"description\": \"云同步描述\"\n  },\n  \"preventAutoScroll\": {\n    \"message\": \"防自动跳转\",\n    \"description\": \"功能选项：防止每次输入后生成答案时自动滚到底部\"\n  },\n  \"preventAutoScrollHint\": {\n    \"message\": \"避免在查看过往回答时按回车导致页面自动跳到底部\",\n    \"description\": \"防自动跳转功能的解释提示\"\n  },\n  \"syncModeDisabled\": {\n    \"message\": \"已禁用\",\n    \"description\": \"同步模式禁用选项\"\n  },\n  \"syncModeManual\": {\n    \"message\": \"手动\",\n    \"description\": \"同步模式手动选项\"\n  },\n  \"syncServerPort\": {\n    \"message\": \"服务端口\",\n    \"description\": \"同步服务端口号\"\n  },\n  \"syncNow\": {\n    \"message\": \"立即同步\",\n    \"description\": \"立即同步按钮\"\n  },\n  \"lastSynced\": {\n    \"message\": \"上次同步：{time}\",\n    \"description\": \"上次同步时间戳\"\n  },\n  \"neverSynced\": {\n    \"message\": \"从未同步\",\n    \"description\": \"从未同步消息\"\n  },\n  \"lastUploaded\": {\n    \"message\": \"上次上传：{time}\",\n    \"description\": \"上次上传时间戳\"\n  },\n  \"neverUploaded\": {\n    \"message\": \"从未上传\",\n    \"description\": \"从未上传消息\"\n  },\n  \"syncSuccess\": {\n    \"message\": \"✓ 同步成功\",\n    \"description\": \"同步成功消息\"\n  },\n  \"syncError\": {\n    \"message\": \"✗ 同步失败：{error}\",\n    \"description\": \"同步错误消息\"\n  },\n  \"syncInProgress\": {\n    \"message\": \"同步中...\",\n    \"description\": \"同步进行中消息\"\n  },\n  \"uploadInProgress\": {\n    \"message\": \"上传中...\",\n    \"description\": \"上传进行中消息\"\n  },\n  \"uploadSuccess\": {\n    \"message\": \"✓ 上传成功\",\n    \"description\": \"上传成功消息\"\n  },\n  \"downloadInProgress\": {\n    \"message\": \"下载中...\",\n    \"description\": \"下载进行中消息\"\n  },\n  \"downloadMergeSuccess\": {\n    \"message\": \"✓ 下载合并成功\",\n    \"description\": \"下载合并成功消息\"\n  },\n  \"syncUpload\": {\n    \"message\": \"上传到云端\",\n    \"description\": \"上传到云端按钮\"\n  },\n  \"syncMerge\": {\n    \"message\": \"从云端下载合并\",\n    \"description\": \"同步（合并）按钮\"\n  },\n  \"syncMode\": {\n    \"message\": \"同步模式\",\n    \"description\": \"同步模式标签\"\n  },\n  \"minutesAgo\": {\n    \"message\": \"分钟前\",\n    \"description\": \"分钟前标签\"\n  },\n  \"syncNoData\": {\n    \"message\": \"未在云端找到同步数据\",\n    \"description\": \"Error message when no data is found in Drive\"\n  },\n  \"syncUploadFailed\": {\n    \"message\": \"上传失败\",\n    \"description\": \"Error message when upload fails\"\n  },\n  \"syncDownloadFailed\": {\n    \"message\": \"下载失败\",\n    \"description\": \"Error message when download fails\"\n  },\n  \"syncAuthFailed\": {\n    \"message\": \"认证失败\",\n    \"description\": \"Error message when authentication fails\"\n  },\n  \"signOut\": {\n    \"message\": \"退出登录\",\n    \"description\": \"退出登录按钮\"\n  },\n  \"signInWithGoogle\": {\n    \"message\": \"使用 Google 登录\",\n    \"description\": \"使用 Google 登录按钮\"\n  },\n  \"nanobananaOptions\": {\n    \"message\": \"NanoBanana 选项\",\n    \"description\": \"NanoBanana options section label\"\n  },\n  \"enableNanobananaWatermarkRemover\": {\n    \"message\": \"去除 NanoBanana 水印\",\n    \"description\": \"启用自动去除生成图片水印功能\"\n  },\n  \"nanobananaWatermarkRemoverHint\": {\n    \"message\": \"自动移除 Gemini 生成图片上的可见水印\",\n    \"description\": \"水印去除功能提示\"\n  },\n  \"nanobananaDownloadTooltip\": {\n    \"message\": \"下载无水印图片 (NanoBanana)\",\n    \"description\": \"Download button tooltip\"\n  },\n  \"deepResearchDownload\": {\n    \"message\": \"下载 Thinking 内容\",\n    \"description\": \"Deep Research download button text\"\n  },\n  \"deepResearchDownloadTooltip\": {\n    \"message\": \"将 Thinking 内容导出为 Markdown 文件\",\n    \"description\": \"Deep Research download button tooltip\"\n  },\n  \"folder_uncategorized\": {\n    \"message\": \"未分类\",\n    \"description\": \"根目录下未分类对话的标签\"\n  },\n  \"timelineLevelTitle\": {\n    \"message\": \"节点层级\",\n    \"description\": \"时间线节点层级右键菜单标题\"\n  },\n  \"timelineLevel1\": {\n    \"message\": \"一级\",\n    \"description\": \"时间线节点一级选项\"\n  },\n  \"timelineLevel2\": {\n    \"message\": \"二级\",\n    \"description\": \"时间线节点二级选项\"\n  },\n  \"timelineLevel3\": {\n    \"message\": \"三级\",\n    \"description\": \"时间线节点三级选项\"\n  },\n  \"timelineCollapse\": {\n    \"message\": \"收起\",\n    \"description\": \"收起子节点\"\n  },\n  \"timelineExpand\": {\n    \"message\": \"展开\",\n    \"description\": \"展开子节点\"\n  },\n  \"timelinePreviewSearch\": {\n    \"message\": \"搜索...\",\n    \"description\": \"Timeline preview panel search placeholder\"\n  },\n  \"timelinePreviewNoResults\": {\n    \"message\": \"无结果\",\n    \"description\": \"Timeline preview panel no search results\"\n  },\n  \"timelinePreviewNoMessages\": {\n    \"message\": \"暂无消息\",\n    \"description\": \"Timeline preview panel no messages\"\n  },\n  \"quoteReply\": {\n    \"message\": \"引用回复\",\n    \"description\": \"引用回复按钮文本\"\n  },\n  \"inputCollapseOptions\": {\n    \"message\": \"输入选项\",\n    \"description\": \"输入选项区域标签\"\n  },\n  \"enableInputCollapse\": {\n    \"message\": \"启用输入框折叠\",\n    \"description\": \"启用输入框折叠开关标签\"\n  },\n  \"enableInputCollapseHint\": {\n    \"message\": \"输入框为空时自动折叠，获得更多阅读空间\",\n    \"description\": \"输入框折叠功能提示\"\n  },\n  \"inputCollapseShortcutHint\": {\n    \"message\": \"{modifier}+I 展开\",\n    \"description\": \"展开折叠输入框的快捷键提示。{modifier} 在 macOS 上替换为 ⌘，其他平台替换为 Ctrl\"\n  },\n  \"inputCollapsePlaceholder\": {\n    \"message\": \"给 Gemini 发消息\",\n    \"description\": \"折叠输入框时显示的占位符文本\"\n  },\n  \"allowCollapseWhenNotEmpty\": {\n    \"message\": \"允许折叠有内容的输入框\",\n    \"description\": \"允许输入框即使包含文字或附件也进行折叠\"\n  },\n  \"allowCollapseWhenNotEmptyHint\": {\n    \"message\": \"即使输入框包含文字或附件也允许折叠\",\n    \"description\": \"解释非空折叠功能的提示文本\"\n  },\n  \"ctrlEnterSend\": {\n    \"message\": \"{modifier}+Enter 发送\",\n    \"description\": \"Modifier+Enter 发送开关标签。{modifier} 在 macOS 上替换为 ⌘，其他平台替换为 Ctrl\"\n  },\n  \"ctrlEnterSendHint\": {\n    \"message\": \"按 {modifier}+Enter 发送消息，Enter 键仅换行\",\n    \"description\": \"Modifier+Enter 发送功能提示。{modifier} 在 macOS 上替换为 ⌘，其他平台替换为 Ctrl\"\n  },\n  \"batch_delete_confirm\": {\n    \"message\": \"确定要删除 {count} 个会话吗？此操作无法撤销。\",\n    \"description\": \"批量删除确认消息\"\n  },\n  \"batch_delete_in_progress\": {\n    \"message\": \"正在删除... ({current}/{total})\",\n    \"description\": \"批量删除进度消息\"\n  },\n  \"batch_delete_success\": {\n    \"message\": \"✓ 已删除 {count} 个会话\",\n    \"description\": \"批量删除成功消息\"\n  },\n  \"batch_delete_partial\": {\n    \"message\": \"删除完成：{success} 个成功，{failed} 个失败\",\n    \"description\": \"批量删除部分成功消息\"\n  },\n  \"batch_delete_limit_reached\": {\n    \"message\": \"一次最多可选择 {max} 个会话\",\n    \"description\": \"批量删除数量限制警告\"\n  },\n  \"batch_delete_button\": {\n    \"message\": \"删除所选\",\n    \"description\": \"多选模式下的批量删除按钮提示\"\n  },\n  \"batch_delete_match_patterns\": {\n    \"message\": \"删除，确认，确定，是，delete,confirm,yes,ok,remove\",\n    \"description\": \"匹配原生删除/确认按钮的关键词列表（逗号分隔）\"\n  },\n  \"generalOptions\": {\n    \"message\": \"通用选项\",\n    \"description\": \"通用选项区域标签\"\n  },\n  \"enableTabTitleUpdate\": {\n    \"message\": \"同步标签页标题与对话\",\n    \"description\": \"启用标签页标题同步开关标签\"\n  },\n  \"enableTabTitleUpdateHint\": {\n    \"message\": \"自动将浏览器标签页标题更新为当前对话的标题\",\n    \"description\": \"标签页标题同步功能提示\"\n  },\n  \"enableMermaidRendering\": {\n    \"message\": \"启用 Mermaid 图表渲染\",\n    \"description\": \"启用 Mermaid 图表渲染开关标签\"\n  },\n  \"enableMermaidRenderingHint\": {\n    \"message\": \"自动将代码块中的 Mermaid 代码渲染为图表\",\n    \"description\": \"Mermaid 渲染功能提示\"\n  },\n  \"enableQuoteReply\": {\n    \"message\": \"启用引用回复\",\n    \"description\": \"启用引用回复开关标签\"\n  },\n  \"enableQuoteReplyHint\": {\n    \"message\": \"在对话中选中文字时显示悬浮按钮，用于引用所选内容\",\n    \"description\": \"引用回复功能提示\"\n  },\n  \"contextSync\": {\n    \"message\": \"上下文同步\",\n    \"description\": \"上下文同步标题\"\n  },\n  \"contextSyncDescription\": {\n    \"message\": \"将对话上下文同步到本地 IDE\",\n    \"description\": \"上下文同步描述\"\n  },\n  \"syncToIDE\": {\n    \"message\": \"同步到 IDE\",\n    \"description\": \"同步到 IDE 按钮\"\n  },\n  \"ideOnline\": {\n    \"message\": \"IDE 在线\",\n    \"description\": \"IDE 在线状态\"\n  },\n  \"ideOffline\": {\n    \"message\": \"IDE 离线\",\n    \"description\": \"IDE 离线状态\"\n  },\n  \"syncing\": {\n    \"message\": \"同步中...\",\n    \"description\": \"同步中状态\"\n  },\n  \"checkServer\": {\n    \"message\": \"请在 VS Code 中启动 AI Sync 服务器\",\n    \"description\": \"检查服务器提示\"\n  },\n  \"capturing\": {\n    \"message\": \"正在获取对话...\",\n    \"description\": \"获取对话状态\"\n  },\n  \"syncedSuccess\": {\n    \"message\": \"同步成功！\",\n    \"description\": \"同步成功消息\"\n  },\n  \"downloadingOriginal\": {\n    \"message\": \"正在下载原始图片\",\n    \"description\": \"下载原始图片时的状态\"\n  },\n  \"downloadingOriginalLarge\": {\n    \"message\": \"正在下载原始图片（大文件）\",\n    \"description\": \"下载较大原始图片时的状态\"\n  },\n  \"downloadLargeWarning\": {\n    \"message\": \"大文件警告\",\n    \"description\": \"大文件下载提示\"\n  },\n  \"downloadProcessing\": {\n    \"message\": \"正在处理水印中\",\n    \"description\": \"处理水印时的状态\"\n  },\n  \"downloadSuccess\": {\n    \"message\": \"正在下载\",\n    \"description\": \"开始下载时的状态\"\n  },\n  \"downloadError\": {\n    \"message\": \"失败\",\n    \"description\": \"下载失败时的状态前缀\"\n  },\n  \"recentsHide\": {\n    \"message\": \"隐藏最近项目\",\n    \"description\": \"隐藏最近预览区域的提示\"\n  },\n  \"recentsShow\": {\n    \"message\": \"显示最近项目\",\n    \"description\": \"显示最近预览区域的提示\"\n  },\n  \"gemsHide\": {\n    \"message\": \"隐藏 Gems\",\n    \"description\": \"隐藏 Gems 列表区域的提示\"\n  },\n  \"gemsShow\": {\n    \"message\": \"显示 Gems\",\n    \"description\": \"显示 Gems 列表区域的提示\"\n  },\n  \"setAsDefaultModel\": {\n    \"message\": \"设为新对话的默认模型\",\n    \"description\": \"Tooltip for setting default model\"\n  },\n  \"cancelDefaultModel\": {\n    \"message\": \"取消默认模型\",\n    \"description\": \"Tooltip for cancelling default model\"\n  },\n  \"defaultModelSet\": {\n    \"message\": \"已设置默认模型：$1\",\n    \"description\": \"Toast message when default model is set\"\n  },\n  \"defaultModelCleared\": {\n    \"message\": \"已取消默认模型\",\n    \"description\": \"Toast message when default model is cleared\"\n  },\n  \"sidebarAutoHide\": {\n    \"message\": \"侧边栏自动收起\",\n    \"description\": \"侧边栏自动收起开关标签\"\n  },\n  \"sidebarAutoHideHint\": {\n    \"message\": \"鼠标离开时自动收起侧边栏，鼠标进入时展开\",\n    \"description\": \"侧边栏自动收起功能提示\"\n  },\n  \"sidebarFullHide\": {\n    \"message\": \"完全隐藏侧边栏\",\n    \"description\": \"完全隐藏侧边栏开关标签\"\n  },\n  \"sidebarFullHideHint\": {\n    \"message\": \"收起时完全隐藏侧边栏，鼠标移到左边缘唤出\",\n    \"description\": \"完全隐藏侧边栏功能提示\"\n  },\n  \"folderSpacing\": {\n    \"message\": \"文件夹间距\",\n    \"description\": \"文件夹间距调节标签\"\n  },\n  \"folderSpacingCompact\": {\n    \"message\": \"紧凑\",\n    \"description\": \"紧凑文件夹间距标签\"\n  },\n  \"folderSpacingSpacious\": {\n    \"message\": \"宽松\",\n    \"description\": \"宽松文件夹间距标签\"\n  },\n  \"folderTreeIndent\": {\n    \"message\": \"子文件夹缩进\",\n    \"description\": \"子文件夹树形缩进调节标签\"\n  },\n  \"folderTreeIndentCompact\": {\n    \"message\": \"更窄\",\n    \"description\": \"较窄的子文件夹缩进标签\"\n  },\n  \"folderTreeIndentSpacious\": {\n    \"message\": \"更宽\",\n    \"description\": \"较宽的子文件夹缩进标签\"\n  },\n  \"export_select_mode_select_all\": {\n    \"message\": \"全选\",\n    \"description\": \"Selection mode select all toggle\"\n  },\n  \"export_select_mode_count\": {\n    \"message\": \"已选择 {count} 条\",\n    \"description\": \"Selection mode selected message count\"\n  },\n  \"export_select_mode_toggle\": {\n    \"message\": \"选择消息\",\n    \"description\": \"Selection mode per-message toggle tooltip\"\n  },\n  \"export_select_mode_select_below\": {\n    \"message\": \"选择下方消息\",\n    \"description\": \"Selection mode top-left button to select messages below current line\"\n  },\n  \"export_select_mode_empty\": {\n    \"message\": \"请至少选择一条消息进行导出\",\n    \"description\": \"Selection mode validation when nothing is selected\"\n  },\n  \"export_format_image_description\": {\n    \"message\": \"单张 PNG 图片，便于移动端分享。\",\n    \"description\": \"Description for Image export format\"\n  },\n  \"export_image_progress\": {\n    \"message\": \"正在生成图片...\",\n    \"description\": \"Progress message while generating image export\"\n  },\n  \"deepResearchSaveReport\": {\n    \"message\": \"保存报告\",\n    \"description\": \"Deep Research save report button\"\n  },\n  \"deepResearchSaveReportTooltip\": {\n    \"message\": \"导出此研究报告\",\n    \"description\": \"Deep Research save report tooltip\"\n  },\n  \"export_fontsize_label\": {\n    \"message\": \"字体大小\",\n    \"description\": \"Label for font size slider in export dialog\"\n  },\n  \"export_fontsize_preview\": {\n    \"message\": \"天地玄黄，宇宙洪荒。日月盈昃，辰宿列张。\",\n    \"description\": \"Preview text for font size slider in export dialog\"\n  },\n  \"export_error_generic\": {\n    \"message\": \"导出失败：{error}\",\n    \"description\": \"带具体原因的导出失败提示\"\n  },\n  \"export_error_refresh_retry\": {\n    \"message\": \"因网络原因，图片导出失败，请刷新页面后重试。\",\n    \"description\": \"图片导出遇到瞬时加载失败时的刷新重试提示\"\n  },\n  \"export_md_include_source_confirm\": {\n    \"message\": \"導出內容包含網路搜尋圖片。是否在 Markdown 中包含圖片來源連結？\",\n    \"description\": \"Confirm dialog when exporting markdown with web search images\"\n  },\n  \"deepResearch_exportedAt\": {\n    \"message\": \"导出时间\",\n    \"description\": \"深度研究导出时间戳标签\"\n  },\n  \"deepResearch_totalPhases\": {\n    \"message\": \"总思考阶段\",\n    \"description\": \"深度研究总思考阶段标签\"\n  },\n  \"deepResearch_thinkingPhase\": {\n    \"message\": \"思考阶段\",\n    \"description\": \"深度研究思考阶段标题\"\n  },\n  \"deepResearch_researchedWebsites\": {\n    \"message\": \"研究网站\",\n    \"description\": \"深度研究已研究网站章节标题\"\n  },\n  \"visualEffect\": {\n    \"message\": \"视觉特效\",\n    \"description\": \"Visual effect selector label\"\n  },\n  \"visualEffectHint\": {\n    \"message\": \"为页面增添季节氛围\",\n    \"description\": \"Visual effect feature hint\"\n  },\n  \"visualEffectOff\": {\n    \"message\": \"关闭\",\n    \"description\": \"Visual effect off option\"\n  },\n  \"visualEffectSnow\": {\n    \"message\": \"飘雪\",\n    \"description\": \"Visual effect snow option\"\n  },\n  \"visualEffectSakura\": {\n    \"message\": \"樱花\",\n    \"description\": \"Visual effect sakura option\"\n  },\n  \"visualEffectRain\": {\n    \"message\": \"雨\",\n    \"description\": \"Visual effect rain option\"\n  },\n  \"changelog_title\": {\n    \"message\": \"更新内容\",\n    \"description\": \"Changelog modal title\"\n  },\n  \"changelog_close\": {\n    \"message\": \"知道了\",\n    \"description\": \"Changelog modal close button\"\n  },\n  \"changelog_docs_link\": {\n    \"message\": \"文档\",\n    \"description\": \"Changelog modal documentation link text\"\n  },\n  \"changelog_recommendation\": {\n    \"message\": \"如果 Voyager 帮到了你，欢迎推荐给朋友或发到社媒，记得 @ 作者哦！\",\n    \"description\": \"Changelog modal recommendation message\"\n  },\n  \"changelog_social_xiaohongshu\": {\n    \"message\": \"小红书\",\n    \"description\": \"Xiaohongshu platform name for social links\"\n  },\n  \"changelog_social_zhihu\": {\n    \"message\": \"知乎\",\n    \"description\": \"Zhihu platform name for social links\"\n  },\n  \"changelog_docs_hint\": {\n    \"message\": \"详细功能见文档\",\n    \"description\": \"Changelog annotation pointing to docs icon\"\n  },\n  \"changelog_rate_chrome\": {\n    \"message\": \"觉得 Voyager 好用吗？在 Chrome 应用商店给个好评，能帮助更多人发现它！\",\n    \"description\": \"Chrome Web Store rating prompt in changelog modal\"\n  },\n  \"changelog_rate_chrome_cta\": {\n    \"message\": \"立即评分\",\n    \"description\": \"Chrome Web Store rating CTA button text\"\n  },\n  \"changelog_badge_mode\": {\n    \"message\": \"通过悬浮球上的 NEW 标记提醒，而非弹出此窗口\",\n    \"description\": \"Changelog notification mode toggle label\"\n  },\n  \"forkConversation\": {\n    \"message\": \"分叉\",\n    \"description\": \"分叉对话按钮标签\"\n  },\n  \"forkConfirm\": {\n    \"message\": \"从此处分叉对话？\",\n    \"description\": \"分叉确认提示\"\n  },\n  \"forkConfirmBtn\": {\n    \"message\": \"分叉\",\n    \"description\": \"分叉确认按钮\"\n  },\n  \"forkCancel\": {\n    \"message\": \"取消\",\n    \"description\": \"分叉取消按钮\"\n  },\n  \"forkBranch\": {\n    \"message\": \"分支\",\n    \"description\": \"分叉分支标签\"\n  },\n  \"forkOriginal\": {\n    \"message\": \"原始\",\n    \"description\": \"原始分叉分支标签\"\n  },\n  \"forkCurrent\": {\n    \"message\": \"当前\",\n    \"description\": \"当前分叉分支标签\"\n  },\n  \"forkPreparing\": {\n    \"message\": \"准备分叉中...\",\n    \"description\": \"分叉准备状态\"\n  },\n  \"forkPasteHint\": {\n    \"message\": \"按回车发送以创建分叉\",\n    \"description\": \"分叉粘贴提示文字\"\n  },\n  \"forkDeleteData\": {\n    \"message\": \"删除此分支\",\n    \"description\": \"Delete fork branch metadata button\"\n  },\n  \"forkDeleteDataConfirm\": {\n    \"message\": \"删除这个分支关联？仅删除 Voyager 的分支元数据，不会删除任何对话。\",\n    \"description\": \"Fork metadata delete confirmation prompt\"\n  },\n  \"enableForkFeature\": {\n    \"message\": \"启用对话分叉\",\n    \"description\": \"Enable conversation fork feature toggle label\"\n  },\n  \"enableForkFeatureHint\": {\n    \"message\": \"在 Gemini 对话中显示 Fork 按钮和分支序号\",\n    \"description\": \"Enable conversation fork feature toggle hint\"\n  },\n  \"enableAccountIsolation\": {\n    \"message\": \"启用硬隔离（Hard Isolation）\",\n    \"description\": \"Enable hard isolation for account-scoped folder data and cloud sync.\"\n  },\n  \"enableAccountIsolationHint\": {\n    \"message\": \"按 Google 账号严格隔离文件夹与云同步数据，避免任何跨账号混用。\",\n    \"description\": \"Hint text for account isolation option.\"\n  },\n  \"currentPlatform\": {\n    \"message\": \"当前平台\",\n    \"description\": \"Current active platform label in popup.\"\n  },\n  \"platformGemini\": {\n    \"message\": \"Gemini\",\n    \"description\": \"Gemini platform label.\"\n  },\n  \"platformAIStudio\": {\n    \"message\": \"AI Studio\",\n    \"description\": \"AI Studio platform label.\"\n  },\n  \"enableOnAIStudio\": {\n    \"message\": \"在 AI Studio 上启用\",\n    \"description\": \"Toggle to enable/disable Voyager on AI Studio\"\n  },\n  \"enableOnAIStudioHint\": {\n    \"message\": \"关闭后将禁用 AI Studio 上的 Voyager 功能（Prompt Manager 不受影响）\",\n    \"description\": \"Hint for AI Studio enable toggle\"\n  },\n  \"export_select_mode_only_user\": {\n    \"message\": \"仅选用户\",\n    \"description\": \"Export selection mode: select only user messages\"\n  },\n  \"export_select_mode_only_ai\": {\n    \"message\": \"仅选模型\",\n    \"description\": \"Export selection mode: select only AI messages\"\n  },\n  \"aiOrgCopyButton\": {\n    \"message\": \"AI 整理\",\n    \"description\": \"复制文件夹结构和对话以供 AI 整理的按钮\"\n  },\n  \"aiOrgCopied\": { \"message\": \"已复制！\", \"description\": \"复制 AI 整理提示后的确认\" },\n  \"aiOrgError\": {\n    \"message\": \"失败 — 请先打开 Gemini\",\n    \"description\": \"复制 AI 整理提示失败时的错误\"\n  },\n  \"aiOrgCopyHint\": {\n    \"message\": \"复制所有对话和文件夹结构作为提示词。粘贴到 Gemini 中即可获得可导入的文件夹方案。\",\n    \"description\": \"AI 整理复制按钮下方的提示文字\"\n  },\n  \"aiOrgCurrentFolders\": { \"message\": \"当前文件夹结构\", \"description\": \"AI 整理提示中的标题\" },\n  \"aiOrgUnfiledConversations\": {\n    \"message\": \"未归类的对话\",\n    \"description\": \"不在任何文件夹中的对话标题\"\n  },\n  \"aiOrgEmpty\": { \"message\": \"空\", \"description\": \"AI 整理提示中空文件夹的标签\" },\n  \"aiOrgInstructions\": { \"message\": \"指示\", \"description\": \"AI 指示部分的标题\" },\n  \"aiOrgInstructionsBody\": {\n    \"message\": \"根据以上对话和文件夹结构，请将它们重新整理为合理的文件夹层级。输出一个使用以下格式的 JSON 文件，可以直接导入到 Gemini Voyager 扩展中（使用「合并」导入策略）。保留已有的合理文件夹，根据需要创建新文件夹，将未归类的对话移入合适的文件夹。每个文件夹需要一个唯一 ID（使用短随机字符串），每个对话必须保留其原始的 conversationId 和 url。\",\n    \"description\": \"AI 生成可导入文件夹结构的指示正文\"\n  },\n  \"showMessageTimestamps\": {\n    \"message\": \"显示消息时间\",\n    \"description\": \"显示每条消息的时间戳\"\n  },\n  \"showMessageTimestampsHint\": {\n    \"message\": \"显示每条消息的发送时间\",\n    \"description\": \"消息时间戳功能提示\"\n  },\n  \"moveSectionUp\": {\n    \"message\": \"上移\",\n    \"description\": \"Tooltip for moving a settings section up in popup\"\n  },\n  \"moveSectionDown\": {\n    \"message\": \"下移\",\n    \"description\": \"Tooltip for moving a settings section down in popup\"\n  }\n}\n"
  },
  {
    "path": "src/locales/zh_TW/messages.json",
    "content": "{\n  \"extName\": {\n    \"message\": \"Voyager\",\n    \"description\": \"擴充功能名稱\"\n  },\n  \"extDescription\": {\n    \"message\": \"透過時間軸導覽、資料夾管理、提示詞庫和對話匯出功能，全面提升您的 Gemini™ 使用體驗。\",\n    \"description\": \"擴充功能描述\"\n  },\n  \"scrollMode\": {\n    \"message\": \"捲動模式\",\n    \"description\": \"捲動模式標籤\"\n  },\n  \"flow\": {\n    \"message\": \"流動\",\n    \"description\": \"流動模式\"\n  },\n  \"jump\": {\n    \"message\": \"跳躍\",\n    \"description\": \"跳躍模式\"\n  },\n  \"hideOuterContainer\": {\n    \"message\": \"隱藏外部容器\",\n    \"description\": \"隱藏外部容器標籤\"\n  },\n  \"draggableTimeline\": {\n    \"message\": \"可拖曳時間軸\",\n    \"description\": \"可拖曳時間軸標籤\"\n  },\n  \"enableMarkerLevel\": {\n    \"message\": \"啟用節點層級\",\n    \"description\": \"啟用時間軸節點層級功能\"\n  },\n  \"enableMarkerLevelHint\": {\n    \"message\": \"右鍵點擊時間軸節點可設定其層級和折疊子節點\",\n    \"description\": \"節點層級功能提示\"\n  },\n  \"experimentalLabel\": {\n    \"message\": \"實驗性\",\n    \"description\": \"實驗性功能標籤\"\n  },\n  \"resetPosition\": {\n    \"message\": \"重設位置\",\n    \"description\": \"重設位置按鈕\"\n  },\n  \"resetTimelinePosition\": {\n    \"message\": \"重設時間軸位置\",\n    \"description\": \"重設時間軸位置按鈕（在時間軸選項中）\"\n  },\n  \"language\": {\n    \"message\": \"語言\",\n    \"description\": \"語言標籤\"\n  },\n  \"pm_title\": {\n    \"message\": \"Voyager\",\n    \"description\": \"面板標題\"\n  },\n  \"pm_add\": {\n    \"message\": \"新增\",\n    \"description\": \"新增 prompt 按鈕\"\n  },\n  \"pm_search_placeholder\": {\n    \"message\": \"搜尋 prompt 或標籤\",\n    \"description\": \"搜尋佔位符\"\n  },\n  \"pm_import\": {\n    \"message\": \"匯入\",\n    \"description\": \"匯入按鈕\"\n  },\n  \"pm_export\": {\n    \"message\": \"匯出\",\n    \"description\": \"匯出按鈕\"\n  },\n  \"pm_prompt_placeholder\": {\n    \"message\": \"Prompt 文字\",\n    \"description\": \"Prompt 輸入佔位符\"\n  },\n  \"pm_tags_placeholder\": {\n    \"message\": \"標籤（逗號分隔）\",\n    \"description\": \"標籤輸入佔位符\"\n  },\n  \"pm_save\": {\n    \"message\": \"儲存\",\n    \"description\": \"儲存\"\n  },\n  \"pm_cancel\": {\n    \"message\": \"取消\",\n    \"description\": \"取消\"\n  },\n  \"pm_all_tags\": {\n    \"message\": \"全部\",\n    \"description\": \"全部標籤\"\n  },\n  \"pm_empty\": {\n    \"message\": \"暫無 prompt\",\n    \"description\": \"空狀態\"\n  },\n  \"pm_copy\": {\n    \"message\": \"複製\",\n    \"description\": \"複製 prompt\"\n  },\n  \"pm_copied\": {\n    \"message\": \"已複製\",\n    \"description\": \"複製成功\"\n  },\n  \"pm_delete\": {\n    \"message\": \"刪除\",\n    \"description\": \"刪除 prompt\"\n  },\n  \"pm_delete_confirm\": {\n    \"message\": \"確定刪除該 prompt 嗎？\",\n    \"description\": \"刪除確認\"\n  },\n  \"pm_lock\": {\n    \"message\": \"鎖定位置\",\n    \"description\": \"鎖定面板位置\"\n  },\n  \"pm_unlock\": {\n    \"message\": \"取消鎖定\",\n    \"description\": \"取消鎖定面板位置\"\n  },\n  \"pm_import_invalid\": {\n    \"message\": \"檔案格式不正確\",\n    \"description\": \"匯入失敗\"\n  },\n  \"pm_import_success\": {\n    \"message\": \"已匯入 {count} 條\",\n    \"description\": \"匯入成功\"\n  },\n  \"pm_duplicate\": {\n    \"message\": \"重複的 prompt\",\n    \"description\": \"新增重複\"\n  },\n  \"pm_deleted\": {\n    \"message\": \"已刪除\",\n    \"description\": \"刪除提示\"\n  },\n  \"pm_edit\": {\n    \"message\": \"編輯\",\n    \"description\": \"編輯 prompt\"\n  },\n  \"pm_expand\": {\n    \"message\": \"展開\",\n    \"description\": \"展開提示詞文字\"\n  },\n  \"pm_collapse\": {\n    \"message\": \"收起\",\n    \"description\": \"收起提示詞文字\"\n  },\n  \"pm_saved\": {\n    \"message\": \"已儲存\",\n    \"description\": \"儲存提示\"\n  },\n  \"pm_settings\": {\n    \"message\": \"設定\",\n    \"description\": \"設定按鈕\"\n  },\n  \"pm_settings_tooltip\": {\n    \"message\": \"調整擴充功能設定\",\n    \"description\": \"設定按鈕提示\"\n  },\n  \"pm_settings_fallback\": {\n    \"message\": \"請點擊瀏覽器工具列中的擴充功能圖示開啟設定\",\n    \"description\": \"設定開啟失敗提示\"\n  },\n  \"pm_backup\": {\n    \"message\": \"本機備份\",\n    \"description\": \"本機備份按鈕\"\n  },\n  \"pm_backup_tooltip\": {\n    \"message\": \"將提示詞和資料夾備份到帶時間戳記的資料夾\",\n    \"description\": \"備份按鈕提示\"\n  },\n  \"pm_backup_cancelled\": {\n    \"message\": \"備份已取消\",\n    \"description\": \"備份取消提示\"\n  },\n  \"pm_backup_error\": {\n    \"message\": \"✗ 備份失敗\",\n    \"description\": \"備份失敗提示\"\n  },\n  \"pm_backup_hint_options\": {\n    \"message\": \"功能已整合在 Gemini 頁面的提示詞管理器中。\",\n    \"description\": \"選項頁面的備份說明\"\n  },\n  \"pm_backup_step1\": {\n    \"message\": \"開啟 Gemini 頁面（gemini.google.com）\",\n    \"description\": \"備份步驟 1\"\n  },\n  \"pm_backup_step2\": {\n    \"message\": \"點擊右下角的擴充功能圖示開啟提示詞管理器\",\n    \"description\": \"備份步驟 2\"\n  },\n  \"pm_backup_step3\": {\n    \"message\": \"點擊 \\\"💾 本機備份\\\" 按鈕，選擇備份資料夾\",\n    \"description\": \"備份步驟 3\"\n  },\n  \"pm_backup_note\": {\n    \"message\": \"備份將包含所有提示詞和資料夾，並儲存在帶時間戳記的資料夾中（格式：backup-YYYYMMDD-HHMMSS）\",\n    \"description\": \"備份功能說明\"\n  },\n  \"pm_theme_light\": {\n    \"message\": \"切換至淺色模式\",\n    \"description\": \"Theme toggle tooltip - switch to light\"\n  },\n  \"pm_theme_dark\": {\n    \"message\": \"切換至深色模式\",\n    \"description\": \"Theme toggle tooltip - switch to dark\"\n  },\n  \"extensionVersion\": {\n    \"message\": \"版本\",\n    \"description\": \"擴充功能版本標籤\"\n  },\n  \"newVersionAvailable\": {\n    \"message\": \"發現新版本\",\n    \"description\": \"更新提醒標題\"\n  },\n  \"currentVersionLabel\": {\n    \"message\": \"目前\",\n    \"description\": \"目前版本號標籤\"\n  },\n  \"latestVersionLabel\": {\n    \"message\": \"最新\",\n    \"description\": \"最新版本號標籤\"\n  },\n  \"updateNow\": {\n    \"message\": \"立即更新\",\n    \"description\": \"跳轉更新 CTA\"\n  },\n  \"safariUpdateNotSynced\": {\n    \"message\": \"更新源尚未同步，請幾小時後再試\",\n    \"description\": \"Safari 上 DMG 檔案尚未上傳到 GitHub releases 時顯示\"\n  },\n  \"starProject\": {\n    \"message\": \"為專案點亮 ⭐️\",\n    \"description\": \"Github 引導\"\n  },\n  \"officialDocs\": {\n    \"message\": \"官方文件\",\n    \"description\": \"Official docs link\"\n  },\n  \"exportChatJson\": {\n    \"message\": \"匯出對話記錄\",\n    \"description\": \"匯出按鈕提示\"\n  },\n  \"folder_title\": {\n    \"message\": \"資料夾\",\n    \"description\": \"資料夾區域標題\"\n  },\n  \"folder_create\": {\n    \"message\": \"建立資料夾\",\n    \"description\": \"建立資料夾按鈕提示\"\n  },\n  \"folder_name_prompt\": {\n    \"message\": \"輸入資料夾名稱：\",\n    \"description\": \"建立資料夾名稱提示\"\n  },\n  \"folder_rename_prompt\": {\n    \"message\": \"輸入新名稱：\",\n    \"description\": \"重新命名資料夾提示\"\n  },\n  \"folder_delete_confirm\": {\n    \"message\": \"確定刪除此資料夾及其所有內容嗎？\",\n    \"description\": \"刪除資料夾確認\"\n  },\n  \"folder_create_subfolder\": {\n    \"message\": \"建立子資料夾\",\n    \"description\": \"建立子資料夾選單項目\"\n  },\n  \"folder_rename\": {\n    \"message\": \"重新命名\",\n    \"description\": \"重新命名資料夾選單項目\"\n  },\n  \"folder_delete\": {\n    \"message\": \"刪除\",\n    \"description\": \"刪除資料夾選單項目\"\n  },\n  \"folder_empty\": {\n    \"message\": \"暫無資料夾\",\n    \"description\": \"沒有資料夾時顯示的佔位提示\"\n  },\n  \"folder_pin\": {\n    \"message\": \"置頂資料夾\",\n    \"description\": \"置頂資料夾選單項目\"\n  },\n  \"folder_unpin\": {\n    \"message\": \"取消置頂\",\n    \"description\": \"取消置頂資料夾選單項目\"\n  },\n  \"folder_remove_conversation\": {\n    \"message\": \"從資料夾中移除\",\n    \"description\": \"移除對話按鈕提示\"\n  },\n  \"folder_remove_conversation_confirm\": {\n    \"message\": \"從此資料夾中移除「{title}」？\",\n    \"description\": \"移除對話確認\"\n  },\n  \"conversation_more\": {\n    \"message\": \"更多選項\",\n    \"description\": \"對話更多選項按鈕提示\"\n  },\n  \"conversation_move_to_folder\": {\n    \"message\": \"移動到資料夾\",\n    \"description\": \"移動對話到資料夾選單項目\"\n  },\n  \"conversation_move_to_folder_title\": {\n    \"message\": \"移動到資料夾\",\n    \"description\": \"移動對話到資料夾對話框標題\"\n  },\n  \"chatWidth\": {\n    \"message\": \"對話區域寬度\",\n    \"description\": \"對話區域寬度調整標籤\"\n  },\n  \"chatWidthNarrow\": {\n    \"message\": \"窄\",\n    \"description\": \"窄對話區域寬度標籤\"\n  },\n  \"chatWidthWide\": {\n    \"message\": \"寬\",\n    \"description\": \"寬對話區域寬度標籤\"\n  },\n  \"sidebarWidth\": {\n    \"message\": \"側邊欄寬度\",\n    \"description\": \"側邊欄寬度調節標籤\"\n  },\n  \"sidebarWidthNarrow\": {\n    \"message\": \"窄\",\n    \"description\": \"側邊欄變窄標籤\"\n  },\n  \"sidebarWidthWide\": {\n    \"message\": \"寬\",\n    \"description\": \"側邊欄變寬標籤\"\n  },\n  \"formula_copied\": {\n    \"message\": \"✓ 公式已複製\",\n    \"description\": \"公式複製成功訊息\"\n  },\n  \"formula_copy_failed\": {\n    \"message\": \"✗ 複製失敗\",\n    \"description\": \"公式複製失敗訊息\"\n  },\n  \"formulaCopyFormat\": {\n    \"message\": \"公式複製格式\",\n    \"description\": \"公式複製格式設定標籤\"\n  },\n  \"formulaCopyFormatLatex\": {\n    \"message\": \"LaTeX\",\n    \"description\": \"LaTeX 格式選項\"\n  },\n  \"formulaCopyFormatUnicodeMath\": {\n    \"message\": \"MathML (Word)\",\n    \"description\": \"Label for MathML formula copy format option (formerly UnicodeMath)\"\n  },\n  \"formulaCopyFormatHint\": {\n    \"message\": \"選擇點擊公式時複製的格式\",\n    \"description\": \"公式複製格式提示\"\n  },\n  \"formulaCopyFormatNoDollar\": {\n    \"message\": \"LaTeX (純文字，無 $ 符號)\",\n    \"description\": \"LaTeX 格式選項，無美元符號\"\n  },\n  \"folder_filter_current_user\": {\n    \"message\": \"帳號隔離模式\",\n    \"description\": \"Show only conversations from the current Google account\"\n  },\n  \"folder_export\": {\n    \"message\": \"匯出資料夾\",\n    \"description\": \"匯出資料夾按鈕提示\"\n  },\n  \"folder_import_export\": {\n    \"message\": \"匯入/匯出資料夾\",\n    \"description\": \"合併的匯入匯出按鈕提示\"\n  },\n  \"folder_cloud_upload\": {\n    \"message\": \"上傳到雲端\",\n    \"description\": \"雲端上傳按鈕提示\"\n  },\n  \"folder_cloud_sync\": {\n    \"message\": \"從雲端同步\",\n    \"description\": \"雲端同步按鈕提示\"\n  },\n  \"folder_import\": {\n    \"message\": \"匯入資料夾\",\n    \"description\": \"匯入資料夾按鈕提示\"\n  },\n  \"folder_import_title\": {\n    \"message\": \"匯入資料夾設定\",\n    \"description\": \"匯入對話框標題\"\n  },\n  \"folder_import_strategy\": {\n    \"message\": \"匯入策略：\",\n    \"description\": \"匯入策略標籤\"\n  },\n  \"folder_import_merge\": {\n    \"message\": \"與現有資料夾合併\",\n    \"description\": \"合併匯入策略\"\n  },\n  \"folder_import_overwrite\": {\n    \"message\": \"覆蓋現有資料夾\",\n    \"description\": \"覆蓋匯入策略\"\n  },\n  \"folder_import_select_file\": {\n    \"message\": \"選擇要匯入的 JSON 檔案\",\n    \"description\": \"匯入檔案選擇提示\"\n  },\n  \"folder_import_success\": {\n    \"message\": \"✓ 已匯入 {folders} 個資料夾，{conversations} 個對話\",\n    \"description\": \"匯入成功訊息\"\n  },\n  \"folder_import_success_skipped\": {\n    \"message\": \"✓ 已匯入 {folders} 個資料夾，{conversations} 個對話（略過 {skipped} 個重複項）\",\n    \"description\": \"匯入成功並略過重複項訊息\"\n  },\n  \"folder_import_error\": {\n    \"message\": \"✗ 匯入失敗：{error}\",\n    \"description\": \"匯入錯誤訊息\"\n  },\n  \"folder_export_success\": {\n    \"message\": \"✓ 資料夾匯出成功\",\n    \"description\": \"匯出成功訊息\"\n  },\n  \"folder_import_invalid_format\": {\n    \"message\": \"檔案格式無效。請選擇有效的資料夾設定檔。\",\n    \"description\": \"無效格式錯誤\"\n  },\n  \"folder_import_confirm_overwrite\": {\n    \"message\": \"這將取代所有現有資料夾。系統將建立備份。是否繼續？\",\n    \"description\": \"覆蓋確認\"\n  },\n  \"folder_import_paste_json\": {\n    \"message\": \"或直接貼上 JSON\",\n    \"description\": \"匯入對話框中顯示貼上文字框的切換按鈕\"\n  },\n  \"folder_import_paste_placeholder\": {\n    \"message\": \"在此貼上 JSON...\",\n    \"description\": \"貼上文字框的佔位文字\"\n  },\n  \"export_dialog_title\": {\n    \"message\": \"匯出對話\",\n    \"description\": \"匯出對話框標題\"\n  },\n  \"export_dialog_select\": {\n    \"message\": \"選擇匯出格式：\",\n    \"description\": \"匯出格式選擇標籤\"\n  },\n  \"export_dialog_warning\": {\n    \"message\": \"⚠️ 點擊匯出後，系統將自動跳轉至首則訊息以載入完整內容。在此期間請勿操作，跳轉完成後將自動繼續匯出。\",\n    \"description\": \"關於內容完整性和自動跳轉的提示\"\n  },\n  \"export_dialog_safari_cmdp_hint\": {\n    \"message\": \"Safari 提示：請先點選下方「匯出」按鈕，稍等片刻，再按 ⌘P 選擇「儲存為 PDF」。\",\n    \"description\": \"Safari 下 PDF 匯出的提示\"\n  },\n  \"export_toast_safari_pdf_ready\": {\n    \"message\": \"現在可以按 Command + P 匯出 PDF。\",\n    \"description\": \"Safari 中完成匯出準備後的提示\"\n  },\n  \"export_dialog_safari_markdown_hint\": {\n    \"message\": \"提醒：由於 Safari 限制，無法提取聊天記錄中的圖片，如需完整匯出，建議使用 PDF 匯出\",\n    \"description\": \"關於 Safari Markdown 匯出無法提取圖片的警告\"\n  },\n  \"export_format_json_description\": {\n    \"message\": \"開發者使用的機器可讀格式\",\n    \"description\": \"JSON 匯出格式描述\"\n  },\n  \"export_format_markdown_description\": {\n    \"message\": \"清晰便攜的文字格式（推薦）\",\n    \"description\": \"Markdown 匯出格式描述\"\n  },\n  \"export_format_pdf_description\": {\n    \"message\": \"適合列印的格式（透過另存為 PDF）\",\n    \"description\": \"PDF 匯出格式描述\"\n  },\n  \"editInputWidth\": {\n    \"message\": \"編輯輸入框寬度\",\n    \"description\": \"編輯輸入框寬度調整標籤\"\n  },\n  \"editInputWidthNarrow\": {\n    \"message\": \"窄\",\n    \"description\": \"窄編輯輸入框寬度標籤\"\n  },\n  \"editInputWidthWide\": {\n    \"message\": \"寬\",\n    \"description\": \"寬編輯輸入框寬度標籤\"\n  },\n  \"timelineOptions\": {\n    \"message\": \"時間軸選項\",\n    \"description\": \"時間軸選項區域標籤\"\n  },\n  \"folderOptions\": {\n    \"message\": \"資料夾選項\",\n    \"description\": \"資料夾選項區域標籤\"\n  },\n  \"enableFolderFeature\": {\n    \"message\": \"啟用資料夾功能\",\n    \"description\": \"啟用或停用資料夾功能\"\n  },\n  \"hideArchivedConversations\": {\n    \"message\": \"隱藏已歸檔對話\",\n    \"description\": \"從主清單隱藏已新增到資料夾的對話\"\n  },\n  \"conversation_star\": {\n    \"message\": \"星號標記對話\",\n    \"description\": \"星號標記對話按鈕提示\"\n  },\n  \"conversation_unstar\": {\n    \"message\": \"取消星號標記\",\n    \"description\": \"取消星號標記對話按鈕提示\"\n  },\n  \"conversation_untitled\": {\n    \"message\": \"未命名\",\n    \"description\": \"無標題對話的備用標題\"\n  },\n  \"geminiOnlyNotice\": {\n    \"message\": \"預設僅在 Gemini（含企業版）和 AI Studio 生效。想在其他網站使用提示詞管理器，請在下方新增並授權。\",\n    \"description\": \"提示預設範圍為 Gemini/AI Studio\"\n  },\n  \"folderManager_dataLossWarning\": {\n    \"message\": \"⚠️ 警告：無法載入資料夾資料。您的資料夾可能已損壞。請檢查瀏覽器主控台以取得詳細資訊，並在可用時嘗試從備份還原。\",\n    \"description\": \"資料夾資料載入失敗時顯示的警告\"\n  },\n  \"backupOptions\": {\n    \"message\": \"自動備份\",\n    \"description\": \"備份選項區域標籤\"\n  },\n  \"backupEnabled\": {\n    \"message\": \"啟用自動備份\",\n    \"description\": \"啟用自動備份開關\"\n  },\n  \"backupNow\": {\n    \"message\": \"立即備份\",\n    \"description\": \"立即備份按鈕\"\n  },\n  \"backupSelectFolder\": {\n    \"message\": \"選擇備份資料夾\",\n    \"description\": \"選擇備份資料夾按鈕\"\n  },\n  \"backupFolderSelected\": {\n    \"message\": \"備份資料夾：{folder}\",\n    \"description\": \"顯示已選擇的備份資料夾\"\n  },\n  \"backupIncludePrompts\": {\n    \"message\": \"包含提示詞\",\n    \"description\": \"備份中包含提示詞開關\"\n  },\n  \"backupIncludeFolders\": {\n    \"message\": \"包含資料夾\",\n    \"description\": \"備份中包含資料夾開關\"\n  },\n  \"backupIntervalLabel\": {\n    \"message\": \"備份間隔\",\n    \"description\": \"備份間隔標籤\"\n  },\n  \"backupIntervalManual\": {\n    \"message\": \"僅手動\",\n    \"description\": \"手動備份模式\"\n  },\n  \"backupIntervalDaily\": {\n    \"message\": \"每天\",\n    \"description\": \"每天備份間隔\"\n  },\n  \"backupIntervalWeekly\": {\n    \"message\": \"每週（7 天）\",\n    \"description\": \"每週備份間隔\"\n  },\n  \"backupLastBackup\": {\n    \"message\": \"上次備份：{time}\",\n    \"description\": \"上次備份時間戳記\"\n  },\n  \"backupNever\": {\n    \"message\": \"從未\",\n    \"description\": \"從未備份過\"\n  },\n  \"backupSuccess\": {\n    \"message\": \"✓ 備份建立成功：{prompts} 個提示詞、{folders} 個資料夾、{conversations} 個對話\",\n    \"description\": \"備份成功訊息\"\n  },\n  \"backupError\": {\n    \"message\": \"✗ 備份失敗：{error}\",\n    \"description\": \"備份錯誤訊息\"\n  },\n  \"backupNotSupported\": {\n    \"message\": \"⚠️ 自動備份需要支援檔案系統存取 API 的現代瀏覽器\",\n    \"description\": \"瀏覽器不支援訊息\"\n  },\n  \"backupSelectFolderFirst\": {\n    \"message\": \"請先選擇備份資料夾\",\n    \"description\": \"未選擇資料夾警告\"\n  },\n  \"backupUserCancelled\": {\n    \"message\": \"備份已取消\",\n    \"description\": \"使用者取消資料夾選擇\"\n  },\n  \"backupConfigSaved\": {\n    \"message\": \"✓ 備份設定已儲存\",\n    \"description\": \"設定儲存訊息\"\n  },\n  \"backupPermissionDenied\": {\n    \"message\": \"⚠️ 無法存取此目錄。請選擇其他位置（例如：文件、下載，或桌面中的自訂資料夾）\",\n    \"description\": \"受限目錄權限被拒絕\"\n  },\n  \"backupConfigureInOptions\": {\n    \"message\": \"備份功能需要在選項頁面中設定。點擊下方按鈕開啟選項頁面（點擊擴充功能圖示旁的三個點 → 選項）。\",\n    \"description\": \"備份設定在選項頁面的說明\"\n  },\n  \"openOptionsPage\": {\n    \"message\": \"開啟選項頁面\",\n    \"description\": \"開啟選項頁面按鈕\"\n  },\n  \"optionsPageSubtitle\": {\n    \"message\": \"擴充功能選項\",\n    \"description\": \"選項頁面副標題\"\n  },\n  \"optionsComingSoon\": {\n    \"message\": \"更多選項即將推出...\",\n    \"description\": \"選項頁面佔位提示\"\n  },\n  \"backupDataAccessNotice\": {\n    \"message\": \"資料存取限制\",\n    \"description\": \"備份資料存取限制標題\"\n  },\n  \"backupDataAccessHint\": {\n    \"message\": \"由於瀏覽器安全限制，選項頁面無法直接讀取 Gemini 頁面的提示詞和資料夾資料。建議使用 Gemini 頁面上的提示詞管理器和資料夾匯出功能進行手動備份。\",\n    \"description\": \"備份資料存取限制說明\"\n  },\n  \"promptManagerOptions\": {\n    \"message\": \"提示詞管理器\",\n    \"description\": \"提示詞管理器選項區域標籤\"\n  },\n  \"hidePromptManager\": {\n    \"message\": \"隱藏提示詞管理器\",\n    \"description\": \"隱藏提示詞管理器懸浮球\"\n  },\n  \"hidePromptManagerHint\": {\n    \"message\": \"隱藏頁面上的提示詞管理器懸浮球\",\n    \"description\": \"隱藏提示詞管理器功能提示\"\n  },\n  \"customWebsites\": {\n    \"message\": \"自訂網站\",\n    \"description\": \"提示詞管理器的自訂網站\"\n  },\n  \"customWebsitesPlaceholder\": {\n    \"message\": \"輸入網站 URL（例如：chatgpt.com）\",\n    \"description\": \"自訂網站輸入佔位符\"\n  },\n  \"customWebsitesHint\": {\n    \"message\": \"新增您想要使用提示詞管理器的網站。這些網站上只會啟用提示詞管理器功能。\",\n    \"description\": \"自訂網站提示\"\n  },\n  \"addWebsite\": {\n    \"message\": \"新增網站\",\n    \"description\": \"新增網站按鈕\"\n  },\n  \"removeWebsite\": {\n    \"message\": \"移除\",\n    \"description\": \"移除網站按鈕\"\n  },\n  \"invalidUrl\": {\n    \"message\": \"URL 格式無效\",\n    \"description\": \"無效 URL 錯誤訊息\"\n  },\n  \"websiteAdded\": {\n    \"message\": \"網站已新增\",\n    \"description\": \"網站新增成功訊息\"\n  },\n  \"websiteRemoved\": {\n    \"message\": \"網站已移除\",\n    \"description\": \"網站移除成功訊息\"\n  },\n  \"permissionDenied\": {\n    \"message\": \"權限被拒絕。請允許存取此網站。\",\n    \"description\": \"權限被拒絕錯誤訊息\"\n  },\n  \"permissionRequestFailed\": {\n    \"message\": \"要求權限失敗，請重試或在擴充功能設定中手動授權。\",\n    \"description\": \"要求權限失敗訊息\"\n  },\n  \"customWebsitesNote\": {\n    \"message\": \"提示：新增網站時會彈出授權，請先允許，再重新整理該網站即可啟用提示詞管理器。\",\n    \"description\": \"自訂網站重新載入/權限提示\"\n  },\n  \"starredHistory\": {\n    \"message\": \"星號標記歷史\",\n    \"description\": \"星號標記歷史標題\"\n  },\n  \"viewStarredHistory\": {\n    \"message\": \"檢視星號標記歷史\",\n    \"description\": \"檢視星號標記歷史按鈕\"\n  },\n  \"noStarredMessages\": {\n    \"message\": \"暫無星號標記訊息\",\n    \"description\": \"無星號標記訊息佔位符\"\n  },\n  \"removeFromStarred\": {\n    \"message\": \"取消星號標記\",\n    \"description\": \"取消星號標記提示\"\n  },\n  \"justNow\": {\n    \"message\": \"剛剛\",\n    \"description\": \"剛剛時間標籤\"\n  },\n  \"hoursAgo\": {\n    \"message\": \"小時前\",\n    \"description\": \"小時前標籤\"\n  },\n  \"yesterday\": {\n    \"message\": \"昨天\",\n    \"description\": \"昨天日期標籤\"\n  },\n  \"daysAgo\": {\n    \"message\": \"天前\",\n    \"description\": \"天前標籤\"\n  },\n  \"loading\": {\n    \"message\": \"載入中...\",\n    \"description\": \"載入訊息\"\n  },\n  \"keyboardShortcuts\": {\n    \"message\": \"鍵盤快速鍵\",\n    \"description\": \"鍵盤快速鍵區段標題\"\n  },\n  \"enableShortcuts\": {\n    \"message\": \"啟用鍵盤快速鍵\",\n    \"description\": \"啟用快速鍵開關標籤\"\n  },\n  \"previousNode\": {\n    \"message\": \"上一個節點\",\n    \"description\": \"上一個時間軸節點快速鍵標籤\"\n  },\n  \"nextNode\": {\n    \"message\": \"下一個節點\",\n    \"description\": \"下一個時間軸節點快速鍵標籤\"\n  },\n  \"shortcutKey\": {\n    \"message\": \"按鍵\",\n    \"description\": \"快速鍵按鍵標籤\"\n  },\n  \"shortcutModifiers\": {\n    \"message\": \"修飾鍵\",\n    \"description\": \"快速鍵修飾鍵標籤\"\n  },\n  \"resetShortcuts\": {\n    \"message\": \"恢復預設\",\n    \"description\": \"重設快速鍵按鈕\"\n  },\n  \"shortcutsResetSuccess\": {\n    \"message\": \"快速鍵已恢復預設設定\",\n    \"description\": \"快速鍵重設成功訊息\"\n  },\n  \"shortcutsDescription\": {\n    \"message\": \"使用鍵盤快速鍵導覽時間軸節點\",\n    \"description\": \"快速鍵描述\"\n  },\n  \"modifierNone\": {\n    \"message\": \"無\",\n    \"description\": \"無修飾鍵\"\n  },\n  \"modifierAlt\": {\n    \"message\": \"Alt\",\n    \"description\": \"Alt 修飾鍵\"\n  },\n  \"modifierCtrl\": {\n    \"message\": \"Ctrl\",\n    \"description\": \"Ctrl 修飾鍵\"\n  },\n  \"modifierShift\": {\n    \"message\": \"Shift\",\n    \"description\": \"Shift 修飾鍵\"\n  },\n  \"modifierMeta\": {\n    \"message\": \"Cmd/Win\",\n    \"description\": \"Meta/Command/Windows 修飾鍵\"\n  },\n  \"preventAutoScroll\": {\n    \"message\": \"防自動跳轉\",\n    \"description\": \"Auto-hide sidebar toggle label\"\n  },\n  \"preventAutoScrollHint\": {\n    \"message\": \"避免在查看過往紀錄時按 Enter 導致頁面強制滾動到底部\",\n    \"description\": \"Auto-hide sidebar feature hint\"\n  },\n  \"cloudSync\": {\n    \"message\": \"雲端同步\",\n    \"description\": \"雲端同步區域標題\"\n  },\n  \"cloudSyncDescription\": {\n    \"message\": \"將資料夾和提示詞同步到 Google Drive\",\n    \"description\": \"雲端同步描述\"\n  },\n  \"syncModeDisabled\": {\n    \"message\": \"已停用\",\n    \"description\": \"同步模式停用選項\"\n  },\n  \"syncModeManual\": {\n    \"message\": \"手動\",\n    \"description\": \"同步模式手動選項\"\n  },\n  \"syncServerPort\": {\n    \"message\": \"服務埠號\",\n    \"description\": \"同步服務埠號\"\n  },\n  \"syncNow\": {\n    \"message\": \"立即同步\",\n    \"description\": \"立即同步按鈕\"\n  },\n  \"lastSynced\": {\n    \"message\": \"上次同步：{time}\",\n    \"description\": \"上次同步時間戳記\"\n  },\n  \"neverSynced\": {\n    \"message\": \"從未同步\",\n    \"description\": \"從未同步訊息\"\n  },\n  \"lastUploaded\": {\n    \"message\": \"上次上傳：{time}\",\n    \"description\": \"上次上傳時間戳記\"\n  },\n  \"neverUploaded\": {\n    \"message\": \"從未上傳\",\n    \"description\": \"從未上傳訊息\"\n  },\n  \"syncSuccess\": {\n    \"message\": \"✓ 同步成功\",\n    \"description\": \"同步成功訊息\"\n  },\n  \"syncError\": {\n    \"message\": \"✗ 同步失敗：{error}\",\n    \"description\": \"同步錯誤訊息\"\n  },\n  \"syncInProgress\": {\n    \"message\": \"同步中...\",\n    \"description\": \"同步進行中訊息\"\n  },\n  \"uploadInProgress\": {\n    \"message\": \"上傳中...\",\n    \"description\": \"上傳進行中訊息\"\n  },\n  \"uploadSuccess\": {\n    \"message\": \"✓ 上傳成功\",\n    \"description\": \"上傳成功訊息\"\n  },\n  \"downloadInProgress\": {\n    \"message\": \"下載中...\",\n    \"description\": \"下載進行中訊息\"\n  },\n  \"downloadMergeSuccess\": {\n    \"message\": \"✓ 下載合併成功\",\n    \"description\": \"下載合併成功訊息\"\n  },\n  \"syncUpload\": {\n    \"message\": \"上傳到雲端\",\n    \"description\": \"上傳到雲端按鈕\"\n  },\n  \"syncMerge\": {\n    \"message\": \"從雲端下載合併\",\n    \"description\": \"同步（合併）按鈕\"\n  },\n  \"syncMode\": {\n    \"message\": \"同步模式\",\n    \"description\": \"同步模式標籤\"\n  },\n  \"minutesAgo\": {\n    \"message\": \"分鐘前\",\n    \"description\": \"分鐘前標籤\"\n  },\n  \"syncNoData\": {\n    \"message\": \"未在雲端找到同步資料\",\n    \"description\": \"Error message when no data is found in Drive\"\n  },\n  \"syncUploadFailed\": {\n    \"message\": \"上傳失敗\",\n    \"description\": \"Error message when upload fails\"\n  },\n  \"syncDownloadFailed\": {\n    \"message\": \"下載失敗\",\n    \"description\": \"Error message when download fails\"\n  },\n  \"syncAuthFailed\": {\n    \"message\": \"認證失敗\",\n    \"description\": \"Error message when authentication fails\"\n  },\n  \"signOut\": {\n    \"message\": \"登出\",\n    \"description\": \"登出按鈕\"\n  },\n  \"signInWithGoogle\": {\n    \"message\": \"使用 Google 登入\",\n    \"description\": \"使用 Google 登入按鈕\"\n  },\n  \"nanobananaOptions\": {\n    \"message\": \"NanoBanana 選項\",\n    \"description\": \"NanoBanana options section label\"\n  },\n  \"enableNanobananaWatermarkRemover\": {\n    \"message\": \"移除 NanoBanana 浮水印\",\n    \"description\": \"啟用自動移除生成圖片浮水印功能\"\n  },\n  \"nanobananaWatermarkRemoverHint\": {\n    \"message\": \"自動移除 Gemini 生成圖片上的可見浮水印\",\n    \"description\": \"浮水印移除功能提示\"\n  },\n  \"nanobananaDownloadTooltip\": {\n    \"message\": \"下載無浮水印圖片 (NanoBanana)\",\n    \"description\": \"Download button tooltip\"\n  },\n  \"deepResearchDownload\": {\n    \"message\": \"下載 Thinking 內容\",\n    \"description\": \"Deep Research download button text\"\n  },\n  \"deepResearchDownloadTooltip\": {\n    \"message\": \"將 Thinking 內容匯出為 Markdown 檔案\",\n    \"description\": \"Deep Research download button tooltip\"\n  },\n  \"folder_uncategorized\": {\n    \"message\": \"未分類\",\n    \"description\": \"根目錄下未分類對話的標籤\"\n  },\n  \"timelineLevelTitle\": {\n    \"message\": \"節點層級\",\n    \"description\": \"時間軸節點層級右鍵選單標題\"\n  },\n  \"timelineLevel1\": {\n    \"message\": \"一級\",\n    \"description\": \"時間軸節點一級選項\"\n  },\n  \"timelineLevel2\": {\n    \"message\": \"二級\",\n    \"description\": \"時間軸節點二級選項\"\n  },\n  \"timelineLevel3\": {\n    \"message\": \"三級\",\n    \"description\": \"時間軸節點三級選項\"\n  },\n  \"timelineCollapse\": {\n    \"message\": \"收起\",\n    \"description\": \"收起子節點\"\n  },\n  \"timelineExpand\": {\n    \"message\": \"展開\",\n    \"description\": \"展開子節點\"\n  },\n  \"timelinePreviewSearch\": {\n    \"message\": \"搜尋...\",\n    \"description\": \"Timeline preview panel search placeholder\"\n  },\n  \"timelinePreviewNoResults\": {\n    \"message\": \"無結果\",\n    \"description\": \"Timeline preview panel no search results\"\n  },\n  \"timelinePreviewNoMessages\": {\n    \"message\": \"暫無訊息\",\n    \"description\": \"Timeline preview panel no messages\"\n  },\n  \"quoteReply\": {\n    \"message\": \"引用回覆\",\n    \"description\": \"引用回覆按鈕文字\"\n  },\n  \"inputCollapseOptions\": {\n    \"message\": \"輸入選項\",\n    \"description\": \"輸入選項區域標籤\"\n  },\n  \"enableInputCollapse\": {\n    \"message\": \"啟用輸入框摺疊\",\n    \"description\": \"啟用輸入框摺疊開關標籤\"\n  },\n  \"enableInputCollapseHint\": {\n    \"message\": \"輸入框為空時自動摺疊，獲得更多閱讀空間\",\n    \"description\": \"輸入框摺疊功能提示\"\n  },\n  \"inputCollapseShortcutHint\": {\n    \"message\": \"{modifier}+I 展開\",\n    \"description\": \"展開折疊輸入框的快捷鍵提示。{modifier} 在 macOS 上替換為 ⌘，其他平台替換為 Ctrl\"\n  },\n  \"inputCollapsePlaceholder\": {\n    \"message\": \"傳送訊息給 Gemini\",\n    \"description\": \"摺疊輸入框時顯示的佔位符文字\"\n  },\n  \"allowCollapseWhenNotEmpty\": {\n    \"message\": \"允許折疊有內容的輸入框\",\n    \"description\": \"允許輸入框即使包含文字或附件也進行折疊\"\n  },\n  \"allowCollapseWhenNotEmptyHint\": {\n    \"message\": \"即使輸入框包含文字或附件也允許折疊\",\n    \"description\": \"解釋非空折疊功能的提示文字\"\n  },\n  \"ctrlEnterSend\": {\n    \"message\": \"{modifier}+Enter 傳送\",\n    \"description\": \"Modifier+Enter 傳送開關標籤。{modifier} 在 macOS 上替換為 ⌘，其他平台替換為 Ctrl\"\n  },\n  \"ctrlEnterSendHint\": {\n    \"message\": \"按 {modifier}+Enter 傳送訊息，Enter 鍵僅換行\",\n    \"description\": \"Modifier+Enter 傳送功能提示。{modifier} 在 macOS 上替換為 ⌘，其他平台替換為 Ctrl\"\n  },\n  \"batch_delete_confirm\": {\n    \"message\": \"確定要刪除 {count} 個對話嗎？此操作無法復原。\",\n    \"description\": \"批次刪除確認訊息\"\n  },\n  \"batch_delete_in_progress\": {\n    \"message\": \"正在刪除... ({current}/{total})\",\n    \"description\": \"批次刪除進度訊息\"\n  },\n  \"batch_delete_success\": {\n    \"message\": \"✓ 已刪除 {count} 個對話\",\n    \"description\": \"批次刪除成功訊息\"\n  },\n  \"batch_delete_partial\": {\n    \"message\": \"刪除完成：{success} 個成功，{failed} 個失敗\",\n    \"description\": \"批次刪除部分成功訊息\"\n  },\n  \"batch_delete_limit_reached\": {\n    \"message\": \"一次最多可選擇 {max} 個對話\",\n    \"description\": \"批次刪除數量限制警告\"\n  },\n  \"batch_delete_button\": {\n    \"message\": \"刪除所選\",\n    \"description\": \"多選模式下的批次刪除按鈕提示\"\n  },\n  \"batch_delete_match_patterns\": {\n    \"message\": \"刪除，確認，確定，是，delete,confirm,yes,ok,remove\",\n    \"description\": \"符合原生刪除/確認按鈕的關鍵字清單（逗號分隔）\"\n  },\n  \"generalOptions\": {\n    \"message\": \"一般選項\",\n    \"description\": \"一般選項區域標籤\"\n  },\n  \"enableTabTitleUpdate\": {\n    \"message\": \"同步分頁標題與對話\",\n    \"description\": \"啟用分頁標題同步開關標籤\"\n  },\n  \"enableTabTitleUpdateHint\": {\n    \"message\": \"自動將瀏覽器分頁標題更新為目前對話的標題\",\n    \"description\": \"分頁標題同步功能提示\"\n  },\n  \"enableMermaidRendering\": {\n    \"message\": \"啟用 Mermaid 圖表轉譯\",\n    \"description\": \"啟用 Mermaid 圖表轉譯開關標籤\"\n  },\n  \"enableMermaidRenderingHint\": {\n    \"message\": \"自動將程式碼區塊中的 Mermaid 程式碼轉譯為圖表\",\n    \"description\": \"Mermaid 轉譯功能提示\"\n  },\n  \"enableQuoteReply\": {\n    \"message\": \"啟用引用回覆\",\n    \"description\": \"啟用引用回覆開關標籤\"\n  },\n  \"enableQuoteReplyHint\": {\n    \"message\": \"在對話中選取文字時顯示懸浮按鈕，用於引用所選內容\",\n    \"description\": \"引用回覆功能提示\"\n  },\n  \"contextSync\": {\n    \"message\": \"上下文同步\",\n    \"description\": \"上下文同步標題\"\n  },\n  \"contextSyncDescription\": {\n    \"message\": \"將對話上下文同步到本機 IDE\",\n    \"description\": \"上下文同步描述\"\n  },\n  \"syncToIDE\": {\n    \"message\": \"同步到 IDE\",\n    \"description\": \"同步到 IDE 按鈕\"\n  },\n  \"ideOnline\": {\n    \"message\": \"IDE 線上\",\n    \"description\": \"IDE 線上狀態\"\n  },\n  \"ideOffline\": {\n    \"message\": \"IDE 離線\",\n    \"description\": \"IDE 離線狀態\"\n  },\n  \"syncing\": {\n    \"message\": \"同步中...\",\n    \"description\": \"同步中狀態\"\n  },\n  \"checkServer\": {\n    \"message\": \"請在 VS Code 中啟動 AI Sync 伺服器\",\n    \"description\": \"檢查伺服器提示\"\n  },\n  \"capturing\": {\n    \"message\": \"正在取得對話...\",\n    \"description\": \"取得對話狀態\"\n  },\n  \"syncedSuccess\": {\n    \"message\": \"同步成功！\",\n    \"description\": \"同步成功訊息\"\n  },\n  \"folder_change_color\": {\n    \"message\": \"更改顏色\",\n    \"description\": \"Change folder color tooltip\"\n  },\n  \"folder_color_default\": {\n    \"message\": \"預設\",\n    \"description\": \"Default folder color\"\n  },\n  \"folder_color_red\": {\n    \"message\": \"紅色\",\n    \"description\": \"Red folder color\"\n  },\n  \"folder_color_orange\": {\n    \"message\": \"橙色\",\n    \"description\": \"Orange folder color\"\n  },\n  \"folder_color_yellow\": {\n    \"message\": \"黃色\",\n    \"description\": \"Yellow folder color\"\n  },\n  \"folder_color_green\": {\n    \"message\": \"綠色\",\n    \"description\": \"Green folder color\"\n  },\n  \"folder_color_blue\": {\n    \"message\": \"藍色\",\n    \"description\": \"Blue folder color\"\n  },\n  \"folder_color_purple\": {\n    \"message\": \"紫色\",\n    \"description\": \"Purple folder color\"\n  },\n  \"folder_color_pink\": {\n    \"message\": \"粉紅色\",\n    \"description\": \"粉紅色資料夾顏色名稱\"\n  },\n  \"folder_color_custom\": {\n    \"message\": \"自定義\",\n    \"description\": \"自定義資料夾顏色名稱\"\n  },\n  \"downloadingOriginal\": {\n    \"message\": \"正在下載原始圖片\",\n    \"description\": \"下載原始圖片時的狀態\"\n  },\n  \"downloadingOriginalLarge\": {\n    \"message\": \"正在下載原始圖片（大檔案）\",\n    \"description\": \"下載較大原始圖片時的狀態\"\n  },\n  \"downloadLargeWarning\": {\n    \"message\": \"大檔案警告\",\n    \"description\": \"大檔案下載提示\"\n  },\n  \"downloadProcessing\": {\n    \"message\": \"正在處理浮水印中\",\n    \"description\": \"處理浮水印時的狀態\"\n  },\n  \"downloadSuccess\": {\n    \"message\": \"正在下載\",\n    \"description\": \"開始下載時的狀態\"\n  },\n  \"downloadError\": {\n    \"message\": \"失敗\",\n    \"description\": \"下載失敗時的狀態前置\"\n  },\n  \"recentsHide\": {\n    \"message\": \"隱藏最近項目\",\n    \"description\": \"隱藏最近預覽區域的提示\"\n  },\n  \"recentsShow\": {\n    \"message\": \"顯示最近項目\",\n    \"description\": \"顯示最近預覽區域的提示\"\n  },\n  \"gemsHide\": {\n    \"message\": \"隱藏 Gems\",\n    \"description\": \"隱藏 Gems 列表區域的提示\"\n  },\n  \"gemsShow\": {\n    \"message\": \"顯示 Gems\",\n    \"description\": \"顯示 Gems 列表區域的提示\"\n  },\n  \"setAsDefaultModel\": {\n    \"message\": \"設為新對話的預設模型\",\n    \"description\": \"Tooltip for setting default model\"\n  },\n  \"cancelDefaultModel\": {\n    \"message\": \"取消預設模型\",\n    \"description\": \"Tooltip for cancelling default model\"\n  },\n  \"defaultModelSet\": {\n    \"message\": \"已設定預設模型：$1\",\n    \"description\": \"Toast message when default model is set\"\n  },\n  \"defaultModelCleared\": {\n    \"message\": \"已取消預設模型\",\n    \"description\": \"Toast message when default model is cleared\"\n  },\n  \"sidebarAutoHide\": {\n    \"message\": \"側邊欄自動收起\",\n    \"description\": \"側邊欄自動收起開關標籤\"\n  },\n  \"sidebarAutoHideHint\": {\n    \"message\": \"滑鼠離開時自動收起側邊欄，滑鼠進入時展開\",\n    \"description\": \"側邊欄自動收起功能提示\"\n  },\n  \"sidebarFullHide\": {\n    \"message\": \"完全隱藏側邊欄\",\n    \"description\": \"完全隱藏側邊欄開關標籤\"\n  },\n  \"sidebarFullHideHint\": {\n    \"message\": \"收起時完全隱藏側邊欄，滑鼠移到左邊緣喚出\",\n    \"description\": \"完全隱藏側邊欄功能提示\"\n  },\n  \"folderSpacing\": {\n    \"message\": \"資料夾間距\",\n    \"description\": \"資料夾間距調節標籤\"\n  },\n  \"folderSpacingCompact\": {\n    \"message\": \"緊湊\",\n    \"description\": \"緊湊資料夾間距標籤\"\n  },\n  \"folderSpacingSpacious\": {\n    \"message\": \"寬鬆\",\n    \"description\": \"寬鬆資料夾間距標籤\"\n  },\n  \"folderTreeIndent\": {\n    \"message\": \"子資料夾縮排\",\n    \"description\": \"子資料夾樹狀縮排調整標籤\"\n  },\n  \"folderTreeIndentCompact\": {\n    \"message\": \"更窄\",\n    \"description\": \"較窄的子資料夾縮排標籤\"\n  },\n  \"folderTreeIndentSpacious\": {\n    \"message\": \"更寬\",\n    \"description\": \"較寬的子資料夾縮排標籤\"\n  },\n  \"export_select_mode_select_all\": {\n    \"message\": \"全選\",\n    \"description\": \"Selection mode select all toggle\"\n  },\n  \"export_select_mode_count\": {\n    \"message\": \"已選擇 {count} 條\",\n    \"description\": \"Selection mode selected message count\"\n  },\n  \"export_select_mode_toggle\": {\n    \"message\": \"選擇訊息\",\n    \"description\": \"Selection mode per-message toggle tooltip\"\n  },\n  \"export_select_mode_select_below\": {\n    \"message\": \"選擇下方訊息\",\n    \"description\": \"Selection mode top-left button to select messages below current line\"\n  },\n  \"export_select_mode_empty\": {\n    \"message\": \"請至少選擇一條訊息進行匯出\",\n    \"description\": \"Selection mode validation when nothing is selected\"\n  },\n  \"export_format_image_description\": {\n    \"message\": \"單張 PNG 圖片，便於行動端分享。\",\n    \"description\": \"Description for Image export format\"\n  },\n  \"export_image_progress\": {\n    \"message\": \"正在產生圖片...\",\n    \"description\": \"Progress message while generating image export\"\n  },\n  \"deepResearchSaveReport\": {\n    \"message\": \"儲存報告\",\n    \"description\": \"Deep Research save report button\"\n  },\n  \"deepResearchSaveReportTooltip\": {\n    \"message\": \"匯出此研究報告\",\n    \"description\": \"Deep Research save report tooltip\"\n  },\n  \"export_fontsize_label\": {\n    \"message\": \"字體大小\",\n    \"description\": \"Label for font size slider in export dialog\"\n  },\n  \"export_fontsize_preview\": {\n    \"message\": \"天地玄黃，宇宙洪荒。日月盈昃，辰宿列張。\",\n    \"description\": \"Preview text for font size slider in export dialog\"\n  },\n  \"export_error_generic\": {\n    \"message\": \"匯出失敗：{error}\",\n    \"description\": \"附帶具體原因的匯出失敗提示\"\n  },\n  \"export_error_refresh_retry\": {\n    \"message\": \"因網路原因，圖片匯出失敗，請重新整理頁面後重試。\",\n    \"description\": \"圖片匯出遇到暫時性載入失敗時的重新整理提示\"\n  },\n  \"export_md_include_source_confirm\": {\n    \"message\": \"匯出內容包含網路搜尋圖片。是否在 Markdown 中包含圖片來源連結？\",\n    \"description\": \"Confirm dialog when exporting markdown with web search images\"\n  },\n  \"deepResearch_exportedAt\": {\n    \"message\": \"匯出時間\",\n    \"description\": \"深度研究匯出時間戳標籤\"\n  },\n  \"deepResearch_totalPhases\": {\n    \"message\": \"總思考階段\",\n    \"description\": \"深度研究總思考階段標籤\"\n  },\n  \"deepResearch_thinkingPhase\": {\n    \"message\": \"思考階段\",\n    \"description\": \"深度研究思考階段標題\"\n  },\n  \"deepResearch_researchedWebsites\": {\n    \"message\": \"研究網站\",\n    \"description\": \"深度研究已研究網站章節標題\"\n  },\n  \"visualEffect\": {\n    \"message\": \"視覺特效\",\n    \"description\": \"Visual effect selector label\"\n  },\n  \"visualEffectHint\": {\n    \"message\": \"為頁面增添季節氛圍\",\n    \"description\": \"Visual effect feature hint\"\n  },\n  \"visualEffectOff\": {\n    \"message\": \"關閉\",\n    \"description\": \"Visual effect off option\"\n  },\n  \"visualEffectSnow\": {\n    \"message\": \"飄雪\",\n    \"description\": \"Visual effect snow option\"\n  },\n  \"visualEffectSakura\": {\n    \"message\": \"櫻花\",\n    \"description\": \"Visual effect sakura option\"\n  },\n  \"visualEffectRain\": {\n    \"message\": \"雨\",\n    \"description\": \"Visual effect rain option\"\n  },\n  \"changelog_title\": {\n    \"message\": \"更新內容\",\n    \"description\": \"Changelog modal title\"\n  },\n  \"changelog_close\": {\n    \"message\": \"知道了\",\n    \"description\": \"Changelog modal close button\"\n  },\n  \"changelog_docs_link\": {\n    \"message\": \"文檔\",\n    \"description\": \"Changelog modal documentation link text\"\n  },\n  \"changelog_recommendation\": {\n    \"message\": \"如果 Voyager 對你有幫助，歡迎推薦給朋友或在社交媒體上分享，記得 @ 作者哦！\",\n    \"description\": \"Changelog modal recommendation message\"\n  },\n  \"changelog_social_xiaohongshu\": {\n    \"message\": \"小紅書\",\n    \"description\": \"Xiaohongshu platform name for social links\"\n  },\n  \"changelog_social_zhihu\": {\n    \"message\": \"知乎\",\n    \"description\": \"Zhihu platform name for social links\"\n  },\n  \"changelog_docs_hint\": {\n    \"message\": \"詳細功能見文檔\",\n    \"description\": \"Changelog annotation pointing to docs icon\"\n  },\n  \"changelog_rate_chrome\": {\n    \"message\": \"覺得 Voyager 好用嗎？在 Chrome 線上應用程式商店給個好評，幫助更多人發現它！\",\n    \"description\": \"Chrome Web Store rating prompt in changelog modal\"\n  },\n  \"changelog_rate_chrome_cta\": {\n    \"message\": \"立即評分\",\n    \"description\": \"Chrome Web Store rating CTA button text\"\n  },\n  \"changelog_badge_mode\": {\n    \"message\": \"透過懸浮球上的 NEW 標記提醒，而非彈出此視窗\",\n    \"description\": \"Changelog notification mode toggle label\"\n  },\n  \"forkConversation\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork conversation button label\"\n  },\n  \"forkConfirm\": {\n    \"message\": \"Fork conversation from this point?\",\n    \"description\": \"Fork confirmation prompt\"\n  },\n  \"forkConfirmBtn\": {\n    \"message\": \"Fork\",\n    \"description\": \"Fork confirmation button\"\n  },\n  \"forkCancel\": {\n    \"message\": \"Cancel\",\n    \"description\": \"Fork cancel button\"\n  },\n  \"forkBranch\": {\n    \"message\": \"Branch\",\n    \"description\": \"Fork branch label\"\n  },\n  \"forkOriginal\": {\n    \"message\": \"Original\",\n    \"description\": \"Original fork branch tag\"\n  },\n  \"forkCurrent\": {\n    \"message\": \"Current\",\n    \"description\": \"Current fork branch tag\"\n  },\n  \"forkPreparing\": {\n    \"message\": \"Preparing fork...\",\n    \"description\": \"Fork preparation status\"\n  },\n  \"forkPasteHint\": {\n    \"message\": \"Press Enter to send and create the fork\",\n    \"description\": \"Fork paste hint text\"\n  },\n  \"forkDeleteData\": {\n    \"message\": \"刪除此分支\",\n    \"description\": \"Delete fork branch metadata button\"\n  },\n  \"forkDeleteDataConfirm\": {\n    \"message\": \"刪除此分支關聯？只會移除 Voyager 的分支中繼資料，不會刪除任何對話。\",\n    \"description\": \"Fork metadata delete confirmation prompt\"\n  },\n  \"enableForkFeature\": {\n    \"message\": \"啟用對話分叉\",\n    \"description\": \"Enable conversation fork feature toggle label\"\n  },\n  \"enableForkFeatureHint\": {\n    \"message\": \"在 Gemini 對話中顯示 Fork 按鈕與分支序號\",\n    \"description\": \"Enable conversation fork feature toggle hint\"\n  },\n  \"enableAccountIsolation\": {\n    \"message\": \"啟用硬隔離（Hard Isolation）\",\n    \"description\": \"Enable hard isolation for account-scoped folder data and cloud sync.\"\n  },\n  \"enableAccountIsolationHint\": {\n    \"message\": \"依 Google 帳號嚴格隔離資料夾與雲端同步資料，避免任何跨帳號混用。\",\n    \"description\": \"Hint text for account isolation option.\"\n  },\n  \"currentPlatform\": {\n    \"message\": \"目前平台\",\n    \"description\": \"Current active platform label in popup.\"\n  },\n  \"platformGemini\": {\n    \"message\": \"Gemini\",\n    \"description\": \"Gemini platform label.\"\n  },\n  \"platformAIStudio\": {\n    \"message\": \"AI Studio\",\n    \"description\": \"AI Studio platform label.\"\n  },\n  \"enableOnAIStudio\": {\n    \"message\": \"在 AI Studio 上啟用\",\n    \"description\": \"Toggle to enable/disable Voyager on AI Studio\"\n  },\n  \"enableOnAIStudioHint\": {\n    \"message\": \"關閉後將停用 AI Studio 上的 Voyager 功能（Prompt Manager 不受影響）\",\n    \"description\": \"Hint for AI Studio enable toggle\"\n  },\n  \"export_select_mode_only_user\": {\n    \"message\": \"僅選用戶\",\n    \"description\": \"Export selection mode: select only user messages\"\n  },\n  \"export_select_mode_only_ai\": {\n    \"message\": \"僅選模型\",\n    \"description\": \"Export selection mode: select only AI messages\"\n  },\n  \"aiOrgCopyButton\": {\n    \"message\": \"AI 整理\",\n    \"description\": \"複製資料夾結構和對話以供 AI 整理的按鈕\"\n  },\n  \"aiOrgCopied\": { \"message\": \"已複製！\", \"description\": \"複製 AI 整理提示後的確認\" },\n  \"aiOrgError\": {\n    \"message\": \"失敗 — 請先開啟 Gemini\",\n    \"description\": \"複製 AI 整理提示失敗時的錯誤\"\n  },\n  \"aiOrgCopyHint\": {\n    \"message\": \"複製所有對話和資料夾結構作為提示詞。貼到 Gemini 中即可獲得可匯入的資料夾方案。\",\n    \"description\": \"AI 整理複製按鈕下方的提示文字\"\n  },\n  \"aiOrgCurrentFolders\": { \"message\": \"目前資料夾結構\", \"description\": \"AI 整理提示中的標題\" },\n  \"aiOrgUnfiledConversations\": {\n    \"message\": \"未歸類的對話\",\n    \"description\": \"不在任何資料夾中的對話標題\"\n  },\n  \"aiOrgEmpty\": { \"message\": \"空\", \"description\": \"AI 整理提示中空資料夾的標籤\" },\n  \"aiOrgInstructions\": { \"message\": \"指示\", \"description\": \"AI 指示部分的標題\" },\n  \"aiOrgInstructionsBody\": {\n    \"message\": \"根據以上對話和資料夾結構，請將它們重新整理為合理的資料夾層級。輸出一個使用以下格式的 JSON 檔案，可以直接匯入到 Gemini Voyager 擴充功能中（使用「合併」匯入策略）。保留已有的合理資料夾，根據需要建立新資料夾，將未歸類的對話移入合適的資料夾。每個資料夾需要一個唯一 ID（使用短隨機字串），每個對話必須保留其原始的 conversationId 和 url。\",\n    \"description\": \"AI 生成可匯入資料夾結構的指示正文\"\n  },\n  \"showMessageTimestamps\": {\n    \"message\": \"顯示訊息時間\",\n    \"description\": \"顯示每則訊息的時間戳\"\n  },\n  \"showMessageTimestampsHint\": {\n    \"message\": \"顯示每則訊息的發送時間\",\n    \"description\": \"訊息時間戳功能提示\"\n  },\n  \"moveSectionUp\": {\n    \"message\": \"上移\",\n    \"description\": \"Tooltip for moving a settings section up in popup\"\n  },\n  \"moveSectionDown\": {\n    \"message\": \"下移\",\n    \"description\": \"Tooltip for moving a settings section down in popup\"\n  }\n}\n"
  },
  {
    "path": "src/pages/background/index.ts",
    "content": "/* Background service worker - handles cross-origin image fetch, popup opening, and sync */\nimport browser from 'webextension-polyfill';\n\nimport {\n  type AccountPlatform,\n  type AccountScope,\n  accountIsolationService,\n  detectAccountPlatformFromUrl,\n  extractRouteUserIdFromUrl,\n} from '@/core/services/AccountIsolationService';\nimport { googleDriveSyncService } from '@/core/services/GoogleDriveSyncService';\nimport { StorageKeys } from '@/core/types/common';\nimport type { FolderData } from '@/core/types/folder';\nimport type { PromptItem, SyncAccountScope, SyncMode } from '@/core/types/sync';\nimport type { ForkNode, ForkNodesData } from '@/pages/content/fork/forkTypes';\nimport type { StarredMessage, StarredMessagesData } from '@/pages/content/timeline/starredTypes';\n\nconst CUSTOM_CONTENT_SCRIPT_ID = 'gv-custom-content-script';\nconst CUSTOM_WEBSITE_KEY = 'gvPromptCustomWebsites';\nconst FETCH_INTERCEPTOR_SCRIPT_ID = 'gv-fetch-interceptor';\n\n// Gemini domains where the fetch interceptor should run\nconst GEMINI_MATCHES = [\n  'https://gemini.google.com/*',\n  'https://aistudio.google.com/*',\n  'https://aistudio.google.cn/*',\n];\n\nfunction isSyncAccountScope(value: unknown): value is SyncAccountScope {\n  if (typeof value !== 'object' || value === null) return false;\n  const scope = value as Record<string, unknown>;\n  return (\n    typeof scope.accountKey === 'string' &&\n    typeof scope.accountId === 'number' &&\n    Number.isFinite(scope.accountId) &&\n    (typeof scope.routeUserId === 'string' || scope.routeUserId === null)\n  );\n}\n\nfunction toSyncAccountScope(scope: AccountScope): SyncAccountScope {\n  return {\n    accountKey: scope.accountKey,\n    accountId: scope.accountId,\n    routeUserId: scope.routeUserId,\n  };\n}\n\nasync function resolveAccountScopeForMessage(\n  sender: chrome.runtime.MessageSender,\n  platform: AccountPlatform,\n  explicitScope?: SyncAccountScope,\n): Promise<SyncAccountScope | null> {\n  const enabled = await accountIsolationService.isIsolationEnabled({\n    platform,\n    pageUrl: sender.tab?.url ?? null,\n  });\n  if (!enabled) return null;\n  if (explicitScope) return explicitScope;\n\n  const resolved = await accountIsolationService.resolveAccountScope({\n    pageUrl: sender.tab?.url ?? null,\n  });\n  return toSyncAccountScope(resolved);\n}\n\nfunction matchesRouteScope(url: string, routeUserId: string | null): boolean {\n  if (!routeUserId) return true;\n  const routeFromUrl = extractRouteUserIdFromUrl(url);\n  return routeFromUrl === null || routeFromUrl === routeUserId;\n}\n\nfunction filterStarredByRouteScope(\n  data: StarredMessagesData,\n  routeUserId: string | null,\n): StarredMessagesData {\n  if (!routeUserId) return data;\n\n  const filteredEntries = Object.entries(data.messages).map(([conversationId, messages]) => {\n    const filteredMessages = messages.filter((message) =>\n      matchesRouteScope(message.conversationUrl, routeUserId),\n    );\n    return [conversationId, filteredMessages] as const;\n  });\n\n  const filteredMessages = Object.fromEntries(\n    filteredEntries.filter((entry) => entry[1].length > 0),\n  );\n  return { messages: filteredMessages };\n}\n\nfunction filterForkNodesByRouteScope(\n  data: ForkNodesData,\n  routeUserId: string | null,\n): ForkNodesData {\n  if (!routeUserId) return data;\n\n  const filteredNodes: Record<string, ForkNode[]> = {};\n  for (const [conversationId, nodes] of Object.entries(data.nodes)) {\n    const filtered = nodes.filter((node) => matchesRouteScope(node.conversationUrl, routeUserId));\n    if (filtered.length > 0) {\n      filteredNodes[conversationId] = filtered;\n    }\n  }\n\n  const filteredGroups: Record<string, string[]> = {};\n  for (const nodes of Object.values(filteredNodes)) {\n    for (const node of nodes) {\n      if (!filteredGroups[node.forkGroupId]) {\n        filteredGroups[node.forkGroupId] = [];\n      }\n      const key = `${node.conversationId}:${node.turnId}`;\n      if (!filteredGroups[node.forkGroupId].includes(key)) {\n        filteredGroups[node.forkGroupId].push(key);\n      }\n    }\n  }\n\n  return {\n    nodes: filteredNodes,\n    groups: filteredGroups,\n  };\n}\n\n/**\n * Register the fetch interceptor script into MAIN world\n * This allows intercepting fetch calls made by the page itself\n */\nasync function registerFetchInterceptor(): Promise<void> {\n  if (!chrome.scripting?.registerContentScripts) return;\n\n  // Check if watermark remover feature is enabled\n  const result = await chrome.storage.sync.get({ geminiWatermarkRemoverEnabled: true });\n  const isEnabled = result.geminiWatermarkRemoverEnabled !== false;\n\n  try {\n    // Always unregister first to update settings\n    await chrome.scripting.unregisterContentScripts({ ids: [FETCH_INTERCEPTOR_SCRIPT_ID] });\n  } catch {\n    // No-op if script was not registered\n  }\n\n  // Only register if watermark remover is enabled\n  if (!isEnabled) {\n    console.log('[Background] Fetch interceptor not registered (watermark remover disabled)');\n    return;\n  }\n\n  try {\n    await chrome.scripting.registerContentScripts([\n      {\n        id: FETCH_INTERCEPTOR_SCRIPT_ID,\n        js: ['fetchInterceptor.js'],\n        matches: GEMINI_MATCHES,\n        world: 'MAIN',\n        runAt: 'document_start',\n        persistAcrossSessions: true,\n      },\n    ]);\n    console.log('[Background] Fetch interceptor registered for MAIN world');\n  } catch (error) {\n    console.error('[Background] Failed to register fetch interceptor:', error);\n  }\n}\n\nconst MANIFEST_DEFAULT_DOMAINS = new Set(\n  [\n    ...(chrome.runtime.getManifest().host_permissions || []),\n    ...(chrome.runtime.getManifest().content_scripts?.flatMap((c) => c.matches || []) || []),\n  ]\n    .map(patternToDomain)\n    .filter((d): d is string => !!d),\n);\n\nfunction patternToDomain(pattern: string | undefined): string | null {\n  if (!pattern) return null;\n  try {\n    const withoutScheme = pattern.replace(/^[^:]+:\\/\\//, '');\n    const hostPart = withoutScheme.replace(/\\/.*$/, '').replace(/^\\*\\./, '');\n    return hostPart || null;\n  } catch {\n    return null;\n  }\n}\n\nfunction toMatchPatterns(domain: string): string[] {\n  const normalized = domain\n    .trim()\n    .toLowerCase()\n    .replace(/^https?:\\/\\//, '')\n    .replace(/^www\\./, '')\n    .replace(/\\/.*$/, '')\n    .replace(/^\\*\\./, '');\n\n  if (!normalized) return [];\n  return [`https://*.${normalized}/*`, `http://*.${normalized}/*`];\n}\n\nfunction extractDomainsFromOrigins(origins?: string[]): string[] {\n  if (!Array.isArray(origins)) return [];\n  const domains = origins\n    .map(patternToDomain)\n    .filter((d): d is string => !!d)\n    .filter((d) => !MANIFEST_DEFAULT_DOMAINS.has(d));\n  return Array.from(new Set(domains));\n}\n\nasync function filterGrantedOrigins(patterns: string[]): Promise<string[]> {\n  const granted: string[] = [];\n\n  for (const origin of patterns) {\n    try {\n      const hasPermission = await browser.permissions.contains({ origins: [origin] });\n      if (hasPermission) {\n        granted.push(origin);\n      }\n    } catch (error) {\n      console.warn('[Background] Failed to check permission for', origin, error);\n    }\n  }\n\n  return granted;\n}\n\nasync function syncCustomContentScripts(domains?: string[]): Promise<void> {\n  if (!chrome.scripting?.registerContentScripts) return;\n\n  const manifestContentScript = chrome.runtime.getManifest().content_scripts?.[0];\n  if (!manifestContentScript) return;\n\n  const domainList =\n    domains ??\n    (\n      await chrome.storage.sync.get({\n        [CUSTOM_WEBSITE_KEY]: [],\n      })\n    )[CUSTOM_WEBSITE_KEY];\n\n  const matchPatterns = Array.from(\n    new Set((Array.isArray(domainList) ? domainList : []).flatMap(toMatchPatterns).filter(Boolean)),\n  );\n\n  const grantedMatches = await filterGrantedOrigins(matchPatterns);\n\n  try {\n    await chrome.scripting.unregisterContentScripts({ ids: [CUSTOM_CONTENT_SCRIPT_ID] });\n  } catch {\n    // No-op if script was not registered\n  }\n\n  if (!grantedMatches.length) return;\n\n  const runAt =\n    manifestContentScript.run_at === 'document_start'\n      ? 'document_start'\n      : manifestContentScript.run_at === 'document_end'\n        ? 'document_end'\n        : 'document_idle';\n\n  try {\n    await chrome.scripting.registerContentScripts([\n      {\n        id: CUSTOM_CONTENT_SCRIPT_ID,\n        js: manifestContentScript.js || [],\n        css: manifestContentScript.css,\n        matches: grantedMatches,\n        allFrames: manifestContentScript.all_frames,\n        runAt,\n        persistAcrossSessions: true,\n      },\n    ]);\n    console.log('[Background] Custom content scripts registered for', grantedMatches);\n  } catch (error) {\n    console.error('[Background] Failed to register custom content scripts:', error);\n  }\n}\n\n// Initial sync for persisted permissions\nvoid syncCustomContentScripts();\n\n// Initial fetch interceptor registration\nvoid registerFetchInterceptor();\n\nchrome.storage.onChanged.addListener((changes, areaName) => {\n  if (areaName !== 'sync') return;\n\n  if (Object.prototype.hasOwnProperty.call(changes, CUSTOM_WEBSITE_KEY)) {\n    const newValue = changes[CUSTOM_WEBSITE_KEY]?.newValue;\n    const domains = Array.isArray(newValue) ? newValue : [];\n    void syncCustomContentScripts(domains);\n  }\n\n  // Re-register fetch interceptor when watermark remover setting changes\n  if (Object.prototype.hasOwnProperty.call(changes, 'geminiWatermarkRemoverEnabled')) {\n    void registerFetchInterceptor();\n  }\n});\n\nchrome.permissions.onAdded.addListener(({ origins }) => {\n  const domains = extractDomainsFromOrigins(origins);\n  if (domains.length) {\n    void browser.storage.sync\n      .get({ [CUSTOM_WEBSITE_KEY]: [] })\n      .then((current) => {\n        const existing = Array.isArray(current[CUSTOM_WEBSITE_KEY])\n          ? current[CUSTOM_WEBSITE_KEY]\n          : [];\n        const merged = Array.from(new Set([...existing, ...domains]));\n        if (merged.length !== existing.length) {\n          return browser.storage.sync.set({ [CUSTOM_WEBSITE_KEY]: merged });\n        }\n      })\n      .catch((error) => {\n        console.warn('[Background] Failed to persist domains from permissions.onAdded:', error);\n      });\n  }\n\n  void syncCustomContentScripts();\n});\n\nchrome.permissions.onRemoved.addListener(() => {\n  void syncCustomContentScripts();\n});\n\n/**\n * Centralized starred messages management to prevent race conditions.\n * All read-modify-write operations are serialized through this background script.\n */\nclass StarredMessagesManager {\n  private operationQueue: Promise<unknown> = Promise.resolve();\n\n  /**\n   * Serialize all operations to prevent race conditions\n   */\n  private serialize<T>(operation: () => Promise<T>): Promise<T> {\n    const promise = this.operationQueue.then(operation, operation);\n    this.operationQueue = promise.catch(() => {}); // Prevent error propagation\n    return promise;\n  }\n\n  private async getFromStorage(): Promise<StarredMessagesData> {\n    try {\n      const result = await chrome.storage.local.get([StorageKeys.TIMELINE_STARRED_MESSAGES]);\n      return result[StorageKeys.TIMELINE_STARRED_MESSAGES] || { messages: {} };\n    } catch (error) {\n      console.error('[Background] Failed to get starred messages:', error);\n      return { messages: {} };\n    }\n  }\n\n  private async saveToStorage(data: StarredMessagesData): Promise<void> {\n    await chrome.storage.local.set({ [StorageKeys.TIMELINE_STARRED_MESSAGES]: data });\n  }\n\n  async addStarredMessage(message: StarredMessage): Promise<boolean> {\n    return this.serialize(async () => {\n      const data = await this.getFromStorage();\n\n      if (!data.messages[message.conversationId]) {\n        data.messages[message.conversationId] = [];\n      }\n\n      // Check if message already exists\n      const exists = data.messages[message.conversationId].some((m) => m.turnId === message.turnId);\n\n      if (!exists) {\n        // Truncate content to save storage space\n        // Popup is ~360px wide with line-clamp-2, showing ~50-60 chars max\n        const MAX_CONTENT_LENGTH = 60;\n        const truncatedMessage: StarredMessage = {\n          ...message,\n          content:\n            message.content.length > MAX_CONTENT_LENGTH\n              ? message.content.slice(0, MAX_CONTENT_LENGTH) + '...'\n              : message.content,\n        };\n        data.messages[message.conversationId].push(truncatedMessage);\n        await this.saveToStorage(data);\n        return true;\n      }\n      return false;\n    });\n  }\n\n  async removeStarredMessage(conversationId: string, turnId: string): Promise<boolean> {\n    return this.serialize(async () => {\n      const data = await this.getFromStorage();\n\n      if (data.messages[conversationId]) {\n        const initialLength = data.messages[conversationId].length;\n        data.messages[conversationId] = data.messages[conversationId].filter(\n          (m) => m.turnId !== turnId,\n        );\n\n        if (data.messages[conversationId].length < initialLength) {\n          // Remove conversation key if no messages left\n          if (data.messages[conversationId].length === 0) {\n            delete data.messages[conversationId];\n          }\n\n          await this.saveToStorage(data);\n          return true;\n        }\n      }\n      return false;\n    });\n  }\n\n  async getAllStarredMessages(): Promise<StarredMessagesData> {\n    return this.getFromStorage();\n  }\n\n  async getStarredMessagesForConversation(conversationId: string): Promise<StarredMessage[]> {\n    const data = await this.getFromStorage();\n    return data.messages[conversationId] || [];\n  }\n\n  async isMessageStarred(conversationId: string, turnId: string): Promise<boolean> {\n    const messages = await this.getStarredMessagesForConversation(conversationId);\n    return messages.some((m) => m.turnId === turnId);\n  }\n}\n\nconst starredMessagesManager = new StarredMessagesManager();\n\n/**\n * Centralized fork nodes management to prevent race conditions.\n * All read-modify-write operations are serialized through this background script.\n */\nclass ForkNodesManager {\n  private operationQueue: Promise<unknown> = Promise.resolve();\n\n  private serialize<T>(operation: () => Promise<T>): Promise<T> {\n    const promise = this.operationQueue.then(operation, operation);\n    this.operationQueue = promise.catch(() => {});\n    return promise;\n  }\n\n  private async getFromStorage(): Promise<ForkNodesData> {\n    try {\n      const result = await chrome.storage.local.get([StorageKeys.FORK_NODES]);\n      return result[StorageKeys.FORK_NODES] || { nodes: {}, groups: {} };\n    } catch (error) {\n      console.error('[Background] Failed to get fork nodes:', error);\n      return { nodes: {}, groups: {} };\n    }\n  }\n\n  private async saveToStorage(data: ForkNodesData): Promise<void> {\n    await chrome.storage.local.set({ [StorageKeys.FORK_NODES]: data });\n  }\n\n  async addForkNode(node: ForkNode): Promise<boolean> {\n    return this.serialize(async () => {\n      const data = await this.getFromStorage();\n\n      if (!data.nodes[node.conversationId]) {\n        data.nodes[node.conversationId] = [];\n      }\n\n      const exists = data.nodes[node.conversationId].some(\n        (n) => n.turnId === node.turnId && n.forkGroupId === node.forkGroupId,\n      );\n\n      if (!exists) {\n        data.nodes[node.conversationId].push(node);\n\n        // Update group index\n        if (!data.groups[node.forkGroupId]) {\n          data.groups[node.forkGroupId] = [];\n        }\n        const groupKey = `${node.conversationId}:${node.turnId}`;\n        if (!data.groups[node.forkGroupId].includes(groupKey)) {\n          data.groups[node.forkGroupId].push(groupKey);\n        }\n\n        await this.saveToStorage(data);\n        return true;\n      }\n      return false;\n    });\n  }\n\n  async removeForkNode(\n    conversationId: string,\n    turnId: string,\n    forkGroupId: string,\n  ): Promise<boolean> {\n    return this.serialize(async () => {\n      const data = await this.getFromStorage();\n\n      if (data.nodes[conversationId]) {\n        const initialLength = data.nodes[conversationId].length;\n        data.nodes[conversationId] = data.nodes[conversationId].filter(\n          (n) => !(n.turnId === turnId && n.forkGroupId === forkGroupId),\n        );\n\n        if (data.nodes[conversationId].length < initialLength) {\n          if (data.nodes[conversationId].length === 0) {\n            delete data.nodes[conversationId];\n          }\n\n          // Update group index\n          if (data.groups[forkGroupId]) {\n            const groupKey = `${conversationId}:${turnId}`;\n            data.groups[forkGroupId] = data.groups[forkGroupId].filter((k) => k !== groupKey);\n            if (data.groups[forkGroupId].length === 0) {\n              delete data.groups[forkGroupId];\n            }\n          }\n\n          await this.saveToStorage(data);\n          return true;\n        }\n      }\n      return false;\n    });\n  }\n\n  async getAllForkNodes(): Promise<ForkNodesData> {\n    return this.getFromStorage();\n  }\n\n  async getForConversation(conversationId: string): Promise<ForkNode[]> {\n    const data = await this.getFromStorage();\n    return data.nodes[conversationId] || [];\n  }\n\n  async getGroup(forkGroupId: string): Promise<ForkNode[]> {\n    const data = await this.getFromStorage();\n    const groupKeys = data.groups[forkGroupId] || [];\n    const nodes: ForkNode[] = [];\n\n    for (const key of groupKeys) {\n      const [convId, turnId] = key.split(':');\n      const convNodes = data.nodes[convId] || [];\n      const match = convNodes.find((n) => n.turnId === turnId && n.forkGroupId === forkGroupId);\n      if (match) nodes.push(match);\n    }\n\n    return nodes.sort((a, b) => a.forkIndex - b.forkIndex);\n  }\n}\n\nconst forkNodesManager = new ForkNodesManager();\n\nchrome.runtime.onMessage.addListener((message, sender, sendResponse) => {\n  (async () => {\n    try {\n      if (message?.type === 'gv.account.resolve') {\n        const payload = message.payload as {\n          pageUrl?: string;\n          routeUserId?: string | null;\n          email?: string | null;\n          platform?: AccountPlatform;\n        };\n        const resolvedPlatform =\n          payload?.platform ??\n          detectAccountPlatformFromUrl(payload?.pageUrl ?? sender.tab?.url ?? null);\n        const scope = await accountIsolationService.resolveAccountScope({\n          pageUrl: payload?.pageUrl ?? sender.tab?.url ?? null,\n          routeUserId: payload?.routeUserId ?? null,\n          email: payload?.email ?? null,\n        });\n        sendResponse({\n          ok: true,\n          scope,\n          enabled: await accountIsolationService.isIsolationEnabled({\n            platform: resolvedPlatform,\n            pageUrl: payload?.pageUrl ?? sender.tab?.url ?? null,\n          }),\n        });\n        return;\n      }\n\n      // Handle starred messages operations\n      if (message && message.type && message.type.startsWith('gv.starred.')) {\n        switch (message.type) {\n          case 'gv.starred.add': {\n            const added = await starredMessagesManager.addStarredMessage(message.payload);\n            sendResponse({ ok: true, added });\n            return;\n          }\n          case 'gv.starred.remove': {\n            const removed = await starredMessagesManager.removeStarredMessage(\n              message.payload.conversationId,\n              message.payload.turnId,\n            );\n            sendResponse({ ok: true, removed });\n            return;\n          }\n          case 'gv.starred.getAll': {\n            const data = await starredMessagesManager.getAllStarredMessages();\n            sendResponse({ ok: true, data });\n            return;\n          }\n          case 'gv.starred.getForConversation': {\n            const messages = await starredMessagesManager.getStarredMessagesForConversation(\n              message.payload.conversationId,\n            );\n            sendResponse({ ok: true, messages });\n            return;\n          }\n          case 'gv.starred.isStarred': {\n            const isStarred = await starredMessagesManager.isMessageStarred(\n              message.payload.conversationId,\n              message.payload.turnId,\n            );\n            sendResponse({ ok: true, isStarred });\n            return;\n          }\n        }\n      }\n\n      // Handle fork nodes operations\n      if (message && message.type && message.type.startsWith('gv.fork.')) {\n        switch (message.type) {\n          case 'gv.fork.add': {\n            const added = await forkNodesManager.addForkNode(message.payload);\n            sendResponse({ ok: true, added });\n            return;\n          }\n          case 'gv.fork.remove': {\n            const removed = await forkNodesManager.removeForkNode(\n              message.payload.conversationId,\n              message.payload.turnId,\n              message.payload.forkGroupId,\n            );\n            sendResponse({ ok: true, removed });\n            return;\n          }\n          case 'gv.fork.getAll': {\n            const data = await forkNodesManager.getAllForkNodes();\n            sendResponse({ ok: true, data });\n            return;\n          }\n          case 'gv.fork.getForConversation': {\n            const nodes = await forkNodesManager.getForConversation(message.payload.conversationId);\n            sendResponse({ ok: true, nodes });\n            return;\n          }\n          case 'gv.fork.getGroup': {\n            const nodes = await forkNodesManager.getGroup(message.payload.forkGroupId);\n            sendResponse({ ok: true, nodes });\n            return;\n          }\n        }\n      }\n\n      // Handle sync operations\n      if (message && message.type && message.type.startsWith('gv.sync.')) {\n        switch (message.type) {\n          case 'gv.sync.authenticate': {\n            const interactive = message.payload?.interactive !== false;\n            const success = await googleDriveSyncService.authenticate(interactive);\n            sendResponse({ ok: success, state: await googleDriveSyncService.getState() });\n            return;\n          }\n          case 'gv.sync.signOut': {\n            await googleDriveSyncService.signOut();\n            sendResponse({ ok: true, state: await googleDriveSyncService.getState() });\n            return;\n          }\n          case 'gv.sync.upload': {\n            const {\n              folders,\n              prompts,\n              interactive,\n              platform: rawPlatform,\n              accountScope: rawScope,\n            } = message.payload as {\n              folders: FolderData;\n              prompts: PromptItem[];\n              interactive?: boolean;\n              platform?: 'gemini' | 'aistudio';\n              accountScope?: unknown;\n            };\n            const platform = rawPlatform || 'gemini';\n            const accountScope = await resolveAccountScopeForMessage(\n              sender,\n              platform,\n              isSyncAccountScope(rawScope) ? rawScope : undefined,\n            );\n            // Also get starred messages and fork nodes from local storage (only for Gemini platform)\n            const starredDataRaw =\n              platform !== 'aistudio' ? await starredMessagesManager.getAllStarredMessages() : null;\n            const forksDataRaw =\n              platform !== 'aistudio' ? await forkNodesManager.getAllForkNodes() : null;\n            const starredData =\n              starredDataRaw && accountScope\n                ? filterStarredByRouteScope(starredDataRaw, accountScope.routeUserId)\n                : starredDataRaw;\n            const forksData =\n              forksDataRaw && accountScope\n                ? filterForkNodesByRouteScope(forksDataRaw, accountScope.routeUserId)\n                : forksDataRaw;\n            const success = await googleDriveSyncService.upload(\n              folders,\n              prompts,\n              starredData,\n              interactive !== false,\n              platform,\n              forksData,\n              accountScope,\n            );\n            sendResponse({ ok: success, state: await googleDriveSyncService.getState() });\n            return;\n          }\n          case 'gv.sync.download': {\n            const interactive = message.payload?.interactive !== false;\n            const platform = (message.payload?.platform as 'gemini' | 'aistudio') || 'gemini';\n            const rawScope = message.payload?.accountScope;\n            const accountScope = await resolveAccountScopeForMessage(\n              sender,\n              platform,\n              isSyncAccountScope(rawScope) ? rawScope : undefined,\n            );\n            const data = await googleDriveSyncService.download(interactive, platform, accountScope);\n            // NOTE: We intentionally do NOT save to storage here.\n            // The caller (Popup) is responsible for merging with local data and saving.\n            // This prevents data loss from overwriting local changes.\n            console.log(\n              `[Background] Downloaded data for ${platform}, returning to caller for merge`,\n            );\n            sendResponse({\n              ok: true,\n              data,\n              state: await googleDriveSyncService.getState(),\n            });\n            return;\n          }\n          case 'gv.sync.getState': {\n            sendResponse({ ok: true, state: await googleDriveSyncService.getState() });\n            return;\n          }\n          case 'gv.sync.setMode': {\n            const mode = message.payload?.mode as SyncMode;\n            if (mode) {\n              await googleDriveSyncService.setMode(mode);\n            }\n            sendResponse({ ok: true, state: await googleDriveSyncService.getState() });\n            return;\n          }\n        }\n      }\n\n      // Handle popup opening request\n      if (message && message.type === 'gv.openPopup') {\n        try {\n          await chrome.action.openPopup();\n          sendResponse({ ok: true });\n        } catch (e) {\n          // Fallback: If openPopup fails, user can click the extension icon\n          console.warn('[GV] Failed to open popup programmatically:', e);\n          sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) });\n        }\n        return;\n      }\n\n      // Handle sync to IDE (bypasses page CSP)\n      if (message?.type === 'gv.syncToIDE') {\n        const url = String(message.url || '');\n        const data = message.data || [];\n        try {\n          const response = await fetch(url, {\n            method: 'POST',\n            headers: { 'Content-Type': 'application/json' },\n            mode: 'cors',\n            body: JSON.stringify(data),\n          });\n\n          if (!response.ok) {\n            sendResponse({ ok: false, error: `HTTP ${response.status}` });\n          } else {\n            const result = await response.json();\n            sendResponse({ ok: true, data: result });\n          }\n        } catch (e) {\n          sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) });\n        }\n        return;\n      }\n\n      // Handle check sync server status (bypasses page CSP)\n      if (message?.type === 'gv.checkSyncStatus') {\n        const url = String(message.url || '');\n        const timeout = Number(message.timeout || 200);\n        const controller = new AbortController();\n        const timeoutId = setTimeout(() => controller.abort(), timeout);\n\n        try {\n          const response = await fetch(url, {\n            method: 'GET',\n            signal: controller.signal,\n          });\n          sendResponse({ ok: response.ok });\n        } catch {\n          sendResponse({ ok: false });\n        } finally {\n          clearTimeout(timeoutId);\n        }\n        return;\n      }\n\n      // Handle image fetch via page context (for Firefox/Safari cookie partitioning)\n      // Uses chrome.scripting.executeScript in MAIN world so the page's own fetch is used,\n      // which has access to the correct Google authentication cookies.\n      if (message?.type === 'gv.fetchImageViaPage') {\n        const url = String(message.url || '');\n        const tabId = sender?.tab?.id;\n        if (!tabId || !/^https?:\\/\\//i.test(url)) {\n          sendResponse({ ok: false, error: 'invalid' });\n          return;\n        }\n        if (!chrome.scripting?.executeScript) {\n          sendResponse({ ok: false, error: 'scripting_api_unavailable' });\n          return;\n        }\n        try {\n          const results = await chrome.scripting.executeScript({\n            target: { tabId },\n            world: 'MAIN' as chrome.scripting.ExecutionWorld,\n            func: async (imageUrl: string) => {\n              const safeFetch = async (credentials: RequestCredentials) => {\n                try {\n                  console.log(`[PageContext] Fetching with ${credentials}:`, imageUrl);\n                  const resp = await fetch(imageUrl, { credentials });\n                  if (resp.ok) return await resp.blob();\n                  console.warn(`[PageContext] Fetch (${credentials}) HTTP error:`, resp.status);\n                } catch (e) {\n                  console.warn(`[PageContext] Fetch (${credentials}) error:`, e);\n                }\n                return null;\n              };\n\n              try {\n                // Try with credentials first, then without (fix for Firefox CSP/CORS)\n                const blob = (await safeFetch('include')) || (await safeFetch('omit'));\n                if (!blob) {\n                  console.error('[PageContext] All fetch attempts failed');\n                  return null;\n                }\n\n                return new Promise<{\n                  contentType: string;\n                  base64: string;\n                } | null>((resolve) => {\n                  const reader = new FileReader();\n                  reader.onload = () => {\n                    const dataUrl = String(reader.result || '');\n                    const commaIdx = dataUrl.indexOf(',');\n                    if (commaIdx < 0) {\n                      resolve(null);\n                      return;\n                    }\n                    resolve({\n                      contentType: blob.type || 'application/octet-stream',\n                      base64: dataUrl.substring(commaIdx + 1),\n                    });\n                  };\n                  reader.onerror = () => resolve(null);\n                  reader.readAsDataURL(blob);\n                });\n              } catch {\n                return null;\n              }\n            },\n            args: [url],\n          });\n          const result = results?.[0]?.result as {\n            contentType: string;\n            base64: string;\n          } | null;\n          if (result?.base64) {\n            sendResponse({\n              ok: true,\n              contentType: result.contentType,\n              base64: result.base64,\n              data: `data:${result.contentType};base64,${result.base64}`,\n            });\n          } else {\n            sendResponse({ ok: false, error: 'page_fetch_failed' });\n          }\n        } catch (e: unknown) {\n          const errMsg = e instanceof Error ? e.message : String(e);\n          sendResponse({ ok: false, error: errMsg });\n        }\n        return;\n      }\n\n      // Handle image fetch\n      if (!message || message.type !== 'gv.fetchImage') return;\n      const url = String(message.url || '');\n      if (!/^https?:\\/\\//i.test(url)) {\n        sendResponse({ ok: false, error: 'invalid_url' });\n        return;\n      }\n\n      const fetchWithFallback = async (fetchUrl: string) => {\n        try {\n          const r1 = await fetch(fetchUrl, { credentials: 'include', redirect: 'follow' });\n          if (r1.ok) return r1;\n        } catch {\n          /* ignore include error */\n        }\n\n        try {\n          const r2 = await fetch(fetchUrl, { credentials: 'omit', redirect: 'follow' });\n          return r2;\n        } catch (e) {\n          throw e;\n        }\n      };\n\n      fetchWithFallback(url)\n        .then((response) => {\n          if (!response.ok) throw new Error(`HTTP ${response.status}`);\n          return response.blob();\n        })\n        .then((blob) => {\n          return blob.arrayBuffer().then((ab) => {\n            const b64 = arrayBufferToBase64(ab);\n            const contentType = blob.type || 'image/png';\n            const dataUrl = `data:${contentType};base64,${b64}`;\n            sendResponse({\n              ok: true,\n              data: dataUrl,\n              contentType,\n              base64: b64,\n            });\n          });\n        })\n        .catch((err) => {\n          console.error('[Background] gv.fetchImage Final failure:', err);\n          sendResponse({ ok: false, error: err.message });\n        });\n      return;\n    } catch (e) {\n      try {\n        sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) });\n      } catch {}\n    }\n  })();\n  return true; // keep channel open for async sendResponse\n});\n\nfunction arrayBufferToBase64(buffer: ArrayBuffer): string {\n  let binary = '';\n  const bytes = new Uint8Array(buffer);\n  const len = bytes.byteLength;\n  for (let i = 0; i < len; i++) {\n    binary += String.fromCharCode(bytes[i]);\n  }\n  // btoa on service worker context is available\n  return btoa(binary);\n}\n"
  },
  {
    "path": "src/pages/content/changelog/__tests__/changelog.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport {\n  extractLocalizedContent,\n  resolveChangelogImageUrl,\n  rewriteChangelogImageUrls,\n} from '../index';\n\ndescribe('extractLocalizedContent', () => {\n  const sampleMarkdown = `<!-- lang:en -->\n### What's New\n- Feature A\n- Bug fix B\n\n<!-- lang:zh -->\n### 更新内容\n- 功能 A\n- 修复 B\n\n<!-- lang:ja -->\n### 新機能\n- 機能 A\n- バグ修正 B`;\n\n  it('extracts the correct language section', () => {\n    const result = extractLocalizedContent(sampleMarkdown, 'zh');\n    expect(result).toContain('更新内容');\n    expect(result).toContain('功能 A');\n    expect(result).not.toContain(\"What's New\");\n  });\n\n  it('extracts English section', () => {\n    const result = extractLocalizedContent(sampleMarkdown, 'en');\n    expect(result).toContain(\"What's New\");\n    expect(result).toContain('Feature A');\n  });\n\n  it('extracts Japanese section', () => {\n    const result = extractLocalizedContent(sampleMarkdown, 'ja');\n    expect(result).toContain('新機能');\n    expect(result).toContain('機能 A');\n  });\n\n  it('falls back to English when requested language is missing', () => {\n    const result = extractLocalizedContent(sampleMarkdown, 'fr');\n    expect(result).toContain(\"What's New\");\n  });\n\n  it('returns empty string when no sections exist', () => {\n    const result = extractLocalizedContent('No language markers here', 'en');\n    expect(result).toBe('');\n  });\n\n  it('handles front matter and strips it from content', () => {\n    const withFrontMatter = `---\nimages:\n  hero: ./assets/1.2.8-hero.gif\n---\n\n<!-- lang:en -->\n### What's New\n- Feature A`;\n\n    const result = extractLocalizedContent(withFrontMatter, 'en');\n    expect(result).toContain(\"What's New\");\n    expect(result).not.toContain('images:');\n    expect(result).not.toContain('---');\n  });\n\n  it('handles single language section', () => {\n    const single = `<!-- lang:en -->\n### Only English\n- Item 1`;\n\n    const result = extractLocalizedContent(single, 'en');\n    expect(result).toContain('Only English');\n  });\n\n  it('handles zh_TW language code', () => {\n    const withZhTW = `<!-- lang:en -->\n### What's New\n- Feature A\n\n<!-- lang:zh_TW -->\n### 更新內容\n- 功能 A`;\n\n    const result = extractLocalizedContent(withZhTW, 'zh_TW');\n    expect(result).toContain('更新內容');\n  });\n\n  it('trims whitespace from extracted content', () => {\n    const result = extractLocalizedContent(sampleMarkdown, 'en');\n    expect(result).not.toMatch(/^\\s/);\n    expect(result).not.toMatch(/\\s$/);\n  });\n});\n\ndescribe('resolveChangelogImageUrl', () => {\n  it('rewrites github raw promotion image URLs to runtime URLs', () => {\n    const source =\n      'https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png';\n\n    const result = resolveChangelogImageUrl(source, (path) => `moz-extension://test-id/${path}`);\n\n    expect(result).toBe('moz-extension://test-id/changelog-promo-banner.png');\n  });\n\n  it('rewrites raw.githubusercontent.com promotion image URLs to runtime URLs', () => {\n    const source =\n      'https://raw.githubusercontent.com/Nagi-ovo/gemini-voyager/main/docs/public/assets/promotion/Promo-Banner-jp.png';\n\n    const result = resolveChangelogImageUrl(source, (path) => `moz-extension://test-id/${path}`);\n\n    expect(result).toBe('moz-extension://test-id/changelog-promo-banner-jp.png');\n  });\n\n  it('keeps unsupported image URLs unchanged', () => {\n    const source =\n      'https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Unknown.png';\n\n    const result = resolveChangelogImageUrl(source, (path) => `moz-extension://test-id/${path}`);\n\n    expect(result).toBe(source);\n  });\n});\n\ndescribe('rewriteChangelogImageUrls', () => {\n  it('rewrites supported markdown image URLs and preserves others', () => {\n    const source = [\n      '![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner-cn.png)',\n      '![external](https://example.com/banner.png)',\n    ].join('\\n');\n\n    const result = rewriteChangelogImageUrls(source, (path) => `moz-extension://test-id/${path}`);\n\n    expect(result).toContain('![banner](moz-extension://test-id/changelog-promo-banner-cn.png)');\n    expect(result).toContain('![external](https://example.com/banner.png)');\n  });\n\n  it('falls back to original URL when runtime URL resolution fails', () => {\n    const source =\n      '![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png)';\n\n    const result = rewriteChangelogImageUrls(source, () => null);\n\n    expect(result).toBe(source);\n  });\n\n  it('skips rewriting when rewrite flag is disabled', () => {\n    const source =\n      '![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png)';\n\n    const result = rewriteChangelogImageUrls(\n      source,\n      (path) => `moz-extension://test-id/${path}`,\n      false,\n    );\n\n    expect(result).toBe(source);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/changelog/index.ts",
    "content": "import DOMPurify from 'dompurify';\nimport { marked } from 'marked';\n\nimport { StorageKeys } from '@/core/types/common';\nimport { isChrome, isFirefox } from '@/core/utils/browser';\nimport { EXTENSION_VERSION } from '@/core/utils/version';\nimport { getCurrentLanguage } from '@/utils/i18n';\nimport type { AppLanguage } from '@/utils/language';\nimport { TRANSLATIONS, type TranslationKey } from '@/utils/translations';\n\n/**\n * Dynamically import all markdown changelog files.\n * Keyed by relative path, e.g. './notes/1.2.8.md'\n */\nconst changelogModules = import.meta.glob('./notes/*.md', {\n  query: '?raw',\n  import: 'default',\n  eager: false,\n}) as Record<string, () => Promise<string>>;\n\nconst MARKDOWN_IMAGE_URL_REGEX = /!\\[([^\\]]*)\\]\\((https?:\\/\\/[^\\s)]+)\\)/g;\n\nconst GITHUB_PROMOTION_PATH_PREFIX =\n  '/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/';\nconst RAW_GITHUBUSERCONTENT_PROMOTION_PATH_PREFIX =\n  '/Nagi-ovo/gemini-voyager/main/docs/public/assets/promotion/';\n\nfunction getPromotionRuntimePath(filename: string): string | null {\n  switch (filename) {\n    case 'Promo-Banner.png':\n      return 'changelog-promo-banner.png';\n    case 'Promo-Banner-cn.png':\n      return 'changelog-promo-banner-cn.png';\n    case 'Promo-Banner-jp.png':\n      return 'changelog-promo-banner-jp.png';\n    case 'Promo-Banner-KO.png':\n      return 'changelog-promo-banner-ko.png';\n    default:\n      return null;\n  }\n}\n\nfunction getRuntimeUrl(path: string): string | null {\n  try {\n    const runtime = (\n      globalThis as typeof globalThis & {\n        browser?: { runtime?: { getURL?: (assetPath: string) => string } };\n        chrome?: { runtime?: { getURL?: (assetPath: string) => string } };\n      }\n    ).browser?.runtime;\n    const fallbackRuntime = (\n      globalThis as typeof globalThis & {\n        chrome?: { runtime?: { getURL?: (assetPath: string) => string } };\n      }\n    ).chrome?.runtime;\n    const getUrl = runtime?.getURL ?? fallbackRuntime?.getURL;\n    return typeof getUrl === 'function' ? getUrl(path) : null;\n  } catch {\n    return null;\n  }\n}\n\nfunction extractPromotionRuntimePath(url: URL): string | null {\n  const host = url.hostname.toLowerCase();\n  const pathname = url.pathname;\n  const isGithubPromotionImage =\n    (host === 'github.com' && pathname.startsWith(GITHUB_PROMOTION_PATH_PREFIX)) ||\n    (host === 'raw.githubusercontent.com' &&\n      pathname.startsWith(RAW_GITHUBUSERCONTENT_PROMOTION_PATH_PREFIX));\n  if (!isGithubPromotionImage) return null;\n\n  const filename = pathname.split('/').pop();\n  return filename ? getPromotionRuntimePath(filename) : null;\n}\n\nexport function resolveChangelogImageUrl(\n  url: string,\n  runtimeUrlResolver: (path: string) => string | null = getRuntimeUrl,\n): string {\n  try {\n    const parsed = new URL(url);\n    const runtimePath = extractPromotionRuntimePath(parsed);\n    if (!runtimePath) return url;\n\n    const runtimeUrl = runtimeUrlResolver(runtimePath);\n    return runtimeUrl ?? url;\n  } catch {\n    return url;\n  }\n}\n\nexport function rewriteChangelogImageUrls(\n  markdown: string,\n  runtimeUrlResolver: (path: string) => string | null = getRuntimeUrl,\n  shouldRewrite: boolean = true,\n): string {\n  if (!shouldRewrite) return markdown;\n\n  return markdown.replace(MARKDOWN_IMAGE_URL_REGEX, (full, alt, url) => {\n    const resolvedUrl = resolveChangelogImageUrl(url, runtimeUrlResolver);\n    if (resolvedUrl === url) return full;\n    return `![${alt}](${resolvedUrl})`;\n  });\n}\n\n/**\n * Strip optional front matter (--- ... ---) from markdown.\n */\nfunction stripFrontMatter(raw: string): string {\n  const match = raw.match(/^---\\r?\\n[\\s\\S]*?\\r?\\n---\\r?\\n([\\s\\S]*)$/);\n  return match ? match[1] : raw;\n}\n\n/**\n * Extract the section matching the user's language from a multi-language\n * markdown file. Falls back to 'en' if the requested language is missing.\n */\nexport function extractLocalizedContent(raw: string, lang: AppLanguage): string {\n  const body = stripFrontMatter(raw);\n\n  // Split by <!-- lang:xx --> markers\n  const sections = new Map<string, string>();\n  const parts = body.split(/<!--\\s*lang:(\\w+)\\s*-->/);\n\n  // parts[0] is text before the first marker (usually empty)\n  // parts[1] = lang code, parts[2] = content, parts[3] = lang code, etc.\n  for (let i = 1; i < parts.length; i += 2) {\n    const langCode = parts[i];\n    const content = parts[i + 1]?.trim() ?? '';\n    if (langCode && content) {\n      sections.set(langCode, content);\n    }\n  }\n\n  return sections.get(lang) ?? sections.get('en') ?? '';\n}\n\n/**\n * Translate a key using an explicit language, bypassing cachedLanguage.\n * This avoids race conditions when initI18n() hasn't finished yet.\n */\nfunction t(key: TranslationKey, lang: AppLanguage): string {\n  return TRANSLATIONS[lang][key] ?? TRANSLATIONS.en[key] ?? key;\n}\n\n/**\n * Get the docs URL for the current language.\n * zh is the root locale (no prefix), others use /{locale}/ prefix.\n */\nfunction getDocsUrl(lang: AppLanguage): string {\n  const base = 'https://voyager.nagi.fun';\n  const path = '/guide/getting-started';\n  if (lang === 'zh') return `${base}${path}`;\n  return `${base}/${lang}${path}`;\n}\n\n/**\n * Get the sponsor page URL for the current language.\n * zh is the root locale (no prefix), others use /{locale}/ prefix.\n */\nfunction getSponsorUrl(lang: AppLanguage): string {\n  const base = 'https://voyager.nagi.fun';\n  const path = '/guide/sponsor.html';\n  if (lang === 'zh') return `${base}${path}`;\n  return `${base}/${lang}${path}`;\n}\n\n/**\n * Show a full-screen lightbox preview for the given image.\n */\nfunction showImageLightbox(src: string, alt: string): void {\n  const lightbox = document.createElement('div');\n  lightbox.className = 'gv-changelog-lightbox';\n\n  const img = document.createElement('img');\n  img.src = src;\n  img.alt = alt;\n  img.className = 'gv-changelog-lightbox-img';\n\n  lightbox.appendChild(img);\n  document.body.appendChild(lightbox);\n\n  const close = (): void => {\n    lightbox.remove();\n    document.removeEventListener('keydown', onKeyDown);\n  };\n\n  const onKeyDown = (e: KeyboardEvent): void => {\n    if (e.key === 'Escape') close();\n  };\n\n  lightbox.addEventListener('click', close);\n  document.addEventListener('keydown', onKeyDown);\n}\n\nconst CHROME_STORE_URL =\n  'https://chromewebstore.google.com/detail/gemini-voyager/iifacdnjakkhjjiengaffnegbndgingi';\n\n/**\n * Read the current changelog notification mode.\n */\nasync function readNotifyMode(): Promise<'popup' | 'badge'> {\n  try {\n    const result = await chrome.storage.local.get(StorageKeys.CHANGELOG_NOTIFY_MODE);\n    const mode = result[StorageKeys.CHANGELOG_NOTIFY_MODE];\n    return mode === 'badge' ? 'badge' : 'popup';\n  } catch {\n    return 'popup';\n  }\n}\n\n/**\n * Render the changelog modal DOM.\n */\nfunction createChangelogModal(\n  htmlContent: string,\n  lang: AppLanguage,\n  initialNotifyMode: 'popup' | 'badge' = 'popup',\n): {\n  overlay: HTMLDivElement;\n  onClose: () => void;\n} {\n  const overlay = document.createElement('div');\n  overlay.className = 'gv-changelog-overlay';\n\n  const dialog = document.createElement('div');\n  dialog.className = 'gv-changelog-dialog';\n\n  // Header\n  const header = document.createElement('div');\n  header.className = 'gv-changelog-header';\n\n  const title = document.createElement('span');\n  title.className = 'gv-changelog-title';\n  title.textContent = t('changelog_title', lang);\n\n  const version = document.createElement('span');\n  version.className = 'gv-changelog-version';\n  version.textContent = `v${EXTENSION_VERSION}`;\n\n  const closeBtn = document.createElement('button');\n  closeBtn.className = 'gv-changelog-close';\n  closeBtn.textContent = '✕';\n  closeBtn.setAttribute('aria-label', 'Close');\n\n  header.appendChild(title);\n  header.appendChild(version);\n  header.appendChild(closeBtn);\n\n  // Body\n  const body = document.createElement('div');\n  body.className = 'gv-changelog-body';\n  body.innerHTML = htmlContent;\n\n  // Bind image zoom on all images in the body\n  body.querySelectorAll<HTMLImageElement>('img').forEach((img) => {\n    img.addEventListener('click', () => showImageLightbox(img.src, img.alt));\n  });\n\n  // Footer\n  const footer = document.createElement('div');\n  footer.className = 'gv-changelog-footer';\n\n  // Recommendation message\n  const recommendation = document.createElement('p');\n  recommendation.className = 'gv-changelog-recommendation';\n  recommendation.textContent = t('changelog_recommendation', lang);\n\n  // Social media handles row\n  const socialRow = document.createElement('div');\n  socialRow.className = 'gv-changelog-social-row';\n  const socialAccounts = [\n    {\n      name: t('changelog_social_xiaohongshu', lang),\n      handle: '@Nagi-ovo',\n      url: 'https://www.xiaohongshu.com/user/profile/5d366136000000001101950a',\n      color: '#FF2442',\n      icon: '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M22.405 9.879c.002.016.01.02.07.019h.725a.797.797 0 0 0 .78-.972.794.794 0 0 0-.884-.618.795.795 0 0 0-.692.794c0 .101-.002.666.001.777zm-11.509 4.808c-.203.001-1.353.004-1.685.003a2.528 2.528 0 0 1-.766-.126.025.025 0 0 0-.03.014L7.7 16.127a.025.025 0 0 0 .01.032c.111.06.336.124.495.124.66.01 1.32.002 1.981 0 .01 0 .02-.006.023-.015l.712-1.545a.025.025 0 0 0-.024-.036zM.477 9.91c-.071 0-.076.002-.076.01a.834.834 0 0 0-.01.08c-.027.397-.038.495-.234 3.06-.012.24-.034.389-.135.607-.026.057-.033.042.003.112.046.092.681 1.523.787 1.74.008.015.011.02.017.02.008 0 .033-.026.047-.044.147-.187.268-.391.371-.606.306-.635.44-1.325.486-1.706.014-.11.021-.22.03-.33l.204-2.616.022-.293c.003-.029 0-.033-.03-.034zm7.203 3.757a1.427 1.427 0 0 1-.135-.607c-.004-.084-.031-.39-.235-3.06a.443.443 0 0 0-.01-.082c-.004-.011-.052-.008-.076-.008h-1.48c-.03.001-.034.005-.03.034l.021.293c.076.982.153 1.964.233 2.946.05.4.186 1.085.487 1.706.103.215.223.419.37.606.015.018.037.051.048.049.02-.003.742-1.642.804-1.765.036-.07.03-.055.003-.112zm3.861-.913h-.872a.126.126 0 0 1-.116-.178l1.178-2.625a.025.025 0 0 0-.023-.035l-1.318-.003a.148.148 0 0 1-.135-.21l.876-1.954a.025.025 0 0 0-.023-.035h-1.56c-.01 0-.02.006-.024.015l-.926 2.068c-.085.169-.314.634-.399.938a.534.534 0 0 0-.02.191.46.46 0 0 0 .23.378.981.981 0 0 0 .46.119h.59c.041 0-.688 1.482-.834 1.972a.53.53 0 0 0-.023.172.465.465 0 0 0 .23.398c.15.092.342.12.475.12l1.66-.001c.01 0 .02-.006.023-.015l.575-1.28a.025.025 0 0 0-.024-.035zm-6.93-4.937H3.1a.032.032 0 0 0-.034.033c0 1.048-.01 2.795-.01 6.829 0 .288-.269.262-.28.262h-.74c-.04.001-.044.004-.04.047.001.037.465 1.064.555 1.263.01.02.03.033.051.033.157.003.767.009.938-.014.153-.02.3-.06.438-.132.3-.156.49-.419.595-.765.052-.172.075-.353.075-.533.002-2.33 0-4.66-.007-6.991a.032.032 0 0 0-.032-.032zm11.784 6.896c0-.014-.01-.021-.024-.022h-1.465c-.048-.001-.049-.002-.05-.049v-4.66c0-.072-.005-.07.07-.07h.863c.08 0 .075.004.075-.074V8.393c0-.082.006-.076-.08-.076h-3.5c-.064 0-.075-.006-.075.073v1.445c0 .083-.006.077.08.077h.854c.075 0 .07-.004.07.07v4.624c0 .095.008.084-.085.084-.37 0-1.11-.002-1.304 0-.048.001-.06.03-.06.03l-.697 1.519s-.014.025-.008.036c.006.01.013.008.058.008 1.748.003 3.495.002 5.243.002.03-.001.034-.006.035-.033v-1.539zm4.177-3.43c0 .013-.007.023-.02.024-.346.006-.692.004-1.037.004-.014-.002-.022-.01-.022-.024-.005-.434-.007-.869-.01-1.303 0-.072-.006-.071.07-.07l.733-.003c.041 0 .081.002.12.015.093.025.16.107.165.204.006.431.002 1.153.001 1.153zm2.67.244a1.953 1.953 0 0 0-.883-.222h-.18c-.04-.001-.04-.003-.042-.04V10.21c0-.132-.007-.263-.025-.394a1.823 1.823 0 0 0-.153-.53 1.533 1.533 0 0 0-.677-.71 2.167 2.167 0 0 0-1-.258c-.153-.003-.567 0-.72 0-.07 0-.068.004-.068-.065V7.76c0-.031-.01-.041-.046-.039H17.93s-.016 0-.023.007c-.006.006-.008.012-.008.023v.546c-.008.036-.057.015-.082.022h-.95c-.022.002-.028.008-.03.032v1.481c0 .09-.004.082.082.082h.913c.082 0 .072.128.072.128V11.19s.003.117-.06.117h-1.482c-.068 0-.06.082-.06.082v1.445s-.01.068.064.068h1.457c.082 0 .076-.006.076.079v3.225c0 .088-.007.081.082.081h1.43c.09 0 .082.007.082-.08v-3.27c0-.029.006-.035.033-.035l2.323-.003c.098 0 .191.02.28.061a.46.46 0 0 1 .274.407c.008.395.003.79.003 1.185 0 .259-.107.367-.33.367h-1.218c-.023.002-.029.008-.028.033.184.437.374.871.57 1.303a.045.045 0 0 0 .04.026c.17.005.34.002.51.003.15-.002.517.004.666-.01a2.03 2.03 0 0 0 .408-.075c.59-.18.975-.698.976-1.313v-1.981c0-.128-.01-.254-.034-.38 0 .078-.029-.641-.724-.998z\"/></svg>',\n    },\n    {\n      handle: '@Nag1ovo',\n      url: 'https://x.com/Nag1ovo',\n      color: '',\n      icon: '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.748l7.73-8.835L1.254 2.25H8.08l4.26 5.632 5.904-5.632zm-1.161 17.52h1.833L7.084 4.126H5.117z\"/></svg>',\n    },\n    {\n      name: t('changelog_social_zhihu', lang),\n      handle: '@Nagi-ovo',\n      url: 'https://www.zhihu.com/people/bu-xue-hao-shu-xue-wu-li-bu-gai-ming',\n      color: '#0066FF',\n      icon: '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M5.721 0C2.251 0 0 2.25 0 5.719V18.28C0 21.751 2.252 24 5.721 24h12.56C21.751 24 24 21.75 24 18.281V5.72C24 2.249 21.75 0 18.281 0zm1.964 4.078c-.271.73-.5 1.434-.68 2.11h4.587c.545-.006.445 1.168.445 1.171H9.384a58.104 58.104 0 01-.112 3.797h2.712c.388.023.393 1.251.393 1.266H9.183a9.223 9.223 0 01-.408 2.102l.757-.604c.452.456 1.512 1.712 1.906 2.177.473.681.063 2.081.063 2.081l-2.794-3.382c-.653 2.518-1.845 3.607-1.845 3.607-.523.468-1.58.82-2.64.516 2.218-1.73 3.44-3.917 3.667-6.497H4.491c0-.015.197-1.243.806-1.266h2.71c.024-.32.086-3.254.086-3.797H6.598c-.136.406-.158.447-.268.753-.594 1.095-1.603 1.122-1.907 1.155.906-1.821 1.416-3.6 1.591-4.064.425-1.124 1.671-1.125 1.671-1.125zM13.078 6h6.377v11.33h-2.573l-2.184 1.373-.401-1.373h-1.219zm1.313 1.219v8.86h.623l.263.937 1.455-.938h1.456v-8.86z\"/></svg>',\n    },\n    {\n      name: 'Bilibili',\n      handle: '@卡普迪姆',\n      url: 'https://space.bilibili.com/312249633',\n      color: '#FB7299',\n      icon: '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 01-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 01.16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z\"/></svg>',\n    },\n  ];\n\n  for (const account of socialAccounts) {\n    const item = document.createElement('a');\n    item.className = 'gv-changelog-social-item';\n    item.href = account.url;\n    item.target = '_blank';\n    item.rel = 'noopener noreferrer';\n    const iconSpan = document.createElement('span');\n    iconSpan.className = 'gv-changelog-social-icon';\n    iconSpan.innerHTML = account.icon;\n    if (account.color) {\n      iconSpan.style.color = account.color;\n    }\n    const textSpan = document.createElement('span');\n    textSpan.textContent = account.name ? `${account.name} ${account.handle}` : account.handle;\n    item.appendChild(iconSpan);\n    item.appendChild(textSpan);\n    socialRow.appendChild(item);\n  }\n\n  // Action row: icons on the left, \"Got it\" button on the right\n  const actionRow = document.createElement('div');\n  actionRow.className = 'gv-changelog-action-row';\n\n  const iconGroup = document.createElement('div');\n  iconGroup.className = 'gv-changelog-icon-group';\n\n  // Sponsor (heart) link\n  const sponsorLink = document.createElement('a');\n  sponsorLink.className = 'gv-changelog-icon-link gv-changelog-icon-sponsor';\n  sponsorLink.href = getSponsorUrl(lang);\n  sponsorLink.target = '_blank';\n  sponsorLink.rel = 'noopener noreferrer';\n  sponsorLink.setAttribute('aria-label', 'Sponsor');\n  sponsorLink.innerHTML =\n    '<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z\"/></svg>';\n\n  // GitHub link\n  const githubLink = document.createElement('a');\n  githubLink.className = 'gv-changelog-icon-link gv-changelog-icon-github';\n  githubLink.href = 'https://github.com/Nagi-ovo/gemini-voyager';\n  githubLink.target = '_blank';\n  githubLink.rel = 'noopener noreferrer';\n  githubLink.setAttribute('aria-label', 'GitHub');\n  githubLink.innerHTML =\n    '<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.115 2.504.337 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z\"/></svg>';\n\n  // X (Twitter) link\n  const xLink = document.createElement('a');\n  xLink.className = 'gv-changelog-icon-link gv-changelog-icon-x';\n  xLink.href = 'https://x.com/Nag1ovo';\n  xLink.target = '_blank';\n  xLink.rel = 'noopener noreferrer';\n  xLink.setAttribute('aria-label', 'X (Twitter)');\n  xLink.innerHTML =\n    '<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.748l7.73-8.835L1.254 2.25H8.08l4.26 5.632 5.904-5.632zm-1.161 17.52h1.833L7.084 4.126H5.117z\"/></svg>';\n\n  // Docs link with annotation\n  const docsWrapper = document.createElement('div');\n  docsWrapper.className = 'gv-changelog-docs-wrapper';\n\n  const docsAnnotation = document.createElement('span');\n  docsAnnotation.className = 'gv-changelog-docs-annotation';\n  docsAnnotation.textContent = t('changelog_docs_hint', lang);\n\n  const docsLink = document.createElement('a');\n  docsLink.className = 'gv-changelog-icon-link gv-changelog-icon-docs';\n  docsLink.href = getDocsUrl(lang);\n  docsLink.target = '_blank';\n  docsLink.rel = 'noopener noreferrer';\n  docsLink.setAttribute('aria-label', t('changelog_docs_link', lang));\n  // Open-book icon\n  docsLink.innerHTML =\n    '<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M21 5c-1.11-.35-2.33-.5-3.5-.5-1.95 0-4.05.4-5.5 1.5-1.45-1.1-3.55-1.5-5.5-1.5S2.45 4.9 1 6v14.65c0 .25.25.5.5.5.1 0 .15-.05.25-.05C3.1 20.45 5.05 20 6.5 20c1.95 0 4.05.4 5.5 1.5 1.35-.85 3.8-1.5 5.5-1.5 1.65 0 3.35.3 4.75 1.05.1.05.15.05.25.05.25 0 .5-.25.5-.5V6c-.6-.45-1.25-.75-2-1zm0 13.5c-1.1-.35-2.3-.5-3.5-.5-1.7 0-4.15.65-5.5 1.5V8c1.35-.85 3.8-1.5 5.5-1.5 1.2 0 2.4.15 3.5.5v11.5z\"/></svg>';\n\n  docsWrapper.appendChild(docsAnnotation);\n  docsWrapper.appendChild(docsLink);\n\n  iconGroup.appendChild(sponsorLink);\n  iconGroup.appendChild(githubLink);\n  iconGroup.appendChild(xLink);\n  iconGroup.appendChild(docsWrapper);\n\n  const gotItBtn = document.createElement('button');\n  gotItBtn.className = 'gv-changelog-got-it';\n  gotItBtn.textContent = t('changelog_close', lang);\n\n  actionRow.appendChild(iconGroup);\n  actionRow.appendChild(gotItBtn);\n\n  // Notification mode toggle\n  const notifyToggle = document.createElement('div');\n  notifyToggle.className = 'gv-changelog-notify-toggle';\n\n  const notifyLabel = document.createElement('label');\n  notifyLabel.className = 'gv-changelog-notify-label';\n\n  const notifyCheckbox = document.createElement('input');\n  notifyCheckbox.type = 'checkbox';\n  notifyCheckbox.className = 'gv-changelog-notify-checkbox';\n  notifyCheckbox.checked = initialNotifyMode === 'badge';\n\n  const notifyText = document.createElement('span');\n  notifyText.textContent = t('changelog_badge_mode', lang);\n\n  notifyLabel.appendChild(notifyCheckbox);\n  notifyLabel.appendChild(notifyText);\n  notifyToggle.appendChild(notifyLabel);\n\n  notifyCheckbox.addEventListener('change', () => {\n    const mode = notifyCheckbox.checked ? 'badge' : 'popup';\n    try {\n      const updates: Record<string, string> = {\n        [StorageKeys.CHANGELOG_NOTIFY_MODE]: mode,\n      };\n      // When switching to badge mode, clear dismissed version so badge appears\n      if (mode === 'badge') {\n        updates[StorageKeys.CHANGELOG_DISMISSED_VERSION] = '';\n      }\n      chrome.storage.local.set(updates);\n    } catch {\n      // Ignore errors\n    }\n  });\n\n  footer.appendChild(recommendation);\n  footer.appendChild(socialRow);\n  footer.appendChild(notifyToggle);\n\n  // Chrome Web Store rating prompt (Chrome only)\n  if (isChrome()) {\n    const ratingBanner = document.createElement('div');\n    ratingBanner.className = 'gv-changelog-chrome-rating';\n\n    const ratingText = document.createElement('span');\n    ratingText.className = 'gv-changelog-chrome-rating-text';\n    ratingText.textContent = t('changelog_rate_chrome', lang);\n\n    const ratingLink = document.createElement('a');\n    ratingLink.className = 'gv-changelog-chrome-rating-link';\n    ratingLink.href = CHROME_STORE_URL;\n    ratingLink.target = '_blank';\n    ratingLink.rel = 'noopener noreferrer';\n    ratingLink.textContent = `⭐ ${t('changelog_rate_chrome_cta', lang)}`;\n\n    ratingBanner.appendChild(ratingText);\n    ratingBanner.appendChild(ratingLink);\n    footer.appendChild(ratingBanner);\n  }\n\n  footer.appendChild(actionRow);\n\n  dialog.appendChild(header);\n  dialog.appendChild(body);\n  dialog.appendChild(footer);\n  overlay.appendChild(dialog);\n\n  const onClose = (): void => {\n    overlay.remove();\n  };\n\n  closeBtn.addEventListener('click', onClose);\n  gotItBtn.addEventListener('click', onClose);\n  overlay.addEventListener('click', (e) => {\n    if (e.target === overlay) {\n      onClose();\n    }\n  });\n\n  return { overlay, onClose };\n}\n\n/**\n * Load and render the changelog modal.\n * @param version - Which version's changelog to show (defaults to EXTENSION_VERSION)\n * @param skipDismissCheck - Skip the dismissed-version check\n */\nasync function showChangelogModal(\n  version = EXTENSION_VERSION,\n  skipDismissCheck = false,\n): Promise<HTMLDivElement | null> {\n  // 1. Check dismissed version\n  if (!skipDismissCheck) {\n    const result = await chrome.storage.local.get(StorageKeys.CHANGELOG_DISMISSED_VERSION);\n    const dismissedVersion = result[StorageKeys.CHANGELOG_DISMISSED_VERSION] as string | undefined;\n    if (dismissedVersion === EXTENSION_VERSION) return null;\n  }\n\n  // 2. Try to load the changelog for the target version\n  const modulePath = `./notes/${version}.md`;\n  const loader = changelogModules[modulePath];\n  if (!loader) return null;\n\n  const rawMarkdown = await loader();\n\n  // 3. Get current language and extract localized content\n  const lang = await getCurrentLanguage();\n  const localizedContent = rewriteChangelogImageUrls(\n    extractLocalizedContent(rawMarkdown, lang),\n    getRuntimeUrl,\n    isFirefox(),\n  );\n  if (!localizedContent) return null;\n\n  // 4. Convert markdown to HTML\n  const rawHtml = await marked.parse(localizedContent);\n  const sanitizedHtml = DOMPurify.sanitize(rawHtml, {\n    ALLOWED_TAGS: [\n      'h1',\n      'h2',\n      'h3',\n      'h4',\n      'h5',\n      'h6',\n      'p',\n      'br',\n      'hr',\n      'ul',\n      'ol',\n      'li',\n      'strong',\n      'em',\n      'code',\n      'pre',\n      'a',\n      'img',\n      'blockquote',\n    ],\n    ALLOWED_ATTR: ['href', 'target', 'rel', 'src', 'alt', 'class'],\n  });\n\n  // 5. Mark as dismissed BEFORE showing — ensures the modal never re-appears\n  //    even if the user navigates away without clicking \"Got it\".\n  //    If this write fails (e.g. extension context invalidated), skip showing\n  //    the modal entirely; it will be shown on the next load with a valid context.\n  try {\n    await chrome.storage.local.set({\n      [StorageKeys.CHANGELOG_DISMISSED_VERSION]: EXTENSION_VERSION,\n    });\n  } catch {\n    return null;\n  }\n\n  // 6. Inject modal\n  const notifyMode = await readNotifyMode();\n  const { overlay } = createChangelogModal(sanitizedHtml, lang, notifyMode);\n  document.body.appendChild(overlay);\n  return overlay;\n}\n\n/**\n * Open the changelog modal for the current version (always shows, no dismiss check).\n */\nexport async function openChangelog(): Promise<void> {\n  await showChangelogModal(EXTENSION_VERSION, true);\n}\n\n/**\n * Check if the current version has an unread changelog.\n */\nexport async function hasUnreadChangelog(): Promise<boolean> {\n  try {\n    const result = await chrome.storage.local.get(StorageKeys.CHANGELOG_DISMISSED_VERSION);\n    const dismissed = result[StorageKeys.CHANGELOG_DISMISSED_VERSION] as string | undefined;\n    return dismissed !== EXTENSION_VERSION;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Show the changelog modal directly (used by badge mode in prompt manager).\n * Returns a Promise that resolves when the modal is closed.\n */\nexport async function showChangelogModalDirect(): Promise<void> {\n  const overlay = await showChangelogModal(EXTENSION_VERSION, true);\n  if (!overlay) {\n    // No notes found for this version — dismiss anyway so badge doesn't persist\n    try {\n      await chrome.storage.local.set({\n        [StorageKeys.CHANGELOG_DISMISSED_VERSION]: EXTENSION_VERSION,\n      });\n    } catch {\n      // Ignore\n    }\n    return;\n  }\n\n  // Return promise that resolves when overlay is removed\n  return new Promise<void>((resolve) => {\n    const observer = new MutationObserver(() => {\n      if (!overlay.isConnected) {\n        observer.disconnect();\n        resolve();\n      }\n    });\n    observer.observe(document.body, { childList: true });\n  });\n}\n\n/**\n * Start the changelog feature.\n * Shows a version-based changelog popup when the user upgrades to a new version.\n * Returns a cleanup function.\n */\nexport async function startChangelog(): Promise<() => void> {\n  let overlayRef: HTMLDivElement | null = null;\n\n  // Debug helper: switch DevTools console context to this extension's content script\n  // (dropdown next to \"top\" in the console), then call:\n  //   __gvChangelog()          — show current version\n  //   __gvChangelog('1.2.8')   — show specific version\n  (window as unknown as Record<string, unknown>).__gvChangelog = (version?: string) => {\n    showChangelogModal(version ?? EXTENSION_VERSION, true);\n  };\n\n  try {\n    // In badge mode, skip auto-showing the modal (prompt manager handles it)\n    const notifyMode = await readNotifyMode();\n    if (notifyMode === 'badge') {\n      return () => {};\n    }\n\n    overlayRef = await showChangelogModal();\n  } catch {\n    // Silently fail — changelog is non-critical\n  }\n\n  return () => {\n    if (overlayRef) {\n      overlayRef.remove();\n      overlayRef = null;\n    }\n  };\n}\n"
  },
  {
    "path": "src/pages/content/changelog/notes/1.2.9.md",
    "content": "<!-- lang:en -->\n\n### Highlights\n\n- From now on, every release will have clear and easy-to-understand release notes. (The author noticed that silent updates made many users think plugin features, like auto-selecting the default model, were native to Gemini.)\n- There's now a ❄️ effect in Settings—bring some winter vibes to your Gemini! (More lightweight visual effects coming soon.)\n- Experimental conversation branches are now supported (see docs for info).\n- Fixed several bugs.\n- The plugin’s promo image below was made using Pencil—feel free to share it!\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png)\n\n<!-- lang:zh -->\n\n### 亮点\n\n- 从现在起每个版本会有便于理解的更新说明（作者发现静默更新会让很多用户把插件功能当成 Gemini 的原生功能，比如自动选择默认模型）\n- 设置里增加了一个❄️效果，让你的 Gemini 更有冬日情趣吧（后续会支持更多轻量特效）\n- 支持实验性的对话分支功能（详情见文档）。\n- 修复了若干 Bug\n- 用 Pencil 制作了插件的宣传图如下，欢迎分享！\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner-cn.png)\n\n<!-- lang:zh_TW -->\n\n### 亮點\n\n- 從現在起每個版本會有便於理解的更新說明（作者發現靜默更新會讓許多用戶把插件功能當成 Gemini 的原生功能，比如自動選擇預設模型）\n- 設定中新增了❄️效果，讓你的 Gemini 更添冬日氛圍！（後續將支持更多輕量特效）\n- 支援實驗性的對話分支功能（詳情請見文件）。\n- 修復了若干 Bug\n- 用 Pencil 製作了插件的宣傳圖如下，歡迎分享！\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner-cn.png)\n\n<!-- lang:ja -->\n\n### ハイライト\n\n- 今後は各バージョンごとに分かりやすいリリースノートを用意します（サイレントアップデートにより多くのユーザーがプラグインの機能（例：デフォルトモデルの自動選択）を Gemini の標準機能だと誤解していました）\n- 設定に❄️エフェクトを追加しました。Gemini に冬の雰囲気をどうぞ！（今後も軽量な演出を追加予定）\n- 実験的な会話分岐機能をサポート（詳細はドキュメント参照）。\n- いくつかのバグを修正しました。\n- Pencil で作成したプロモーション画像をご自由に共有ください！\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner-jp.png)\n\n<!-- lang:fr -->\n\n### Points forts\n\n- Désormais, chaque version aura des notes de version faciles à comprendre (l’auteur a constaté que les mises à jour silencieuses faisaient croire à de nombreux utilisateurs que certaines fonctionnalités du plugin, comme la sélection automatique du modèle par défaut, faisaient partie intégrante de Gemini).\n- Un effet ❄️ est disponible dans les paramètres, pour donner une touche hivernale à votre Gemini ! (D’autres petits effets seront ajoutés à l’avenir)\n- Prise en charge expérimentale des branches de conversation (voir la documentation).\n- Correction de plusieurs bugs.\n- L’image promotionnelle du plugin ci-dessous a été créée avec Pencil—n’hésitez pas à la partager !\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png)\n\n<!-- lang:es -->\n\n### Destacados\n\n- A partir de ahora, cada versión tendrá notas de lanzamiento fáciles de entender (el autor notó que las actualizaciones silenciosas hacían que muchos usuarios pensaran que funciones del complemento, como la selección automática del modelo predeterminado, eran nativas de Gemini).\n- ¡Nuevo efecto ❄️ en la configuración! Dale un toque invernal a tu Gemini. (Próximamente más efectos ligeros)\n- Ahora se admite la función experimental de ramas de conversación (ver documentación).\n- Corregidos varios errores.\n- La imagen promocional del complemento, creada con Pencil, está a continuación. ¡Siéntete libre de compartirla!\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png)\n\n<!-- lang:pt -->\n\n### Destaques\n\n- A partir de agora, cada lançamento terá notas de atualização fáceis de entender (o autor percebeu que atualizações silenciosas faziam muitos usuários pensarem que recursos do plugin, como seleção automática do modelo padrão, eram nativos do Gemini).\n- Agora há um efeito de ❄️ nas configurações—deixe seu Gemini com clima de inverno! (Mais efeitos leves em breve)\n- Suporte experimental para ramificações de conversa (veja a documentação).\n- Diversos bugs corrigidos.\n- A imagem promocional do plugin abaixo foi criada no Pencil—sinta-se à vontade para compartilhá-la!\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png)\n\n<!-- lang:ar -->\n\n### أبرز التحديثات\n\n- بدءًا من الآن، ستحتوي كل نسخة على ملاحظات تحديث واضحة وسهلة الفهم (لاحظ المؤلف أن التحديثات الصامتة جعلت العديد من المستخدمين يظنون أن بعض ميزات الإضافة، مثل اختيار النموذج الافتراضي آليًا، هي من وظائف Gemini الأصلية).\n- يمكنك الآن تفعيل تأثير ❄️ في الإعدادات لإضفاء أجواء شتوية على Gemini (سيتم دعم المزيد من التأثيرات الخفيفة مستقبلًا).\n- دعم ميزة تجريبية لتفرعات المحادثة (راجع الوثائق للمزيد).\n- تم إصلاح عدد من الأخطاء البرمجية.\n- تم إعداد صورة ترويجية للإضافة باستخدام Pencil — لا تتردد في مشاركتها!\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png)\n\n<!-- lang:ru -->\n\n### Основные изменения\n\n- Начиная с этой версии, для каждого выпуска будут подробные и понятные заметки о релизе (автор заметил, что из-за тихих обновлений многие пользователи считали функции плагина, такие как автоматический выбор модели по умолчанию, родными для Gemini).\n- В настройках появился эффект ❄️ — добавьте зимнее настроение своему Gemini! (В будущем появятся и другие лёгкие визуальные эффекты)\n- Добавлена экспериментальная поддержка ветвления диалогов (подробности в документации).\n- Исправлено несколько ошибок.\n- Промо-изображение плагина ниже создано в Pencil — делитесь им свободно!\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png)\n\n<!-- lang:ko -->\n\n### 주요 변경사항\n\n- 이제부터 각 버전별로 이해하기 쉬운 업데이트 노트를 제공합니다 (조용한 업데이트로 인해 많은 사용자가 자동 기본 모델 선택 등 플러그인 기능을 Gemini 의 기본 기능으로 착각하는 일이 있었습니다).\n- 설정에 ❄️ 효과가 추가되어 Gemini 에 겨울 분위기를 더할 수 있습니다! (추가 가벼운 효과도 곧 지원될 예정이에요)\n- 실험적인 대화 분기 기능이 추가되었습니다 (자세한 내용은 문서 참고).\n- 여러 버그가 수정되었습니다.\n- 플러그인 홍보 이미지는 아래와 같이 Pencil 로 제작하였습니다. 자유롭게 공유하세요!\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner-KO.png)\n"
  },
  {
    "path": "src/pages/content/changelog/notes/1.3.0.md",
    "content": "<!-- lang:en -->\n\n### Highlights\n\n- Added RTL (right-to-left) layout support for Arabic, Hebrew, Farsi, and Urdu users — the timeline, tooltips, and preview panel now adapt correctly. If you're a native speaker and notice anything that doesn't feel right, feel free to open an issue or PR!\n- Fixed several bugs in the timeline, prompt manager, and fork features.\n- Experimental conversation branches are now supported (see docs for info).\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png)\n\n<!-- lang:zh -->\n\n### 亮点\n\n- 新增对阿拉伯语、希伯来语、波斯语、乌尔都语等从右到左（RTL）语言的布局支持——时间轴、提示框和预览面板现在可以正确适配。如果母语用户在使用中发现插件有适配不好的地方，随时欢迎提交 issue 或 PR，让我们一起获得更好的体验。\n- 修复了时间轴、提示词管理器和对话分支等功能的若干 Bug。\n- 支持实验性的对话分支功能（详情见文档）。\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner-cn.png)\n\n<!-- lang:zh_TW -->\n\n### 亮點\n\n- 新增阿拉伯語、希伯來語、波斯語、烏爾都語等從右至左（RTL）語言的佈局支援——時間軸、提示框和預覽面板現已正確適配。如果母語用戶在使用中發現插件有適配不好的地方，隨時歡迎提交 issue 或 PR，讓我們一起獲得更好的體驗。\n- 修復了時間軸、提示詞管理器和對話分支等功能的若干 Bug。\n- 支援實驗性的對話分支功能（詳情請見文件）。\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner-cn.png)\n\n<!-- lang:ja -->\n\n### ハイライト\n\n- アラビア語・ヘブライ語・ペルシャ語・ウルドゥー語などの RTL（右から左）レイアウトに対応しました。タイムライン・ツールチップ・プレビューパネルが正しく適応します。母語話者の方でお気づきの点があれば、気軽に issue や PR をどうぞ！\n- タイムライン・プロンプト管理・会話分岐機能のバグをいくつか修正しました。\n- 実験的な会話分岐機能をサポート（詳細はドキュメント参照）。\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner-jp.png)\n\n<!-- lang:fr -->\n\n### Points forts\n\n- Ajout du support de la mise en page RTL (de droite à gauche) pour les utilisateurs arabophones, hébraïques, persans et ourdous — la timeline, les infobulles et le panneau de prévisualisation s'adaptent désormais correctement. Si vous êtes locuteur natif et remarquez un problème d'adaptation, n'hésitez pas à ouvrir une issue ou une PR !\n- Correction de plusieurs bugs dans la timeline, le gestionnaire de prompts et les branches de conversation.\n- Prise en charge expérimentale des branches de conversation (voir la documentation).\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png)\n\n<!-- lang:es -->\n\n### Destacados\n\n- Se añade compatibilidad con el diseño RTL (de derecha a izquierda) para usuarios de árabe, hebreo, persa y urdu — la línea de tiempo, las sugerencias y el panel de vista previa ahora se adaptan correctamente. Si eres hablante nativo y notas algo que no se adapta bien, ¡abre un issue o PR cuando quieras!\n- Corrección de varios errores en la línea de tiempo, el gestor de prompts y las ramas de conversación.\n- Ahora se admite la función experimental de ramas de conversación (ver documentación).\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png)\n\n<!-- lang:pt -->\n\n### Destaques\n\n- Suporte a layout RTL (da direita para a esquerda) para usuários de árabe, hebraico, persa e urdu — a linha do tempo, as dicas e o painel de pré-visualização agora se adaptam corretamente. Se você é falante nativo e perceber algo que não está bem adaptado, fique à vontade para abrir uma issue ou PR!\n- Corrigidos vários erros na linha do tempo, no gerenciador de prompts e nas ramificações de conversa.\n- Suporte experimental para ramificações de conversa (veja a documentação).\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png)\n\n<!-- lang:ar -->\n\n### أبرز التحديثات\n\n- إضافة دعم تخطيط RTL (من اليمين إلى اليسار) لمستخدمي اللغات العربية والعبرية والفارسية والأردية — يتكيف شريط الزمن ونصائح الأدوات ولوحة المعاينة الآن بشكل صحيح. إذا لاحظت كمستخدم متحدث أصلي أي مشكلة في التكيف، فلا تتردد في فتح issue أو تقديم PR!\n- تم إصلاح عدد من الأخطاء في شريط الزمن وإدارة التعليمات والمحادثات المتفرعة.\n- دعم ميزة تجريبية لتفرعات المحادثة (راجع الوثائق للمزيد).\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png)\n\n<!-- lang:ru -->\n\n### Основные изменения\n\n- Добавлена поддержка RTL-макета (справа налево) для пользователей арабского, иврита, персидского и урду — временная шкала, подсказки и панель предпросмотра теперь корректно адаптируются. Если вы носитель языка и заметили что-то требующее доработки, смело открывайте issue или PR!\n- Исправлено несколько ошибок в таймлайне, менеджере промптов и ветвлении диалогов.\n- Добавлена экспериментальная поддержка ветвления диалогов (подробности в документации).\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner.png)\n\n<!-- lang:ko -->\n\n### 주요 변경사항\n\n- 아랍어, 히브리어, 페르시아어, 우르두어 사용자를 위한 RTL(오른쪽에서 왼쪽) 레이아웃 지원이 추가되었습니다 — 타임라인, 툴팁, 미리보기 패널이 이제 올바르게 적응합니다. 해당 언어 원어민 사용자로서 적응이 잘 되지 않는 부분을 발견하시면 언제든지 issue나 PR을 열어주세요!\n- 타임라인, 프롬프트 관리자, 대화 분기 기능의 여러 버그를 수정했습니다.\n- 실험적인 대화 분기 기능이 추가되었습니다 (자세한 내용은 문서 참고).\n\n![banner](https://github.com/Nagi-ovo/gemini-voyager/raw/main/docs/public/assets/promotion/Promo-Banner-KO.png)\n"
  },
  {
    "path": "src/pages/content/changelog/notes/1.3.1.md",
    "content": "<!-- lang:en -->\n\n### Highlights\n\n- Platform-aware keyboard shortcuts: modifier keys now display correctly for your OS (⌘ on macOS, Ctrl on Windows/Linux).\n- New export filter: select messages by role (user-only or AI-only) in export mode.\n- Added option to hide the \"Upgrade to Google AI Ultra\" upsell prompt.\n- New keyboard shortcuts for collapsing the input box and other enhanced features.\n- Chat width now adapts properly for split-screen / narrow windows and supports Gemini table blocks in widescreen.\n- Mermaid diagrams now auto-fit to the viewport when opening the fullscreen viewer.\n- Fixed several bugs in folder sync, cloud authentication, KaTeX PDF export, and Ctrl+Enter scoping.\n\n<!-- lang:zh -->\n\n### 亮点\n\n- 快捷键现在会根据操作系统自动显示对应的修饰键（macOS 显示 ⌘，Windows/Linux 显示 Ctrl）。\n- 导出模式新增按角色筛选功能：可以只导出用户消息或 AI 消息。\n- 新增隐藏\"升级到 Google AI Ultra\"推广提示的选项。\n- 新增折叠输入框等增强功能的快捷键。\n- 聊天宽度现在能正确适配分屏/窄窗口，并支持 Gemini 宽屏下的表格块。\n- Mermaid 图表在全屏查看器中打开时会自动适应视口。\n- 修复了文件夹同步、云端认证、KaTeX PDF 导出、Ctrl+Enter 作用域等若干 Bug。\n\n<!-- lang:zh_TW -->\n\n### 亮點\n\n- 快捷鍵現在會根據作業系統自動顯示對應的修飾鍵（macOS 顯示 ⌘，Windows/Linux 顯示 Ctrl）。\n- 匯出模式新增按角色篩選功能：可以只匯出使用者訊息或 AI 訊息。\n- 新增隱藏「升級到 Google AI Ultra」推廣提示的選項。\n- 新增摺疊輸入框等增強功能的快捷鍵。\n- 聊天寬度現在能正確適配分屏/窄視窗，並支援 Gemini 寬螢幕下的表格區塊。\n- Mermaid 圖表在全螢幕檢視器中開啟時會自動適應視窗。\n- 修復了資料夾同步、雲端驗證、KaTeX PDF 匯出、Ctrl+Enter 作用域等若干 Bug。\n\n<!-- lang:ja -->\n\n### ハイライト\n\n- キーボードショートカットが OS に合わせた修飾キーを表示するようになりました（macOS は ⌘、Windows/Linux は Ctrl）。\n- エクスポートモードに送信者別フィルターを追加：ユーザーのみまたは AI のみのメッセージを選択可能に。\n- 「Google AI Ultra にアップグレード」の案内を非表示にするオプションを追加。\n- 入力ボックスの折りたたみなど、新しいキーボードショートカットを追加。\n- チャット幅が分割画面/狭いウィンドウに正しく適応し、ワイドスクリーンでの Gemini テーブルブロックにも対応。\n- Mermaid ダイアグラムがフルスクリーンビューアーで開いた際にビューポートに自動フィットするようになりました。\n- フォルダ同期、クラウド認証、KaTeX PDF エクスポート、Ctrl+Enter のスコープなど、複数のバグを修正。\n\n<!-- lang:fr -->\n\n### Points forts\n\n- Les raccourcis clavier affichent désormais les touches modificatrices adaptées à votre système (⌘ sur macOS, Ctrl sur Windows/Linux).\n- Nouveau filtre d'exportation : sélectionnez les messages par rôle (utilisateur uniquement ou IA uniquement) en mode export.\n- Ajout d'une option pour masquer l'invite « Passer à Google AI Ultra ».\n- Nouveaux raccourcis clavier pour réduire la zone de saisie et d'autres fonctionnalités améliorées.\n- La largeur du chat s'adapte désormais correctement en écran partagé / fenêtre étroite et prend en charge les blocs de tableaux Gemini en mode écran large.\n- Les diagrammes Mermaid s'ajustent automatiquement à la fenêtre d'affichage en mode plein écran.\n- Correction de plusieurs bugs dans la synchronisation des dossiers, l'authentification cloud, l'export PDF KaTeX et la portée de Ctrl+Entrée.\n\n<!-- lang:es -->\n\n### Destacados\n\n- Los atajos de teclado ahora muestran las teclas modificadoras apropiadas para tu sistema (⌘ en macOS, Ctrl en Windows/Linux).\n- Nuevo filtro de exportación: selecciona mensajes por rol (solo usuario o solo IA) en el modo de exportación.\n- Nueva opción para ocultar la solicitud de \"Actualizar a Google AI Ultra\".\n- Nuevos atajos de teclado para colapsar el cuadro de entrada y otras funciones mejoradas.\n- El ancho del chat ahora se adapta correctamente a pantallas divididas/ventanas estrechas y soporta bloques de tabla de Gemini en pantalla ancha.\n- Los diagramas Mermaid se ajustan automáticamente a la ventana al abrir el visor de pantalla completa.\n- Corrección de varios errores en la sincronización de carpetas, autenticación en la nube, exportación PDF KaTeX y el alcance de Ctrl+Enter.\n\n<!-- lang:pt -->\n\n### Destaques\n\n- Os atalhos de teclado agora exibem as teclas modificadoras corretas para seu sistema (⌘ no macOS, Ctrl no Windows/Linux).\n- Novo filtro de exportação: selecione mensagens por papel (apenas usuário ou apenas IA) no modo de exportação.\n- Nova opção para ocultar o aviso \"Atualizar para Google AI Ultra\".\n- Novos atalhos de teclado para recolher a caixa de entrada e outras funcionalidades aprimoradas.\n- A largura do chat agora se adapta corretamente a telas divididas/janelas estreitas e suporta blocos de tabela do Gemini em tela ampla.\n- Os diagramas Mermaid agora se ajustam automaticamente à janela ao abrir o visualizador em tela cheia.\n- Correção de vários bugs na sincronização de pastas, autenticação na nuvem, exportação PDF KaTeX e escopo do Ctrl+Enter.\n\n<!-- lang:ar -->\n\n### أبرز التحديثات\n\n- أصبحت اختصارات لوحة المفاتيح تعرض مفاتيح التعديل المناسبة لنظامك (⌘ على macOS، Ctrl على Windows/Linux).\n- فلتر تصدير جديد: اختر الرسائل حسب الدور (المستخدم فقط أو الذكاء الاصطناعي فقط) في وضع التصدير.\n- خيار جديد لإخفاء إشعار \"الترقية إلى Google AI Ultra\".\n- اختصارات جديدة لطي مربع الإدخال وميزات محسنة أخرى.\n- عرض الدردشة يتكيف الآن بشكل صحيح مع الشاشات المقسمة/النوافذ الضيقة ويدعم كتل جداول Gemini في الشاشة العريضة.\n- مخططات Mermaid تتناسب تلقائياً مع إطار العرض عند فتح العارض بملء الشاشة.\n- إصلاح عدد من الأخطاء في مزامنة المجلدات والمصادقة السحابية وتصدير KaTeX PDF ونطاق Ctrl+Enter.\n\n<!-- lang:ru -->\n\n### Основные изменения\n\n- Клавиатурные сочетания теперь показывают правильные клавиши-модификаторы для вашей ОС (⌘ на macOS, Ctrl на Windows/Linux).\n- Новый фильтр экспорта: выбор сообщений по роли (только пользователь или только ИИ) в режиме экспорта.\n- Добавлена опция скрытия предложения «Перейти на Google AI Ultra».\n- Новые сочетания клавиш для сворачивания поля ввода и других улучшенных функций.\n- Ширина чата теперь корректно адаптируется для разделённых экранов / узких окон и поддерживает табличные блоки Gemini в широкоэкранном режиме.\n- Диаграммы Mermaid автоматически подстраиваются под область просмотра при открытии в полноэкранном режиме.\n- Исправлено несколько ошибок в синхронизации папок, облачной аутентификации, экспорте PDF с KaTeX и области действия Ctrl+Enter.\n\n<!-- lang:ko -->\n\n### 주요 변경사항\n\n- 키보드 단축키가 이제 운영체제에 맞는 수정자 키를 표시합니다 (macOS 는 ⌘, Windows/Linux는 Ctrl).\n- 새 내보내기 필터: 내보내기 모드에서 역할별 (사용자만 또는 AI 만) 메시지 선택 가능.\n- \"Google AI Ultra 로 업그레이드\" 프롬프트를 숨기는 옵션 추가.\n- 입력 상자 접기 등 향상된 기능을 위한 새 키보드 단축키 추가.\n- 채팅 너비가 분할 화면/좁은 창에 올바르게 적응하고, 와이드스크린에서 Gemini 표 블록을 지원합니다.\n- Mermaid 다이어그램이 전체 화면 뷰어에서 열릴 때 뷰포트에 자동으로 맞춰집니다.\n- 폴더 동기화, 클라우드 인증, KaTeX PDF 내보내기, Ctrl+Enter 범위 지정 등 여러 버그를 수정했습니다.\n"
  },
  {
    "path": "src/pages/content/changelog/notes/1.3.2.md",
    "content": "<!-- lang:en -->\n\n### Highlights\n\n- Drag-and-drop reordering for folders and conversations — rearrange your sidebar freely.\n- New changelog notification mode: choose between a popup or a \"NEW\" badge on the floating button. Switch modes directly from the changelog popup.\n- Visual effects: sakura petal effect, cinematic rain effect, and smooth drain transitions when switching between effects.\n- Width adjuster toggle: enable or disable CSS injection for chat width and sidebar width features.\n- Extension renamed from \"Gemini Voyager\" to \"Voyager\".\n- Fixed folder drag-and-drop bugs (conversation duplication, reorder index, Chrome split-screen trigger).\n- Improved Google Drive Sync authentication flow handling.\n\n<!-- lang:zh -->\n\n### 亮点\n\n- 文件夹和对话支持拖拽排序——自由调整侧边栏顺序。\n- 新增更新提醒方式选择：可以选择弹窗提醒，或在悬浮球上显示 NEW 标记。可在更新日志弹窗中直接切换模式。\n- 视觉效果：新增樱花飘落效果、电影感雨滴效果，切换效果时支持平滑过渡。\n- 宽度调节开关：可选择是否注入聊天宽度和侧边栏宽度的 CSS。\n- 扩展正式更名为 \"Voyager\"（原 \"Gemini Voyager\"）。\n- 修复文件夹拖拽排序的多个 Bug（对话重复、排序索引、Chrome 分屏触发）。\n- 改进 Google Drive 同步的认证流程处理。\n\n<!-- lang:zh_TW -->\n\n### 亮點\n\n- 資料夾和對話支援拖曳排序——自由調整側邊欄順序。\n- 新增更新提醒方式選擇：可以選擇彈窗提醒，或在懸浮球上顯示 NEW 標記。可在更新日誌彈窗中直接切換模式。\n- 視覺效果：新增櫻花飄落效果、電影感雨滴效果，切換效果時支援平滑過渡。\n- 寬度調節開關：可選擇是否注入聊天寬度和側邊欄寬度的 CSS。\n- 擴充功能正式更名為「Voyager」（原「Gemini Voyager」）。\n- 修復資料夾拖曳排序的多個 Bug（對話重複、排序索引、Chrome 分屏觸發）。\n- 改進 Google Drive 同步的驗證流程處理。\n\n<!-- lang:ja -->\n\n### ハイライト\n\n- フォルダと会話のドラッグ＆ドロップ並べ替え——サイドバーを自由に整理できます。\n- 新しい更新通知モード：ポップアップまたはフローティングボタンの「NEW」バッジから選択可能。変更履歴ポップアップから直接モードを切り替えられます。\n- ビジュアルエフェクト：桜の花びらエフェクト、シネマティックな雨エフェクト、エフェクト切り替え時のスムーズなトランジション。\n- 幅調整トグル：チャット幅とサイドバー幅の CSS 注入を有効/無効に切り替え可能。\n- 拡張機能の名称を「Gemini Voyager」から「Voyager」に変更。\n- フォルダのドラッグ＆ドロップに関する複数のバグを修正（会話の重複、並べ替えインデックス、Chrome の分割画面トリガー）。\n- Google Drive 同期の認証フロー処理を改善。\n\n<!-- lang:fr -->\n\n### Points forts\n\n- Glisser-deposer pour reordonner les dossiers et les conversations — organisez votre barre laterale librement.\n- Nouveau mode de notification des mises a jour : choisissez entre une fenetre contextuelle ou un badge \"NEW\" sur le bouton flottant. Changez de mode directement depuis la fenetre du changelog.\n- Effets visuels : petales de sakura, effet de pluie cinematique et transitions fluides entre les effets.\n- Bouton d'ajustement de largeur : activez ou desactivez l'injection CSS pour la largeur du chat et de la barre laterale.\n- L'extension a ete renommee de \"Gemini Voyager\" en \"Voyager\".\n- Correction de plusieurs bugs de glisser-deposer des dossiers (duplication de conversations, index de tri, declenchement d'ecran divise Chrome).\n- Amelioration du flux d'authentification Google Drive Sync.\n\n<!-- lang:es -->\n\n### Destacados\n\n- Reordenamiento por arrastrar y soltar para carpetas y conversaciones — organiza tu barra lateral libremente.\n- Nuevo modo de notificacion de actualizaciones: elige entre una ventana emergente o una insignia \"NEW\" en el boton flotante. Cambia de modo directamente desde la ventana del registro de cambios.\n- Efectos visuales: petalos de sakura, efecto de lluvia cinematica y transiciones suaves entre efectos.\n- Interruptor de ajuste de ancho: activa o desactiva la inyeccion CSS para las funciones de ancho del chat y la barra lateral.\n- La extension fue renombrada de \"Gemini Voyager\" a \"Voyager\".\n- Correccion de varios errores de arrastrar y soltar en carpetas (duplicacion de conversaciones, indice de reorden, activacion de pantalla dividida en Chrome).\n- Mejora del flujo de autenticacion de Google Drive Sync.\n\n<!-- lang:pt -->\n\n### Destaques\n\n- Reordenacao por arrastar e soltar para pastas e conversas — organize sua barra lateral livremente.\n- Novo modo de notificacao de atualizacoes: escolha entre um pop-up ou um selo \"NEW\" no botao flutuante. Alterne o modo diretamente na janela do changelog.\n- Efeitos visuais: petalas de sakura, efeito de chuva cinematica e transicoes suaves entre efeitos.\n- Botao de ajuste de largura: ative ou desative a injecao CSS para as funcoes de largura do chat e da barra lateral.\n- A extensao foi renomeada de \"Gemini Voyager\" para \"Voyager\".\n- Correcao de varios bugs de arrastar e soltar em pastas (duplicacao de conversas, indice de reordenacao, ativacao de tela dividida no Chrome).\n- Melhoria no fluxo de autenticacao do Google Drive Sync.\n\n<!-- lang:ar -->\n\n### أبرز التحديثات\n\n- إعادة ترتيب المجلدات والمحادثات بالسحب والإفلات — نظّم شريطك الجانبي بحرية.\n- وضع إشعار تحديث جديد: اختر بين نافذة منبثقة أو شارة \"NEW\" على الزر العائم. يمكنك التبديل بين الأوضاع مباشرة من نافذة سجل التغييرات.\n- تأثيرات بصرية: تأثير بتلات الساكورا، تأثير المطر السينمائي، وانتقالات سلسة بين التأثيرات.\n- مفتاح تبديل ضبط العرض: تفعيل أو تعطيل حقن CSS لميزات عرض الدردشة والشريط الجانبي.\n- تم تغيير اسم الإضافة من \"Gemini Voyager\" إلى \"Voyager\".\n- إصلاح عدة أخطاء في السحب والإفلات للمجلدات (تكرار المحادثات، فهرس الترتيب، تشغيل الشاشة المقسمة في Chrome).\n- تحسين معالجة تدفق المصادقة في مزامنة Google Drive.\n\n<!-- lang:ru -->\n\n### Основные изменения\n\n- Перетаскивание для изменения порядка папок и бесед — организуйте боковую панель по своему усмотрению.\n- Новый режим уведомлений об обновлениях: выберите между всплывающим окном или значком «NEW» на плавающей кнопке. Режим можно переключить прямо в окне списка изменений.\n- Визуальные эффекты: лепестки сакуры, кинематографический эффект дождя и плавные переходы между эффектами.\n- Переключатель настройки ширины: включение или отключение CSS-инъекции для функций ширины чата и боковой панели.\n- Расширение переименовано из «Gemini Voyager» в «Voyager».\n- Исправлено несколько ошибок перетаскивания папок (дублирование бесед, индекс сортировки, срабатывание разделённого экрана Chrome).\n- Улучшена обработка потока аутентификации синхронизации Google Drive.\n\n<!-- lang:ko -->\n\n### 주요 변경사항\n\n- 폴더와 대화의 드래그 앤 드롭 재정렬 — 사이드바를 자유롭게 정리하세요.\n- 새로운 업데이트 알림 모드: 팝업 또는 플로팅 버튼의 \"NEW\" 배지 중 선택할 수 있습니다. 변경 로그 팝업에서 직접 모드를 전환할 수 있습니다.\n- 시각 효과: 벚꽃잎 효과, 시네마틱 비 효과, 효과 전환 시 부드러운 트랜지션.\n- 너비 조절 토글: 채팅 너비와 사이드바 너비 기능의 CSS 주입을 활성화/비활성화할 수 있습니다.\n- 확장 프로그램 이름이 \"Gemini Voyager\"에서 \"Voyager\"로 변경되었습니다.\n- 폴더 드래그 앤 드롭 관련 여러 버그 수정 (대화 중복, 재정렬 인덱스, Chrome 분할 화면 트리거).\n- Google Drive 동기화 인증 흐름 처리 개선.\n"
  },
  {
    "path": "src/pages/content/changelog/notes/1.3.3.md",
    "content": "<!-- lang:en -->\n\n### Highlights\n\n- Fixed drag-and-drop onto conversations inside folders not working correctly.\n- Fixed AI Studio history titles being lost when dragging into folders.\n- Fixed chat width input-container gradient not extending to match custom chat width.\n\n<!-- lang:zh -->\n\n### 亮点\n\n- 修复拖拽对话到文件夹内的对话上不生效的问题。\n- 修复将 AI Studio 历史记录拖入文件夹时标题丢失的问题。\n- 修复聊天宽度调整后输入框渐变未跟随扩展的问题。\n\n<!-- lang:zh_TW -->\n\n### 亮點\n\n- 修復拖曳對話到資料夾內的對話上不生效的問題。\n- 修復將 AI Studio 歷史紀錄拖入資料夾時標題遺失的問題。\n- 修復聊天寬度調整後輸入框漸層未跟隨擴展的問題。\n\n<!-- lang:ja -->\n\n### ハイライト\n\n- フォルダ内の会話へのドラッグ＆ドロップが正しく動作しない問題を修正。\n- AI Studio の履歴タイトルがフォルダにドラッグした際に失われる問題を修正。\n- チャット幅の入力コンテナグラデーションがカスタム幅に合わせて拡張されない問題を修正。\n\n<!-- lang:fr -->\n\n### Points forts\n\n- Correction du glisser-deposer sur les conversations a l'interieur des dossiers qui ne fonctionnait pas correctement.\n- Correction de la perte des titres d'historique AI Studio lors du glissement dans les dossiers.\n- Correction du degrade du conteneur de saisie qui ne s'etendait pas pour correspondre a la largeur de chat personnalisee.\n\n<!-- lang:es -->\n\n### Destacados\n\n- Corregido el arrastrar y soltar sobre conversaciones dentro de carpetas que no funcionaba correctamente.\n- Corregida la perdida de titulos del historial de AI Studio al arrastrar a carpetas.\n- Corregido el degradado del contenedor de entrada que no se extendia para coincidir con el ancho de chat personalizado.\n\n<!-- lang:pt -->\n\n### Destaques\n\n- Corrigido o arrastar e soltar em conversas dentro de pastas que nao funcionava corretamente.\n- Corrigida a perda de titulos do historico do AI Studio ao arrastar para pastas.\n- Corrigido o gradiente do contêiner de entrada que nao se estendia para corresponder a largura de chat personalizada.\n\n<!-- lang:ar -->\n\n### أبرز التحديثات\n\n- إصلاح السحب والإفلات على المحادثات داخل المجلدات الذي لم يكن يعمل بشكل صحيح.\n- إصلاح فقدان عناوين سجل AI Studio عند السحب إلى المجلدات.\n- إصلاح تدرج حاوية الإدخال الذي لم يمتد ليتوافق مع عرض الدردشة المخصص.\n\n<!-- lang:ru -->\n\n### Основные изменения\n\n- Исправлено перетаскивание на беседы внутри папок, которое не работало корректно.\n- Исправлена потеря заголовков истории AI Studio при перетаскивании в папки.\n- Исправлен градиент контейнера ввода, который не расширялся в соответствии с пользовательской шириной чата.\n\n<!-- lang:ko -->\n\n### 주요 변경사항\n\n- 폴더 내 대화에 드래그 앤 드롭이 올바르게 작동하지 않는 문제 수정.\n- AI Studio 기록 제목이 폴더로 드래그할 때 손실되는 문제 수정.\n- 채팅 너비에 맞게 입력 컨테이너 그라데이션이 확장되지 않는 문제 수정.\n"
  },
  {
    "path": "src/pages/content/changelog/notes/1.3.4.md",
    "content": "<!-- lang:en -->\n\n> 📢 **This update includes an important announcement — please take a moment to read the changelog.**\n\n### 🔖 Name Change Notice\n\nDue to trademark and copyright concerns, this extension has been officially renamed to **Voyager**. The Chrome Web Store review is still pending — if you can't find it under the new name, please search for the old name or install directly from GitHub.\n\n### What's New\n\n- **AI Folder Organization**: Automatically sort conversations into folders using AI. Supports paste-import from clipboard for bulk operations.\n- **Safari DMG Link**: Safari popup now surfaces a direct DMG download link extracted from GitHub Releases.\n- **UI Refresh**: Refined color system with neutral cool grays and a green accent; unified folder panel design throughout.\n\n### Bug Fixes\n\n- Fixed default model selector breaking after Gemini updated its menu structure (new `role=\"menuitem\"` variant).\n- Fixed Fork button not opening on Firefox and Safari due to popup blockers.\n- Fixed Fork button vertical misalignment with native action buttons.\n- Fixed folder import dialog: light mode color rendering and menu icon alignment.\n- Fixed drag-and-drop onto conversations inside folders not applying correctly.\n- Fixed AI Studio history titles being stripped when dragging into a folder.\n- Fixed chat width input-container gradient not expanding to match custom chat width.\n\n### A note from the developer\n\nHi there — just a quick word from me. I'm a grad student, and Voyager is a free, open-source side project I maintain in my spare time. Occasionally, Google updates Gemini's UI without notice, which can temporarily break things on our end. Similarly, features like watermark removal depend on your local network environment and may not work for everyone. If something isn't working, I'd really appreciate it if you could open a GitHub issue first — I read every single one. Jumping straight to a 1-star review doesn't help me fix the problem, and honestly, it stings a little. Thanks for your patience and understanding. :)\n\n<!-- lang:zh -->\n\n> 📢 **本次更新包含重要公告，建议阅读本次 Changelog。**\n\n### 🔖 改名公告\n\n由于商标版权问题，本插件已正式改名为 **Voyager**。Chrome 商店审核仍在进行中——如果通过新名称找不到，请搜索旧名称或直接从 GitHub 安装。\n\n### 新功能\n\n- **AI 智能文件夹整理**：使用 AI 自动将对话分类到文件夹，支持从剪贴板粘贴批量导入。\n- **Safari DMG 直链**：Safari 弹窗现在直接显示从 GitHub Releases 提取的 DMG 下载链接。\n- **UI 焕新**：采用中性冷灰 + 绿色强调色的新配色系统，文件夹面板设计统一。\n\n### Bug 修复\n\n- 修复 Gemini 更新菜单结构后（新 `role=\"menuitem\"` 变体）默认模型选择器失效的问题。\n- 修复 Firefox 和 Safari 上因弹窗拦截导致 Fork 按钮无法打开的问题。\n- 修复 Fork 按钮与原生操作按钮的垂直对齐偏移。\n- 修复文件夹导入对话框浅色模式配色错误和菜单图标对齐问题。\n- 修复拖拽对话到文件夹内的对话上不生效的问题。\n- 修复将 AI Studio 历史记录拖入文件夹时标题丢失的问题。\n- 修复聊天宽度调整后输入框渐变未随自定义宽度扩展的问题。\n\n### 开发者碎碎念\n\n大家好，我是一名在读研究生，Voyager 是我课余时间维护的免费开源项目。Google 偶尔会悄悄改动 Gemini 的界面，导致插件的部分功能临时出问题——这不是 bug，是\"被动挨打\"。去水印等功能也依赖你的本地网络环境，不一定对所有人都有效。遇到问题的话，麻烦先到 GitHub 提个 issue，我每条都会看。上来就甩一星差评，问题修不了，开发者的心倒是碎了。感谢理解 :)\n\n<!-- lang:zh_TW -->\n\n> 📢 **本次更新包含重要公告，建議閱讀本次 Changelog。**\n\n### 🔖 改名公告\n\n由於商標版權問題，本外掛已正式改名為 **Voyager**。Chrome 商店審核仍在進行中——如果透過新名稱找不到，請搜尋舊名稱或直接從 GitHub 安裝。\n\n### 新功能\n\n- **AI 智慧資料夾整理**：使用 AI 自動將對話分類到資料夾，支援從剪貼簿貼上批次匯入。\n- **Safari DMG 直連**：Safari 彈窗現在直接顯示從 GitHub Releases 提取的 DMG 下載連結。\n- **UI 煥新**：採用中性冷灰 + 綠色強調色的新配色系統，資料夾面板設計統一。\n\n### Bug 修復\n\n- 修復 Gemini 更新選單結構後（新 `role=\"menuitem\"` 變體）預設模型選擇器失效的問題。\n- 修復 Firefox 和 Safari 上因彈出式視窗攔截導致 Fork 按鈕無法開啟的問題。\n- 修復 Fork 按鈕與原生操作按鈕的垂直對齊偏移。\n- 修復資料夾匯入對話框淺色模式配色錯誤和選單圖示對齊問題。\n- 修復拖曳對話到資料夾內的對話上不生效的問題。\n- 修復將 AI Studio 歷史紀錄拖入資料夾時標題遺失的問題。\n- 修復聊天寬度調整後輸入框漸層未隨自訂寬度擴展的問題。\n\n### 開發者碎碎念\n\n大家好，我是一名在讀研究生，Voyager 是我課餘時間維護的免費開源專案。Google 偶爾會悄悄改動 Gemini 的介面，導致外掛的部分功能暫時出問題——這不是 bug，是「被動挨打」。去浮水印等功能也依賴你的本地網路環境，不一定對所有人都有效。遇到問題的話，麻煩先到 GitHub 提個 issue，我每條都會看。上來就甩一星負評，問題修不了，開發者的心倒是碎了。感謝理解 :)\n\n<!-- lang:ja -->\n\n> 📢 **本アップデートには重要なお知らせが含まれています。Changelog をご一読ください。**\n\n### 🔖 名称変更のお知らせ\n\n商標・著作権の問題により、本拡張機能は正式に **Voyager** へと名称変更されました。Chrome ウェブストアの審査はまだ進行中です。新しい名前で見つからない場合は、旧名称で検索するか、GitHub から直接インストールしてください。\n\n### 新機能\n\n- **AI フォルダ整理**：AI を使って会話を自動的にフォルダに分類。クリップボードからの一括貼り付けインポートにも対応。\n- **Safari DMG リンク**：Safari ポップアップに GitHub Releases から抽出した DMG ダウンロードリンクを直接表示。\n- **UI リフレッシュ**：ニュートラルなクールグレーとグリーンアクセントの新配色システム、統一されたフォルダパネルデザイン。\n\n### バグ修正\n\n- Gemini がメニュー構造を更新後（新 `role=\"menuitem\"` バリアント）、デフォルトモデルセレクターが機能しない問題を修正。\n- Firefox と Safari でポップアップブロッカーにより Fork ボタンが開けない問題を修正。\n- Fork ボタンのネイティブアクションボタンとの垂直方向のズレを修正。\n- フォルダインポートダイアログのライトモードカラーとメニューアイコンの配置を修正。\n- フォルダ内の会話へのドラッグ＆ドロップが正しく適用されない問題を修正。\n- AI Studio の履歴タイトルがフォルダにドラッグした際に失われる問題を修正。\n- チャット幅の入力コンテナグラデーションがカスタム幅に合わせて拡張されない問題を修正。\n\n### 開発者よりひとこと\n\nこんにちは。私は大学院生で、Voyager は空き時間にメンテナンスしている無料のオープンソースプロジェクトです。Google が Gemini の UI を予告なく変更することがあり、一時的に機能が壊れることがあります。また、透かし除去などの機能はお使いのネットワーク環境に依存するため、すべての方に有効とは限りません。問題が発生した場合は、まず GitHub で issue を開いていただけると助かります——すべて目を通しています。いきなり星1のレビューをされても問題は解決しませんし、正直ちょっと凹みます。ご理解いただけると嬉しいです :)\n\n<!-- lang:fr -->\n\n> 📢 **Cette mise à jour contient une annonce importante — veuillez prendre un moment pour lire le changelog.**\n\n### 🔖 Avis de changement de nom\n\nEn raison de problemes de marque et de droits d'auteur, cette extension a ete officiellement renommee **Voyager**. La validation sur le Chrome Web Store est toujours en cours — si vous ne la trouvez pas sous le nouveau nom, recherchez l'ancien nom ou installez-la directement depuis GitHub.\n\n### Nouveautes\n\n- **Organisation des dossiers par IA** : Triez automatiquement les conversations dans des dossiers grace a l'IA. Supporte l'import en lot par collage depuis le presse-papiers.\n- **Lien DMG Safari** : Le popup Safari affiche maintenant un lien de telechargement DMG direct extrait des GitHub Releases.\n- **Interface rafraichie** : Nouveau systeme de couleurs avec des gris froids neutres et un accent vert, design unifie du panneau de dossiers.\n\n### Corrections de bugs\n\n- Correction du selecteur de modele par defaut qui ne fonctionnait plus apres la mise a jour de la structure de menu de Gemini (nouvelle variante `role=\"menuitem\"`).\n- Correction du bouton Fork qui ne s'ouvrait pas sur Firefox et Safari a cause des bloqueurs de pop-ups.\n- Correction du desalignement vertical du bouton Fork avec les boutons d'action natifs.\n- Correction des couleurs du mode clair et de l'alignement de l'icone du menu dans la boite de dialogue d'import de dossiers.\n- Correction du glisser-deposer sur les conversations a l'interieur des dossiers qui ne fonctionnait pas correctement.\n- Correction de la perte des titres d'historique AI Studio lors du glissement dans les dossiers.\n- Correction du degrade du conteneur de saisie qui ne s'etendait pas pour correspondre a la largeur de chat personnalisee.\n\n### Un mot du developpeur\n\nBonjour — petit message de ma part. Je suis etudiant en master et Voyager est un projet open source gratuit que je maintiens sur mon temps libre. Google modifie parfois l'interface de Gemini sans prevenir, ce qui peut temporairement casser certaines fonctionnalites. De meme, des fonctions comme la suppression du filigrane dependent de votre environnement reseau et peuvent ne pas fonctionner pour tout le monde. Si quelque chose ne marche pas, je vous serais reconnaissant d'ouvrir d'abord une issue sur GitHub — je les lis toutes. Mettre directement un avis 1 etoile ne m'aide pas a resoudre le probleme, et franchement, ca fait un peu mal. Merci pour votre patience et votre comprehension :)\n\n<!-- lang:es -->\n\n> 📢 **Esta actualización incluye un anuncio importante — por favor, tómate un momento para leer el changelog.**\n\n### 🔖 Aviso de cambio de nombre\n\nDebido a problemas de marcas registradas y derechos de autor, esta extension ha sido renombrada oficialmente a **Voyager**. La revision en Chrome Web Store aun esta pendiente — si no la encuentras con el nuevo nombre, busca el nombre anterior o instalala directamente desde GitHub.\n\n### Novedades\n\n- **Organizacion de carpetas con IA**: Ordena automaticamente las conversaciones en carpetas usando IA. Admite importacion masiva por pegado desde el portapapeles.\n- **Enlace DMG de Safari**: El popup de Safari ahora muestra un enlace directo de descarga DMG extraido de GitHub Releases.\n- **Interfaz renovada**: Nuevo sistema de colores con grises frios neutros y acento verde, diseno unificado del panel de carpetas.\n\n### Correcciones de errores\n\n- Corregido el selector de modelo predeterminado que no funcionaba tras la actualizacion de la estructura de menu de Gemini (nueva variante `role=\"menuitem\"`).\n- Corregido el boton Fork que no se abria en Firefox y Safari debido a bloqueadores de ventanas emergentes.\n- Corregido el desalineamiento vertical del boton Fork con los botones de accion nativos.\n- Corregidos los colores del modo claro y la alineacion del icono del menu en el dialogo de importacion de carpetas.\n- Corregido el arrastrar y soltar sobre conversaciones dentro de carpetas que no se aplicaba correctamente.\n- Corregida la perdida de titulos del historial de AI Studio al arrastrar a carpetas.\n- Corregido el degradado del contenedor de entrada que no se extendia para coincidir con el ancho de chat personalizado.\n\n### Una nota del desarrollador\n\nHola — un mensaje rapido de mi parte. Soy estudiante de posgrado y Voyager es un proyecto de codigo abierto y gratuito que mantengo en mi tiempo libre. A veces Google actualiza la interfaz de Gemini sin previo aviso, lo que puede romper temporalmente algunas funciones. Del mismo modo, funciones como la eliminacion de marcas de agua dependen de tu entorno de red y pueden no funcionar para todos. Si algo no funciona, te agradeceria mucho que primero abrieras un issue en GitHub — leo todos y cada uno. Ir directamente a dejar una resena de 1 estrella no me ayuda a solucionar el problema, y sinceramente, duele un poco. Gracias por tu paciencia y comprension :)\n\n<!-- lang:pt -->\n\n> 📢 **Esta atualização contém um anúncio importante — por favor, reserve um momento para ler o changelog.**\n\n### 🔖 Aviso de mudanca de nome\n\nDevido a problemas de marca registrada e direitos autorais, esta extensao foi oficialmente renomeada para **Voyager**. A revisao na Chrome Web Store ainda esta pendente — se nao encontra-la pelo novo nome, pesquise pelo nome antigo ou instale diretamente do GitHub.\n\n### Novidades\n\n- **Organizacao de pastas com IA**: Organize automaticamente as conversas em pastas usando IA. Suporta importacao em lote por colagem a partir da area de transferencia.\n- **Link DMG no Safari**: O popup do Safari agora exibe um link direto para download do DMG extraido do GitHub Releases.\n- **Interface renovada**: Novo sistema de cores com cinzas frios neutros e destaque verde, design unificado do painel de pastas.\n\n### Correcoes de bugs\n\n- Corrigido o seletor de modelo padrao que nao funcionava apos o Gemini atualizar a estrutura do menu (nova variante `role=\"menuitem\"`).\n- Corrigido o botao Fork que nao abria no Firefox e Safari devido a bloqueadores de pop-ups.\n- Corrigido o desalinhamento vertical do botao Fork com os botoes de acao nativos.\n- Corrigidas as cores do modo claro e o alinhamento do icone do menu no dialogo de importacao de pastas.\n- Corrigido o arrastar e soltar em conversas dentro de pastas que nao se aplicava corretamente.\n- Corrigida a perda de titulos do historico do AI Studio ao arrastar para pastas.\n- Corrigido o gradiente do contentor de entrada que nao se estendia para corresponder a largura de chat personalizada.\n\n### Uma nota do desenvolvedor\n\nOla — uma palavrinha rapida. Sou estudante de pos-graduacao e o Voyager e um projeto de codigo aberto e gratuito que mantenho no meu tempo livre. As vezes o Google atualiza a interface do Gemini sem aviso, o que pode temporariamente quebrar algumas funcoes. Da mesma forma, funcoes como a remocao de marca d'agua dependem do seu ambiente de rede e podem nao funcionar para todos. Se algo nao estiver funcionando, agradeco muito se voce puder abrir uma issue no GitHub primeiro — eu leio todas. Ir direto para uma avaliacao de 1 estrela nao me ajuda a resolver o problema, e sinceramente, doi um pouco. Obrigado pela paciencia e compreensao :)\n\n<!-- lang:ar -->\n\n> 📢 **يتضمن هذا التحديث إعلاناً مهماً — يُرجى أخذ لحظة لقراءة Changelog.**\n\n### 🔖 إشعار تغيير الاسم\n\nبسبب مخاوف تتعلق بالعلامات التجارية وحقوق النشر، تم تغيير اسم هذا الامتداد رسمياً إلى **Voyager**. مراجعة متجر Chrome لا تزال جارية — إذا لم تتمكن من إيجاده باسمه الجديد، فابحث بالاسم القديم أو ثبّته مباشرة من GitHub.\n\n### الجديد\n\n- **تنظيم المجلدات بالذكاء الاصطناعي**: ترتيب المحادثات تلقائياً في مجلدات بالذكاء الاصطناعي. يدعم الاستيراد الجماعي بالصق من الحافظة.\n- **رابط DMG في Safari**: نافذة Safari المنبثقة تعرض الآن رابط تحميل DMG مباشر مستخرج من GitHub Releases.\n- **واجهة محدثة**: نظام ألوان جديد برمادي بارد محايد ولون أخضر مميز، تصميم موحد لوحة المجلدات.\n\n### إصلاحات الأخطاء\n\n- إصلاح محدد النموذج الافتراضي الذي توقف عن العمل بعد تحديث بنية قائمة Gemini (متغير `role=\"menuitem\"` الجديد).\n- إصلاح زر Fork الذي لم يفتح في Firefox وSafari بسبب حاصرات النوافذ المنبثقة.\n- إصلاح عدم توافق المحاذاة العمودية لزر Fork مع أزرار الإجراءات الأصلية.\n- إصلاح ألوان الوضع الفاتح ومحاذاة أيقونة القائمة في مربع حوار استيراد المجلدات.\n- إصلاح السحب والإفلات على المحادثات داخل المجلدات الذي لم يُطبَّق بشكل صحيح.\n- إصلاح فقدان عناوين سجل AI Studio عند السحب إلى المجلدات.\n- إصلاح تدرج حاوية الإدخال الذي لم يمتد ليتوافق مع عرض الدردشة المخصص.\n\n### كلمة من المطور\n\nمرحباً — كلمة سريعة مني. أنا طالب دراسات عليا، و Voyager مشروع مفتوح المصدر ومجاني أطوره في وقت فراغي. أحياناً تقوم Google بتحديث واجهة Gemini دون إشعار مسبق، مما قد يتسبب في تعطل بعض الميزات مؤقتاً. كذلك، ميزات مثل إزالة العلامة المائية تعتمد على بيئة شبكتك المحلية وقد لا تعمل مع الجميع. إذا واجهت مشكلة، أرجو أن تفتح issue على GitHub أولاً — أقرأ كل واحدة منها. القفز مباشرة إلى تقييم نجمة واحدة لا يساعدني في حل المشكلة، وبصراحة، يؤلم قليلاً. شكراً لصبركم وتفهمكم :)\n\n<!-- lang:ru -->\n\n> 📢 **Это обновление содержит важное объявление — пожалуйста, уделите минуту и прочитайте changelog.**\n\n### 🔖 Уведомление о смене названия\n\nВ связи с вопросами товарных знаков и авторских прав расширение было официально переименовано в **Voyager**. Проверка в Chrome Web Store ещё не завершена — если не можете найти его под новым названием, ищите по старому или устанавливайте напрямую с GitHub.\n\n### Что нового\n\n- **AI-организация папок**: Автоматическая сортировка бесед по папкам с помощью ИИ. Поддерживается массовый импорт через вставку из буфера обмена.\n- **Ссылка DMG в Safari**: Всплывающее окно Safari теперь отображает прямую ссылку для скачивания DMG из GitHub Releases.\n- **Обновлённый интерфейс**: Новая цветовая схема с нейтральными холодными серыми тонами и зелёным акцентом, унифицированный дизайн панели папок.\n\n### Исправление ошибок\n\n- Исправлен селектор модели по умолчанию, который не работал после обновления структуры меню Gemini (новый вариант `role=\"menuitem\"`).\n- Исправлена кнопка Fork, которая не открывалась в Firefox и Safari из-за блокировщиков всплывающих окон.\n- Исправлено вертикальное смещение кнопки Fork относительно нативных кнопок действий.\n- Исправлены цвета светлого режима и выравнивание иконки меню в диалоге импорта папок.\n- Исправлено перетаскивание на беседы внутри папок, которое не применялось корректно.\n- Исправлена потеря заголовков истории AI Studio при перетаскивании в папки.\n- Исправлен градиент контейнера ввода, который не расширялся в соответствии с пользовательской шириной чата.\n\n### Слово от разработчика\n\nПривет — пара слов от меня. Я аспирант, и Voyager — это бесплатный open-source проект, который я поддерживаю в свободное время. Иногда Google обновляет интерфейс Gemini без предупреждения, что может временно ломать некоторые функции. Также функции вроде удаления водяных знаков зависят от вашей сетевой среды и могут работать не у всех. Если что-то не работает, пожалуйста, сначала откройте issue на GitHub — я читаю каждый. Ставить сразу 1 звезду в отзывах не помогает мне исправить проблему, и, честно говоря, немного обидно. Спасибо за терпение и понимание :)\n\n<!-- lang:ko -->\n\n> 📢 **이번 업데이트에는 중요한 공지가 포함되어 있습니다 — 잠시 시간을 내어 changelog를 읽어주세요.**\n\n### 🔖 이름 변경 공지\n\n상표 및 저작권 문제로 인해 이 확장 프로그램은 공식적으로 **Voyager**로 이름이 변경되었습니다. Chrome 웹 스토어 검토가 아직 진행 중입니다 — 새 이름으로 찾을 수 없는 경우 이전 이름으로 검색하거나 GitHub에서 직접 설치해 주세요.\n\n### 새로운 기능\n\n- **AI 폴더 정리**: AI를 사용하여 대화를 자동으로 폴더에 분류. 클립보드에서 붙여넣기로 일괄 가져오기 지원.\n- **Safari DMG 링크**: Safari 팝업에서 GitHub Releases에서 추출한 DMG 직접 다운로드 링크 표시.\n- **UI 새단장**: 중립적인 쿨 그레이와 그린 액센트의 새로운 색상 시스템, 통합된 폴더 패널 디자인.\n\n### 버그 수정\n\n- Gemini가 메뉴 구조를 업데이트한 후(새 `role=\"menuitem\"` 변형) 기본 모델 선택기가 작동하지 않는 문제 수정.\n- Firefox 및 Safari에서 팝업 차단기로 인해 Fork 버튼이 열리지 않는 문제 수정.\n- Fork 버튼과 기본 작업 버튼의 수직 정렬 불일치 수정.\n- 폴더 가져오기 대화상자의 라이트 모드 색상 및 메뉴 아이콘 정렬 수정.\n- 폴더 내 대화에 드래그 앤 드롭이 올바르게 적용되지 않는 문제 수정.\n- AI Studio 기록 제목이 폴더로 드래그할 때 손실되는 문제 수정.\n- 채팅 너비에 맞게 입력 컨테이너 그라데이션이 확장되지 않는 문제 수정.\n\n### 개발자의 한마디\n\n안녕하세요 — 잠깐 한마디 드립니다. 저는 대학원생이고, Voyager는 여가 시간에 유지하는 무료 오픈소스 프로젝트입니다. Google이 예고 없이 Gemini UI를 변경하면 일부 기능이 일시적으로 작동하지 않을 수 있습니다. 또한 워터마크 제거 같은 기능은 사용자의 네트워크 환경에 따라 달라질 수 있어 모든 분께 동작하지 않을 수 있습니다. 문제가 발생하면 먼저 GitHub에서 issue를 열어주시면 감사하겠습니다 — 모두 읽고 있습니다. 바로 별 1개 리뷰를 남기시면 문제 해결에 도움이 되지 않고, 솔직히 마음이 좀 아픕니다. 이해해 주셔서 감사합니다 :)\n"
  },
  {
    "path": "src/pages/content/changelog/notes/1.3.5.md",
    "content": "<!-- lang:en -->\n\n### What's New\n\n- **AI Studio Master Toggle**: Quickly enable or disable all AI Studio features from the popup.\n- **Sidebar Left-Edge Hover Trigger**: Hover near the left edge of the screen to reveal a fully hidden sidebar.\n- **Timeline Edge-Drag Resize**: Drag the edge of the timeline bar to resize its background width.\n- **Dark Mode Circle Transition**: Smooth circle-expand animation when toggling dark mode in the popup.\n\n### Bug Fixes\n\n- Fixed width adjusters not auto-enabling for upgrading users.\n- Fixed LaTeX syntax being stripped when quoting math equations in replies.\n- Fixed sidebar full-hide and auto-collapse state not syncing correctly.\n- Fixed missing storage default for sidebar full-hide toggle.\n- Fixed export dropdown disappearing on hover.\n- Fixed star button being injected into non-model menus.\n- Fixed font rendering inconsistency in fork and timeline by using explicit sans-serif font stack.\n- Fixed accidental click firing after dragging the prompt trigger button.\n- Fixed pointer-events blocking interaction in content area.\n\n<!-- lang:zh -->\n\n### 新功能\n\n- **AI Studio 总开关**：在弹窗中一键启用或禁用所有 AI Studio 功能。\n- **侧边栏左侧悬停触发**：鼠标悬停在屏幕左边缘即可唤出完全隐藏的侧边栏。\n- **时间线边缘拖拽调整**：拖拽时间线栏的边缘来调整背景宽度。\n- **暗色模式圆形过渡**：在弹窗中切换暗色模式时的平滑圆形展开动画。\n\n### Bug 修复\n\n- 修复升级用户的宽度调节器未自动启用的问题。\n- 修复引用数学公式回复时 LaTeX 语法被移除的问题。\n- 修复侧边栏完全隐藏与自动折叠状态不同步的问题。\n- 修复侧边栏完全隐藏开关缺少默认存储值的问题。\n- 修复导出下拉菜单在悬停时消失的问题。\n- 修复星标按钮被注入到非模型菜单中的问题。\n- 修复 Fork 和时间线中字体渲染不一致的问题。\n- 修复拖拽提示词触发按钮后误触发点击的问题。\n- 修复内容区域 pointer-events 阻止交互的问题。\n\n<!-- lang:zh_TW -->\n\n### 新功能\n\n- **AI Studio 總開關**：在彈窗中一鍵啟用或停用所有 AI Studio 功能。\n- **側邊欄左側懸停觸發**：滑鼠懸停在螢幕左邊緣即可喚出完全隱藏的側邊欄。\n- **時間線邊緣拖曳調整**：拖曳時間線列的邊緣來調整背景寬度。\n- **暗色模式圓形過渡**：在彈窗中切換暗色模式時的平滑圓形展開動畫。\n\n### Bug 修復\n\n- 修復升級使用者的寬度調節器未自動啟用的問題。\n- 修復引用數學公式回覆時 LaTeX 語法被移除的問題。\n- 修復側邊欄完全隱藏與自動摺疊狀態不同步的問題。\n- 修復側邊欄完全隱藏開關缺少預設儲存值的問題。\n- 修復匯出下拉選單在懸停時消失的問題。\n- 修復星標按鈕被注入到非模型選單中的問題。\n- 修復 Fork 和時間線中字型渲染不一致的問題。\n- 修復拖曳提示詞觸發按鈕後誤觸發點擊的問題。\n- 修復內容區域 pointer-events 阻止互動的問題。\n\n<!-- lang:ja -->\n\n### 新機能\n\n- **AI Studio マスタートグル**：ポップアップからすべての AI Studio 機能をまとめて有効/無効化。\n- **サイドバー左端ホバートリガー**：画面左端にホバーして完全に非表示のサイドバーを表示。\n- **タイムラインエッジドラッグリサイズ**：タイムラインバーの端をドラッグして背景幅を調整。\n- **ダークモード円形トランジション**：ポップアップでダークモードを切り替える際のスムーズな円形展開アニメーション。\n\n### バグ修正\n\n- アップグレードユーザーの幅調整機能が自動有効化されない問題を修正。\n- 数式を引用返信する際に LaTeX 構文が除去される問題を修正。\n- サイドバーの完全非表示と自動折りたたみの状態が同期しない問題を修正。\n- サイドバー完全非表示トグルのストレージデフォルト値が欠落している問題を修正。\n- エクスポートドロップダウンがホバー時に消える問題を修正。\n- 星ボタンがモデル以外のメニューに挿入される問題を修正。\n- Fork とタイムラインのフォントレンダリングの不一致を修正。\n- プロンプトトリガーボタンのドラッグ後に誤クリックが発生する問題を修正。\n- コンテンツエリアの pointer-events がインタラクションをブロックする問題を修正。\n\n<!-- lang:fr -->\n\n### Nouveautes\n\n- **Interrupteur global AI Studio** : Activez ou desactivez toutes les fonctionnalites AI Studio depuis le popup.\n- **Declencheur par survol du bord gauche** : Survolez le bord gauche de l'ecran pour reveler la barre laterale completement masquee.\n- **Redimensionnement par glissement du bord de la timeline** : Faites glisser le bord de la barre de timeline pour ajuster sa largeur.\n- **Transition circulaire du mode sombre** : Animation d'expansion circulaire fluide lors du basculement du mode sombre dans le popup.\n\n### Corrections de bugs\n\n- Correction des ajusteurs de largeur non actives automatiquement pour les utilisateurs en mise a jour.\n- Correction de la syntaxe LaTeX supprimee lors de la citation de formules mathematiques.\n- Correction de la desynchronisation entre le masquage complet et la reduction automatique de la barre laterale.\n- Correction de la valeur par defaut manquante pour le bouton de masquage complet de la barre laterale.\n- Correction du menu deroulant d'exportation qui disparaissait au survol.\n- Correction du bouton etoile injecte dans des menus non lies aux modeles.\n- Correction de l'incoherence du rendu des polices dans Fork et la timeline.\n- Correction du clic accidentel apres le glissement du bouton declencheur de prompt.\n- Correction des pointer-events bloquant l'interaction dans la zone de contenu.\n\n<!-- lang:es -->\n\n### Novedades\n\n- **Interruptor general de AI Studio**: Activa o desactiva todas las funciones de AI Studio desde el popup.\n- **Activacion por hover en el borde izquierdo**: Pasa el raton por el borde izquierdo de la pantalla para mostrar la barra lateral completamente oculta.\n- **Redimensionar timeline arrastrando el borde**: Arrastra el borde de la barra de timeline para ajustar su ancho.\n- **Transicion circular del modo oscuro**: Animacion de expansion circular suave al alternar el modo oscuro en el popup.\n\n### Correcciones de errores\n\n- Corregido que los ajustadores de ancho no se activaban automaticamente para usuarios en actualizacion.\n- Corregida la sintaxis LaTeX eliminada al citar ecuaciones matematicas en respuestas.\n- Corregida la desincronizacion entre el ocultamiento completo y la reduccion automatica de la barra lateral.\n- Corregido el valor predeterminado faltante para el boton de ocultamiento completo de la barra lateral.\n- Corregido el menu desplegable de exportacion que desaparecia al pasar el raton.\n- Corregido el boton de estrella inyectado en menus no relacionados con modelos.\n- Corregida la inconsistencia en el renderizado de fuentes en Fork y la timeline.\n- Corregido el clic accidental al soltar el boton de activacion de prompts despues de arrastrarlo.\n- Corregidos los pointer-events que bloqueaban la interaccion en el area de contenido.\n\n<!-- lang:pt -->\n\n### Novidades\n\n- **Interruptor geral do AI Studio**: Ative ou desative todas as funcionalidades do AI Studio a partir do popup.\n- **Ativacao por hover na borda esquerda**: Passe o rato pela borda esquerda do ecra para revelar a barra lateral completamente oculta.\n- **Redimensionar timeline arrastando a borda**: Arraste a borda da barra de timeline para ajustar a sua largura.\n- **Transicao circular do modo escuro**: Animacao de expansao circular suave ao alternar o modo escuro no popup.\n\n### Correcoes de bugs\n\n- Corrigido os ajustadores de largura nao ativados automaticamente para utilizadores em atualizacao.\n- Corrigida a sintaxe LaTeX removida ao citar equacoes matematicas nas respostas.\n- Corrigida a dessincronizacao entre o ocultamento completo e a reducao automatica da barra lateral.\n- Corrigido o valor padrao em falta para o botao de ocultamento completo da barra lateral.\n- Corrigido o menu suspenso de exportacao que desaparecia ao passar o rato.\n- Corrigido o botao de estrela injetado em menus nao relacionados com modelos.\n- Corrigida a inconsistencia no renderizado de fontes no Fork e na timeline.\n- Corrigido o clique acidental apos arrastar o botao de ativacao de prompts.\n- Corrigidos os pointer-events que bloqueavam a interacao na area de conteudo.\n\n<!-- lang:ar -->\n\n### الجديد\n\n- **مفتاح رئيسي لـ AI Studio**: تفعيل أو تعطيل جميع ميزات AI Studio من النافذة المنبثقة.\n- **تشغيل الشريط الجانبي بالتمرير على الحافة اليسرى**: مرر الماوس بالقرب من حافة الشاشة اليسرى لإظهار الشريط الجانبي المخفي بالكامل.\n- **تغيير حجم شريط الجدول الزمني بالسحب**: اسحب حافة شريط الجدول الزمني لتغيير عرض الخلفية.\n- **انتقال دائري للوضع الداكن**: رسوم متحركة سلسة بتوسع دائري عند تبديل الوضع الداكن في النافذة المنبثقة.\n\n### إصلاحات الأخطاء\n\n- إصلاح عدم تفعيل ضبط العرض تلقائياً للمستخدمين المحدّثين.\n- إصلاح إزالة صيغة LaTeX عند اقتباس المعادلات الرياضية في الردود.\n- إصلاح عدم مزامنة حالة الإخفاء الكامل والطي التلقائي للشريط الجانبي.\n- إصلاح القيمة الافتراضية المفقودة لمفتاح الإخفاء الكامل للشريط الجانبي.\n- إصلاح اختفاء قائمة التصدير المنسدلة عند التمرير.\n- إصلاح حقن زر النجمة في قوائم غير متعلقة بالنماذج.\n- إصلاح عدم تناسق عرض الخطوط في Fork والجدول الزمني.\n- إصلاح النقر العرضي بعد سحب زر تشغيل الأوامر.\n- إصلاح pointer-events التي تمنع التفاعل في منطقة المحتوى.\n\n<!-- lang:ru -->\n\n### Что нового\n\n- **Главный переключатель AI Studio**: Быстро включайте или отключайте все функции AI Studio из всплывающего окна.\n- **Триггер при наведении на левый край**: Наведите курсор на левый край экрана, чтобы показать полностью скрытую боковую панель.\n- **Изменение ширины таймлайна перетаскиванием**: Перетащите край полосы таймлайна для изменения ширины фона.\n- **Круговой переход тёмного режима**: Плавная анимация кругового расширения при переключении тёмного режима.\n\n### Исправление ошибок\n\n- Исправлено автоматическое включение регуляторов ширины для обновляющихся пользователей.\n- Исправлено удаление синтаксиса LaTeX при цитировании математических формул.\n- Исправлена рассинхронизация полного скрытия и автосворачивания боковой панели.\n- Исправлено отсутствие значения по умолчанию для переключателя полного скрытия боковой панели.\n- Исправлено исчезновение выпадающего меню экспорта при наведении.\n- Исправлена вставка кнопки-звёздочки в меню, не связанные с моделями.\n- Исправлена несогласованность отображения шрифтов в Fork и таймлайне.\n- Исправлен случайный клик после перетаскивания кнопки-триггера промпта.\n- Исправлена блокировка взаимодействия pointer-events в области контента.\n\n<!-- lang:ko -->\n\n### 새로운 기능\n\n- **AI Studio 마스터 토글**: 팝업에서 모든 AI Studio 기능을 한 번에 활성화/비활성화.\n- **사이드바 왼쪽 가장자리 호버 트리거**: 화면 왼쪽 가장자리에 마우스를 올려 완전히 숨겨진 사이드바를 표시.\n- **타임라인 가장자리 드래그 크기 조절**: 타임라인 바의 가장자리를 드래그하여 배경 너비를 조절.\n- **다크 모드 원형 전환**: 팝업에서 다크 모드를 전환할 때 부드러운 원형 확장 애니메이션.\n\n### 버그 수정\n\n- 업그레이드 사용자의 너비 조절기가 자동 활성화되지 않는 문제 수정.\n- 수학 공식을 인용 답변할 때 LaTeX 구문이 제거되는 문제 수정.\n- 사이드바 완전 숨김과 자동 접기 상태가 동기화되지 않는 문제 수정.\n- 사이드바 완전 숨김 토글의 기본 저장 값이 누락된 문제 수정.\n- 내보내기 드롭다운이 호버 시 사라지는 문제 수정.\n- 별표 버튼이 모델과 무관한 메뉴에 삽입되는 문제 수정.\n- Fork 및 타임라인의 폰트 렌더링 불일치 수정.\n- 프롬프트 트리거 버튼 드래그 후 의도치 않은 클릭이 발생하는 문제 수정.\n- 콘텐츠 영역의 pointer-events 가 상호작용을 차단하는 문제 수정.\n"
  },
  {
    "path": "src/pages/content/changelog/notes/1.3.6.md",
    "content": "<!-- lang:en -->\n\n> 📢 **Important announcement about the Chrome Web Store listing — please read.**\n\n### What's New\n\n- **Prompt Manager Theme Toggle**: Independent light/dark mode switch in the Prompt Manager header. Useful for non-Gemini websites where theme detection may not work correctly.\n- **Starred Timestamps**: Starred conversations now show precise starredAt timestamps in the popup and timeline preview.\n\n### About the New Chrome Web Store Listing\n\nStarting from v1.3.6, Voyager is published as a **new Chrome Web Store item**.\n\nHere's why: our original listing was taken down back around v1.3.1 due to a report filed by **ENFTracer.ai**. We've been appealing ever since, but Google has only been sending template responses, and the reporting party has never replied to any of our emails. To get the extension back to you faster and more conveniently, we've decided to publish under a new listing.\n\nThis means our previous listing — with **160,000+ users** and **700–800 reviews** — is gone, at least for now. It's a real loss, and we're genuinely sad about it. A huge thank-you to everyone who supported us there. We hope you'll follow us to the new home and continue the journey together.\n\n<!-- lang:zh -->\n\n> 📢 **关于 Chrome 商店上架的重要公告，请务必阅读。**\n\n### 新功能\n\n- **提示词管理器主题切换**：在提示词管理器标题栏新增独立的亮色/暗色模式开关，在非 Gemini 网站上主题检测不准确时尤为实用。\n- **星标时间戳**：星标对话现在在弹窗和时间线预览中显示精确的标星时间。\n\n### 关于 Chrome 商店新上架\n\n从 v1.3.6 起，Voyager 将以**全新的 Chrome 商店项目**发布。\n\n原因是这样的：我们的原始商品在 v1.3.1 前后被 **ENFTracer.ai** 举报下架。此后我们一直在申诉，但谷歌只回复模板邮件，举报方也始终不回复我们的任何邮件。为了让大家更快、更方便地用上插件，我们决定直接另开一个新的商品来上架。\n\n这意味着我们之前那个积累了 **16 万用户**和 **七八百条评论**的老商品，要暂时和大家告别了。说实话挺可惜的，也非常感谢大家一直以来的支持与陪伴。希望大家能跟我们一起搬到新家，继续这段旅程。\n\n<!-- lang:zh_TW -->\n\n> 📢 **關於 Chrome 商店上架的重要公告，請務必閱讀。**\n\n### 新功能\n\n- **提示詞管理器主題切換**：在提示詞管理器標題列新增獨立的亮色/暗色模式開關，在非 Gemini 網站上主題偵測不準確時尤為實用。\n- **星標時間戳記**：星標對話現在在彈窗和時間線預覽中顯示精確的標星時間。\n\n### 關於 Chrome 商店新上架\n\n從 v1.3.6 起，Voyager 將以**全新的 Chrome 商店項目**發佈。\n\n原因是這樣的：我們的原始商品在 v1.3.1 前後被 **ENFTracer.ai** 舉報下架。此後我們一直在申訴，但 Google 只回覆範本郵件，舉報方也始終不回覆我們的任何郵件。為了讓大家更快、更方便地用上外掛，我們決定直接另開一個新的商品來上架。\n\n這意味著我們之前那個累積了 **16 萬使用者**和**七八百則評論**的舊商品，要暫時和大家告別了。說實話挺可惜的，也非常感謝大家一直以來的支持與陪伴。希望大家能跟我們一起搬到新家，繼續這段旅程。\n\n<!-- lang:ja -->\n\n> 📢 **Chrome ウェブストアの掲載に関する重要なお知らせです。ぜひお読みください。**\n\n### 新機能\n\n- **プロンプトマネージャーのテーマ切替**：プロンプトマネージャーのヘッダーにライト/ダークモードの独立トグルを追加。Gemini 以外のサイトでテーマ検出が正しく動作しない場合に便利です。\n- **スター付きタイムスタンプ**：スター付き会話のポップアップとタイムラインプレビューに正確なスター付与時刻を表示。\n\n### Chrome ウェブストアの新規掲載について\n\nv1.3.6 より、Voyager は**新しい Chrome ウェブストアアイテム**として公開されます。\n\n経緯：v1.3.1 前後に **ENFTracer.ai** からの報告により、元の掲載が削除されました。以来ずっと異議申し立てを続けてきましたが、Google はテンプレート返信のみで、報告者側も一切メールに返信がありません。皆さんにより早く便利に拡張機能をお届けするため、新しいアイテムとして再公開することを決断しました。\n\nこれにより、**16 万人以上のユーザー**と **700〜800 件のレビュー**を集めた元の掲載とは一旦お別れとなります。非常に残念ですが、皆さんのこれまでのご支援に心から感謝しています。新しいページでも引き続きよろしくお願いいたします。\n\n<!-- lang:fr -->\n\n> 📢 **Annonce importante concernant la fiche Chrome Web Store — veuillez lire.**\n\n### Nouveautés\n\n- **Thème du gestionnaire de prompts** : Bouton de basculement clair/sombre indépendant dans l'en-tête du gestionnaire de prompts. Utile sur les sites non-Gemini où la détection du thème peut ne pas fonctionner correctement.\n- **Horodatages des favoris** : Les conversations favorites affichent désormais l'heure exacte d'ajout aux favoris dans le popup et l'aperçu de la timeline.\n\n### À propos de la nouvelle fiche Chrome Web Store\n\nÀ partir de la v1.3.6, Voyager est publié en tant que **nouvel élément Chrome Web Store**.\n\nVoici pourquoi : notre fiche originale a été retirée aux alentours de la v1.3.1 suite à un signalement de **ENFTracer.ai**. Nous avons fait appel depuis, mais Google ne répond qu'avec des messages automatiques, et la partie signalante n'a jamais répondu à aucun de nos e-mails. Pour vous permettre d'accéder à l'extension plus rapidement et plus facilement, nous avons décidé de publier sous une nouvelle fiche.\n\nCela signifie que notre ancienne fiche — avec **plus de 160 000 utilisateurs** et **700 à 800 avis** — a disparu, au moins pour l'instant. C'est une vraie perte et nous en sommes sincèrement attristés. Un grand merci à tous ceux qui nous ont soutenus. Nous espérons que vous nous suivrez dans notre nouveau foyer.\n\n<!-- lang:es -->\n\n> 📢 **Anuncio importante sobre la ficha de Chrome Web Store — por favor, léelo.**\n\n### Novedades\n\n- **Tema del gestor de prompts**: Interruptor independiente de modo claro/oscuro en la cabecera del gestor de prompts. Útil en sitios que no son Gemini donde la detección de tema puede no funcionar correctamente.\n- **Marcas de tiempo de favoritos**: Las conversaciones favoritas ahora muestran la hora exacta de marcado en el popup y la vista previa de la línea de tiempo.\n\n### Sobre la nueva ficha de Chrome Web Store\n\nA partir de la v1.3.6, Voyager se publica como un **nuevo elemento en Chrome Web Store**.\n\nEl motivo: nuestra ficha original fue retirada alrededor de la v1.3.1 debido a un reporte de **ENFTracer.ai**. Hemos estado apelando desde entonces, pero Google solo envía respuestas automáticas y la parte denunciante nunca ha respondido a ninguno de nuestros correos. Para que podáis acceder a la extensión de forma más rápida y cómoda, hemos decidido publicar bajo una nueva ficha.\n\nEsto significa que nuestra ficha anterior — con **más de 160 000 usuarios** y **entre 700 y 800 reseñas** — se ha ido, al menos por ahora. Es una verdadera pérdida y lo sentimos mucho. Muchas gracias a todos los que nos apoyasteis allí. Esperamos que nos sigáis en nuestro nuevo hogar.\n\n<!-- lang:pt -->\n\n> 📢 **Anúncio importante sobre a listagem na Chrome Web Store — por favor, leia.**\n\n### Novidades\n\n- **Tema do gestor de prompts**: Interruptor independente de modo claro/escuro no cabeçalho do gestor de prompts. Útil em sites que não são o Gemini, onde a deteção de tema pode não funcionar corretamente.\n- **Carimbos de data/hora dos favoritos**: As conversas favoritas agora mostram o momento exato em que foram marcadas no popup e na pré-visualização da linha do tempo.\n\n### Sobre a nova listagem na Chrome Web Store\n\nA partir da v1.3.6, o Voyager é publicado como um **novo item na Chrome Web Store**.\n\nO motivo: a nossa listagem original foi removida por volta da v1.3.1 devido a uma denúncia de **ENFTracer.ai**. Temos estado a recorrer desde então, mas a Google apenas envia respostas automáticas e a parte denunciante nunca respondeu a nenhum dos nossos e-mails. Para que possam aceder à extensão de forma mais rápida e conveniente, decidimos publicar sob uma nova listagem.\n\nIsto significa que a nossa listagem anterior — com **mais de 160 000 utilizadores** e **700 a 800 avaliações** — desapareceu, pelo menos por agora. É uma verdadeira perda e estamos genuinamente tristes. Um grande obrigado a todos os que nos apoiaram. Esperamos que nos sigam para o novo lar.\n\n<!-- lang:ar -->\n\n> 📢 **إعلان مهم بخصوص صفحة Chrome Web Store — يرجى القراءة.**\n\n### الجديد\n\n- **تبديل سمة مدير الأوامر**: مفتاح مستقل للوضع الفاتح/الداكن في رأس مدير الأوامر. مفيد للمواقع غير Gemini حيث قد لا يعمل اكتشاف السمة بشكل صحيح.\n- **طوابع زمنية للمفضلة**: تعرض المحادثات المفضلة الآن وقت الإضافة الدقيق في النافذة المنبثقة ومعاينة الخط الزمني.\n\n### حول صفحة Chrome Web Store الجديدة\n\nبدءًا من الإصدار 1.3.6، يتم نشر Voyager كـ**عنصر جديد في Chrome Web Store**.\n\nالسبب: تمت إزالة صفحتنا الأصلية حوالي الإصدار 1.3.1 بسبب بلاغ من **ENFTracer.ai**. ظللنا نستأنف منذ ذلك الحين، لكن Google ترسل ردودًا نموذجية فقط، والطرف المُبلِّغ لم يرد على أي من رسائلنا الإلكترونية. لتتمكنوا من الوصول إلى الإضافة بشكل أسرع وأسهل، قررنا النشر تحت صفحة جديدة.\n\nهذا يعني أن صفحتنا السابقة — بأكثر من **160,000 مستخدم** و**700-800 تقييم** — قد اختفت، على الأقل في الوقت الحالي. إنها خسارة حقيقية ونحن حزينون لذلك. شكر كبير لكل من دعمنا هناك. نأمل أن تتابعونا في منزلنا الجديد.\n\n<!-- lang:ko -->\n\n> 📢 **Chrome 웹 스토어 등록에 관한 중요 공지 — 꼭 읽어주세요.**\n\n### 새로운 기능\n\n- **프롬프트 매니저 테마 전환**: 프롬프트 매니저 헤더에 독립적인 라이트/다크 모드 전환 스위치 추가. Gemini가 아닌 웹사이트에서 테마 감지가 제대로 작동하지 않을 때 유용합니다.\n- **즐겨찾기 타임스탬프**: 즐겨찾기한 대화에 팝업과 타임라인 미리보기에서 정확한 즐겨찾기 시간이 표시됩니다.\n\n### Chrome 웹 스토어 신규 등록에 대하여\n\nv1.3.6부터 Voyager는 **새로운 Chrome 웹 스토어 항목**으로 게시됩니다.\n\n이유는 다음과 같습니다: v1.3.1 즈음에 **ENFTracer.ai**의 신고로 기존 등록이 삭제되었습니다. 그 이후로 계속 이의를 제기해왔지만, Google은 템플릿 답변만 보내고 신고 측은 우리의 이메일에 한 번도 답장하지 않았습니다. 여러분이 더 빠르고 편리하게 확장 프로그램을 사용할 수 있도록, 새로운 항목으로 게시하기로 결정했습니다.\n\n이는 **16만 명 이상의 사용자**와 **700~800개의 리뷰**가 있던 이전 등록을 잠시 떠나야 한다는 뜻입니다. 정말 아쉽고, 그곳에서 응원해주신 모든 분께 진심으로 감사드립니다. 새로운 곳에서도 함께해주시길 바랍니다.\n\n<!-- lang:ru -->\n\n> 📢 **Важное объявление о странице в Chrome Web Store — пожалуйста, прочитайте.**\n\n### Новое\n\n- **Переключатель темы менеджера промптов**: Независимый переключатель светлого/тёмного режима в заголовке менеджера промптов. Полезно на сайтах, отличных от Gemini, где определение темы может работать некорректно.\n- **Метки времени избранного**: Избранные беседы теперь показывают точное время добавления в избранное во всплывающем окне и предпросмотре временной шкалы.\n\n### О новой странице в Chrome Web Store\n\nНачиная с v1.3.6, Voyager публикуется как **новый элемент Chrome Web Store**.\n\nПричина: наша оригинальная страница была удалена примерно в версии 1.3.1 по жалобе **ENFTracer.ai**. С тех пор мы подавали апелляции, но Google отвечает только шаблонными письмами, а заявитель так и не ответил ни на одно наше письмо. Чтобы вы могли быстрее и удобнее пользоваться расширением, мы решили опубликовать его как новый элемент.\n\nЭто означает, что наша прежняя страница — с **более 160 000 пользователями** и **700–800 отзывами** — пока недоступна. Это настоящая потеря, и нам очень грустно. Огромное спасибо всем, кто поддерживал нас там. Надеемся, вы последуете за нами на новую страницу.\n"
  },
  {
    "path": "src/pages/content/chatWidth/__tests__/chatWidth.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nconst STYLE_ID = 'gemini-voyager-chat-width';\nconst STORAGE_KEY = 'geminiChatWidth';\nconst MOCK_SCREEN_WIDTH = 1920;\n\ntype StorageChangeListener = (\n  changes: Record<string, chrome.storage.StorageChange>,\n  area: string,\n) => void;\n\nfunction getInjectedStyle(): HTMLStyleElement {\n  const style = document.getElementById(STYLE_ID);\n  expect(style).not.toBeNull();\n  return style as HTMLStyleElement;\n}\n\nfunction percentToPixels(percent: number): number {\n  return Math.round((percent / 100) * MOCK_SCREEN_WIDTH);\n}\n\nfunction expectTableRuleWidth(styleText: string, percent: number): void {\n  const px = percentToPixels(percent);\n  const escapedWidth = px.toString();\n  const tableRulePattern = new RegExp(\n    String.raw`\\/\\* Gemini table containers \\*\\/[\\s\\S]*table-block,[\\s\\S]*\\.table-block,[\\s\\S]*\\.table-block \\.table-content[\\s\\S]*\\{[\\s\\S]*max-width: ${escapedWidth}px !important;[\\s\\S]*width: min\\(100%, ${escapedWidth}px\\) !important;`,\n  );\n  expect(styleText).toMatch(tableRulePattern);\n}\n\nfunction expectSingleTableScrollbarRules(styleText: string): void {\n  expect(styleText).toContain('.table-block.has-scrollbar');\n  expect(styleText).toContain('.table-block.new-table-style');\n  expect(styleText).toContain('overflow-x: hidden !important;');\n  expect(styleText).toContain('.table-block .table-content');\n  expect(styleText).toContain('overflow-x: auto !important;');\n}\n\ndescribe('chatWidth', () => {\n  let storageChangeListeners: StorageChangeListener[];\n\n  beforeEach(() => {\n    vi.resetModules();\n    vi.clearAllMocks();\n\n    document.head.innerHTML = '';\n    document.body.innerHTML = '<main></main>';\n\n    // Mock screen dimensions for deterministic tests\n    Object.defineProperty(window, 'screen', {\n      value: { availWidth: MOCK_SCREEN_WIDTH, width: MOCK_SCREEN_WIDTH },\n      writable: true,\n      configurable: true,\n    });\n\n    storageChangeListeners = [];\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (value: Record<string, unknown>) => void) => {\n        callback({ [STORAGE_KEY]: 85, gvChatWidthEnabled: true });\n      },\n    );\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation((listener: StorageChangeListener) => {\n      storageChangeListeners.push(listener);\n    });\n  });\n\n  afterEach(() => {\n    window.dispatchEvent(new Event('beforeunload'));\n  });\n\n  it('applies widescreen rules to Gemini table blocks', async () => {\n    const { startChatWidthAdjuster } = await import('../index');\n    startChatWidthAdjuster();\n\n    const styleText = getInjectedStyle().textContent ?? '';\n\n    expectTableRuleWidth(styleText, 85);\n    expect(styleText).toContain('table-block .table-block');\n    expect(styleText).toContain('.table-block.has-scrollbar');\n    expect(styleText).toContain('.table-block.new-table-style');\n    expect(styleText).toContain('.table-block .table-content');\n    expectSingleTableScrollbarRules(styleText);\n  });\n\n  it('updates table widescreen rules when width setting changes', async () => {\n    const { startChatWidthAdjuster } = await import('../index');\n    startChatWidthAdjuster();\n\n    expect(storageChangeListeners.length).toBeGreaterThan(0);\n\n    storageChangeListeners[0]({ [STORAGE_KEY]: { oldValue: 85, newValue: 92 } }, 'sync');\n\n    const styleText = getInjectedStyle().textContent ?? '';\n    expectTableRuleWidth(styleText, 92);\n    expect(styleText).toContain('table-block .table-content');\n    expectSingleTableScrollbarRules(styleText);\n  });\n\n  it('adapts width for narrow viewports (split-screen behavior)', async () => {\n    // Simulate: user sets 70% on a 1920px screen → 1344px max-width\n    // In split-screen (960px viewport), min(100%, 1344px) fills the viewport\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (value: Record<string, unknown>) => void) => {\n        callback({ [STORAGE_KEY]: 70, gvChatWidthEnabled: true });\n      },\n    );\n\n    const { startChatWidthAdjuster } = await import('../index');\n    startChatWidthAdjuster();\n\n    const styleText = getInjectedStyle().textContent ?? '';\n    const expectedPx = percentToPixels(70); // 1344\n    expect(styleText).toContain(`max-width: ${expectedPx}px !important`);\n    expect(styleText).toContain(`width: min(100%, ${expectedPx}px) !important`);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/chatWidth/index.ts",
    "content": "/**\n * Adjusts the chat area width based on user settings (stored as viewport %)\n */\n\nconst STYLE_ID = 'gemini-voyager-chat-width';\nconst DEFAULT_PERCENT = 70;\nconst MIN_PERCENT = 30;\nconst MAX_PERCENT = 100;\nconst LEGACY_BASELINE_PX = 1200;\n\n// Selectors based on the export functionality that already works\nfunction getUserSelectors(): string[] {\n  return [\n    '.user-query-bubble-container',\n    '.user-query-container',\n    'user-query-content',\n    'user-query',\n    'div[aria-label=\"User message\"]',\n    'article[data-author=\"user\"]',\n    '[data-message-author-role=\"user\"]',\n  ];\n}\n\nfunction getAssistantSelectors(): string[] {\n  return [\n    'model-response',\n    '.model-response',\n    'response-container',\n    '.response-container',\n    '.presented-response-container',\n    '[aria-label=\"Gemini response\"]',\n    '[data-message-author-role=\"assistant\"]',\n    '[data-message-author-role=\"model\"]',\n    'article[data-author=\"assistant\"]',\n  ];\n}\n\nfunction getTableSelectors(): string[] {\n  return [\n    'table-block',\n    '.table-block',\n    'table-block .table-block',\n    'table-block .table-content',\n    '.table-block.new-table-style',\n    '.table-block.has-scrollbar',\n    '.table-block .table-content',\n  ];\n}\n\nconst clampPercent = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, Math.round(value)));\n\nconst normalizePercent = (value: number, fallback: number) => {\n  if (!Number.isFinite(value)) return fallback;\n  if (value > MAX_PERCENT) {\n    const approx = (value / LEGACY_BASELINE_PX) * 100;\n    return clampPercent(approx, MIN_PERCENT, MAX_PERCENT);\n  }\n  return clampPercent(value, MIN_PERCENT, MAX_PERCENT);\n};\n\nfunction applyWidth(widthPercent: number) {\n  const normalizedPercent = normalizePercent(widthPercent, DEFAULT_PERCENT);\n  // Use screen width as reference to compute pixel-based max-width.\n  // This provides adaptive behavior for split-screen / narrow windows:\n  // - Fullscreen: width ≈ percent% of screen (as intended by the slider)\n  // - Split-screen: content fills available space since pixel max-width > viewport\n  const screenWidth = screen.availWidth || screen.width || 1920;\n  const widthValue = `${Math.round((normalizedPercent / 100) * screenWidth)}px`;\n\n  let style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n  if (!style) {\n    style = document.createElement('style');\n    style.id = STYLE_ID;\n    document.head.appendChild(style);\n  }\n\n  const userSelectors = getUserSelectors();\n  const assistantSelectors = getAssistantSelectors();\n  const tableSelectors = getTableSelectors();\n\n  // Build comprehensive CSS rules\n  const userRules = userSelectors.map((sel) => `${sel}`).join(',\\n    ');\n  const assistantRules = assistantSelectors.map((sel) => `${sel}`).join(',\\n    ');\n  const tableRules = tableSelectors.map((sel) => `${sel}`).join(',\\n    ');\n\n  // A small gap to account for scrollbars\n  const GAP_PX = 10;\n\n  style.textContent = `\n    /* Remove width constraints from outer containers that contain conversations */\n    .content-wrapper:has(chat-window),\n    .main-content:has(chat-window),\n    .content-container:has(chat-window),\n    .content-container:has(.conversation-container) {\n      max-width: none !important;\n    }\n\n    /* Remove width constraints from main and conversation containers, but not buttons */\n    [role=\"main\"]:has(chat-window),\n    [role=\"main\"]:has(.conversation-container) {\n      max-width: none !important;\n    }\n\n    /* Target chat window and related containers; A small gap to account for scrollbars */\n    chat-window,\n    .chat-container,\n    chat-window-content,\n    .chat-history-scroll-container,\n    .chat-history,\n    .conversation-container {\n      max-width: none !important;\n      padding-right: ${GAP_PX}px !important;\n      box-sizing: border-box !important;\n    }\n\n    main > div:has(user-query),\n    main > div:has(model-response),\n    main > div:has(.conversation-container) {\n      max-width: none !important;\n      width: 100% !important;\n    }\n\n    /* Fallback for browsers without :has() support */\n    @supports not selector(:has(*)) {\n      .content-wrapper,\n      .main-content,\n      .content-container {\n        max-width: none !important;\n      }\n\n      main > div:not(:has(button)):not(.main-menu-button) {\n        max-width: none !important;\n        width: 100% !important;\n      }\n    }\n\n    /* User query containers */\n    ${userRules} {\n      max-width: ${widthValue} !important;\n      width: min(100%, ${widthValue}) !important;\n      margin-left: auto !important;\n      margin-right: auto !important;\n    }\n\n    /* Model response containers */\n    ${assistantRules} {\n      max-width: ${widthValue} !important;\n      width: min(100%, ${widthValue}) !important;\n      margin-left: auto !important;\n      margin-right: auto !important;\n    }\n\n    /* Gemini table containers */\n    ${tableRules} {\n      max-width: ${widthValue} !important;\n      width: min(100%, ${widthValue}) !important;\n      margin-left: auto !important;\n      margin-right: auto !important;\n      box-sizing: border-box !important;\n    }\n\n    table-block .table-block,\n    .table-block.has-scrollbar,\n    .table-block.new-table-style {\n      overflow-x: hidden !important;\n    }\n\n    table-block .table-content,\n    .table-block .table-content {\n      width: 100% !important;\n      overflow-x: auto !important;\n    }\n\n    model-response:has(> .deferred-response-indicator),\n    .response-container:has(img[src*=\"sparkle\"]), \n    main > div:has(img[src*=\"sparkle\"]) {\n      max-width: ${widthValue} !important;\n      width: min(100%, ${widthValue}) !important;\n      margin-left: auto !important;\n      margin-right: auto !important;\n    }\n\n    /* Additional deep targeting for nested elements */\n    user-query,\n    user-query > *,\n    user-query > * > *,\n    model-response,\n    model-response > *,\n    model-response > * > *,\n    response-container,\n    response-container > *,\n    response-container > * > * {\n      max-width: ${widthValue} !important;\n    }\n\n    /* Target specific internal containers that might have fixed widths */\n    .presented-response-container,\n    [data-message-author-role] {\n      max-width: ${widthValue} !important;\n    }\n\n    /* Extend input-container gradient to match chat width */\n    input-container {\n      max-width: none !important;\n      width: 100% !important;\n    }\n\n    input-container .input-area-container,\n    input-container input-area-v2 {\n      max-width: ${widthValue} !important;\n      width: min(100%, ${widthValue}) !important;\n      margin-left: auto !important;\n      margin-right: auto !important;\n    }\n\n    /* Specific fix for user bubble background to fit content but respect max-width */\n    .user-query-bubble-with-background {\n      max-width: ${widthValue} !important;\n      width: fit-content !important;\n    }\n  `;\n}\n\nfunction removeStyles() {\n  const style = document.getElementById(STYLE_ID);\n  if (style) {\n    style.remove();\n  }\n}\n\nconst ENABLED_KEY = 'gvChatWidthEnabled';\n\nexport function startChatWidthAdjuster() {\n  let currentWidthPercent = DEFAULT_PERCENT;\n  let enabled = false;\n\n  // Load initial state — request keys without defaults so we can distinguish\n  // \"key never existed\" (upgrade) from \"explicitly set to false\"\n  chrome.storage?.sync?.get(['geminiChatWidth', ENABLED_KEY], (res) => {\n    const storedWidth = res?.geminiChatWidth;\n    const numericStoredWidth = typeof storedWidth === 'number' ? storedWidth : DEFAULT_PERCENT;\n    const normalized = normalizePercent(numericStoredWidth, DEFAULT_PERCENT);\n    currentWidthPercent = normalized;\n\n    const enabledRaw = res?.[ENABLED_KEY];\n    if (enabledRaw === undefined) {\n      // Upgrade path: enabled key was never set.\n      // Auto-enable if user had previously customized the width.\n      enabled =\n        typeof storedWidth === 'number' &&\n        normalizePercent(storedWidth, DEFAULT_PERCENT) !== DEFAULT_PERCENT;\n      if (enabled) {\n        try {\n          chrome.storage?.sync?.set({ [ENABLED_KEY]: true });\n        } catch {}\n      }\n    } else {\n      enabled = enabledRaw === true;\n    }\n\n    if (enabled) {\n      applyWidth(currentWidthPercent);\n    }\n\n    if (typeof storedWidth === 'number' && storedWidth !== normalized) {\n      try {\n        chrome.storage?.sync?.set({ geminiChatWidth: normalized });\n      } catch (e) {\n        console.warn('[Gemini Voyager] Failed to migrate chat width to %:', e);\n      }\n    }\n  });\n\n  // Listen for changes from storage\n  const storageChangeHandler = (\n    changes: Record<string, chrome.storage.StorageChange>,\n    area: string,\n  ) => {\n    if (area !== 'sync') return;\n\n    if (changes[ENABLED_KEY]) {\n      enabled = changes[ENABLED_KEY].newValue === true;\n      if (enabled) {\n        applyWidth(currentWidthPercent);\n      } else {\n        removeStyles();\n      }\n    }\n\n    if (changes.geminiChatWidth) {\n      const newWidth = changes.geminiChatWidth.newValue;\n      if (typeof newWidth === 'number') {\n        const normalized = normalizePercent(newWidth, DEFAULT_PERCENT);\n        currentWidthPercent = normalized;\n        if (enabled) {\n          applyWidth(currentWidthPercent);\n        }\n\n        if (normalized !== newWidth) {\n          try {\n            chrome.storage?.sync?.set({ geminiChatWidth: normalized });\n          } catch (e) {\n            console.warn('[Gemini Voyager] Failed to migrate chat width to % on change:', e);\n          }\n        }\n      }\n    }\n  };\n\n  chrome.storage?.onChanged?.addListener(storageChangeHandler);\n\n  // Re-apply styles when DOM changes (for dynamic content)\n  // Use debouncing and cache the width to avoid storage reads\n  let debounceTimer: number | null = null;\n  const observer = new MutationObserver(() => {\n    if (debounceTimer !== null) {\n      clearTimeout(debounceTimer);\n    }\n    debounceTimer = window.setTimeout(() => {\n      if (enabled) {\n        applyWidth(currentWidthPercent);\n      }\n      debounceTimer = null;\n    }, 200);\n  });\n\n  // Observe the main conversation area for changes\n  const main = document.querySelector('main');\n  if (main) {\n    observer.observe(main, {\n      childList: true,\n      subtree: true,\n    });\n  }\n\n  // Clean up on unload to prevent memory leaks\n  window.addEventListener(\n    'beforeunload',\n    () => {\n      observer.disconnect();\n      removeStyles();\n      // Remove storage listener\n      try {\n        chrome.storage?.onChanged?.removeListener(storageChangeHandler);\n      } catch (e) {\n        console.error('[Gemini Voyager] Failed to remove storage listener on unload:', e);\n      }\n    },\n    { once: true },\n  );\n}\n"
  },
  {
    "path": "src/pages/content/contextSync/capture.ts",
    "content": "import { getMatchedAdapter } from '@/features/contextSync/adapters';\nimport { DialogNode } from '@/features/contextSync/types';\n\nimport { getBrowserName } from '../../../core/utils/browser';\n\nexport class ContextCaptureService {\n  private static instance: ContextCaptureService;\n\n  private constructor() {}\n\n  static getInstance(): ContextCaptureService {\n    if (!this.instance) {\n      this.instance = new ContextCaptureService();\n    }\n    return this.instance;\n  }\n\n  async captureDialogue(): Promise<DialogNode[]> {\n    const host = window.location.hostname;\n    const adapter = getMatchedAdapter(host);\n    const messages: DialogNode[] = [];\n\n    let queries: HTMLElement[] = [];\n    let responses: HTMLElement[] = [];\n\n    if (adapter.user_selector && adapter.ai_selector) {\n      queries = adapter.user_selector\n        ? (Array.from(document.querySelectorAll(adapter.user_selector.join(','))) as HTMLElement[])\n        : [];\n      responses = adapter.ai_selector\n        ? (Array.from(document.querySelectorAll(adapter.ai_selector.join(','))) as HTMLElement[])\n        : [];\n    }\n\n    console.log(`[ContextSync] Found ${queries.length} queries and ${responses.length} responses.`);\n\n    const maxLength = Math.max(queries.length, responses.length);\n\n    for (let i = 0; i < maxLength; i++) {\n      if (i < queries.length) {\n        const info = await this.extractNodeInfo(queries[i], 'user');\n        if (info) messages.push(info);\n      }\n      if (i < responses.length) {\n        const info = await this.extractNodeInfo(responses[i], 'assistant');\n        if (info) messages.push(info);\n      }\n    }\n\n    return messages;\n  }\n\n  private static async getBase64Safe(url: string): Promise<string | null> {\n    if (!url || url === 'about:blank') return null;\n\n    // If it's a blob URL, it's already a high-res/processed image in the page context\n    if (url.startsWith('blob:')) {\n      try {\n        const resp = await fetch(url);\n        const blob = await resp.blob();\n        return new Promise((resolve) => {\n          const reader = new FileReader();\n          reader.onloadend = () => resolve(reader.result as string);\n          reader.onerror = () => {\n            console.error('[ContextSync] FileReader error for blob');\n            resolve(null);\n          };\n          reader.readAsDataURL(blob);\n        });\n      } catch (e) {\n        console.error('[ContextSync] Failed to fetch blob URL:', e);\n        return null;\n      }\n    }\n\n    // Determine the \"best\" URL to fetch.\n    // For Google images, try to request the original size (=s0).\n    let targetUrl = url;\n    const isGoogleImage = url.includes('googleusercontent.com') || url.includes('ggpht.com');\n\n    if (isGoogleImage) {\n      // 1. Convert rd-gg to rd-gg-dl for better access to original resolution.\n      // NOTE: We only do this for Chrome-based browsers.\n      // Firefox handles rd-gg-dl poorly (NetworkError/CORS), so it stays on rd-gg.\n      const isFirefox = getBrowserName().includes('Firefox');\n      if (!isFirefox && targetUrl.includes('/rd-gg/')) {\n        targetUrl = targetUrl.replace('/rd-gg/', '/rd-gg-dl/');\n      }\n\n      // 2. Request original size (=s0).\n      // This is a known Google image parameter for full resolution.\n      if (targetUrl.match(/=[swh]\\d+/)) {\n        targetUrl = targetUrl.replace(/=[swh]\\d+[^?#]*/, '=s0');\n      } else if (!targetUrl.includes('=s0')) {\n        // If no sizing parameter found, append =s0\n        // We use =s0 which is generally safer for these types of URLs\n        if (targetUrl.includes('=')) {\n          // If there's already some other parameter, append another one?\n          // Usually Google params are =sNN-pp-kk. If it doesn't match [swh]\\d, we just append.\n          targetUrl += '-s0';\n        } else {\n          targetUrl += '=s0';\n        }\n      }\n    }\n\n    // Helper for fetch with potential credentials fallback\n    const fetchToBlob = async (fetchUrl: string): Promise<Blob | null> => {\n      try {\n        const resp = await fetch(fetchUrl, {\n          credentials: 'include',\n          mode: 'cors' as RequestMode,\n        });\n        if (resp.ok) return await resp.blob();\n      } catch {\n        /* ignore credentials error */\n      }\n\n      try {\n        const resp = await fetch(fetchUrl, {\n          credentials: 'omit',\n          mode: 'cors' as RequestMode,\n        });\n        if (resp.ok) return await resp.blob();\n      } catch (e) {\n        console.error('[ContextSync] Image fetch failed (all content-script attempts):', e);\n      }\n      return null;\n    };\n\n    // Strategy 1: Attempt direct fetch from content script\n    try {\n      const blob = await fetchToBlob(targetUrl);\n      if (blob) {\n        return new Promise((resolve) => {\n          const reader = new FileReader();\n          reader.onloadend = () => resolve(reader.result as string);\n          reader.onerror = () => resolve(null);\n          reader.readAsDataURL(blob);\n        });\n      }\n    } catch (e) {\n      console.warn('[ContextSync] Strategy 1 (Direct) exception:', e);\n    }\n\n    // Strategy 2: Background fetch\n    return new Promise((resolve) => {\n      chrome.runtime.sendMessage({ type: 'gv.fetchImage', url: targetUrl }, (response) => {\n        if (response && response.ok) {\n          resolve(response.data);\n        } else {\n          // Strategy 3: Fetch via page context\n          chrome.runtime.sendMessage(\n            { type: 'gv.fetchImageViaPage', url: targetUrl },\n            (pageResponse) => {\n              if (pageResponse && pageResponse.ok) {\n                resolve(pageResponse.data);\n              } else {\n                console.error('[ContextSync] Image fetch failed (all methods):', targetUrl);\n                resolve(null);\n              }\n            },\n          );\n        }\n      });\n    });\n  }\n\n  private convertTableToMarkdown(table: HTMLTableElement): string {\n    try {\n      const rows = Array.from(table.rows);\n      if (rows.length === 0) return '';\n\n      const data = rows.map((row) => {\n        const cells = Array.from(row.cells);\n        return cells.map((cell) => {\n          return cell.innerText.trim().replace(/\\|/g, '\\\\|').replace(/\\n/g, '___BR___');\n        });\n      });\n\n      const maxCols = data.reduce((max, row) => Math.max(max, row.length), 0);\n      if (maxCols === 0) return '';\n\n      let md = '\\n\\n';\n\n      const headerRow = data[0];\n      while (headerRow.length < maxCols) headerRow.push('');\n      md += '| ' + headerRow.join(' | ') + ' |\\n';\n\n      md += '| ' + Array(maxCols).fill('---').join(' | ') + ' |\\n';\n\n      for (let i = 1; i < data.length; i++) {\n        const row = data[i];\n        while (row.length < maxCols) row.push('');\n        md += '| ' + row.join(' | ') + ' |\\n';\n      }\n\n      return md + '\\n';\n    } catch (e) {\n      console.error('Table conversion failed', e);\n      return table.innerText;\n    }\n  }\n\n  private async extractNodeInfo(\n    el: HTMLElement,\n    forceRole: 'user' | 'assistant' | null = null,\n  ): Promise<DialogNode | null> {\n    if (el.offsetParent === null) return null;\n    if (['SCRIPT', 'STYLE', 'NAV', 'HEADER', 'FOOTER', 'SVG', 'PATH'].includes(el.tagName))\n      return null;\n\n    const clone = el.cloneNode(true) as HTMLElement;\n\n    // 处理表格\n    const tables = Array.from(clone.querySelectorAll('table')).reverse();\n    tables.forEach((table) => {\n      const md = this.convertTableToMarkdown(table as HTMLTableElement);\n      table.replaceWith(document.createTextNode(md));\n    });\n\n    // 处理图片：复用导出功能的全面选择器逻辑\n    const imgBase64List: string[] = [];\n    const imageSelectors = [\n      'user-query-file-preview img',\n      '.preview-image',\n      'generated-image img',\n      'single-image img',\n      '.attachment-container.generated-images img',\n    ].join(',');\n\n    const imgElements = Array.from(clone.querySelectorAll(imageSelectors)) as HTMLImageElement[];\n    if (imgElements.length > 0) {\n      console.log(`[ContextSync] Found ${imgElements.length} image(s)`);\n      for (const imgEl of imgElements) {\n        // Use attribute if available, otherwise fallback to property\n        let src = imgEl.getAttribute('src') || imgEl.src || '';\n        if (!src || src === 'about:blank') continue;\n\n        // Resolve relative URLs to absolute\n        if (src.startsWith('/')) {\n          src = window.location.origin + src;\n        }\n\n        const base64 = await ContextCaptureService.getBase64Safe(src);\n        if (base64) {\n          imgBase64List.push(base64);\n          console.log('[ContextSync] Converted image to Base64 (length):', base64.length);\n        }\n      }\n      console.log(\n        `[ContextSync] Successfully converted ${imgBase64List.length} image(s) to Base64`,\n      );\n    }\n\n    let text = clone.innerText.trim();\n    if (text.length < 1 && imgBase64List.length === 0) return null;\n\n    text = text.replace(/</g, '&lt;').replace(/>/g, '&gt;');\n    text = text.replace(/___BR___/g, '<br>');\n\n    return {\n      url: window.location.hostname,\n      className: el.className,\n      text: text,\n      images: imgBase64List,\n      is_ai_likely: forceRole === 'assistant',\n      is_user_likely: forceRole === 'user',\n      rect: {\n        top: el.getBoundingClientRect().top,\n        left: el.getBoundingClientRect().left,\n        width: el.getBoundingClientRect().width,\n      },\n    };\n  }\n}\n"
  },
  {
    "path": "src/pages/content/contextSync/index.ts",
    "content": "import { SyncService } from '@/features/contextSync/services/SyncService';\n\nimport { ContextCaptureService } from './capture';\n\nexport function startContextSync() {\n  console.log('🚀 AI Context Sync Feature Initialized');\n\n  // Listen for messages from Popup\n  chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {\n    if (request.action === 'sync_to_ide') {\n      handleSyncRequest(sendResponse);\n      return true; // Keep channel open\n    }\n  });\n}\n\nasync function handleSyncRequest(sendResponse: (response: unknown) => void) {\n  try {\n    const captureService = ContextCaptureService.getInstance();\n    const syncService = SyncService.getInstance();\n\n    const data = await captureService.captureDialogue();\n    const result = await syncService.syncToIDE(data);\n\n    sendResponse({ status: 'success', data: result });\n  } catch (err) {\n    console.error('Sync failed', err);\n    sendResponse({ status: 'error', message: (err as Error).message });\n  }\n}\n"
  },
  {
    "path": "src/pages/content/deepResearch/__tests__/menuButton.test.ts",
    "content": "import { readFileSync } from 'node:fs';\nimport { resolve } from 'node:path';\nimport { describe, expect, it } from 'vitest';\n\nimport type { AppLanguage } from '@/utils/language';\nimport type { TranslationKey } from '@/utils/translations';\n\nimport {\n  applyDeepResearchDownloadButtonI18n,\n  applyDeepResearchSaveReportButtonI18n,\n  injectDownloadButton,\n  isDeepResearchReportMenuPanel,\n  showDeepResearchExportProgressOverlay,\n} from '../menuButton';\n\nfunction createNativeMenuButton({\n  testId,\n  label,\n  iconName,\n  buttonClassName,\n  iconClassName,\n}: {\n  testId: string;\n  label: string;\n  iconName: string;\n  buttonClassName: string;\n  iconClassName: string;\n}): HTMLButtonElement {\n  const button = document.createElement('button');\n  button.className = buttonClassName;\n  button.setAttribute('role', 'menuitem');\n  button.setAttribute('tabindex', '0');\n  button.setAttribute('data-test-id', testId);\n\n  const icon = document.createElement('mat-icon');\n  icon.className = iconClassName;\n  icon.setAttribute('fonticon', iconName);\n  icon.setAttribute('aria-hidden', 'true');\n  icon.textContent = '';\n\n  const text = document.createElement('span');\n  text.className = 'mat-mdc-menu-item-text';\n  text.textContent = label;\n\n  const ripple = document.createElement('div');\n  ripple.className = 'mat-ripple mat-mdc-menu-ripple';\n  ripple.setAttribute('matripple', '');\n\n  button.appendChild(icon);\n  button.appendChild(text);\n  button.appendChild(ripple);\n  return button;\n}\n\nfunction createDeepResearchReportMenuPanel(): HTMLElement {\n  const panel = document.createElement('div');\n  panel.className = 'mat-mdc-menu-panel';\n  panel.setAttribute('role', 'menu');\n\n  const content = document.createElement('div');\n  content.className = 'mat-mdc-menu-content';\n\n  const shareContainer = document.createElement('div');\n  shareContainer.setAttribute('data-test-id', 'share-button-tooltip-container');\n  const shareButtonWrapper = document.createElement('share-button');\n  const shareButton = createNativeMenuButton({\n    testId: 'share-button',\n    label: 'Share',\n    iconName: 'share',\n    buttonClassName:\n      'mat-mdc-menu-item mat-focus-indicator share-button menu-item-button ng-star-inserted',\n    iconClassName:\n      'mat-icon notranslate gds-icon-l google-symbols mat-ligature-font mat-icon-no-color',\n  });\n  shareButtonWrapper.appendChild(shareButton);\n  shareContainer.appendChild(shareButtonWrapper);\n  content.appendChild(shareContainer);\n\n  const exportToDocs = document.createElement('export-to-docs-button');\n  exportToDocs.setAttribute('data-test-id', 'export-to-docs-button');\n  const docsButton = createNativeMenuButton({\n    testId: 'export-to-docs-button',\n    label: 'Export to Docs',\n    iconName: 'docs',\n    buttonClassName: 'mat-mdc-menu-item mat-focus-indicator menu-item-button ng-star-inserted',\n    iconClassName:\n      'mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color ng-star-inserted',\n  });\n  exportToDocs.appendChild(docsButton);\n  content.appendChild(exportToDocs);\n\n  const copyButton = document.createElement('copy-button');\n  copyButton.setAttribute('data-test-id', 'copy-button');\n  const nativeCopyButton = createNativeMenuButton({\n    testId: 'copy-button',\n    label: 'Copy contents',\n    iconName: 'content_copy',\n    buttonClassName:\n      'mat-mdc-menu-item mat-focus-indicator copy-button menu-item-button ng-star-inserted',\n    iconClassName: 'mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color',\n  });\n  copyButton.appendChild(nativeCopyButton);\n  content.appendChild(copyButton);\n\n  panel.appendChild(content);\n  document.body.appendChild(panel);\n  return panel;\n}\n\ndescribe('applyDeepResearchDownloadButtonI18n', () => {\n  it('identifies deep research report share/export menu panel', () => {\n    const panel = document.createElement('div');\n    panel.className = 'mat-mdc-menu-panel';\n    panel.setAttribute('role', 'menu');\n\n    const content = document.createElement('div');\n    content.className = 'mat-mdc-menu-content';\n\n    const shareContainer = document.createElement('div');\n    shareContainer.setAttribute('data-test-id', 'share-button-tooltip-container');\n    content.appendChild(shareContainer);\n\n    const exportToDocs = document.createElement('export-to-docs-button');\n    exportToDocs.setAttribute('data-test-id', 'export-to-docs-button');\n    content.appendChild(exportToDocs);\n\n    const copyButton = document.createElement('copy-button');\n    copyButton.setAttribute('data-test-id', 'copy-button');\n    content.appendChild(copyButton);\n\n    panel.appendChild(content);\n\n    expect(isDeepResearchReportMenuPanel(panel)).toBe(true);\n  });\n\n  it('rejects sidebar conversation menu panel for deep research injection', () => {\n    const panel = document.createElement('div');\n    panel.className = 'mat-mdc-menu-panel';\n    panel.setAttribute('role', 'menu');\n\n    const content = document.createElement('div');\n    content.className = 'mat-mdc-menu-content';\n\n    const pin = document.createElement('button');\n    pin.setAttribute('data-test-id', 'pin-button');\n    content.appendChild(pin);\n\n    const rename = document.createElement('button');\n    rename.setAttribute('data-test-id', 'rename-button');\n    content.appendChild(rename);\n\n    panel.appendChild(content);\n\n    expect(isDeepResearchReportMenuPanel(panel)).toBe(false);\n  });\n\n  it('updates label and tooltip according to language', () => {\n    const button = document.createElement('button');\n    const span = document.createElement('span');\n    span.className = 'mat-mdc-menu-item-text';\n    span.textContent = ' placeholder';\n    button.appendChild(span);\n\n    const dict: Record<AppLanguage, Record<string, string>> = {\n      en: { deepResearchDownload: 'Download', deepResearchDownloadTooltip: 'Download (MD)' },\n      zh: { deepResearchDownload: '下载', deepResearchDownloadTooltip: '下载（MD）' },\n      zh_TW: { deepResearchDownload: '下載', deepResearchDownloadTooltip: '下載（MD）' },\n      ja: {\n        deepResearchDownload: 'ダウンロード',\n        deepResearchDownloadTooltip: 'ダウンロード（MD）',\n      },\n      fr: { deepResearchDownload: 'Télécharger', deepResearchDownloadTooltip: 'Télécharger (MD)' },\n      es: { deepResearchDownload: 'Descargar', deepResearchDownloadTooltip: 'Descargar (MD)' },\n      pt: { deepResearchDownload: 'Baixar', deepResearchDownloadTooltip: 'Baixar (MD)' },\n      ar: { deepResearchDownload: 'تحميل', deepResearchDownloadTooltip: 'تحميل (MD)' },\n      ru: { deepResearchDownload: 'Скачать', deepResearchDownloadTooltip: 'Скачать (MD)' },\n      ko: { deepResearchDownload: '다운로드', deepResearchDownloadTooltip: '다운로드 (MD)' },\n    };\n\n    applyDeepResearchDownloadButtonI18n(button, dict, 'ja');\n\n    expect(button.title).toBe('ダウンロード（MD）');\n    expect(button.getAttribute('aria-label')).toBe('ダウンロード（MD）');\n    expect(span.textContent).toBe('ダウンロード');\n  });\n\n  it('updates save report label and tooltip according to language', () => {\n    const button = document.createElement('button');\n    const span = document.createElement('span');\n    span.className = 'mat-mdc-menu-item-text';\n    span.textContent = ' placeholder';\n    button.appendChild(span);\n\n    const dict: Record<AppLanguage, Record<string, string>> = {\n      en: { deepResearchSaveReport: 'Save report', deepResearchSaveReportTooltip: 'Save report' },\n      zh: { deepResearchSaveReport: '保存报告', deepResearchSaveReportTooltip: '保存报告' },\n      zh_TW: { deepResearchSaveReport: '儲存報告', deepResearchSaveReportTooltip: '儲存報告' },\n      ja: {\n        deepResearchSaveReport: 'レポートを保存',\n        deepResearchSaveReportTooltip: 'レポートを保存',\n      },\n      fr: {\n        deepResearchSaveReport: 'Enregistrer le rapport',\n        deepResearchSaveReportTooltip: 'Enregistrer le rapport',\n      },\n      es: {\n        deepResearchSaveReport: 'Guardar informe',\n        deepResearchSaveReportTooltip: 'Guardar informe',\n      },\n      pt: {\n        deepResearchSaveReport: 'Salvar relatório',\n        deepResearchSaveReportTooltip: 'Salvar relatório',\n      },\n      ar: { deepResearchSaveReport: 'حفظ التقرير', deepResearchSaveReportTooltip: 'حفظ التقرير' },\n      ru: {\n        deepResearchSaveReport: 'Сохранить отчет',\n        deepResearchSaveReportTooltip: 'Сохранить отчет',\n      },\n      ko: {\n        deepResearchSaveReport: '보고서 저장',\n        deepResearchSaveReportTooltip: '보고서 저장',\n      },\n    };\n\n    applyDeepResearchSaveReportButtonI18n(button, dict, 'zh');\n\n    expect(button.title).toBe('保存报告');\n    expect(button.getAttribute('aria-label')).toBe('保存报告');\n    expect(span.textContent).toBe('保存报告');\n  });\n\n  it('injects deep research export buttons using native menu item style baseline', async () => {\n    const w = window as unknown as {\n      chrome?: {\n        storage?: {\n          sync?: {\n            get: (key: string, cb: (result: Record<string, unknown>) => void) => void;\n          };\n          onChanged?: {\n            addListener: (fn: (...args: unknown[]) => void) => void;\n            removeListener: (fn: (...args: unknown[]) => void) => void;\n          };\n        };\n      };\n    };\n    w.chrome = {\n      storage: {\n        sync: {\n          get: (_key, cb) => cb({}),\n        },\n        onChanged: {\n          addListener: () => {},\n          removeListener: () => {},\n        },\n      },\n    };\n\n    const panel = createDeepResearchReportMenuPanel();\n    const templateButton = panel.querySelector(\n      'export-to-docs-button button.mat-mdc-menu-item',\n    ) as HTMLElement;\n    const templateIcon = templateButton.querySelector('mat-icon') as HTMLElement;\n    const templateText = templateButton.querySelector('.mat-mdc-menu-item-text') as HTMLElement;\n\n    await injectDownloadButton(panel);\n\n    const download = panel.querySelector('.gv-deep-research-download') as HTMLElement | null;\n    const saveReport = panel.querySelector('.gv-deep-research-save-report') as HTMLElement | null;\n    expect(download).toBeTruthy();\n    expect(saveReport).toBeTruthy();\n\n    const downloadIcon = download?.querySelector('mat-icon') as HTMLElement | null;\n    const saveReportIcon = saveReport?.querySelector('mat-icon') as HTMLElement | null;\n    const downloadText = download?.querySelector('.mat-mdc-menu-item-text') as HTMLElement | null;\n    const saveReportText = saveReport?.querySelector(\n      '.mat-mdc-menu-item-text',\n    ) as HTMLElement | null;\n\n    expect(downloadIcon?.className).toBe(templateIcon.className);\n    expect(saveReportIcon?.className).toBe(templateIcon.className);\n    expect(downloadText?.className).toBe(templateText?.className);\n    expect(saveReportText?.className).toBe(templateText?.className);\n    expect(saveReport?.textContent?.toLowerCase()).not.toContain('description');\n  });\n\n  it('renders and removes deep research export progress overlay', () => {\n    const dict: Record<AppLanguage, Record<string, string>> = {\n      en: { pm_export: 'Export', loading: 'Loading' },\n      zh: { pm_export: '导出', loading: '加载中' },\n      zh_TW: { pm_export: '匯出', loading: '載入中' },\n      ja: { pm_export: 'エクスポート', loading: '読み込み中' },\n      fr: { pm_export: 'Exporter', loading: 'Chargement' },\n      es: { pm_export: 'Exportar', loading: 'Cargando' },\n      pt: { pm_export: 'Exportar', loading: 'Carregando' },\n      ar: { pm_export: 'تصدير', loading: 'جارٍ التحميل' },\n      ru: { pm_export: 'Экспорт', loading: 'Загрузка' },\n      ko: { pm_export: '내보내기', loading: '로딩 중' },\n    };\n\n    const t = (key: TranslationKey): string => {\n      if (key === 'pm_export' || key === 'loading') {\n        return dict.en[key];\n      }\n      return '';\n    };\n    const hide = showDeepResearchExportProgressOverlay(t);\n\n    const overlay = document.querySelector('.gv-export-progress-overlay');\n    expect(overlay).toBeTruthy();\n    expect(overlay?.textContent).toContain('Export...');\n    expect(overlay?.textContent).toContain('Loading');\n\n    hide();\n\n    expect(document.querySelector('.gv-export-progress-overlay')).toBeNull();\n  });\n\n  it('wires Safari PDF report export success to runtime toast guidance', () => {\n    const code = readFileSync(\n      resolve(process.cwd(), 'src/pages/content/deepResearch/menuButton.ts'),\n      'utf8',\n    );\n\n    expect(code).toContain(\"format === 'pdf'\");\n    expect(code).toContain('isSafari()');\n    expect(code).toContain('showExportToast(');\n    expect(code).toContain(\"t('export_toast_safari_pdf_ready')\");\n  });\n});\n"
  },
  {
    "path": "src/pages/content/deepResearch/__tests__/reportExtractor.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { extractDeepResearchReportTitle, findDeepResearchReportRoot } from '../reportExtractor';\n\ndescribe('reportExtractor', () => {\n  it('finds report markdown outside thinking panels', () => {\n    document.body.innerHTML = `\n      <deep-research-immersive-panel>\n        <thinking-panel>\n          <div class=\"markdown\">Thinking trace content should not be exported even if long.</div>\n        </thinking-panel>\n        <section>\n          <div class=\"markdown-main-panel\">\n            <h1>Final Report</h1>\n            <p>This is the final report body.</p>\n          </div>\n        </section>\n      </deep-research-immersive-panel>\n    `;\n\n    const root = findDeepResearchReportRoot();\n    expect(root).not.toBeNull();\n    expect(root?.textContent).toContain('Final Report');\n    expect(root?.textContent).not.toContain('Thinking trace');\n  });\n\n  it('returns null when immersive panel is missing', () => {\n    document.body.innerHTML = '<div class=\"markdown\">Standalone content</div>';\n    const root = findDeepResearchReportRoot();\n    expect(root).toBeNull();\n  });\n\n  it('extracts title from heading first, then document title', () => {\n    document.title = 'Gemini';\n    document.body.innerHTML = `\n      <div class=\"markdown\">\n        <h1>Revenue Deep Research Report</h1>\n        <p>Body</p>\n      </div>\n    `;\n    const root = document.querySelector('.markdown') as HTMLElement;\n    expect(extractDeepResearchReportTitle(root)).toBe('Revenue Deep Research Report');\n\n    root.innerHTML = '<p>No heading here</p>';\n    document.title = 'Cross-border Analysis';\n    expect(extractDeepResearchReportTitle(root)).toBe('Cross-border Analysis');\n  });\n});\n"
  },
  {
    "path": "src/pages/content/deepResearch/download.ts",
    "content": "/**\n * File download module for Deep Research exports\n */\n\n/**\n * Generate filename with timestamp\n */\nexport function generateFilename(): string {\n  const pad = (n: number): string => String(n).padStart(2, '0');\n  const d = new Date();\n  const year = d.getFullYear();\n  const month = pad(d.getMonth() + 1);\n  const day = pad(d.getDate());\n  const hours = pad(d.getHours());\n  const minutes = pad(d.getMinutes());\n  const seconds = pad(d.getSeconds());\n\n  return `deep-research-thinking-${year}${month}${day}-${hours}${minutes}${seconds}.md`;\n}\n\n/**\n * Download markdown content as file\n */\nexport function downloadMarkdown(content: string): void {\n  try {\n    const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = generateFilename();\n\n    // Append to body, click, and remove\n    document.body.appendChild(a);\n    a.click();\n\n    // Cleanup\n    setTimeout(() => {\n      try {\n        document.body.removeChild(a);\n      } catch (error) {\n        console.error('[Gemini Voyager] Error removing download link:', error);\n      }\n      URL.revokeObjectURL(url);\n    }, 100);\n\n    console.log('[Gemini Voyager] Deep Research thinking content downloaded successfully');\n  } catch (error) {\n    console.error('[Gemini Voyager] Error downloading markdown:', error);\n  }\n}\n"
  },
  {
    "path": "src/pages/content/deepResearch/extractor.ts",
    "content": "/**\n * DOM extraction module for Deep Research thinking content\n */\nimport type { BrowseChip, ThinkingContent, ThinkingItem, ThinkingSection } from './types';\n\n/**\n * Extract a single thought item (header + content)\n * Returns simplified object without type field (will be added by caller)\n */\nfunction extractThoughtItem(thoughtElement: Element): { header: string; content: string } | null {\n  try {\n    const headerEl = thoughtElement.querySelector('.thought-header');\n    // Select the content div that has gds-body-m and gds-italic but is NOT the header\n    const contentEl = thoughtElement.querySelector('.gds-body-m.gds-italic:not(.thought-header)');\n\n    if (!headerEl || !contentEl) {\n      return null;\n    }\n\n    const header = headerEl.textContent?.trim() || '';\n    const content = contentEl.textContent?.trim() || '';\n\n    if (!header && !content) {\n      return null;\n    }\n\n    return { header, content };\n  } catch (error) {\n    console.error('[Gemini Voyager] Error extracting thought item:', error);\n    return null;\n  }\n}\n\n/**\n * Extract browse chips (website links) from a browse-chip-list\n */\nfunction extractBrowseChips(browseListElement: Element): BrowseChip[] {\n  try {\n    const chips: BrowseChip[] = [];\n    const chipElements = browseListElement.querySelectorAll(\n      'browse-web-chip a[data-test-id=\"browse-chip-link\"]',\n    );\n\n    chipElements.forEach((chipEl) => {\n      try {\n        const url = chipEl.getAttribute('href') || '';\n        const domainEl = chipEl.querySelector('[data-test-id=\"domain-name\"]');\n        const titleEl = chipEl.querySelector('[data-test-id=\"title\"]');\n\n        const domain = domainEl?.textContent?.trim() || '';\n        const title = titleEl?.textContent?.trim() || '';\n\n        if (url && domain) {\n          chips.push({ url, domain, title });\n        }\n      } catch (error) {\n        console.error('[Gemini Voyager] Error extracting browse chip:', error);\n      }\n    });\n\n    return chips;\n  } catch (error) {\n    console.error('[Gemini Voyager] Error extracting browse chips:', error);\n    return [];\n  }\n}\n\n/**\n * Extract a single thinking section (thoughts + browse chips in order)\n */\nfunction extractThinkingSection(panelElement: Element): ThinkingSection | null {\n  try {\n    const items: ThinkingItem[] = [];\n\n    // Get all item-container elements in order\n    const itemContainers = panelElement.querySelectorAll('.item-container');\n\n    itemContainers.forEach((container) => {\n      // Check if this container has a thought-item\n      const thoughtEl = container.querySelector('thought-item');\n      if (thoughtEl) {\n        const thought = extractThoughtItem(thoughtEl);\n        if (thought) {\n          items.push({\n            type: 'thought',\n            header: thought.header,\n            content: thought.content,\n          });\n        }\n      }\n\n      // Check if this container has a browse-chip-list\n      const browseListEl = container.querySelector('browse-chip-list');\n      if (browseListEl) {\n        const chips = extractBrowseChips(browseListEl);\n        if (chips.length > 0) {\n          items.push({\n            type: 'browse-chips',\n            chips,\n          });\n        }\n      }\n    });\n\n    // Only return if we have content\n    if (items.length === 0) {\n      return null;\n    }\n\n    return { items };\n  } catch (error) {\n    console.error('[Gemini Voyager] Error extracting thinking section:', error);\n    return null;\n  }\n}\n\n/**\n * Extract all thinking panels from the Deep Research conversation\n */\nexport function extractThinkingPanels(): ThinkingContent | null {\n  try {\n    // Check if we're in a Deep Research conversation\n    const deepResearchPanel = document.querySelector('deep-research-immersive-panel');\n    if (!deepResearchPanel) {\n      console.log('[Gemini Voyager] Not a Deep Research conversation');\n      return null;\n    }\n\n    const sections: ThinkingSection[] = [];\n\n    // Find all thinking-panel elements\n    const thinkingPanels = deepResearchPanel.querySelectorAll('thinking-panel');\n\n    thinkingPanels.forEach((panel) => {\n      const section = extractThinkingSection(panel);\n      if (section) {\n        sections.push(section);\n      }\n    });\n\n    if (sections.length === 0) {\n      console.log('[Gemini Voyager] No thinking content found');\n      return null;\n    }\n\n    // Try to get conversation title\n    const title = getConversationTitle();\n\n    return {\n      sections,\n      exportedAt: new Date().toISOString(),\n      title,\n    };\n  } catch (error) {\n    console.error('[Gemini Voyager] Error extracting thinking panels:', error);\n    return null;\n  }\n}\n\n/**\n * Get conversation title from the page\n */\nfunction getConversationTitle(): string {\n  try {\n    // Strategy 1: Get from page title\n    const titleElement = document.querySelector('title');\n    if (titleElement) {\n      const title = titleElement.textContent?.trim();\n      if (\n        title &&\n        title !== 'Gemini' &&\n        title !== 'Google Gemini' &&\n        !title.startsWith('Gemini -') &&\n        title.length > 0\n      ) {\n        return title;\n      }\n    }\n\n    // Strategy 2: Try to get from sidebar\n    const selectors = [\n      'mat-list-item.mdc-list-item--activated [mat-line]',\n      'mat-list-item[aria-current=\"page\"] [mat-line]',\n      '.conversation-list-item.active .conversation-title',\n    ];\n\n    for (const selector of selectors) {\n      const element = document.querySelector(selector);\n      if (element?.textContent?.trim() && element.textContent.trim() !== 'New chat') {\n        return element.textContent.trim();\n      }\n    }\n  } catch (error) {\n    console.error('[Gemini Voyager] Error getting conversation title:', error);\n  }\n\n  return 'Deep Research Conversation';\n}\n"
  },
  {
    "path": "src/pages/content/deepResearch/formatter.ts",
    "content": "/**\n * Markdown formatting module for Deep Research thinking content\n */\nimport { getCurrentLanguage, getTranslation } from '@/utils/i18n';\n\nimport type { BrowseChip, ThinkingContent, ThinkingItem, ThinkingSection } from './types';\n\n/**\n * Format a single thought item as Markdown\n */\nfunction formatThoughtItem(header: string, content: string): string {\n  if (!header && !content) {\n    return '';\n  }\n\n  const parts: string[] = [];\n\n  if (header) {\n    parts.push(`### ${header}\\n`);\n  }\n\n  if (content) {\n    parts.push(content);\n  }\n\n  return parts.join('\\n');\n}\n\n/**\n * Format browse chips as Markdown list\n */\nasync function formatBrowseChips(chips: BrowseChip[]): Promise<string> {\n  if (chips.length === 0) {\n    return '';\n  }\n\n  const currentLang = await getCurrentLanguage();\n  const researchedWebsitesLabel = await getTranslation('deepResearch_researchedWebsites');\n\n  // If current language is English, only show English label (avoid duplication)\n  const header =\n    currentLang === 'en'\n      ? `#### Researched Websites\\n`\n      : `#### ${researchedWebsitesLabel} / Researched Websites\\n`;\n\n  const lines: string[] = [header];\n\n  chips.forEach((chip) => {\n    const title = chip.title ? ` - ${chip.title}` : '';\n    lines.push(`- [${chip.domain}](${chip.url})${title}`);\n  });\n\n  return lines.join('\\n');\n}\n\n/**\n * Format a thinking item (either thought or browse chips)\n */\nasync function formatThinkingItem(item: ThinkingItem): Promise<string> {\n  if (item.type === 'thought') {\n    return formatThoughtItem(item.header, item.content);\n  } else if (item.type === 'browse-chips') {\n    return await formatBrowseChips(item.chips);\n  }\n  return '';\n}\n\n/**\n * Format a thinking section as Markdown\n */\nasync function formatThinkingSection(section: ThinkingSection, index: number): Promise<string> {\n  const parts: string[] = [];\n\n  // Section header\n  const currentLang = await getCurrentLanguage();\n  const thinkingPhaseLabel = await getTranslation('deepResearch_thinkingPhase');\n\n  // If current language is English, only show English label (avoid duplication)\n  const header =\n    currentLang === 'en'\n      ? `## Thinking Phase ${index + 1}\\n`\n      : `## ${thinkingPhaseLabel} ${index + 1} / Thinking Phase ${index + 1}\\n`;\n\n  parts.push(header);\n\n  // Format items in order\n  for (const item of section.items) {\n    const formatted = await formatThinkingItem(item);\n    if (formatted) {\n      parts.push(formatted);\n      parts.push(''); // Add blank line between items\n    }\n  }\n\n  return parts.join('\\n');\n}\n\n/**\n * Format timestamp as readable string\n */\nfunction formatTimestamp(isoString: string): string {\n  try {\n    const date = new Date(isoString);\n    const year = date.getFullYear();\n    const month = String(date.getMonth() + 1).padStart(2, '0');\n    const day = String(date.getDate()).padStart(2, '0');\n    const hours = String(date.getHours()).padStart(2, '0');\n    const minutes = String(date.getMinutes()).padStart(2, '0');\n    const seconds = String(date.getSeconds()).padStart(2, '0');\n\n    return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\n  } catch (error) {\n    console.error('[Gemini Voyager] Error formatting timestamp:', error);\n    return isoString;\n  }\n}\n\n/**\n * Convert thinking content to Markdown format\n */\nexport async function formatToMarkdown(content: ThinkingContent): Promise<string> {\n  const parts: string[] = [];\n\n  // Get current language and translations\n  const currentLang = await getCurrentLanguage();\n  const exportedAtLabel = await getTranslation('deepResearch_exportedAt');\n  const totalPhasesLabel = await getTranslation('deepResearch_totalPhases');\n\n  // Title\n  parts.push(`# ${content.title}\\n`);\n\n  // Metadata - if current language is English, only show English labels (avoid duplication)\n  if (currentLang === 'en') {\n    parts.push(`**Exported At:** ${formatTimestamp(content.exportedAt)}\\n`);\n    parts.push(`**Total Phases:** ${content.sections.length}\\n`);\n  } else {\n    parts.push(`**${exportedAtLabel} / Exported At:** ${formatTimestamp(content.exportedAt)}\\n`);\n    parts.push(`**${totalPhasesLabel} / Total Phases:** ${content.sections.length}\\n`);\n  }\n  parts.push('---\\n');\n\n  // Format each section\n  for (let index = 0; index < content.sections.length; index++) {\n    const section = content.sections[index];\n    const formatted = await formatThinkingSection(section, index);\n    if (formatted) {\n      parts.push(formatted);\n      // Add separator between sections (except for the last one)\n      if (index < content.sections.length - 1) {\n        parts.push('---\\n');\n      }\n    }\n  }\n\n  // Footer\n  parts.push('\\n---\\n');\n  parts.push('*Generated by [Voyager](https://github.com/Nagi-ovo/gemini-voyager)*');\n\n  return parts.join('\\n');\n}\n"
  },
  {
    "path": "src/pages/content/deepResearch/index.ts",
    "content": "/**\n * Deep Research export feature - Main entry point\n * Detects Deep Research conversations and injects download button into menu\n */\nimport { injectDownloadButton } from './menuButton';\n\n/**\n * Check if we're in a Deep Research conversation\n */\nfunction isDeepResearchConversation(): boolean {\n  return !!document.querySelector('deep-research-immersive-panel');\n}\n\nfunction getMenuPanelsFromNode(node: HTMLElement): HTMLElement[] {\n  const panels: HTMLElement[] = [];\n  if (node.matches('.mat-mdc-menu-panel[role=\"menu\"]')) {\n    panels.push(node);\n  }\n  panels.push(\n    ...Array.from(node.querySelectorAll<HTMLElement>('.mat-mdc-menu-panel[role=\"menu\"]')),\n  );\n  return panels;\n}\n\n/**\n * Observe menu opening and inject button if needed\n */\nfunction observeMenuOpening(): void {\n  // Use MutationObserver to watch for menu panel appearing\n  const observer = new MutationObserver((mutations) => {\n    for (const mutation of mutations) {\n      mutation.addedNodes.forEach((node) => {\n        if (node instanceof HTMLElement) {\n          const panels = getMenuPanelsFromNode(node);\n          if (panels.length === 0) return;\n          if (!isDeepResearchConversation()) return;\n          panels.forEach((panel) => {\n            // Small delay to ensure menu is fully rendered\n            setTimeout(() => {\n              void injectDownloadButton(panel);\n            }, 50);\n          });\n        }\n      });\n    }\n  });\n\n  observer.observe(document.body, {\n    childList: true,\n    subtree: true,\n  });\n\n  console.log('[Gemini Voyager] Deep Research export observer initialized');\n}\n\n/**\n * Start Deep Research export feature\n */\nexport function startDeepResearchExport(): void {\n  try {\n    // Only run on gemini.google.com\n    if (location.hostname !== 'gemini.google.com') {\n      return;\n    }\n\n    console.log('[Gemini Voyager] Initializing Deep Research export feature');\n\n    // Start observing for menu opening\n    observeMenuOpening();\n  } catch (error) {\n    console.error('[Gemini Voyager] Error starting Deep Research export:', error);\n  }\n}\n"
  },
  {
    "path": "src/pages/content/deepResearch/menuButton.ts",
    "content": "/**\n * Menu button injection module for Deep Research export\n */\nimport { StorageKeys } from '@/core/types/common';\nimport { isSafari } from '@/core/utils/browser';\nimport { ConversationExportService } from '@/features/export/services/ConversationExportService';\nimport type {\n  ConversationMetadata,\n  ChatTurn as ExportChatTurn,\n  ExportFormat,\n} from '@/features/export/types/export';\nimport { ExportDialog } from '@/features/export/ui/ExportDialog';\nimport { resolveExportErrorMessage } from '@/features/export/ui/ExportErrorMessage';\nimport { showExportToast } from '@/features/export/ui/ExportToast';\nimport { type AppLanguage, normalizeLanguage } from '@/utils/language';\nimport { extractMessageDictionary } from '@/utils/localeMessages';\nimport type { TranslationKey } from '@/utils/translations';\n\nimport {\n  createMenuItemFromNativeTemplate,\n  updateMenuItemTemplateLabel,\n} from '../shared/nativeMenuItemTemplate';\nimport { downloadMarkdown } from './download';\nimport { extractThinkingPanels } from './extractor';\nimport { formatToMarkdown } from './formatter';\nimport { extractDeepResearchReportTitle, findDeepResearchReportRoot } from './reportExtractor';\n\ntype Dictionaries = Record<AppLanguage, Record<string, string>>;\nconst DOWNLOAD_BUTTON_CLASS = 'gv-deep-research-download';\nconst SAVE_REPORT_BUTTON_CLASS = 'gv-deep-research-save-report';\nconst INJECTED_BUTTON_CLASSES = [DOWNLOAD_BUTTON_CLASS, SAVE_REPORT_BUTTON_CLASS];\nconst TEMPLATE_EXCLUDED_CLASS_NAMES = [...INJECTED_BUTTON_CLASSES, 'share-button'];\n\n/**\n * Wait for an element to appear in the DOM\n */\nfunction waitForElement(selector: string, timeout: number = 5000): Promise<Element | null> {\n  return new Promise((resolve) => {\n    const element = document.querySelector(selector);\n    if (element) {\n      return resolve(element);\n    }\n\n    const observer = new MutationObserver(() => {\n      const found = document.querySelector(selector);\n      if (found) {\n        observer.disconnect();\n        resolve(found);\n      }\n    });\n\n    observer.observe(document.body, {\n      childList: true,\n      subtree: true,\n    });\n\n    setTimeout(() => {\n      observer.disconnect();\n      resolve(null);\n    }, timeout);\n  });\n}\n\n/**\n * Load i18n dictionaries\n */\nasync function loadDictionaries(): Promise<Dictionaries> {\n  try {\n    const [enRaw, zhRaw, zhTWRaw, jaRaw, frRaw, esRaw, ptRaw, arRaw, ruRaw, koRaw] =\n      await Promise.all([\n        import(/* @vite-ignore */ '../../../locales/en/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/zh/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/zh_TW/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/ja/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/fr/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/es/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/pt/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/ar/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/ru/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/ko/messages.json'),\n      ]);\n\n    return {\n      en: extractMessageDictionary(enRaw),\n      zh: extractMessageDictionary(zhRaw),\n      zh_TW: extractMessageDictionary(zhTWRaw),\n      ja: extractMessageDictionary(jaRaw),\n      fr: extractMessageDictionary(frRaw),\n      es: extractMessageDictionary(esRaw),\n      pt: extractMessageDictionary(ptRaw),\n      ar: extractMessageDictionary(arRaw),\n      ru: extractMessageDictionary(ruRaw),\n      ko: extractMessageDictionary(koRaw),\n    };\n  } catch (error) {\n    console.error('[Gemini Voyager] Error loading dictionaries:', error);\n    return {\n      en: {},\n      zh: {},\n      zh_TW: {},\n      ja: {},\n      fr: {},\n      es: {},\n      pt: {},\n      ar: {},\n      ru: {},\n      ko: {},\n    };\n  }\n}\n\nexport function applyDeepResearchDownloadButtonI18n(\n  button: HTMLButtonElement,\n  dict: Dictionaries,\n  lang: AppLanguage,\n): void {\n  const t = (key: TranslationKey) => dict[lang]?.[key] ?? dict.en?.[key] ?? key;\n  const text = t('deepResearchDownload');\n  const tooltip = t('deepResearchDownloadTooltip');\n\n  updateMenuItemTemplateLabel(button, text, tooltip);\n}\n\nexport function applyDeepResearchSaveReportButtonI18n(\n  button: HTMLButtonElement,\n  dict: Dictionaries,\n  lang: AppLanguage,\n): void {\n  const t = (key: TranslationKey) => dict[lang]?.[key] ?? dict.en?.[key] ?? key;\n  const text = t('deepResearchSaveReport');\n  const tooltip = t('deepResearchSaveReportTooltip');\n\n  updateMenuItemTemplateLabel(button, text, tooltip);\n}\n\n/**\n * Get user language preference\n */\nasync function getLanguage(): Promise<AppLanguage> {\n  try {\n    const stored = await new Promise<unknown>((resolve) => {\n      try {\n        const w = window as Window & {\n          chrome?: typeof chrome;\n          browser?: { storage?: { sync?: { get: (key: string) => Promise<unknown> } } };\n        };\n        // Chrome uses callback-based API\n        if (w.chrome?.storage?.sync?.get) {\n          w.chrome.storage.sync.get(StorageKeys.LANGUAGE, resolve);\n        }\n        // Firefox uses Promise-based API\n        else if (w.browser?.storage?.sync?.get) {\n          w.browser.storage.sync\n            .get(StorageKeys.LANGUAGE)\n            .then(resolve)\n            .catch(() => resolve({}));\n        } else {\n          resolve({});\n        }\n      } catch {\n        resolve({});\n      }\n    });\n\n    const rec = stored && typeof stored === 'object' ? (stored as Record<string, unknown>) : {};\n    const lang =\n      typeof rec[StorageKeys.LANGUAGE] === 'string'\n        ? (rec[StorageKeys.LANGUAGE] as string)\n        : undefined;\n    return normalizeLanguage(lang || navigator.language || 'en');\n  } catch {\n    return 'en';\n  }\n}\n\n/**\n * Handle download button click\n */\nasync function handleDownload(): Promise<void> {\n  try {\n    console.log('[Gemini Voyager] Extracting Deep Research thinking content...');\n\n    const content = extractThinkingPanels();\n    if (!content) {\n      console.warn('[Gemini Voyager] No thinking content found');\n      return;\n    }\n\n    const markdown = await formatToMarkdown(content);\n    downloadMarkdown(markdown);\n  } catch (error) {\n    console.error('[Gemini Voyager] Error handling download:', error);\n  }\n}\n\n/**\n * Create menu button matching Material Design style\n */\nfunction createMenuButtonFallback({\n  text,\n  tooltip,\n  className,\n  iconName,\n  onClick,\n}: {\n  text: string;\n  tooltip: string;\n  className: string;\n  iconName: string;\n  onClick: () => void;\n}): HTMLButtonElement {\n  const button = document.createElement('button');\n  button.className = `mat-mdc-menu-item mat-focus-indicator menu-item-button ${className}`;\n  button.setAttribute('mat-menu-item', '');\n  button.setAttribute('role', 'menuitem');\n  button.setAttribute('tabindex', '0');\n  button.setAttribute('aria-disabled', 'false');\n  button.setAttribute('aria-label', tooltip);\n  button.title = tooltip;\n\n  // Create icon\n  const icon = document.createElement('mat-icon');\n  icon.className =\n    'mat-icon notranslate menu-icon google-symbols mat-ligature-font mat-icon-no-color';\n  icon.setAttribute('role', 'img');\n  icon.setAttribute('fonticon', iconName);\n  icon.setAttribute('aria-hidden', 'true');\n  icon.textContent = '';\n\n  // Create text span\n  const span = document.createElement('span');\n  span.className = 'mat-mdc-menu-item-text';\n  span.textContent = text;\n\n  // Create ripple effect\n  const ripple = document.createElement('div');\n  ripple.className = 'mat-ripple mat-mdc-menu-ripple';\n  ripple.setAttribute('matripple', '');\n\n  button.appendChild(icon);\n  button.appendChild(span);\n  button.appendChild(ripple);\n\n  // Add click handler\n  button.addEventListener('click', (event) => {\n    event.preventDefault();\n    event.stopPropagation();\n    onClick();\n  });\n\n  return button;\n}\n\nfunction createMenuButton({\n  text,\n  tooltip,\n  className,\n  iconName,\n  onClick,\n  menuContent,\n}: {\n  text: string;\n  tooltip: string;\n  className: string;\n  iconName: string;\n  onClick: () => void;\n  menuContent: HTMLElement;\n}): HTMLButtonElement {\n  const button =\n    createMenuItemFromNativeTemplate({\n      menuContent,\n      injectedClassName: className,\n      iconName,\n      label: text,\n      tooltip,\n      excludedClassNames: TEMPLATE_EXCLUDED_CLASS_NAMES,\n    }) ??\n    createMenuButtonFallback({\n      text,\n      tooltip,\n      className,\n      iconName,\n      onClick: () => {},\n    });\n\n  button.addEventListener('click', (event) => {\n    event.preventDefault();\n    event.stopPropagation();\n    onClick();\n  });\n\n  return button;\n}\n\nfunction createDownloadButton(\n  text: string,\n  tooltip: string,\n  menuContent: HTMLElement,\n): HTMLButtonElement {\n  return createMenuButton({\n    text,\n    tooltip,\n    className: DOWNLOAD_BUTTON_CLASS,\n    iconName: 'download',\n    onClick: () => void handleDownload(),\n    menuContent,\n  });\n}\n\nfunction sanitizeFilenamePart(value: string): string {\n  const cleaned = value\n    .trim()\n    .replace(/[\\\\/:*?\"<>|]/g, '')\n    .replace(/\\s+/g, '-')\n    .replace(/\\.+$/g, '')\n    .slice(0, 80);\n  return cleaned || 'deep-research-report';\n}\n\nfunction buildReportFilename(format: ExportFormat, title: string): string {\n  const base = sanitizeFilenamePart(title || 'deep-research-report');\n  if (format === 'json') return `${base}.json`;\n  if (format === 'markdown') return `${base}.md`;\n  if (format === 'pdf') return `${base}.pdf`;\n  return `${base}.png`;\n}\n\nexport function showDeepResearchExportProgressOverlay(\n  t: (key: TranslationKey) => string,\n): () => void {\n  const overlay = document.createElement('div');\n  overlay.className = 'gv-export-progress-overlay';\n\n  const card = document.createElement('div');\n  card.className = 'gv-export-progress-card';\n\n  const spinner = document.createElement('div');\n  spinner.className = 'gv-export-progress-spinner';\n\n  const title = document.createElement('div');\n  title.className = 'gv-export-progress-title';\n  title.textContent = `${t('pm_export')}...`;\n\n  const desc = document.createElement('div');\n  desc.className = 'gv-export-progress-desc';\n  desc.textContent = t('loading');\n\n  card.appendChild(spinner);\n  card.appendChild(title);\n  card.appendChild(desc);\n  overlay.appendChild(card);\n  document.body.appendChild(overlay);\n\n  return () => {\n    try {\n      overlay.remove();\n    } catch {}\n  };\n}\n\nfunction handleSaveReport(dict: Dictionaries, lang: AppLanguage): void {\n  const reportRoot = findDeepResearchReportRoot();\n  if (!reportRoot) {\n    console.warn('[Gemini Voyager] Report content root not found');\n    return;\n  }\n\n  const reportTitle = extractDeepResearchReportTitle(reportRoot);\n  const metadata: ConversationMetadata = {\n    url: location.href,\n    exportedAt: new Date().toISOString(),\n    count: 1,\n    title: reportTitle,\n  };\n\n  const turns: ExportChatTurn[] = [\n    {\n      user: '',\n      assistant: '',\n      starred: false,\n      omitEmptySections: true,\n      assistantElement: reportRoot,\n    },\n  ];\n\n  const t = (key: TranslationKey) => dict[lang]?.[key] ?? dict.en?.[key] ?? key;\n  const dialog = new ExportDialog();\n  dialog.show({\n    onExport: async (format) => {\n      const hideProgress = showDeepResearchExportProgressOverlay(t);\n      try {\n        const filename = buildReportFilename(format, reportTitle);\n        const resultPromise = ConversationExportService.export(turns, metadata, {\n          format,\n          filename,\n          layout: 'document',\n        });\n        const minVisiblePromise = new Promise((resolve) => setTimeout(resolve, 420));\n        const [result] = await Promise.all([resultPromise, minVisiblePromise]);\n        if (!result.success) {\n          alert(resolveExportErrorMessage(result.error, t));\n        } else if (format === 'pdf' && isSafari()) {\n          showExportToast(t('export_toast_safari_pdf_ready'), { autoDismissMs: 5000 });\n        }\n      } catch (error) {\n        console.error('[Gemini Voyager] Report export error:', error);\n        alert('Export error occurred.');\n      } finally {\n        hideProgress();\n      }\n    },\n    onCancel: () => {},\n    translations: {\n      title: t('deepResearchSaveReport'),\n      selectFormat: t('export_dialog_select'),\n      warning: '',\n      safariCmdpHint: t('export_dialog_safari_cmdp_hint'),\n      safariMarkdownHint: t('export_dialog_safari_markdown_hint'),\n      cancel: t('pm_cancel'),\n      export: t('pm_export'),\n      fontSizeLabel: t('export_fontsize_label'),\n      fontSizePreview: t('export_fontsize_preview'),\n      formatDescriptions: {\n        json: t('export_format_json_description'),\n        markdown: t('export_format_markdown_description'),\n        pdf: t('export_format_pdf_description'),\n        image: t('export_format_image_description'),\n      },\n    },\n  });\n}\n\nfunction createSaveReportButton(\n  text: string,\n  tooltip: string,\n  dict: Dictionaries,\n  menuContent: HTMLElement,\n): HTMLButtonElement {\n  return createMenuButton({\n    text,\n    tooltip,\n    className: SAVE_REPORT_BUTTON_CLASS,\n    iconName: 'description',\n    onClick: () => {\n      void getLanguage().then((currentLanguage) => {\n        handleSaveReport(dict, currentLanguage);\n      });\n    },\n    menuContent,\n  });\n}\n\ntype StorageChange = { newValue?: unknown };\ntype StorageChanges = Record<string, StorageChange>;\n\ntype StorageOnChanged = {\n  addListener: (fn: (changes: StorageChanges, area: string) => void) => void;\n  removeListener: (fn: (changes: StorageChanges, area: string) => void) => void;\n};\n\ntype ExtensionStorage = {\n  onChanged?: StorageOnChanged;\n};\n\nfunction getExtensionStorage(): ExtensionStorage | null {\n  const w = window as unknown as {\n    chrome?: { storage?: ExtensionStorage };\n    browser?: { storage?: ExtensionStorage };\n  };\n  return w.chrome?.storage ?? w.browser?.storage ?? null;\n}\n\nexport function isDeepResearchReportMenuPanel(menuPanel: HTMLElement): boolean {\n  if (!menuPanel.matches('.mat-mdc-menu-panel[role=\"menu\"]')) return false;\n  const menuContent = menuPanel.querySelector('.mat-mdc-menu-content');\n  if (!(menuContent instanceof HTMLElement)) return false;\n\n  const hasShareContainer = Boolean(\n    menuContent.querySelector('[data-test-id=\"share-button-tooltip-container\"]'),\n  );\n  const hasReportExportActions = Boolean(\n    menuContent.querySelector('[data-test-id=\"export-to-docs-button\"]') ||\n      menuContent.querySelector('[data-test-id=\"copy-button\"]'),\n  );\n\n  return hasShareContainer && hasReportExportActions;\n}\n\n/**\n * Inject download button into menu\n */\nexport async function injectDownloadButton(targetMenuPanel?: HTMLElement): Promise<void> {\n  try {\n    // Load i18n\n    const dict = await loadDictionaries();\n    const lang = await getLanguage();\n    const t = (key: TranslationKey) => dict[lang]?.[key] ?? dict.en?.[key] ?? key;\n\n    const menuPanel = targetMenuPanel ?? (await waitForElement('.mat-mdc-menu-panel[role=\"menu\"]'));\n    if (!menuPanel) {\n      console.log('[Gemini Voyager] Menu panel not found');\n      return;\n    }\n    if (!(menuPanel instanceof HTMLElement)) return;\n    if (!menuPanel.isConnected) return;\n    if (!isDeepResearchReportMenuPanel(menuPanel)) return;\n\n    // Find the menu content container\n    const menuContent = menuPanel.querySelector('.mat-mdc-menu-content');\n    if (!menuContent) {\n      console.log('[Gemini Voyager] Menu content not found');\n      return;\n    }\n\n    let downloadButton = menuPanel.querySelector(\n      `.${DOWNLOAD_BUTTON_CLASS}`,\n    ) as HTMLButtonElement | null;\n    if (!downloadButton) {\n      downloadButton = createDownloadButton(\n        t('deepResearchDownload'),\n        t('deepResearchDownloadTooltip'),\n        menuContent as HTMLElement,\n      );\n      menuContent.appendChild(downloadButton);\n    }\n\n    let saveReportButton = menuPanel.querySelector(\n      `.${SAVE_REPORT_BUTTON_CLASS}`,\n    ) as HTMLButtonElement | null;\n    if (!saveReportButton) {\n      saveReportButton = createSaveReportButton(\n        t('deepResearchSaveReport'),\n        t('deepResearchSaveReportTooltip'),\n        dict,\n        menuContent as HTMLElement,\n      );\n      menuContent.appendChild(saveReportButton);\n    }\n\n    applyDeepResearchDownloadButtonI18n(downloadButton, dict, lang);\n    applyDeepResearchSaveReportButtonI18n(saveReportButton, dict, lang);\n\n    // Keep button text/tooltip in sync with runtime language changes\n    const storage = getExtensionStorage();\n    const onChanged = storage?.onChanged;\n    if (onChanged?.addListener && onChanged?.removeListener) {\n      let currentLang: AppLanguage = lang;\n      const handler = (changes: StorageChanges, area: string) => {\n        if (area !== 'sync') return;\n        const nextRaw = changes?.[StorageKeys.LANGUAGE]?.newValue;\n        if (typeof nextRaw !== 'string') return;\n        currentLang = normalizeLanguage(nextRaw);\n        applyDeepResearchDownloadButtonI18n(downloadButton, dict, currentLang);\n        applyDeepResearchSaveReportButtonI18n(saveReportButton, dict, currentLang);\n      };\n\n      onChanged.addListener(handler);\n\n      const cleanup = () => {\n        try {\n          onChanged.removeListener(handler);\n        } catch {}\n      };\n\n      const observer = new MutationObserver(() => {\n        if (typeof document === 'undefined') {\n          cleanup();\n          observer.disconnect();\n          return;\n        }\n        const downloadDetached = !document.contains(downloadButton);\n        const saveReportDetached = !document.contains(saveReportButton);\n        if (downloadDetached && saveReportDetached) {\n          cleanup();\n          observer.disconnect();\n        }\n      });\n\n      observer.observe(document.body, { childList: true, subtree: true });\n\n      window.addEventListener(\n        'beforeunload',\n        () => {\n          cleanup();\n          try {\n            observer.disconnect();\n          } catch {}\n        },\n        { once: true },\n      );\n    }\n\n    console.log('[Gemini Voyager] Deep Research menu buttons injected successfully');\n  } catch (error) {\n    console.error('[Gemini Voyager] Error injecting Deep Research menu buttons:', error);\n  }\n}\n"
  },
  {
    "path": "src/pages/content/deepResearch/reportExtractor.ts",
    "content": "/**\n * Deep Research report extraction (canvas right-side report body)\n *\n * Goal: find the primary rendered report content while excluding Thinking panels.\n */\n\nconst GENERIC_TITLES = new Set([\n  'Gemini',\n  'Google Gemini',\n  'Google AI Studio',\n  'New chat',\n  'Deep Research',\n]);\n\nfunction isMeaningfulTitle(title: string): boolean {\n  const t = title.trim();\n  if (!t) return false;\n  if (GENERIC_TITLES.has(t)) return false;\n  if (t.startsWith('Gemini -') || t.startsWith('Google AI Studio -')) return false;\n  return true;\n}\n\nfunction scoreCandidate(el: HTMLElement): number {\n  const text = (el.textContent || '').trim();\n  // Favor content length; short containers are usually menu labels or side UI.\n  return text.length;\n}\n\n/**\n * Find the best report root element within the Deep Research immersive panel.\n *\n * Heuristic: choose the largest markdown-like container that is not inside `thinking-panel`.\n */\nexport function findDeepResearchReportRoot(): HTMLElement | null {\n  const panel = document.querySelector('deep-research-immersive-panel');\n  if (!panel) return null;\n\n  const candidates = Array.from(\n    panel.querySelectorAll<HTMLElement>('.markdown, .markdown-main-panel, message-content'),\n  ).filter((el) => !el.closest('thinking-panel'));\n\n  let best: { el: HTMLElement; score: number } | null = null;\n  for (const el of candidates) {\n    const score = scoreCandidate(el);\n    if (score === 0) continue;\n    if (!best || score > best.score) {\n      best = { el, score };\n    }\n  }\n\n  return best?.el ?? null;\n}\n\n/**\n * Extract a human-readable title for the report.\n */\nexport function extractDeepResearchReportTitle(reportRoot: HTMLElement): string {\n  // Prefer the first heading in the report content.\n  const heading =\n    reportRoot.querySelector('h1') ||\n    reportRoot.querySelector('h2') ||\n    reportRoot.querySelector('[role=\"heading\"]');\n\n  const headingText = heading?.textContent?.trim() || '';\n  if (isMeaningfulTitle(headingText)) return headingText;\n\n  const docTitle = document.title || '';\n  if (isMeaningfulTitle(docTitle)) return docTitle.trim();\n\n  return 'Deep Research Report';\n}\n"
  },
  {
    "path": "src/pages/content/deepResearch/types.ts",
    "content": "/**\n * Type definitions for Deep Research thinking content extraction\n */\n\nexport interface ThoughtItem {\n  type: 'thought';\n  header: string;\n  content: string;\n}\n\nexport interface BrowseChip {\n  url: string;\n  domain: string;\n  title: string;\n}\n\nexport interface BrowseChipGroup {\n  type: 'browse-chips';\n  chips: BrowseChip[];\n}\n\nexport type ThinkingItem = ThoughtItem | BrowseChipGroup;\n\nexport interface ThinkingSection {\n  items: ThinkingItem[]; // Ordered mix of thoughts and browse chips\n}\n\nexport interface ThinkingContent {\n  sections: ThinkingSection[];\n  exportedAt: string;\n  title: string;\n}\n"
  },
  {
    "path": "src/pages/content/defaultModel/__tests__/modelLocker.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\ndescribe('DefaultModelManager (default model locker)', () => {\n  let destroyManager: (() => void) | null = null;\n\n  beforeEach(() => {\n    vi.useFakeTimers();\n    vi.resetModules();\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_keys: unknown, callback: (items: Record<string, unknown>) => void) => {\n        callback({});\n      },\n    );\n\n    (chrome.storage.sync.set as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_items: unknown, callback: () => void) => {\n        callback();\n      },\n    );\n\n    (chrome.storage.sync.remove as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_keys: unknown, callback: () => void) => {\n        callback();\n      },\n    );\n\n    (\n      chrome as unknown as {\n        i18n?: { getMessage: (key: string, substitutions?: string[]) => string };\n      }\n    ).i18n = {\n      getMessage: (key: string, substitutions?: string[]) =>\n        substitutions?.length ? `${key}:${substitutions.join(',')}` : key,\n    };\n\n    document.body.innerHTML = '';\n    history.replaceState({}, '', '/');\n  });\n\n  afterEach(() => {\n    destroyManager?.();\n    destroyManager = null;\n\n    vi.runOnlyPendingTimers();\n    vi.useRealTimers();\n    vi.restoreAllMocks();\n    document.body.innerHTML = '';\n  });\n\n  it('does not query the whole document for menu panel on unrelated DOM mutations', async () => {\n    const querySelectorSpy = vi.spyOn(document, 'querySelector');\n\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    // Trigger a burst of DOM mutations that are unrelated to the menu panel.\n    for (let i = 0; i < 50; i++) {\n      const div = document.createElement('div');\n      div.textContent = `node-${i}`;\n      document.body.appendChild(div);\n    }\n\n    await Promise.resolve(); // flush MutationObserver microtasks\n    await vi.advanceTimersByTimeAsync(100); // Use a finite time advance to avoid infinite setInterval loop\n\n    const selectors = querySelectorSpy.mock.calls.map((call) => call[0]);\n    expect(selectors).not.toContain('.mat-mdc-menu-panel');\n    expect(selectors).not.toContain('.mat-mdc-menu-panel[role=\"menu\"]');\n  });\n\n  it('injects star buttons even when menu items render after the panel is added', async () => {\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    const menuPanel = document.createElement('div');\n    menuPanel.className = 'mat-mdc-menu-panel';\n    menuPanel.setAttribute('role', 'menu');\n    document.body.appendChild(menuPanel);\n\n    await Promise.resolve(); // observer sees panel\n    await vi.advanceTimersByTimeAsync(60); // initial delayed injection attempt\n\n    // Render menu item after panel exists (common in Gemini).\n    const item = document.createElement('div');\n    item.setAttribute('role', 'menuitemradio');\n    item.innerHTML = `\n      <div class=\"title-and-description\">\n        <div class=\"mode-title\">Model A</div>\n      </div>\n    `;\n    menuPanel.appendChild(item);\n\n    await Promise.resolve();\n    await vi.advanceTimersByTimeAsync(500); // Use a finite time advance to avoid infinite setInterval loop\n\n    expect(item.querySelector('.gv-default-star-btn')).not.toBeNull();\n  });\n\n  it('injects star buttons into compact bottom-sheet mode switch list', async () => {\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    const mobileList = document.createElement('mat-action-list');\n    mobileList.className = 'gds-mode-switch-menu-list';\n    mobileList.setAttribute('role', 'group');\n\n    const item = document.createElement('button');\n    item.setAttribute('role', 'menuitemradio');\n    item.innerHTML = `\n      <div class=\"title-and-description\">\n        <div>\n          <span class=\"gds-title-m\">Pro</span>\n          <span class=\"gds-body-m\">Advanced math and code</span>\n        </div>\n      </div>\n    `;\n    mobileList.appendChild(item);\n    document.body.appendChild(mobileList);\n\n    await Promise.resolve();\n    await vi.advanceTimersByTimeAsync(200);\n\n    expect(item.querySelector('.gv-default-star-btn')).not.toBeNull();\n  });\n\n  it('injects star buttons when menu items use role=\"menuitem\" instead of \"menuitemradio\"', async () => {\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    const menuPanel = document.createElement('div');\n    menuPanel.className = 'mat-mdc-menu-panel gds-mode-switch-menu';\n    menuPanel.setAttribute('role', 'menu');\n\n    const item = document.createElement('button');\n    item.setAttribute('role', 'menuitem');\n    item.setAttribute('data-mode-id', 'e051ce1aa80aa576');\n    item.classList.add('bard-mode-list-button');\n    item.innerHTML = `\n      <span class=\"mat-mdc-menu-item-text\">\n        <div class=\"title-and-check\">\n          <div class=\"title-and-description\">\n            <div>\n              <span class=\"gds-label-l\">思考</span>\n              <span class=\"mode-desc gds-body-s\">解决复杂问题</span>\n            </div>\n          </div>\n        </div>\n      </span>\n    `;\n    menuPanel.appendChild(item);\n    document.body.appendChild(menuPanel);\n\n    await Promise.resolve();\n    await vi.advanceTimersByTimeAsync(200);\n\n    expect(item.querySelector('.gv-default-star-btn')).not.toBeNull();\n  });\n\n  it('auto-locks model when menu uses role=\"menuitem\" variant', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_keys: unknown, callback: (items: Record<string, unknown>) => void) => {\n        callback({\n          gvDefaultModel: {\n            id: 'e051ce1aa80aa576',\n            name: 'Thinking',\n          },\n        });\n      },\n    );\n\n    history.replaceState({}, '', '/u/0/app?hl=zh');\n\n    const selectorBtn = document.createElement('button');\n    selectorBtn.className = 'input-area-switch-label';\n    selectorBtn.textContent = '快速';\n    selectorBtn.click = vi.fn();\n    document.body.appendChild(selectorBtn);\n\n    const menuPanel = document.createElement('div');\n    menuPanel.className = 'mat-mdc-menu-panel gds-mode-switch-menu';\n    menuPanel.setAttribute('role', 'menu');\n\n    const fastItem = document.createElement('button');\n    fastItem.setAttribute('role', 'menuitem');\n    fastItem.setAttribute('data-mode-id', '56fdd199312815e2');\n    fastItem.classList.add('bard-mode-list-button', 'is-selected');\n    fastItem.innerHTML = `\n      <span class=\"mat-mdc-menu-item-text\">\n        <div class=\"title-and-description\">\n          <div><span class=\"gds-label-l\">快速</span></div>\n        </div>\n      </span>\n    `;\n    fastItem.click = vi.fn();\n\n    const thinkingItem = document.createElement('button');\n    thinkingItem.setAttribute('role', 'menuitem');\n    thinkingItem.setAttribute('data-mode-id', 'e051ce1aa80aa576');\n    thinkingItem.classList.add('bard-mode-list-button');\n    thinkingItem.innerHTML = `\n      <span class=\"mat-mdc-menu-item-text\">\n        <div class=\"title-and-description\">\n          <div><span class=\"gds-label-l\">思考</span></div>\n        </div>\n      </span>\n    `;\n    thinkingItem.click = vi.fn();\n\n    menuPanel.appendChild(fastItem);\n    menuPanel.appendChild(thinkingItem);\n    document.body.appendChild(menuPanel);\n\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    await vi.advanceTimersByTimeAsync(1000);\n    await vi.advanceTimersByTimeAsync(500);\n\n    expect(thinkingItem.click).toHaveBeenCalledTimes(1);\n    expect(fastItem.click).toHaveBeenCalledTimes(0);\n  });\n\n  it('locks to Pro without matching \"pro\" inside \"problems\" (Thinking description)', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_keys: unknown, callback: (items: Record<string, unknown>) => void) => {\n        callback({ gvDefaultModel: 'Pro' });\n      },\n    );\n\n    history.replaceState({}, '', '/u/0/app?hl=zh&pageId=none');\n\n    const selectorBtn = document.createElement('button');\n    selectorBtn.className = 'input-area-switch-label';\n    selectorBtn.textContent = 'Thinking';\n    selectorBtn.click = vi.fn();\n    document.body.appendChild(selectorBtn);\n\n    const menuPanel = document.createElement('div');\n    menuPanel.className = 'mat-mdc-menu-panel';\n    menuPanel.setAttribute('role', 'menu');\n\n    const thinkingItem = document.createElement('button');\n    thinkingItem.setAttribute('role', 'menuitemradio');\n    thinkingItem.innerHTML = `\n      <div class=\"title-and-description\">\n        <div class=\"mode-title\">Thinking</div>\n      </div>\n      <span class=\"mode-desc\">Solves complex problems</span>\n    `;\n    thinkingItem.click = vi.fn();\n\n    const proItem = document.createElement('button');\n    proItem.setAttribute('role', 'menuitemradio');\n    proItem.innerHTML = `\n      <div class=\"title-and-description\">\n        <div class=\"mode-title\">Pro</div>\n      </div>\n      <span class=\"mode-desc\">Thinks longer for advanced math &amp; code</span>\n    `;\n    proItem.click = vi.fn();\n\n    menuPanel.appendChild(thinkingItem);\n    menuPanel.appendChild(proItem);\n    document.body.appendChild(menuPanel);\n\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    // Wait for the first interval tick (1s) and then the menu handling delay (500ms).\n    await vi.advanceTimersByTimeAsync(1000);\n    await vi.advanceTimersByTimeAsync(500);\n\n    expect(proItem.click).toHaveBeenCalledTimes(1);\n    expect(thinkingItem.click).toHaveBeenCalledTimes(0);\n  });\n\n  it('locks by data-mode-id so it works across languages (e.g. Japanese titles)', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_keys: unknown, callback: (items: Record<string, unknown>) => void) => {\n        callback({\n          gvDefaultModel: {\n            id: 'e051ce1aa80aa576',\n            name: 'Thinking',\n          },\n        });\n      },\n    );\n\n    history.replaceState({}, '', '/u/1/app?hl=zh&pageId=none');\n\n    const selectorBtn = document.createElement('button');\n    selectorBtn.className = 'input-area-switch-label';\n    selectorBtn.textContent = 'Pro';\n    selectorBtn.click = vi.fn();\n    document.body.appendChild(selectorBtn);\n\n    const menuPanel = document.createElement('div');\n    menuPanel.className = 'mat-mdc-menu-panel';\n    menuPanel.setAttribute('role', 'menu');\n\n    const fastItem = document.createElement('button');\n    fastItem.setAttribute('role', 'menuitemradio');\n    fastItem.setAttribute('data-mode-id', '56fdd199312815e2');\n    fastItem.innerHTML = `\n      <div class=\"title-and-description\">\n        <div class=\"mode-title\">高速モード</div>\n      </div>\n    `;\n    fastItem.click = vi.fn();\n\n    const thinkingItem = document.createElement('button');\n    thinkingItem.setAttribute('role', 'menuitemradio');\n    thinkingItem.setAttribute('data-mode-id', 'e051ce1aa80aa576');\n    thinkingItem.setAttribute('aria-checked', 'false');\n    thinkingItem.innerHTML = `\n      <div class=\"title-and-description\">\n        <div class=\"mode-title\">思考モード</div>\n      </div>\n      <span class=\"mode-desc\">複雑な問題を解決</span>\n    `;\n    thinkingItem.click = vi.fn();\n\n    const proItem = document.createElement('button');\n    proItem.setAttribute('role', 'menuitemradio');\n    proItem.setAttribute('data-mode-id', 'e6fa609c3fa255c0');\n    proItem.innerHTML = `\n      <div class=\"title-and-description\">\n        <div class=\"mode-title\">Pro</div>\n      </div>\n    `;\n    proItem.click = vi.fn();\n\n    menuPanel.appendChild(fastItem);\n    menuPanel.appendChild(thinkingItem);\n    menuPanel.appendChild(proItem);\n    document.body.appendChild(menuPanel);\n\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    await vi.advanceTimersByTimeAsync(1000);\n    await vi.advanceTimersByTimeAsync(500);\n\n    expect(thinkingItem.click).toHaveBeenCalledTimes(1);\n    expect(proItem.click).toHaveBeenCalledTimes(0);\n  });\n\n  it('locks by id in compact bottom-sheet layout using jslog metadata fallback', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_keys: unknown, callback: (items: Record<string, unknown>) => void) => {\n        callback({\n          gvDefaultModel: {\n            id: 'e051ce1aa80aa576',\n            name: 'Thinking',\n          },\n        });\n      },\n    );\n\n    history.replaceState({}, '', '/u/0/app?hl=zh');\n\n    const selectorBtn = document.createElement('button');\n    selectorBtn.className = 'input-area-switch-label';\n    selectorBtn.textContent = '快速';\n    selectorBtn.click = vi.fn();\n    document.body.appendChild(selectorBtn);\n\n    const mobileList = document.createElement('mat-action-list');\n    mobileList.className = 'gds-mode-switch-menu-list';\n    mobileList.setAttribute('role', 'group');\n\n    const fastItem = document.createElement('button');\n    fastItem.setAttribute('role', 'menuitemradio');\n    fastItem.setAttribute(\n      'jslog',\n      '242569;track:generic_click;BardVeMetadataKey:[null,null,null,null,[\"56fdd199312815e2\"]]',\n    );\n    fastItem.innerHTML = `\n      <div class=\"title-and-description\">\n        <div>\n          <span class=\"gds-title-m\">快速</span>\n          <span class=\"gds-body-m\">快速回答</span>\n        </div>\n      </div>\n    `;\n    fastItem.click = vi.fn();\n\n    const thinkingItem = document.createElement('button');\n    thinkingItem.setAttribute('role', 'menuitemradio');\n    thinkingItem.setAttribute('aria-checked', 'false');\n    thinkingItem.setAttribute(\n      'jslog',\n      '242569;track:generic_click;BardVeMetadataKey:[null,null,null,null,[\"e051ce1aa80aa576\"]]',\n    );\n    thinkingItem.innerHTML = `\n      <div class=\"title-and-description\">\n        <div>\n          <span class=\"gds-title-m\">思考</span>\n          <span class=\"gds-body-m\">解决复杂问题</span>\n        </div>\n      </div>\n    `;\n    thinkingItem.click = vi.fn();\n\n    const proItem = document.createElement('button');\n    proItem.setAttribute('role', 'menuitemradio');\n    proItem.setAttribute(\n      'jslog',\n      '242569;track:generic_click;BardVeMetadataKey:[null,null,null,null,[\"e6fa609c3fa255c0\"]]',\n    );\n    proItem.innerHTML = `\n      <div class=\"title-and-description\">\n        <div>\n          <span class=\"gds-title-m\">Pro</span>\n          <span class=\"gds-body-m\">使用 3.1 Pro 处理高阶数学和代码任务</span>\n        </div>\n      </div>\n    `;\n    proItem.click = vi.fn();\n\n    mobileList.appendChild(fastItem);\n    mobileList.appendChild(thinkingItem);\n    mobileList.appendChild(proItem);\n    document.body.appendChild(mobileList);\n\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    await vi.advanceTimersByTimeAsync(1000);\n    await vi.advanceTimersByTimeAsync(500);\n\n    expect(thinkingItem.click).toHaveBeenCalledTimes(1);\n    expect(proItem.click).toHaveBeenCalledTimes(0);\n  });\n\n  it('focuses chat input after auto-switching model', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_keys: unknown, callback: (items: Record<string, unknown>) => void) => {\n        callback({ gvDefaultModel: 'Pro' });\n      },\n    );\n\n    history.replaceState({}, '', '/u/0/app?hl=en');\n\n    const selectorBtn = document.createElement('button');\n    selectorBtn.className = 'input-area-switch-label';\n    selectorBtn.textContent = 'Flash';\n    selectorBtn.click = vi.fn();\n    document.body.appendChild(selectorBtn);\n\n    const menuPanel = document.createElement('div');\n    menuPanel.className = 'mat-mdc-menu-panel';\n    menuPanel.setAttribute('role', 'menu');\n\n    const flashItem = document.createElement('button');\n    flashItem.setAttribute('role', 'menuitemradio');\n    flashItem.innerHTML = `\n      <div class=\"title-and-description\">\n        <div class=\"mode-title\">Flash</div>\n      </div>\n    `;\n    flashItem.click = vi.fn();\n\n    const proItem = document.createElement('button');\n    proItem.setAttribute('role', 'menuitemradio');\n    proItem.innerHTML = `\n      <div class=\"title-and-description\">\n        <div class=\"mode-title\">Pro</div>\n      </div>\n    `;\n    proItem.click = vi.fn();\n\n    menuPanel.appendChild(flashItem);\n    menuPanel.appendChild(proItem);\n    document.body.appendChild(menuPanel);\n\n    const main = document.createElement('main');\n    const richTextarea = document.createElement('rich-textarea');\n    const input = document.createElement('div');\n    input.setAttribute('contenteditable', 'true');\n    input.setAttribute('role', 'textbox');\n    const focusSpy = vi.spyOn(input, 'focus').mockImplementation(() => {});\n    richTextarea.appendChild(input);\n    main.appendChild(richTextarea);\n    document.body.appendChild(main);\n\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    await vi.advanceTimersByTimeAsync(1500);\n\n    expect(proItem.click).toHaveBeenCalledTimes(1);\n    expect(focusSpy).toHaveBeenCalled();\n  });\n\n  it('does not focus chat input when target model is already selected', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_keys: unknown, callback: (items: Record<string, unknown>) => void) => {\n        callback({ gvDefaultModel: 'Pro' });\n      },\n    );\n\n    history.replaceState({}, '', '/u/0/app?hl=en');\n\n    const selectorBtn = document.createElement('button');\n    selectorBtn.className = 'input-area-switch-label';\n    selectorBtn.textContent = 'Flash';\n    selectorBtn.click = vi.fn();\n    document.body.appendChild(selectorBtn);\n\n    const menuPanel = document.createElement('div');\n    menuPanel.className = 'mat-mdc-menu-panel';\n    menuPanel.setAttribute('role', 'menu');\n\n    const proItem = document.createElement('button');\n    proItem.setAttribute('role', 'menuitemradio');\n    proItem.setAttribute('aria-checked', 'true');\n    proItem.innerHTML = `\n      <div class=\"title-and-description\">\n        <div class=\"mode-title\">Pro</div>\n      </div>\n    `;\n    proItem.click = vi.fn();\n\n    menuPanel.appendChild(proItem);\n    document.body.appendChild(menuPanel);\n\n    const main = document.createElement('main');\n    const richTextarea = document.createElement('rich-textarea');\n    const input = document.createElement('div');\n    input.setAttribute('contenteditable', 'true');\n    input.setAttribute('role', 'textbox');\n    const focusSpy = vi.spyOn(input, 'focus').mockImplementation(() => {});\n    richTextarea.appendChild(input);\n    main.appendChild(richTextarea);\n    document.body.appendChild(main);\n\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    await vi.advanceTimersByTimeAsync(1500);\n\n    expect(proItem.click).toHaveBeenCalledTimes(0);\n    expect(focusSpy).not.toHaveBeenCalled();\n  });\n\n  it('skips auto-selection when default model is Flash (Gemini default)', async () => {\n    // Set default model to Flash (by ID)\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_keys: unknown, callback: (items: Record<string, unknown>) => void) => {\n        callback({\n          gvDefaultModel: {\n            id: '56fdd199312815e2', // Flash model ID\n            name: 'Flash',\n          },\n        });\n      },\n    );\n\n    history.replaceState({}, '', '/u/0/app?hl=en');\n\n    const selectorBtn = document.createElement('button');\n    selectorBtn.className = 'input-area-switch-label';\n    selectorBtn.textContent = 'Flash';\n    selectorBtn.click = vi.fn();\n    document.body.appendChild(selectorBtn);\n\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    // Wait for the interval tick and menu handling delay\n    await vi.advanceTimersByTimeAsync(1500);\n\n    // Since Flash is the default model, no click should be triggered\n    expect(selectorBtn.click).toHaveBeenCalledTimes(0);\n  });\n\n  it('skips auto-selection when default model name contains \"flash\" (case insensitive)', async () => {\n    // Set default model to Flash (by name)\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_keys: unknown, callback: (items: Record<string, unknown>) => void) => {\n        callback({ gvDefaultModel: '2.0 Flash' });\n      },\n    );\n\n    history.replaceState({}, '', '/app');\n\n    const selectorBtn = document.createElement('button');\n    selectorBtn.className = 'input-area-switch-label';\n    selectorBtn.textContent = 'Flash';\n    selectorBtn.click = vi.fn();\n    document.body.appendChild(selectorBtn);\n\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    // Wait for the interval tick and menu handling delay\n    await vi.advanceTimersByTimeAsync(1500);\n\n    // Since Flash is the default model, no click should be triggered\n    expect(selectorBtn.click).toHaveBeenCalledTimes(0);\n  });\n\n  it('does not inject star buttons into the settings menu (desktop-settings-menu)', async () => {\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    // Simulate the Gemini settings/profile dropdown (has class desktop-settings-menu)\n    const settingsMenu = document.createElement('div');\n    settingsMenu.className = 'mat-mdc-menu-panel collapsed desktop-settings-menu ia-redesign';\n    settingsMenu.setAttribute('role', 'menu');\n\n    const settingsItem = document.createElement('a');\n    settingsItem.setAttribute('role', 'menuitem');\n    settingsItem.innerHTML = `\n      <span class=\"mat-mdc-menu-item-text\">\n        <div class=\"menu-entry-with-badge\">\n          <span class=\"gds-label-l\">个人使用场景</span>\n        </div>\n      </span>\n    `;\n    settingsMenu.appendChild(settingsItem);\n\n    const themeItem = document.createElement('button');\n    themeItem.setAttribute('role', 'menuitem');\n    themeItem.innerHTML = `\n      <span class=\"mat-mdc-menu-item-text\">\n        <span class=\"gds-label-l\">主题</span>\n      </span>\n    `;\n    settingsMenu.appendChild(themeItem);\n\n    document.body.appendChild(settingsMenu);\n\n    await Promise.resolve();\n    await vi.advanceTimersByTimeAsync(500);\n\n    // Star buttons should NOT be injected into settings menu items\n    expect(settingsItem.querySelector('.gv-default-star-btn')).toBeNull();\n    expect(themeItem.querySelector('.gv-default-star-btn')).toBeNull();\n  });\n\n  it('does not inject star buttons into the theme submenu (menuitemradio without model markers)', async () => {\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    // Simulate the Gemini theme picker submenu (has menuitemradio but no model markers)\n    const themeMenu = document.createElement('div');\n    themeMenu.className = 'mat-mdc-menu-panel';\n    themeMenu.setAttribute('role', 'menu');\n\n    const systemItem = document.createElement('button');\n    systemItem.setAttribute('role', 'menuitemradio');\n    systemItem.setAttribute('aria-checked', 'false');\n    systemItem.innerHTML = `\n      <span class=\"mat-mdc-menu-item-text\">\n        <span class=\"menu-item-title-with-trailing-component\">\n          <span class=\"gds-label-l\">系统</span>\n        </span>\n      </span>\n    `;\n    themeMenu.appendChild(systemItem);\n\n    const darkItem = document.createElement('button');\n    darkItem.setAttribute('role', 'menuitemradio');\n    darkItem.setAttribute('aria-checked', 'true');\n    darkItem.innerHTML = `\n      <span class=\"mat-mdc-menu-item-text\">\n        <span class=\"menu-item-title-with-trailing-component\">\n          <span class=\"gds-label-l\">深色</span>\n        </span>\n      </span>\n    `;\n    themeMenu.appendChild(darkItem);\n\n    document.body.appendChild(themeMenu);\n\n    await Promise.resolve();\n    await vi.advanceTimersByTimeAsync(500);\n\n    // Star buttons should NOT be injected into theme menu items\n    expect(systemItem.querySelector('.gv-default-star-btn')).toBeNull();\n    expect(darkItem.querySelector('.gv-default-star-btn')).toBeNull();\n  });\n\n  it('stops retrying after consecutive failures when target model is not found', async () => {\n    // Set default model to a model that won't be found\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_keys: unknown, callback: (items: Record<string, unknown>) => void) => {\n        callback({\n          gvDefaultModel: {\n            id: 'nonexistent-model-id',\n            name: 'Nonexistent Model',\n          },\n        });\n      },\n    );\n\n    history.replaceState({}, '', '/u/2/app?hl=zh');\n\n    const selectorBtn = document.createElement('button');\n    selectorBtn.className = 'input-area-switch-label';\n    selectorBtn.textContent = 'Flash'; // Current model is Flash, not the target\n    selectorBtn.click = vi.fn();\n    document.body.appendChild(selectorBtn);\n\n    // Create menu panel with items that don't include the target model\n    const menuPanel = document.createElement('div');\n    menuPanel.className = 'mat-mdc-menu-panel';\n    menuPanel.setAttribute('role', 'menu');\n\n    const flashItem = document.createElement('button');\n    flashItem.setAttribute('role', 'menuitemradio');\n    flashItem.setAttribute('data-mode-id', '56fdd199312815e2');\n    flashItem.innerHTML = `\n      <div class=\"title-and-description\">\n        <div class=\"mode-title\">Flash</div>\n      </div>\n    `;\n    flashItem.click = vi.fn();\n\n    menuPanel.appendChild(flashItem);\n    document.body.appendChild(menuPanel);\n\n    const { default: DefaultModelManager } = await import('../modelLocker');\n    await DefaultModelManager.getInstance().init();\n    destroyManager = () => DefaultModelManager.getInstance().destroy();\n\n    // Advance timers for 3 retry attempts (1 second each) + initial delay\n    // Each attempt should open the menu and fail to find the target\n    await vi.advanceTimersByTimeAsync(4000);\n\n    // The selector button should have been clicked at most 3 times (maxConsecutiveFailures)\n    // because after 3 consecutive failures, it should stop retrying\n    expect((selectorBtn.click as ReturnType<typeof vi.fn>).mock.calls.length).toBeLessThanOrEqual(\n      3,\n    );\n  });\n});\n"
  },
  {
    "path": "src/pages/content/defaultModel/modelLocker.ts",
    "content": "import { storageService } from '../../../core/services/StorageService';\nimport { StorageKeys } from '../../../core/types/common';\nimport './styles.css';\n\ntype DefaultModelSetting =\n  | { kind: 'id'; id: string; name: string }\n  | { kind: 'name'; name: string };\n\ntype StoredDefaultModelSetting = { id: string; name: string };\n\n// Known Flash/Fast model IDs that should skip auto-selection (page defaults to these)\nconst FAST_MODEL_IDS = new Set([\n  '56fdd199312815e2', // Gemini 2.0 Flash\n]);\n\n// Known Flash/Fast model name patterns (case-insensitive)\nconst FAST_MODEL_NAMES = ['flash', '2.0 flash', 'gemini 2.0 flash', 'fast', '高速', '高速モード'];\n\n// Gemini may use either role=\"menuitemradio\" or role=\"menuitem\" depending on the UI variant.\nconst MODE_ITEM_SELECTOR = '[role=\"menuitemradio\"], [role=\"menuitem\"]';\n\n// Fallback selector that excludes known non-model menus (e.g. the settings/profile dropdown).\nconst NON_MODEL_MENU_EXCLUSION_FALLBACK =\n  '.mat-mdc-menu-panel[role=\"menu\"]:not(.desktop-settings-menu)';\n\nconst CHAT_INPUT_SELECTORS = [\n  'main rich-textarea [contenteditable=\"true\"]',\n  'rich-textarea [contenteditable=\"true\"]',\n  'main div[contenteditable=\"true\"][role=\"textbox\"]',\n  'div[contenteditable=\"true\"][role=\"textbox\"]',\n  'main .input-area textarea',\n  '.input-area textarea',\n  'main [contenteditable=\"true\"]',\n  'main textarea',\n] as const;\n\nclass DefaultModelManager {\n  private static instance: DefaultModelManager;\n  private observer: MutationObserver | null = null;\n  private checkTimer: number | null = null;\n  private isLocked = false;\n  private currentDefaultModel: DefaultModelSetting | null = null;\n  private initialized = false;\n  private pendingMenuPanelInjections = new WeakSet<HTMLElement>();\n  private menuPanelInjectAttempts = new WeakMap<HTMLElement, number>();\n  private started = false;\n  private popStateHandler: (() => void) | null = null;\n  private originalPushState: History['pushState'] | null = null;\n  private originalReplaceState: History['replaceState'] | null = null;\n  private lastCheckedPath: string | null = null;\n  private sidebarClickHandler: ((e: Event) => void) | null = null;\n  private urlCheckTimer: number | null = null;\n  // Track if we've already auto-selected for this navigation to prevent duplicates\n  private autoSelectSessionId: string | null = null;\n  // Track consecutive failures to stop retrying when model is unavailable\n  private consecutiveFailures = 0;\n  private readonly maxConsecutiveFailures = 3;\n\n  private constructor() {}\n\n  public static getInstance(): DefaultModelManager {\n    if (!DefaultModelManager.instance) {\n      DefaultModelManager.instance = new DefaultModelManager();\n    }\n    return DefaultModelManager.instance;\n  }\n\n  public async init() {\n    if (this.started) return;\n    this.started = true;\n\n    // Initialize cache\n    const result = await storageService.get<unknown>(StorageKeys.DEFAULT_MODEL);\n    this.currentDefaultModel = result.success ? this.parseStoredDefaultModel(result.data) : null;\n    this.initialized = true;\n\n    this.initObserver();\n    void this.checkAndLockModel();\n    // Listen for URL changes (SPA navigation)\n    this.popStateHandler = () => {\n      void this.checkAndLockModelWithDelay();\n    };\n    window.addEventListener('popstate', this.popStateHandler);\n\n    // Hack for SPA: hook into history methods\n    if (!this.originalPushState) {\n      this.originalPushState = history.pushState;\n    }\n    if (!this.originalReplaceState) {\n      this.originalReplaceState = history.replaceState;\n    }\n\n    history.pushState = (...args: Parameters<History['pushState']>) => {\n      this.originalPushState?.apply(history, args);\n      void this.checkAndLockModelWithDelay();\n    };\n    history.replaceState = (...args: Parameters<History['replaceState']>) => {\n      this.originalReplaceState?.apply(history, args);\n      void this.checkAndLockModelWithDelay();\n    };\n\n    // Listen for sidebar \"New Chat\" link clicks (SPA internal navigation)\n    this.sidebarClickHandler = (e: Event) => {\n      const target = e.target as HTMLElement;\n      // Check if clicked on a link that leads to /app (new conversation) or /gem/ (new gem conversation)\n      const link = target.closest('a[href*=\"/app\"]') || target.closest('a[href*=\"/gem/\"]');\n      if (link) {\n        // Delay to allow SPA navigation to complete\n        void this.checkAndLockModelWithDelay();\n      }\n    };\n    document.addEventListener('click', this.sidebarClickHandler, true);\n\n    // Periodic URL check as a fallback for edge cases\n    this.urlCheckTimer = window.setInterval(() => {\n      const currentPath = window.location.pathname;\n      if (currentPath !== this.lastCheckedPath && this.isNewConversation()) {\n        this.lastCheckedPath = currentPath;\n        void this.checkAndLockModel();\n      }\n    }, 500);\n  }\n\n  public destroy(): void {\n    if (!this.started) return;\n    this.started = false;\n\n    if (this.observer) {\n      this.observer.disconnect();\n      this.observer = null;\n    }\n\n    if (this.checkTimer) {\n      clearInterval(this.checkTimer);\n      this.checkTimer = null;\n    }\n\n    if (this.urlCheckTimer) {\n      clearInterval(this.urlCheckTimer);\n      this.urlCheckTimer = null;\n    }\n\n    if (this.popStateHandler) {\n      window.removeEventListener('popstate', this.popStateHandler);\n      this.popStateHandler = null;\n    }\n\n    if (this.sidebarClickHandler) {\n      document.removeEventListener('click', this.sidebarClickHandler, true);\n      this.sidebarClickHandler = null;\n    }\n\n    if (this.originalPushState) {\n      history.pushState = this.originalPushState;\n      this.originalPushState = null;\n    }\n\n    if (this.originalReplaceState) {\n      history.replaceState = this.originalReplaceState;\n      this.originalReplaceState = null;\n    }\n\n    this.pendingMenuPanelInjections = new WeakSet<HTMLElement>();\n    this.menuPanelInjectAttempts = new WeakMap<HTMLElement, number>();\n  }\n\n  private initObserver() {\n    // Observe only for the mode switch panel/bottom-sheet being added; Gemini UI triggers many mutations and\n    // querying the entire document on every mutation can cause severe jank/crashes.\n    this.observer = new MutationObserver((mutations) => {\n      for (const mutation of mutations) {\n        for (const node of Array.from(mutation.addedNodes)) {\n          if (!(node instanceof HTMLElement)) continue;\n\n          const menuPanel = this.resolveModeSwitchContainer(node);\n\n          if (menuPanel) {\n            this.scheduleMenuPanelInjection(menuPanel);\n          }\n        }\n      }\n    });\n\n    this.observer.observe(document.body, { childList: true, subtree: true });\n  }\n\n  private resolveModeSwitchContainer(root: HTMLElement): HTMLElement | null {\n    if (\n      root.matches('.mat-mdc-menu-panel.gds-mode-switch-menu[role=\"menu\"]') ||\n      root.matches('mat-action-list.gds-mode-switch-menu-list') ||\n      root.matches(NON_MODEL_MENU_EXCLUSION_FALLBACK)\n    ) {\n      return root;\n    }\n\n    return (\n      root.querySelector<HTMLElement>('.mat-mdc-menu-panel.gds-mode-switch-menu[role=\"menu\"]') ??\n      root.querySelector<HTMLElement>('mat-action-list.gds-mode-switch-menu-list') ??\n      root.querySelector<HTMLElement>(NON_MODEL_MENU_EXCLUSION_FALLBACK)\n    );\n  }\n\n  private getModeSwitchMenuPanel(): HTMLElement | null {\n    return (\n      document.querySelector<HTMLElement>(\n        '.mat-mdc-menu-panel.gds-mode-switch-menu[role=\"menu\"]',\n      ) ??\n      document.querySelector<HTMLElement>('mat-action-list.gds-mode-switch-menu-list') ??\n      document.querySelector<HTMLElement>(NON_MODEL_MENU_EXCLUSION_FALLBACK)\n    );\n  }\n\n  private async waitForModeSwitchMenuPanel(timeoutMs: number): Promise<HTMLElement | null> {\n    const startedAt = Date.now();\n    const pollIntervalMs = 50;\n    while (Date.now() - startedAt < timeoutMs) {\n      const panel = this.getModeSwitchMenuPanel();\n      if (panel?.isConnected) return panel;\n      await new Promise<void>((resolve) => window.setTimeout(resolve, pollIntervalMs));\n    }\n    return null;\n  }\n\n  private scheduleMenuPanelInjection(menuPanel: HTMLElement) {\n    if (this.pendingMenuPanelInjections.has(menuPanel)) return;\n    this.pendingMenuPanelInjections.add(menuPanel);\n\n    const delayMs = 50; // allow menu content to render\n    window.setTimeout(() => {\n      if (!this.started) return;\n\n      this.pendingMenuPanelInjections.delete(menuPanel);\n\n      void this.injectStarButtons(menuPanel).then((didInject) => {\n        if (didInject) {\n          this.menuPanelInjectAttempts.delete(menuPanel);\n          return;\n        }\n\n        if (!menuPanel.isConnected) return;\n\n        const attempts = (this.menuPanelInjectAttempts.get(menuPanel) ?? 0) + 1;\n        this.menuPanelInjectAttempts.set(menuPanel, attempts);\n\n        const maxAttempts = 10;\n        if (attempts < maxAttempts) {\n          this.scheduleMenuPanelInjection(menuPanel);\n        }\n      });\n    }, delayMs);\n  }\n\n  private async injectStarButtons(menuPanel: HTMLElement): Promise<boolean> {\n    const items = menuPanel.querySelectorAll(MODE_ITEM_SELECTOR);\n    if (!items.length) return false;\n\n    // Guard: only inject into menus that look like a model selector.\n    // Model menus always contain .title-and-description, .mode-title, or data-mode-id.\n    // Non-model menus (theme picker, help, etc.) lack these even if they use menuitemradio.\n    const isModelMenu =\n      menuPanel.querySelector('[data-mode-id]') !== null ||\n      menuPanel.querySelector('.mode-title') !== null ||\n      menuPanel.querySelector('.title-and-description') !== null;\n    if (!isModelMenu) return false;\n\n    // Use cached value efficiently\n    if (!this.initialized) {\n      const result = await storageService.get<unknown>(StorageKeys.DEFAULT_MODEL);\n      this.currentDefaultModel = result.success ? this.parseStoredDefaultModel(result.data) : null;\n      this.initialized = true;\n    }\n\n    const currentDefault = this.currentDefaultModel;\n\n    items.forEach((item) => {\n      const modelName = this.getModelNameFromItem(item as HTMLElement);\n      if (!modelName) return;\n\n      // Avoid duplicates\n      if (item.querySelector('.gv-default-star-btn')) {\n        // Update state\n        this.updateStarState(item as HTMLElement, modelName, currentDefault);\n        return;\n      }\n\n      const btn = document.createElement('button');\n      btn.className = 'gv-default-star-btn';\n      btn.innerHTML = this.getStarIcon(false); // Default empty\n      btn.title = chrome.i18n.getMessage('setAsDefaultModel');\n\n      btn.addEventListener('click', async (e) => {\n        e.stopPropagation(); // Prevent menu item selection\n        e.preventDefault();\n        await this.handleStarClick(modelName, btn);\n      });\n\n      // Finding the correct container (title-and-description)\n      const titleContainer = item.querySelector('.title-and-description');\n\n      if (titleContainer) {\n        const titleEl = titleContainer.querySelector('.mode-title, .gds-title-m, .gds-label-l');\n        if (titleEl) {\n          const titleParent = titleEl.parentElement;\n          let wrapper = titleContainer.querySelector('.gv-title-wrapper') as HTMLElement | null;\n          if (!wrapper && titleParent?.classList.contains('gv-title-wrapper')) {\n            wrapper = titleParent;\n          }\n\n          if (!wrapper) {\n            // Create wrapper\n            wrapper = document.createElement('div');\n            wrapper.className = 'gv-title-wrapper';\n            wrapper.style.cssText = 'display: flex; align-items: center; width: 100%;';\n\n            // Insert wrapper where the title currently lives.\n            if (titleParent) {\n              titleParent.insertBefore(wrapper, titleEl);\n            } else {\n              titleContainer.appendChild(wrapper);\n            }\n\n            // Move title into wrapper\n            wrapper.appendChild(titleEl);\n          }\n\n          // Append star to wrapper\n          wrapper.appendChild(btn);\n        } else {\n          // Fallback if structure changes\n          titleContainer.appendChild(btn);\n        }\n      } else {\n        // Fallback\n        item.appendChild(btn);\n      }\n      this.updateStarState(item as HTMLElement, modelName, currentDefault);\n    });\n\n    return true;\n  }\n\n  private getModelNameFromItem(item: HTMLElement): string {\n    const titleEl = item.querySelector('.mode-title, .gds-title-m, .gds-label-l');\n    return titleEl?.textContent?.trim() || '';\n  }\n\n  private getModelIdFromItem(item: HTMLElement): string | null {\n    const raw = item.getAttribute('data-mode-id') || item.dataset.modeId;\n    if (typeof raw === 'string') {\n      const id = raw.trim();\n      if (id.length) return id;\n    }\n\n    // Compact layout may omit data-mode-id but keeps the internal model id in jslog metadata.\n    const jslog = item.getAttribute('jslog');\n    if (typeof jslog === 'string') {\n      const matchedIds = jslog.match(/[a-f0-9]{16}/gi);\n      const id = matchedIds?.[matchedIds.length - 1]?.trim();\n      if (id) return id;\n    }\n\n    return null;\n  }\n\n  private isDefaultForItem(\n    currentDefault: DefaultModelSetting | null,\n    item: HTMLElement,\n    modelName: string,\n  ): boolean {\n    if (!currentDefault) return false;\n    if (currentDefault.kind === 'id') {\n      const id = this.getModelIdFromItem(item);\n      return id === currentDefault.id;\n    }\n    return currentDefault.name === modelName;\n  }\n\n  private updateStarState(\n    item: HTMLElement,\n    modelName: string,\n    currentDefault: DefaultModelSetting | null,\n  ) {\n    const btn = item.querySelector('.gv-default-star-btn') as HTMLElement;\n    if (!btn) return;\n\n    // Ensure mousedown/click stops propagation (idempotent)\n    if (!btn.hasAttribute('data-event-bound')) {\n      btn.setAttribute('data-event-bound', 'true');\n      btn.addEventListener('mousedown', (e) => e.stopPropagation());\n      btn.addEventListener('click', (e) => e.stopPropagation());\n    }\n\n    const isDefault = this.isDefaultForItem(currentDefault, item, modelName);\n    if (isDefault) {\n      btn.classList.add('is-default');\n      btn.innerHTML = this.getStarIcon(true);\n      btn.title = chrome.i18n.getMessage('cancelDefaultModel');\n    } else {\n      btn.classList.remove('is-default');\n      btn.innerHTML = this.getStarIcon(false);\n      btn.title = chrome.i18n.getMessage('setAsDefaultModel');\n    }\n  }\n\n  private getStarIcon(filled: boolean): string {\n    if (filled) {\n      return `<svg viewBox=\"0 0 24 24\"><path d=\"M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z\" fill=\"currentColor\"></path></svg>`;\n    } else {\n      return `<svg viewBox=\"0 0 24 24\"><path d=\"M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"></path></svg>`;\n    }\n  }\n\n  private async handleStarClick(modelName: string, btn: HTMLElement) {\n    const closestItem = btn.closest(MODE_ITEM_SELECTOR);\n    const modelItem = closestItem instanceof HTMLElement ? closestItem : null;\n    const modelId = modelItem ? this.getModelIdFromItem(modelItem) : null;\n\n    // 1. Optimistic UI Update (Instant feedback)\n    const isCurrentlyDefault = modelItem\n      ? this.isDefaultForItem(this.currentDefaultModel, modelItem, modelName)\n      : this.currentDefaultModel?.kind === 'name'\n        ? this.currentDefaultModel.name === modelName\n        : false;\n\n    const nextDefault: DefaultModelSetting | null = isCurrentlyDefault\n      ? null\n      : modelId\n        ? { kind: 'id', id: modelId, name: modelName }\n        : { kind: 'name', name: modelName };\n\n    // Update cache immediately\n    this.currentDefaultModel = nextDefault;\n\n    // Update current button immediately\n    if (modelItem) {\n      this.updateStarState(modelItem, modelName, nextDefault);\n    }\n\n    // Show Toast immediately\n    if (nextDefault) {\n      this.showToast(chrome.i18n.getMessage('defaultModelSet', [modelName]));\n    } else {\n      this.showToast(chrome.i18n.getMessage('defaultModelCleared'));\n    }\n\n    // Update other buttons (e.g. if switching from A to B)\n    const menuPanel = this.getModeSwitchMenuPanel();\n    if (menuPanel) {\n      // Re-run injection to update all other buttons based on new cache\n      void this.injectStarButtons(menuPanel as HTMLElement);\n    }\n\n    // 2. Perform async storage operation in background\n    if (isCurrentlyDefault) {\n      await storageService.remove(StorageKeys.DEFAULT_MODEL);\n    } else {\n      if (nextDefault?.kind === 'id') {\n        const toStore: StoredDefaultModelSetting = {\n          id: nextDefault.id,\n          name: nextDefault.name,\n        };\n        await storageService.set(StorageKeys.DEFAULT_MODEL, toStore);\n      } else {\n        await storageService.set(StorageKeys.DEFAULT_MODEL, modelName);\n      }\n    }\n  }\n\n  private showToast(message: string) {\n    const toast = document.createElement('div');\n    toast.style.cssText = `\n      position: fixed;\n      bottom: 24px;\n      left: 50%;\n      transform: translateX(-50%);\n      background: #323232;\n      color: white;\n      padding: 12px 24px;\n      border-radius: 4px;\n      font-size: 14px;\n      z-index: 10000;\n      box-shadow: 0 2px 10px rgba(0,0,0,0.2);\n      transition: opacity 0.3s;\n    `;\n    toast.textContent = message;\n    document.body.appendChild(toast);\n    setTimeout(() => {\n      toast.style.opacity = '0';\n      setTimeout(() => toast.remove(), 300);\n    }, 3000);\n  }\n\n  // ==================== Auto Lock Logic ====================\n\n  /**\n   * Delayed version of checkAndLockModel for SPA navigation.\n   * Adds a small delay to ensure the URL has actually changed.\n   */\n  private async checkAndLockModelWithDelay() {\n    // Wait for SPA navigation to complete\n    await new Promise<void>((resolve) => window.setTimeout(resolve, 150));\n    void this.checkAndLockModel();\n  }\n\n  private async checkAndLockModel() {\n    // Only lock on new conversation pages\n    if (!this.isNewConversation()) return;\n\n    // Update last checked path\n    this.lastCheckedPath = window.location.pathname;\n\n    const result = await storageService.get<unknown>(StorageKeys.DEFAULT_MODEL);\n    const targetModel = result.success ? this.parseStoredDefaultModel(result.data) : null;\n    this.currentDefaultModel = targetModel;\n    this.initialized = true;\n\n    if (!targetModel) return;\n\n    // Check if the target model is a Flash/Fast model (default model, skip auto-selection)\n    if (this.isFastModel(targetModel)) {\n      return;\n    }\n\n    // Generate a unique session ID to prevent duplicate selections in the same navigation\n    const sessionId = `${window.location.pathname}-${Date.now()}`;\n    this.autoSelectSessionId = sessionId;\n    // Reset failure counter for new session\n    this.consecutiveFailures = 0;\n\n    // Start checking loop\n    let attempts = 0;\n    const maxAttempts = 20;\n\n    if (this.checkTimer) clearInterval(this.checkTimer);\n\n    this.checkTimer = window.setInterval(async () => {\n      // Abort if session changed (e.g., user navigated away and came back)\n      if (this.autoSelectSessionId !== sessionId) {\n        if (this.checkTimer) clearInterval(this.checkTimer);\n        return;\n      }\n\n      attempts++;\n      if (attempts > maxAttempts) {\n        if (this.checkTimer) clearInterval(this.checkTimer);\n        return;\n      }\n\n      await this.tryLockToModel(targetModel);\n    }, 1000);\n  }\n\n  private isNewConversation() {\n    const path = window.location.pathname;\n    // Supports multi-profile paths like /u/0/app as well as /app.\n    // Also supports Gem paths like /gem/xyz or /u/0/gem/xyz\n    return /^\\/(u\\/\\d+\\/)?(app\\/?|gem\\/.*)$/.test(path);\n  }\n\n  /**\n   * Check if the given model is a Flash/Fast model (Gemini's default model).\n   * If yes, we don't need to auto-switch since the page already defaults to it.\n   */\n  private isFastModel(model: DefaultModelSetting): boolean {\n    if (model.kind === 'id') {\n      return FAST_MODEL_IDS.has(model.id);\n    }\n    const normalizedName = model.name.toLowerCase().trim();\n    return FAST_MODEL_NAMES.some(\n      (fastName) => normalizedName === fastName || normalizedName.includes(fastName),\n    );\n  }\n\n  private async tryLockToModel(targetModel: DefaultModelSetting) {\n    // Ported from https://github.com/urzeye/tampermonkey-scripts (Gemini Helper)\n    const normalize = (s: string) => s.toLowerCase().trim();\n    const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n    const targetName = normalize(targetModel.name);\n    const targetAsWholeWord = new RegExp(`(^|\\\\b)${escapeRegExp(targetName)}(\\\\b|$)`, 'i');\n\n    // 1. Find selector button\n    const selectorBtn =\n      document.querySelector('.input-area-switch-label') ||\n      document.querySelector('[data-test-id=\"model-selector\"]') ||\n      document.querySelector('button[aria-haspopup=\"menu\"].mat-mdc-menu-trigger'); // General fallback\n\n    if (!selectorBtn) return;\n\n    // 2. Check current model text - early return if already correct\n    const currentText = selectorBtn.textContent || '';\n    const normalizedCurrent = normalize(currentText);\n\n    // For both 'id' and 'name' kinds, we can check if the button text matches the target name\n    // This helps avoid unnecessary menu clicks when the model is already selected\n    if (targetAsWholeWord.test(normalizedCurrent) || normalizedCurrent === targetName) {\n      // Already correct\n      if (this.checkTimer) clearInterval(this.checkTimer);\n      return;\n    }\n\n    // 3. Switch model\n    // This part is tricky because we need to open the menu and click safely\n    if (this.isLocked) return; // Prevent concurrent locks\n    this.isLocked = true;\n\n    try {\n      (selectorBtn as HTMLElement).click();\n\n      const menuPanel = await this.waitForModeSwitchMenuPanel(1500);\n      if (!menuPanel) return;\n\n      const items = menuPanel.querySelectorAll(MODE_ITEM_SELECTOR);\n      let found = false;\n      let switchedModel = false;\n\n      if (targetModel.kind === 'id') {\n        const targetItem = Array.from(items).find((item) => {\n          if (!(item instanceof HTMLElement)) return false;\n          return this.getModelIdFromItem(item) === targetModel.id;\n        });\n\n        if (targetItem instanceof HTMLElement) {\n          const alreadySelected =\n            targetItem.getAttribute('aria-checked') === 'true' ||\n            targetItem.classList.contains('is-selected');\n\n          if (!alreadySelected) {\n            targetItem.click();\n            switchedModel = true;\n          } else {\n            // Already selected, close menu to avoid stuck UI\n            document.body.click();\n          }\n\n          found = true;\n        }\n      } else {\n        for (const item of Array.from(items)) {\n          const modelName = this.getModelNameFromItem(item as HTMLElement);\n          if (normalize(modelName) === targetName) {\n            const alreadySelected =\n              (item as HTMLElement).getAttribute('aria-checked') === 'true' ||\n              (item as HTMLElement).classList.contains('is-selected');\n\n            if (!alreadySelected) {\n              (item as HTMLElement).click();\n              switchedModel = true;\n            } else {\n              // Already selected, close menu to avoid stuck UI\n              document.body.click();\n            }\n            found = true;\n            break;\n          }\n        }\n      }\n\n      if (!found) {\n        // Fallback: whole-word match on the full text content (includes description).\n        for (const item of Array.from(items)) {\n          const text = (item as HTMLElement).textContent || '';\n          if (targetAsWholeWord.test(normalize(text))) {\n            const alreadySelected =\n              (item as HTMLElement).getAttribute('aria-checked') === 'true' ||\n              (item as HTMLElement).classList.contains('is-selected');\n\n            if (!alreadySelected) {\n              (item as HTMLElement).click();\n              switchedModel = true;\n            } else {\n              // Already selected, close menu to avoid stuck UI\n              document.body.click();\n            }\n            found = true;\n            break;\n          }\n        }\n      }\n\n      if (found && this.checkTimer) {\n        clearInterval(this.checkTimer);\n        this.consecutiveFailures = 0;\n      }\n      if (switchedModel) {\n        this.focusChatInputAfterAutoSwitch();\n      }\n\n      if (!found) {\n        // Close menu if not found to avoid stuck menu\n        document.body.click();\n\n        // Track consecutive failures - if model is consistently not found,\n        // stop trying to avoid endless flashing (e.g., model not available for this account)\n        this.consecutiveFailures++;\n        if (this.consecutiveFailures >= this.maxConsecutiveFailures) {\n          if (this.checkTimer) {\n            clearInterval(this.checkTimer);\n          }\n        }\n      }\n    } catch (e) {\n      console.error('Auto lock failed', e);\n    } finally {\n      this.isLocked = false;\n    }\n  }\n\n  private focusChatInputAfterAutoSwitch(): void {\n    const focusDelayMs = 120;\n    window.setTimeout(() => {\n      const input = this.findChatInputElement();\n      if (!input) return;\n\n      try {\n        input.focus({ preventScroll: true });\n      } catch {\n        input.focus();\n      }\n    }, focusDelayMs);\n  }\n\n  private findChatInputElement(): HTMLElement | null {\n    for (const selector of CHAT_INPUT_SELECTORS) {\n      const candidates = document.querySelectorAll<HTMLElement>(selector);\n      for (const candidate of Array.from(candidates)) {\n        if (!candidate.isConnected) continue;\n        if (candidate instanceof HTMLTextAreaElement && candidate.disabled) continue;\n        return candidate;\n      }\n    }\n    return null;\n  }\n\n  private parseStoredDefaultModel(value: unknown): DefaultModelSetting | null {\n    if (typeof value === 'string') {\n      const name = value.trim();\n      return name.length ? { kind: 'name', name } : null;\n    }\n\n    if (this.isStoredDefaultModelSetting(value)) {\n      const id = value.id.trim();\n      const name = value.name.trim();\n      if (!id.length || !name.length) return null;\n      return { kind: 'id', id, name };\n    }\n\n    return null;\n  }\n\n  private isStoredDefaultModelSetting(value: unknown): value is StoredDefaultModelSetting {\n    if (typeof value !== 'object' || value === null) return false;\n    const record = value as Record<string, unknown>;\n    return typeof record.id === 'string' && typeof record.name === 'string';\n  }\n}\n\nexport default DefaultModelManager;\n"
  },
  {
    "path": "src/pages/content/defaultModel/styles.css",
    "content": "/* Default model star button */\n.gv-default-star-btn {\n  background: transparent;\n  border: none;\n  cursor: pointer;\n  padding: 2px;\n  width: 20px;\n  height: 20px;\n  border-radius: 50%;\n  color: #5f6368;\n  position: relative;\n  margin-left: 6px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  z-index: 100;\n  pointer-events: auto;\n  opacity: 0;\n  transition:\n    opacity 0.2s,\n    background-color 0.2s,\n    color 0.2s;\n}\n\n/* Show on hover or if it's the default model */\n[role='menuitemradio']:hover .gv-default-star-btn,\n[role='menuitem']:hover .gv-default-star-btn,\n.gv-default-star-btn.is-default {\n  opacity: 1;\n}\n\n.gv-default-star-btn:hover {\n  background-color: rgba(60, 64, 67, 0.08);\n}\n\n.gv-default-star-btn.is-default {\n  color: #fbbc04;\n  /* Google Yellow / Star color */\n}\n\n.gv-default-star-btn svg {\n  width: 14px;\n  height: 14px;\n  pointer-events: none;\n  /* Let clicks pass to button */\n}\n\n/* Ensure parent menu item has relative positioning */\n[role='menuitemradio'],\n[role='menuitem'] {\n  position: relative;\n}\n"
  },
  {
    "path": "src/pages/content/editInputWidth/index.ts",
    "content": "/**\n * Adjusts the edit input textarea width based on user settings\n * Targets the edit mode textarea in Gemini conversations\n *\n * Based on the chatWidth implementation pattern\n */\n\nconst STYLE_ID = 'gemini-voyager-edit-input-width';\nconst DEFAULT_PERCENT = 60;\nconst MIN_PERCENT = 30;\nconst MAX_PERCENT = 100;\nconst LEGACY_BASELINE_PX = 1200;\n\nconst clampPercent = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, Math.round(value)));\n\nconst normalizePercent = (value: number, fallback: number) => {\n  if (!Number.isFinite(value)) return fallback;\n  if (value > MAX_PERCENT) {\n    const approx = (value / LEGACY_BASELINE_PX) * 100;\n    return clampPercent(approx, MIN_PERCENT, MAX_PERCENT);\n  }\n  return clampPercent(value, MIN_PERCENT, MAX_PERCENT);\n};\n\n/**\n * Selectors for edit mode containers\n * Based on actual DOM structure: .query-content.edit-mode\n */\nfunction getEditModeSelectors(): string[] {\n  return ['.query-content.edit-mode', 'div.edit-mode', '[class*=\"edit-mode\"]'];\n}\n\n/**\n * Applies the specified width (%) to edit input elements\n * Following the chatWidth pattern with container width removal and precise targeting\n */\nfunction applyWidth(widthPercent: number): void {\n  const normalizedPercent = normalizePercent(widthPercent, DEFAULT_PERCENT);\n  const widthValue = `${normalizedPercent}vw`;\n\n  let style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n  if (!style) {\n    style = document.createElement('style');\n    style.id = STYLE_ID;\n    document.head.appendChild(style);\n  }\n\n  const editModeSelectors = getEditModeSelectors();\n  const editModeRules = editModeSelectors.map((sel) => `${sel}`).join(',\\n    ');\n\n  style.textContent = `\n    /* Remove width constraints from outer containers that contain edit mode (similar to chatWidth) */\n    .content-wrapper:has(.edit-mode),\n    .main-content:has(.edit-mode),\n    .content-container:has(.edit-mode) {\n      max-width: none !important;\n    }\n\n    /* Remove width constraints from main container when it has edit mode */\n    [role=\"main\"]:has(.edit-mode) {\n      max-width: none !important;\n    }\n\n    main > div:has(.edit-mode) {\n      max-width: none !important;\n      width: 100% !important;\n    }\n\n    /* Target edit mode containers directly */\n    ${editModeRules} {\n      max-width: ${widthValue} !important;\n      width: min(100%, ${widthValue}) !important;\n    }\n\n    /* Target the edit-container within edit-mode */\n    .edit-mode .edit-container,\n    .query-content.edit-mode .edit-container {\n      max-width: ${widthValue} !important;\n      width: min(100%, ${widthValue}) !important;\n    }\n\n    /* Target Material Design form field */\n    .edit-mode .mat-mdc-form-field,\n    .edit-container .mat-mdc-form-field,\n    .edit-mode .edit-form {\n      max-width: ${widthValue} !important;\n      width: 100% !important;\n    }\n\n    /* Target text field wrapper and flex container */\n    .edit-mode .mat-mdc-text-field-wrapper,\n    .edit-mode .mat-mdc-form-field-flex,\n    .edit-mode .mdc-text-field {\n      max-width: ${widthValue} !important;\n      width: 100% !important;\n    }\n\n    /* Target form field infix (contains the textarea) */\n    .edit-mode .mat-mdc-form-field-infix {\n      max-width: ${widthValue} !important;\n      width: 100% !important;\n    }\n\n    /* Target the textarea itself */\n    .edit-mode textarea,\n    .edit-container textarea,\n    .edit-mode .mat-mdc-input-element,\n    .edit-mode .cdk-textarea-autosize {\n      max-width: ${widthValue} !important;\n      width: 100% !important;\n      box-sizing: border-box !important;\n    }\n\n    /* ===== Main chat input area (input-container > input-area-v2) ===== */\n    input-container {\n      max-width: ${widthValue} !important;\n      width: min(100%, ${widthValue}) !important;\n      margin-left: auto !important;\n      margin-right: auto !important;\n    }\n\n    input-container .input-area-container {\n      max-width: 100% !important;\n      width: 100% !important;\n    }\n\n    input-area-v2 {\n      max-width: 100% !important;\n      width: 100% !important;\n    }\n\n    input-area-v2 .input-area {\n      max-width: 100% !important;\n      width: 100% !important;\n    }\n\n    /* Fallback for browsers without :has() support */\n    @supports not selector(:has(*)) {\n      .content-wrapper,\n      .main-content,\n      .content-container {\n        max-width: none !important;\n      }\n    }\n  `;\n}\n\n/**\n * Removes the injected styles\n */\nfunction removeStyles(): void {\n  const style = document.getElementById(STYLE_ID);\n  if (style) {\n    style.remove();\n  }\n}\n\nconst ENABLED_KEY = 'gvEditInputWidthEnabled';\n\n/**\n * Initializes and starts the edit input width adjuster\n */\nexport function startEditInputWidthAdjuster(): void {\n  let currentWidthPercent = DEFAULT_PERCENT;\n  let enabled = false;\n\n  // Load initial state — request keys without defaults so we can distinguish\n  // \"key never existed\" (upgrade) from \"explicitly set to false\"\n  chrome.storage?.sync?.get(['geminiEditInputWidth', ENABLED_KEY], (res) => {\n    const storedWidth = res?.geminiEditInputWidth;\n    const normalized = normalizePercent(\n      typeof storedWidth === 'number' ? storedWidth : DEFAULT_PERCENT,\n      DEFAULT_PERCENT,\n    );\n    currentWidthPercent = normalized;\n\n    const enabledRaw = res?.[ENABLED_KEY];\n    if (enabledRaw === undefined) {\n      enabled =\n        typeof storedWidth === 'number' &&\n        normalizePercent(storedWidth, DEFAULT_PERCENT) !== DEFAULT_PERCENT;\n      if (enabled) {\n        try {\n          chrome.storage?.sync?.set({ [ENABLED_KEY]: true });\n        } catch {}\n      }\n    } else {\n      enabled = enabledRaw === true;\n    }\n\n    if (enabled) {\n      applyWidth(currentWidthPercent);\n    }\n\n    if (typeof storedWidth === 'number' && storedWidth !== normalized) {\n      try {\n        chrome.storage?.sync?.set({ geminiEditInputWidth: normalized });\n      } catch (e) {\n        console.warn('[Gemini Voyager] Failed to migrate edit input width to %:', e);\n      }\n    }\n  });\n\n  // Listen for changes from storage (when user adjusts in popup)\n  chrome.storage?.onChanged?.addListener((changes, area) => {\n    if (area !== 'sync') return;\n\n    if (changes[ENABLED_KEY]) {\n      enabled = changes[ENABLED_KEY].newValue === true;\n      if (enabled) {\n        applyWidth(currentWidthPercent);\n      } else {\n        removeStyles();\n      }\n    }\n\n    if (changes.geminiEditInputWidth) {\n      const newWidth = changes.geminiEditInputWidth.newValue;\n      if (typeof newWidth === 'number') {\n        const normalized = normalizePercent(newWidth, DEFAULT_PERCENT);\n        currentWidthPercent = normalized;\n        if (enabled) {\n          applyWidth(currentWidthPercent);\n        }\n\n        if (normalized !== newWidth) {\n          try {\n            chrome.storage?.sync?.set({ geminiEditInputWidth: normalized });\n          } catch (e) {\n            console.warn('[Gemini Voyager] Failed to migrate edit input width to % on change:', e);\n          }\n        }\n      }\n    }\n  });\n\n  // Re-apply styles when DOM changes (for dynamic content)\n  // Use debouncing and cache the width to avoid storage reads\n  let debounceTimer: number | null = null;\n  const observer = new MutationObserver(() => {\n    if (debounceTimer !== null) {\n      clearTimeout(debounceTimer);\n    }\n    debounceTimer = window.setTimeout(() => {\n      if (enabled) {\n        applyWidth(currentWidthPercent);\n      }\n      debounceTimer = null;\n    }, 200);\n  });\n\n  // Observe the main conversation area for changes\n  const main = document.querySelector('main');\n  if (main) {\n    observer.observe(main, {\n      childList: true,\n      subtree: true,\n      attributes: true,\n      attributeFilter: ['class'], // Watch for class changes (e.g., edit-mode added)\n    });\n  }\n\n  // Clean up on unload\n  window.addEventListener('beforeunload', () => {\n    observer.disconnect();\n    removeStyles();\n  });\n}\n"
  },
  {
    "path": "src/pages/content/export/__tests__/conversationDom.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { filterOutDeepResearchImmersiveNodes, resolveConversationRoot } from '../conversationDom';\n\ndescribe('conversationDom', () => {\n  it('prefers #chat-history as conversation root when available', () => {\n    document.body.innerHTML = `\n      <main id=\"main-root\">\n        <div id=\"chat-history\">\n          <div class=\"user-query-container\">left conversation message</div>\n        </div>\n        <deep-research-immersive-panel>\n          <div class=\"user-query-container\">report panel content</div>\n        </deep-research-immersive-panel>\n      </main>\n    `;\n\n    const root = resolveConversationRoot({\n      userSelectors: ['.user-query-container'],\n      doc: document,\n    });\n\n    expect(root.id).toBe('chat-history');\n  });\n\n  it('filters out nodes inside deep research immersive panel', () => {\n    document.body.innerHTML = `\n      <main>\n        <div id=\"chat-history\">\n          <div id=\"msg-left\" class=\"response-container\">left conversation response</div>\n        </div>\n        <deep-research-immersive-panel>\n          <div id=\"msg-report\" class=\"response-container\">report response</div>\n        </deep-research-immersive-panel>\n      </main>\n    `;\n\n    const all = Array.from(document.querySelectorAll<HTMLElement>('.response-container'));\n    const filtered = filterOutDeepResearchImmersiveNodes(all);\n\n    expect(filtered).toHaveLength(1);\n    expect(filtered[0].id).toBe('msg-left');\n  });\n});\n"
  },
  {
    "path": "src/pages/content/export/__tests__/conversationMenuI18n.test.ts",
    "content": "import { readFileSync } from 'node:fs';\nimport { resolve } from 'node:path';\nimport { describe, expect, it } from 'vitest';\n\ndescribe('conversation menu i18n', () => {\n  it('uses exportChatJson text for conversation menu item label', () => {\n    const code = readFileSync(resolve(process.cwd(), 'src/pages/content/export/index.ts'), 'utf8');\n\n    expect(code).toMatch(/const label[\\s\\S]*\\['exportChatJson'\\]/);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/export/__tests__/conversationMenuInjection.test.ts",
    "content": "import { describe, expect, it, vi } from 'vitest';\n\nimport {\n  getConversationMenuContext,\n  getResponseMenuContext,\n  injectConversationMenuExportButton,\n  injectResponseMenuExportButton,\n  isConversationMenuPanel,\n  isResponseMenuPanel,\n} from '../conversationMenuInjection';\n\nfunction createNativeMenuButton(\n  testId: string,\n  label: string,\n  iconName: string,\n  useFontIcon: boolean = true,\n): HTMLButtonElement {\n  const button = document.createElement('button');\n  button.className = 'mat-mdc-menu-item mat-focus-indicator';\n  button.setAttribute('role', 'menuitem');\n  button.setAttribute('tabindex', '0');\n  button.setAttribute('data-test-id', testId);\n\n  const icon = document.createElement('mat-icon');\n  icon.className =\n    'mat-icon notranslate gds-icon-m google-symbols mat-ligature-font mat-icon-no-color';\n  if (useFontIcon) {\n    icon.setAttribute('fonticon', iconName);\n  }\n  icon.setAttribute('aria-hidden', 'true');\n  icon.textContent = iconName;\n\n  const text = document.createElement('span');\n  text.className = 'mat-mdc-menu-item-text';\n  const innerText = document.createElement('span');\n  innerText.className = 'gds-label-m';\n  innerText.textContent = label;\n  text.appendChild(innerText);\n\n  const ripple = document.createElement('div');\n  ripple.className = 'mat-ripple mat-mdc-menu-ripple';\n  ripple.setAttribute('matripple', '');\n\n  button.appendChild(icon);\n  button.appendChild(text);\n  button.appendChild(ripple);\n  return button;\n}\n\nfunction createConversationMenuPanel(useFontIcon: boolean = true): HTMLElement {\n  const panel = document.createElement('div');\n  panel.className = 'mat-mdc-menu-panel';\n  panel.setAttribute('role', 'menu');\n\n  const content = document.createElement('div');\n  content.className = 'mat-mdc-menu-content';\n\n  const pin = createNativeMenuButton('pin-button', 'Pin', 'keep', useFontIcon);\n  const rename = createNativeMenuButton('rename-button', 'Rename', 'edit', useFontIcon);\n\n  content.appendChild(pin);\n  content.appendChild(rename);\n  panel.appendChild(content);\n  document.body.appendChild(panel);\n  return panel;\n}\n\nfunction createResponseMenuPanel(useFontIcon: boolean = true): HTMLElement {\n  const panel = document.createElement('div');\n  panel.className = 'mat-mdc-menu-panel';\n  panel.setAttribute('role', 'menu');\n\n  const content = document.createElement('div');\n  content.className = 'mat-mdc-menu-content';\n\n  const exportToDocs = createNativeMenuButton(\n    'export-to-docs-button',\n    'Export to Docs',\n    'docs',\n    useFontIcon,\n  );\n  exportToDocs.removeAttribute('data-test-id');\n\n  const draftInGmail = createNativeMenuButton(\n    'draft-in-gmail-button',\n    'Draft in Gmail',\n    'gmail',\n    useFontIcon,\n  );\n  draftInGmail.removeAttribute('data-test-id');\n\n  const reportLegalIssue = createNativeMenuButton(\n    'report-legal-issue-button',\n    'Report legal issue',\n    'flag',\n    useFontIcon,\n  );\n  reportLegalIssue.removeAttribute('data-test-id');\n\n  content.appendChild(exportToDocs);\n  content.appendChild(draftInGmail);\n  content.appendChild(reportLegalIssue);\n  panel.appendChild(content);\n  document.body.appendChild(panel);\n  return panel;\n}\n\ndescribe('conversationMenuInjection', () => {\n  it('does not treat deep research share/export menu as conversation menu', () => {\n    const panel = document.createElement('div');\n    panel.className = 'mat-mdc-menu-panel';\n    panel.setAttribute('role', 'menu');\n\n    const content = document.createElement('div');\n    content.className = 'mat-mdc-menu-content';\n\n    const shareContainer = document.createElement('div');\n    shareContainer.setAttribute('data-test-id', 'share-button-tooltip-container');\n    const shareButton = document.createElement('button');\n    shareButton.setAttribute('data-test-id', 'share-button');\n    shareContainer.appendChild(shareButton);\n    content.appendChild(shareContainer);\n\n    const exportToDocs = document.createElement('export-to-docs-button');\n    exportToDocs.setAttribute('data-test-id', 'export-to-docs-button');\n    content.appendChild(exportToDocs);\n\n    const copyButton = document.createElement('copy-button');\n    copyButton.setAttribute('data-test-id', 'copy-button');\n    content.appendChild(copyButton);\n\n    panel.appendChild(content);\n    document.body.appendChild(panel);\n\n    const deepResearchTrigger = document.createElement('button');\n    deepResearchTrigger.setAttribute('data-test-id', 'export-menu-button');\n    deepResearchTrigger.setAttribute('aria-haspopup', 'menu');\n    deepResearchTrigger.setAttribute('aria-expanded', 'true');\n    deepResearchTrigger.setAttribute('aria-controls', 'mat-menu-panel-dr');\n    panel.id = 'mat-menu-panel-dr';\n    document.body.appendChild(deepResearchTrigger);\n\n    expect(isConversationMenuPanel(panel)).toBe(false);\n    expect(getConversationMenuContext(panel)).toBeNull();\n  });\n\n  it('identifies conversation menu panel by known conversation action test ids', () => {\n    const panel = createConversationMenuPanel();\n    expect(isConversationMenuPanel(panel)).toBe(true);\n  });\n\n  it('does not treat model switch menu as conversation menu', () => {\n    const panel = createConversationMenuPanel();\n    panel.classList.add('gds-mode-switch-menu');\n    expect(isConversationMenuPanel(panel)).toBe(false);\n  });\n\n  it('identifies sidebar conversation menu context from expanded trigger', () => {\n    const sidebarContainer = document.createElement('div');\n    sidebarContainer.setAttribute('data-test-id', 'overflow-container');\n    const sidebarTrigger = document.createElement('button');\n    sidebarTrigger.setAttribute('data-test-id', 'actions-menu-button');\n    sidebarTrigger.setAttribute('aria-haspopup', 'menu');\n    sidebarTrigger.setAttribute('aria-expanded', 'true');\n    sidebarContainer.appendChild(sidebarTrigger);\n    document.body.appendChild(sidebarContainer);\n\n    const panel = createConversationMenuPanel();\n    panel.id = 'mat-menu-panel-32';\n    sidebarTrigger.setAttribute('aria-controls', 'mat-menu-panel-32');\n\n    expect(isConversationMenuPanel(panel)).toBe(true);\n    const context = getConversationMenuContext(panel);\n    expect(context?.menuType).toBe('sidebar');\n    expect(\n      injectConversationMenuExportButton(panel, {\n        label: 'Export',\n        tooltip: 'Export chat history',\n        onClick: vi.fn(),\n      }),\n    ).toBeTruthy();\n\n    sidebarTrigger.setAttribute('aria-expanded', 'false');\n    sidebarContainer.remove();\n    panel.remove();\n  });\n\n  it('still treats top title conversation menu as conversation menu with same trigger test id', () => {\n    const panel = createConversationMenuPanel();\n    panel.id = 'mat-menu-panel-25';\n\n    const topTrigger = document.createElement('button');\n    topTrigger.setAttribute('data-test-id', 'actions-menu-button');\n    topTrigger.setAttribute('aria-haspopup', 'menu');\n    topTrigger.setAttribute('aria-expanded', 'true');\n    topTrigger.setAttribute('aria-controls', 'mat-menu-panel-25');\n    document.body.appendChild(topTrigger);\n\n    expect(isConversationMenuPanel(panel)).toBe(true);\n    const context = getConversationMenuContext(panel);\n    expect(context?.menuType).toBe('top');\n    const injected = injectConversationMenuExportButton(panel, {\n      label: 'Export',\n      tooltip: 'Export chat history',\n      onClick: vi.fn(),\n    });\n    expect(injected).toBeTruthy();\n\n    topTrigger.setAttribute('aria-expanded', 'false');\n    topTrigger.remove();\n    panel.remove();\n  });\n\n  it('injects export button after pin button and avoids duplicate injection', () => {\n    const panel = createConversationMenuPanel();\n    const onClick = vi.fn();\n\n    const first = injectConversationMenuExportButton(panel, {\n      label: 'Export',\n      tooltip: 'Export chat history',\n      onClick,\n    });\n    const second = injectConversationMenuExportButton(panel, {\n      label: 'Export',\n      tooltip: 'Export chat history',\n      onClick,\n    });\n\n    expect(first).toBeTruthy();\n    expect(second).toBe(first);\n\n    const content = panel.querySelector('.mat-mdc-menu-content') as HTMLElement;\n    const items = Array.from(content.children);\n    const pin = content.querySelector('[data-test-id=\"pin-button\"]');\n    expect(pin).toBeTruthy();\n    expect(items[1]).toBe(first);\n  });\n\n  it('inherits native icon/text classes to keep alignment and weight consistent', () => {\n    const panel = createConversationMenuPanel();\n    const onClick = vi.fn();\n    const pin = panel.querySelector('[data-test-id=\"pin-button\"]') as HTMLButtonElement;\n    const pinIcon = pin.querySelector('mat-icon');\n    const pinText = pin.querySelector('.mat-mdc-menu-item-text > span');\n\n    const button = injectConversationMenuExportButton(panel, {\n      label: '导出对话记录',\n      tooltip: '导出对话记录',\n      onClick,\n    });\n\n    expect(button).toBeTruthy();\n    const icon = button?.querySelector('mat-icon');\n    const text = button?.querySelector('.mat-mdc-menu-item-text > span');\n    expect(icon?.className).toBe(pinIcon?.className);\n    expect(text?.className).toBe(pinText?.className);\n    expect(text?.textContent).toBe('导出对话记录');\n  });\n\n  it('does not force fonticon when native icon uses ligature text only', () => {\n    const panel = createConversationMenuPanel(false);\n    const onClick = vi.fn();\n\n    const button = injectConversationMenuExportButton(panel, {\n      label: '导出对话记录',\n      tooltip: '导出对话记录',\n      onClick,\n    });\n\n    expect(button).toBeTruthy();\n    const icon = button?.querySelector('mat-icon');\n    expect(icon?.hasAttribute('fonticon')).toBe(false);\n    expect((icon?.textContent || '').trim()).toBe('download');\n  });\n\n  it('does not override native overlay positioning styles', () => {\n    const panel = createConversationMenuPanel();\n    const overlayBox = document.createElement('div');\n    overlayBox.className = 'cdk-overlay-connected-position-bounding-box';\n    const overlayPane = document.createElement('div');\n    overlayPane.className = 'cdk-overlay-pane';\n    overlayPane.style.position = 'static';\n    overlayPane.style.left = '12px';\n    overlayPane.style.right = '8px';\n    overlayPane.appendChild(panel);\n    overlayBox.appendChild(overlayPane);\n    document.body.appendChild(overlayBox);\n\n    const trigger = document.createElement('button');\n    trigger.setAttribute('aria-expanded', 'true');\n    trigger.setAttribute('aria-haspopup', 'menu');\n    trigger.setAttribute('aria-label', 'Open menu for conversation actions.');\n    document.body.appendChild(trigger);\n\n    const asRect = (left: number, top: number, width: number, height: number): DOMRect => {\n      const right = left + width;\n      const bottom = top + height;\n      return {\n        x: left,\n        y: top,\n        width,\n        height,\n        top,\n        left,\n        right,\n        bottom,\n        toJSON: () => ({}),\n      } as DOMRect;\n    };\n\n    vi.spyOn(trigger, 'getBoundingClientRect').mockImplementation(() => asRect(150, 16, 100, 40));\n    vi.spyOn(overlayBox, 'getBoundingClientRect').mockImplementation(() => asRect(0, 56, 400, 724));\n    vi.spyOn(overlayPane, 'getBoundingClientRect').mockImplementation(() =>\n      asRect(0, 56, 280, 256),\n    );\n\n    injectConversationMenuExportButton(panel, {\n      label: 'Export',\n      tooltip: 'Export chat history',\n      onClick: vi.fn(),\n    });\n\n    expect(overlayPane.style.position).toBe('static');\n    expect(overlayPane.style.left).toBe('12px');\n    expect(overlayPane.style.right).toBe('8px');\n    expect(panel.style.transformOrigin).toBe('');\n  });\n\n  it('identifies assistant response menu panel from expanded more-menu trigger', () => {\n    const panel = createResponseMenuPanel();\n    panel.id = 'mat-menu-panel-77';\n\n    const responseMoreTrigger = document.createElement('button');\n    responseMoreTrigger.setAttribute('data-test-id', 'more-menu-button');\n    responseMoreTrigger.setAttribute('aria-haspopup', 'menu');\n    responseMoreTrigger.setAttribute('aria-expanded', 'true');\n    responseMoreTrigger.setAttribute('aria-controls', 'mat-menu-panel-77');\n    document.body.appendChild(responseMoreTrigger);\n\n    expect(isResponseMenuPanel(panel)).toBe(true);\n    const context = getResponseMenuContext(panel);\n    expect(context?.trigger).toBe(responseMoreTrigger);\n  });\n\n  it('does not treat conversation menu as assistant response menu', () => {\n    const panel = createConversationMenuPanel();\n    panel.id = 'mat-menu-panel-78';\n\n    const conversationTrigger = document.createElement('button');\n    conversationTrigger.setAttribute('data-test-id', 'actions-menu-button');\n    conversationTrigger.setAttribute('aria-haspopup', 'menu');\n    conversationTrigger.setAttribute('aria-expanded', 'true');\n    conversationTrigger.setAttribute('aria-controls', 'mat-menu-panel-78');\n    document.body.appendChild(conversationTrigger);\n\n    expect(isResponseMenuPanel(panel)).toBe(false);\n    expect(getResponseMenuContext(panel)).toBeNull();\n  });\n\n  it('injects assistant-response export button after Export to Docs and avoids duplicate injection', () => {\n    const panel = createResponseMenuPanel();\n    panel.id = 'mat-menu-panel-79';\n    const onClick = vi.fn();\n\n    const responseMoreTrigger = document.createElement('button');\n    responseMoreTrigger.setAttribute('data-test-id', 'more-menu-button');\n    responseMoreTrigger.setAttribute('aria-haspopup', 'menu');\n    responseMoreTrigger.setAttribute('aria-expanded', 'true');\n    responseMoreTrigger.setAttribute('aria-controls', 'mat-menu-panel-79');\n    document.body.appendChild(responseMoreTrigger);\n\n    const first = injectResponseMenuExportButton(panel, {\n      label: '导出对话记录',\n      tooltip: '导出对话记录',\n      onClick,\n    });\n    const second = injectResponseMenuExportButton(panel, {\n      label: '导出对话记录',\n      tooltip: '导出对话记录',\n      onClick,\n    });\n\n    expect(first).toBeTruthy();\n    expect(second).toBe(first);\n\n    const content = panel.querySelector('.mat-mdc-menu-content') as HTMLElement;\n    const items = Array.from(content.children);\n    const exportToDocs = Array.from(content.querySelectorAll<HTMLButtonElement>('button')).find(\n      (button) => button.querySelector('mat-icon')?.getAttribute('fonticon') === 'docs',\n    );\n    expect(exportToDocs).toBeTruthy();\n    expect(items[items.indexOf(exportToDocs as Element) + 1]).toBe(first);\n  });\n\n  it('identifies assistant response menu by native action icons even when trigger linkage is missing', () => {\n    document\n      .querySelectorAll('[aria-haspopup=\"menu\"][aria-expanded=\"true\"]')\n      .forEach((node) => node.remove());\n    const panel = createResponseMenuPanel();\n    expect(isResponseMenuPanel(panel)).toBe(true);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/export/__tests__/responseActionImageButton.test.ts",
    "content": "import { afterEach, describe, expect, it, vi } from 'vitest';\n\nimport { injectResponseActionCopyImageButtons } from '../responseActionImageButton';\n\nfunction createNativeActionButton({\n  testId,\n  iconName,\n  ariaLabel,\n}: {\n  testId: string;\n  iconName: string;\n  ariaLabel: string;\n}): HTMLButtonElement {\n  const button = document.createElement('button');\n  button.className = 'mdc-icon-button mat-mdc-icon-button gds-icon-button';\n  button.setAttribute('type', 'button');\n  button.setAttribute('data-test-id', testId);\n  button.setAttribute('aria-label', ariaLabel);\n  button.title = ariaLabel;\n\n  const icon = document.createElement('mat-icon');\n  icon.className =\n    'mat-icon notranslate gds-icon-m google-symbols mat-ligature-font mat-icon-no-color';\n  icon.setAttribute('fonticon', iconName);\n  icon.setAttribute('aria-hidden', 'true');\n  icon.textContent = iconName;\n\n  button.appendChild(icon);\n  return button;\n}\n\nfunction createAssistantActionBar(): HTMLElement {\n  const host = document.createElement('div');\n  host.setAttribute('data-message-author-role', 'assistant');\n\n  const bar = document.createElement('div');\n  bar.className = 'message-actions';\n\n  const like = createNativeActionButton({\n    testId: 'rate-up-button',\n    iconName: 'thumb_up',\n    ariaLabel: 'Good response',\n  });\n  const copy = createNativeActionButton({\n    testId: 'copy-button',\n    iconName: 'content_copy',\n    ariaLabel: 'Copy response',\n  });\n  const more = createNativeActionButton({\n    testId: 'more-menu-button',\n    iconName: 'more_vert',\n    ariaLabel: 'More options',\n  });\n\n  bar.appendChild(like);\n  bar.appendChild(copy);\n  bar.appendChild(more);\n  host.appendChild(bar);\n  document.body.appendChild(host);\n  return bar;\n}\n\nfunction createNestedAssistantActionBar(): HTMLElement {\n  const modelResponse = document.createElement('model-response');\n  const responseContainer = document.createElement('div');\n  responseContainer.className = 'response-container';\n  const messageActions = document.createElement('message-actions');\n  const actionsContainer = document.createElement('div');\n  actionsContainer.className = 'actions-container-v2';\n  const buttons = document.createElement('div');\n  buttons.className = 'buttons-container-v2';\n\n  const copy = createNativeActionButton({\n    testId: 'copy-button',\n    iconName: 'content_copy',\n    ariaLabel: 'Copy response',\n  });\n  const moreWrapper = document.createElement('div');\n  moreWrapper.className = 'more-menu-button-container';\n  const more = createNativeActionButton({\n    testId: 'more-menu-button',\n    iconName: 'more_vert',\n    ariaLabel: 'Show more options',\n  });\n  moreWrapper.appendChild(more);\n\n  buttons.appendChild(copy);\n  buttons.appendChild(moreWrapper);\n  actionsContainer.appendChild(buttons);\n  messageActions.appendChild(actionsContainer);\n  responseContainer.appendChild(messageActions);\n  modelResponse.appendChild(responseContainer);\n  document.body.appendChild(modelResponse);\n\n  return buttons;\n}\n\ndescribe('responseActionImageButton', () => {\n  afterEach(() => {\n    document.body.innerHTML = '';\n  });\n\n  it('injects copy-image button between native copy and more buttons', () => {\n    const bar = createAssistantActionBar();\n    const onClick = vi.fn();\n\n    const injected = injectResponseActionCopyImageButtons(document, {\n      label: 'Copy response as image',\n      tooltip: 'Copy response as image',\n      onClick,\n    });\n\n    expect(injected).toHaveLength(1);\n\n    const children = Array.from(bar.children);\n    const copyIndex = children.findIndex((el) => el.getAttribute('data-test-id') === 'copy-button');\n    const insertedIndex = children.findIndex(\n      (el) => el.getAttribute('data-test-id') === 'gv-copy-image-button',\n    );\n    const moreIndex = children.findIndex(\n      (el) => el.getAttribute('data-test-id') === 'more-menu-button',\n    );\n\n    expect(copyIndex).toBeGreaterThanOrEqual(0);\n    expect(insertedIndex).toBe(copyIndex + 1);\n    expect(moreIndex).toBe(insertedIndex + 1);\n\n    const inserted = children[insertedIndex] as HTMLButtonElement;\n    const copyIcon = bar.querySelector('[data-test-id=\"copy-button\"] mat-icon') as HTMLElement;\n    const insertedIcon = inserted.querySelector('mat-icon') as HTMLElement;\n\n    expect(inserted.className).toBe(\n      (bar.querySelector('[data-test-id=\"copy-button\"]') as HTMLElement).className,\n    );\n    expect(insertedIcon.className).toBe(copyIcon.className);\n    expect(insertedIcon.getAttribute('fonticon')).toBe('image');\n    expect((insertedIcon.textContent || '').trim()).toBe('image');\n\n    inserted.click();\n    expect(onClick).toHaveBeenCalledTimes(1);\n  });\n\n  it('avoids duplicate injection and updates tooltip/label on reinjection', () => {\n    createAssistantActionBar();\n    const onClick = vi.fn();\n\n    const first = injectResponseActionCopyImageButtons(document, {\n      label: 'Copy response as image',\n      tooltip: 'Copy response as image',\n      onClick,\n    });\n    const second = injectResponseActionCopyImageButtons(document, {\n      label: '复制回复为图片',\n      tooltip: '复制回复为图片',\n      onClick,\n    });\n\n    expect(first).toHaveLength(1);\n    expect(second).toHaveLength(1);\n    expect(second[0]).toBe(first[0]);\n    expect(second[0].getAttribute('aria-label')).toBe('复制回复为图片');\n    expect(second[0].title).toBe('复制回复为图片');\n  });\n\n  it('does not duplicate click handlers after repeated reinjection', () => {\n    createAssistantActionBar();\n    const onClick = vi.fn();\n\n    injectResponseActionCopyImageButtons(document, {\n      label: 'Copy response as image',\n      tooltip: 'Copy response as image',\n      onClick,\n    });\n    const second = injectResponseActionCopyImageButtons(document, {\n      label: 'Copy response as image',\n      tooltip: 'Copy response as image',\n      onClick,\n    });\n    const third = injectResponseActionCopyImageButtons(document, {\n      label: 'Copy response as image',\n      tooltip: 'Copy response as image',\n      onClick,\n    });\n\n    expect(second).toHaveLength(1);\n    expect(third).toHaveLength(1);\n    const button = document.querySelector('[data-test-id=\"gv-copy-image-button\"]');\n    expect(button).toBeTruthy();\n\n    (button as HTMLButtonElement).click();\n    expect(onClick).toHaveBeenCalledTimes(1);\n  });\n\n  it('does not inject when action row has no more-options button', () => {\n    const host = document.createElement('div');\n    host.setAttribute('data-message-author-role', 'assistant');\n    const bar = document.createElement('div');\n    const copy = createNativeActionButton({\n      testId: 'copy-button',\n      iconName: 'content_copy',\n      ariaLabel: 'Copy response',\n    });\n    bar.appendChild(copy);\n    host.appendChild(bar);\n    document.body.appendChild(host);\n\n    const injected = injectResponseActionCopyImageButtons(document, {\n      label: 'Copy response as image',\n      tooltip: 'Copy response as image',\n      onClick: vi.fn(),\n    });\n    expect(injected).toHaveLength(0);\n  });\n\n  it('injects when copy/more buttons are nested under buttons-container-v2', () => {\n    const buttons = createNestedAssistantActionBar();\n    const injected = injectResponseActionCopyImageButtons(document, {\n      label: 'Copy response as image',\n      tooltip: 'Copy response as image',\n      onClick: vi.fn(),\n    });\n\n    expect(injected).toHaveLength(1);\n    expect(buttons.querySelector('[data-test-id=\"gv-copy-image-button\"]')).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/export/__tests__/responseImageCopy.test.ts",
    "content": "import { toBlob } from 'html-to-image';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport {\n  copyElementAsImageToClipboard,\n  copyImageBlobToClipboard,\n  downloadImageBlob,\n} from '../responseImageCopy';\n\nvi.mock('html-to-image', () => ({\n  toBlob: vi.fn(),\n}));\n\nclass MockClipboardItem {\n  data: Record<string, Blob>;\n\n  constructor(data: Record<string, Blob>) {\n    this.data = data;\n  }\n}\n\ndescribe('responseImageCopy', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('writes rendered png blob to clipboard', async () => {\n    const target = document.createElement('div');\n    const blob = new Blob(['img'], { type: 'image/png' });\n    (toBlob as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(blob);\n\n    const clipboardWrite = vi.fn().mockResolvedValue(undefined);\n\n    await copyElementAsImageToClipboard(target, {\n      clipboard: { write: clipboardWrite },\n      ClipboardItemCtor: MockClipboardItem as unknown as typeof ClipboardItem,\n    });\n\n    expect(toBlob).toHaveBeenCalledOnce();\n    expect(clipboardWrite).toHaveBeenCalledOnce();\n\n    const [items] = clipboardWrite.mock.calls[0] as [MockClipboardItem[]];\n    expect(items).toHaveLength(1);\n    expect(items[0].data['image/png']).toBe(blob);\n  });\n\n  it('writes provided image blob to clipboard directly', async () => {\n    const blob = new Blob(['img'], { type: 'image/png' });\n    const clipboardWrite = vi.fn().mockResolvedValue(undefined);\n\n    await copyImageBlobToClipboard(blob, {\n      clipboard: { write: clipboardWrite },\n      ClipboardItemCtor: MockClipboardItem as unknown as typeof ClipboardItem,\n    });\n\n    expect(clipboardWrite).toHaveBeenCalledOnce();\n    const [items] = clipboardWrite.mock.calls[0] as [MockClipboardItem[]];\n    expect(items[0].data['image/png']).toBe(blob);\n  });\n\n  it('throws when render returns null blob', async () => {\n    const target = document.createElement('div');\n    (toBlob as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(null);\n\n    await expect(\n      copyElementAsImageToClipboard(target, {\n        clipboard: { write: vi.fn() },\n        ClipboardItemCtor: MockClipboardItem as unknown as typeof ClipboardItem,\n      }),\n    ).rejects.toThrow('Image render failed');\n  });\n\n  it('throws when clipboard image API is unavailable', async () => {\n    const target = document.createElement('div');\n    (toBlob as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(\n      new Blob(['img'], { type: 'image/png' }),\n    );\n\n    await expect(\n      copyElementAsImageToClipboard(target, {\n        clipboard: null,\n        ClipboardItemCtor: MockClipboardItem as unknown as typeof ClipboardItem,\n      }),\n    ).rejects.toThrow('Clipboard image copy is not supported');\n  });\n\n  it('falls back to a sanitized clone when first render fails on external resources', async () => {\n    const target = document.createElement('div');\n    const img = document.createElement('img');\n    img.src = 'https://example.com/blocked.png';\n    target.appendChild(img);\n    document.body.appendChild(target);\n\n    const blob = new Blob(['img'], { type: 'image/png' });\n    (toBlob as unknown as ReturnType<typeof vi.fn>)\n      .mockRejectedValueOnce(new Error('Failed to fetch resource'))\n      .mockResolvedValueOnce(blob);\n\n    const clipboardWrite = vi.fn().mockResolvedValue(undefined);\n\n    await copyElementAsImageToClipboard(target, {\n      clipboard: { write: clipboardWrite },\n      ClipboardItemCtor: MockClipboardItem as unknown as typeof ClipboardItem,\n    });\n\n    expect(toBlob).toHaveBeenCalledTimes(2);\n\n    const secondCallTarget = (toBlob as unknown as ReturnType<typeof vi.fn>).mock.calls[1]?.[0];\n    expect(secondCallTarget).not.toBe(target);\n    expect((secondCallTarget as HTMLElement).querySelector('img')).toBeNull();\n    expect(clipboardWrite).toHaveBeenCalledOnce();\n  });\n\n  it('falls back when primary render returns null blob', async () => {\n    const target = document.createElement('div');\n    target.textContent = 'content';\n    document.body.appendChild(target);\n\n    const blob = new Blob(['img'], { type: 'image/png' });\n    (toBlob as unknown as ReturnType<typeof vi.fn>)\n      .mockResolvedValueOnce(null)\n      .mockResolvedValueOnce(blob);\n\n    const clipboardWrite = vi.fn().mockResolvedValue(undefined);\n\n    await copyElementAsImageToClipboard(target, {\n      clipboard: { write: clipboardWrite },\n      ClipboardItemCtor: MockClipboardItem as unknown as typeof ClipboardItem,\n    });\n\n    expect(toBlob).toHaveBeenCalledTimes(2);\n    expect(clipboardWrite).toHaveBeenCalledOnce();\n  });\n\n  it('downloads image blob via object URL and revokes it', () => {\n    vi.useFakeTimers();\n    const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test');\n    const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});\n\n    const appendSpy = vi.spyOn(document.body, 'appendChild');\n    const removeSpy = vi.spyOn(document.body, 'removeChild');\n    const clickSpy = vi.fn();\n    const nativeCreateElement = document.createElement.bind(document);\n    const anchor = nativeCreateElement('a');\n    anchor.click = clickSpy;\n    const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName) => {\n      if (tagName.toLowerCase() === 'a') return anchor;\n      return nativeCreateElement(tagName);\n    });\n\n    const blob = new Blob(['img'], { type: 'image/png' });\n    downloadImageBlob(blob, 'reply-image.png');\n\n    expect(createObjectURLSpy).toHaveBeenCalledOnce();\n    expect(anchor.href).toContain('blob:test');\n    expect(anchor.download).toBe('reply-image.png');\n    expect(clickSpy).toHaveBeenCalledOnce();\n    expect(appendSpy).toHaveBeenCalled();\n\n    vi.runAllTimers();\n    expect(removeSpy).toHaveBeenCalled();\n    expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:test');\n\n    createElementSpy.mockRestore();\n    vi.useRealTimers();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/export/__tests__/selectionModeInteraction.test.ts",
    "content": "import { readFileSync } from 'node:fs';\nimport { resolve } from 'node:path';\nimport { describe, expect, it } from 'vitest';\n\ndescribe('selection mode interaction', () => {\n  it('uses checkbox-only selection without select-below behavior', () => {\n    const code = readFileSync(resolve(process.cwd(), 'src/pages/content/export/index.ts'), 'utf8');\n\n    expect(code).not.toContain('gv-export-select-below-pill');\n    expect(code).not.toContain('export_select_mode_select_below');\n    expect(code).not.toContain('selectBelowIds(');\n    expect(code).not.toContain('findSelectionStartIdAtLine(');\n  });\n\n  it('pins selection bar to top and uses top-center compact progress toast styles', () => {\n    const css = readFileSync(resolve(process.cwd(), 'public/contentStyle.css'), 'utf8');\n    const overlayBlock = css.match(/\\.gv-export-progress-overlay\\s*{([\\s\\S]*?)}/)?.[1] ?? '';\n    const cardBlock = css.match(/\\.gv-export-progress-card\\s*{([\\s\\S]*?)}/)?.[1] ?? '';\n\n    expect(css).toMatch(/\\.gv-export-select-bar\\s*{[\\s\\S]*top:\\s*12px;/);\n    expect(css).not.toContain('.gv-export-select-below-pill');\n    expect(overlayBlock).toContain('position: fixed;');\n    expect(overlayBlock).toContain('left: 50%;');\n    expect(overlayBlock).toContain('transform: translateX(-50%);');\n    expect(overlayBlock).toContain('top: 12px;');\n    expect(overlayBlock).toContain('pointer-events: none;');\n    expect(cardBlock).toContain('border-radius: 999px;');\n    expect(cardBlock).toContain('backdrop-filter: blur(10px);');\n  });\n\n  it('supports dark-theme selectors for export dialog and progress toast', () => {\n    const css = readFileSync(resolve(process.cwd(), 'public/contentStyle.css'), 'utf8');\n\n    expect(css).toContain('html.dark-theme .gv-export-dialog');\n    expect(css).toContain('body.dark-theme .gv-export-dialog');\n    expect(css).toContain('html.dark-theme .gv-export-progress-card');\n    expect(css).toContain(\"body[data-theme='dark'] .gv-export-progress-card\");\n  });\n\n  it('keeps logo wrapper from blocking top-bar button clicks', () => {\n    const css = readFileSync(resolve(process.cwd(), 'public/contentStyle.css'), 'utf8');\n    const wrapperBlock = css.match(/\\.gv-logo-dropdown-wrapper\\s*{([\\s\\S]*?)}/)?.[1] ?? '';\n    const logoBlock =\n      css\n        .match(\n          /\\.gv-logo-dropdown-wrapper \\[data-test-id='logo'\\],\\s*\\.gv-logo-dropdown-wrapper \\.logo\\s*{([\\s\\S]*?)}/,\n        )\n        ?.at(1) ?? '';\n\n    expect(wrapperBlock).toContain('pointer-events: none;');\n    expect(wrapperBlock).toContain('width: fit-content;');\n    expect(logoBlock).toContain('pointer-events: auto;');\n  });\n\n  it('wires Safari PDF success path to runtime toast guidance', () => {\n    const code = readFileSync(resolve(process.cwd(), 'src/pages/content/export/index.ts'), 'utf8');\n\n    expect(code).toContain(\"format === 'pdf'\");\n    expect(code).toContain('isSafari()');\n    expect(code).toContain('showExportToast(');\n    expect(code).toContain(\"t('export_toast_safari_pdf_ready')\");\n  });\n\n  it('aligns selection bar and export progress toast with shared alignment hook', () => {\n    const code = readFileSync(resolve(process.cwd(), 'src/pages/content/export/index.ts'), 'utf8');\n\n    expect(code).toContain('function alignElementToConversationTitleCenter(');\n    expect(code).toContain('cleanupTasks.push(alignElementToConversationTitleCenter(bar));');\n    expect(code).toContain(\n      'const unbindAlignment = alignElementToConversationTitleCenter(overlay);',\n    );\n  });\n\n  it('falls back to direct download on Safari when clipboard copy fails', () => {\n    const code = readFileSync(resolve(process.cwd(), 'src/pages/content/export/index.ts'), 'utf8');\n\n    expect(code).toContain('let blobForFallback: Blob | null = null;');\n    expect(code).toContain('if (isSafari() && blobForFallback)');\n    expect(code).toContain('downloadImageBlob(blobForFallback, buildResponseImageFilename());');\n  });\n\n  it('uses conversation canvas based alignment and avoids sidebar title selectors', () => {\n    const code = readFileSync(resolve(process.cwd(), 'src/pages/content/export/index.ts'), 'utf8');\n\n    expect(code).toContain('function resolveConversationCanvasCenterX(');\n    expect(code).toContain('#chat-history');\n    expect(code).toContain('infinite-scroller.chat-history');\n    expect(code).toContain('function isLikelySidebarElement(');\n    expect(code).not.toContain('function resolveConversationTitleElement(');\n    expect(code).not.toContain('candidate.closest(\\'[data-test-id=\"conversation\"]\\')');\n  });\n\n  it('renders role-based selection buttons with correct data actions and localization keys', () => {\n    const code = readFileSync(resolve(process.cwd(), 'src/pages/content/export/index.ts'), 'utf8');\n\n    // Confirm building of buttons\n    expect(code).toContain(\"dataset.gvExportAction = 'selectUser'\");\n    expect(code).toContain(\"dataset.gvExportAction = 'selectAI'\");\n    expect(code).toContain(\"className = 'gv-export-select-role-btn'\");\n\n    // Confirm translation keys are used\n    expect(code).toContain(\"t('export_select_mode_only_user')\");\n    expect(code).toContain(\"t('export_select_mode_only_ai')\");\n  });\n\n  it('applies horizontal scrolling to the export selection bar and prevents text wrapping', () => {\n    const css = readFileSync(resolve(process.cwd(), 'public/contentStyle.css'), 'utf8');\n\n    // Check for container scroll behaviors\n    const barBlock = css.match(/\\.gv-export-select-bar\\s*{([\\s\\S]*?)}/)?.[1] ?? '';\n    expect(barBlock).toContain('overflow-x: auto;');\n    expect(barBlock).toContain('scrollbar-width: none;');\n\n    // Check for nowrapping and no shrinking on buttons\n    const btnBlock =\n      css.match(\n        /\\.gv-export-select-all-toggle,\\s*\\.gv-export-select-role-btn\\s*{([\\s\\S]*?)}/,\n      )?.[1] ?? '';\n    expect(btnBlock).toContain('white-space: nowrap;');\n    expect(btnBlock).toContain('flex-shrink: 0;');\n  });\n});\n"
  },
  {
    "path": "src/pages/content/export/__tests__/selectionUtils.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport {\n  filterItemsBySelectedIds,\n  findSelectionStartIdAtLine,\n  groupSelectedMessagesByTurn,\n  resolveInitialSelectedMessageIds,\n  selectBelowIds,\n} from '../selectionUtils';\n\ndescribe('selectionUtils', () => {\n  describe('filterItemsBySelectedIds', () => {\n    it('filters items by selected ids and keeps order', () => {\n      const items: Array<{ id: string }> = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];\n      const selected = new Set(['c', 'b']);\n\n      const result = filterItemsBySelectedIds(items, (x) => x.id, selected);\n\n      expect(result.map((x) => x.id)).toEqual(['b', 'c']);\n    });\n\n    it('drops items without an id', () => {\n      const items: Array<{ id?: string }> = [{ id: 'a' }, {}, { id: 'b' }];\n      const selected = new Set(['a', 'b']);\n\n      const result = filterItemsBySelectedIds(items, (x) => x.id, selected);\n\n      expect(result).toHaveLength(2);\n      expect(result[0].id).toBe('a');\n      expect(result[1].id).toBe('b');\n    });\n  });\n\n  describe('selectBelowIds', () => {\n    it('selects ids starting from the given id (inclusive)', () => {\n      const ids = ['a', 'b', 'c', 'd'];\n      const selected = selectBelowIds(ids, 'c');\n\n      expect(Array.from(selected)).toEqual(['c', 'd']);\n    });\n\n    it('returns empty set when start id is not found', () => {\n      const ids = ['a', 'b'];\n      const selected = selectBelowIds(ids, 'missing');\n\n      expect(selected.size).toBe(0);\n    });\n  });\n\n  describe('findSelectionStartIdAtLine', () => {\n    it('returns intersecting item when line falls inside its bounds', () => {\n      const items = [\n        { id: 'a', top: -10, bottom: 10 },\n        { id: 'b', top: 12, bottom: 20 },\n      ];\n\n      expect(findSelectionStartIdAtLine(items, 0)).toBe('a');\n    });\n\n    it('returns next item when no item intersects the line', () => {\n      const items = [\n        { id: 'a', top: -20, bottom: -10 },\n        { id: 'b', top: 5, bottom: 20 },\n        { id: 'c', top: 25, bottom: 40 },\n      ];\n\n      expect(findSelectionStartIdAtLine(items, 0)).toBe('b');\n    });\n\n    it('returns null when there is no item at or below line', () => {\n      const items = [{ id: 'a', top: -20, bottom: -10 }];\n\n      expect(findSelectionStartIdAtLine(items, 0)).toBe(null);\n    });\n  });\n\n  describe('groupSelectedMessagesByTurn', () => {\n    it('groups user and assistant messages of same turn', () => {\n      const selected = [\n        { messageId: 't1:u', role: 'user' as const, text: 'U1', starred: false },\n        { messageId: 't1:a', role: 'assistant' as const, text: 'A1', starred: true },\n      ];\n\n      const turns = groupSelectedMessagesByTurn(selected);\n\n      expect(turns).toHaveLength(1);\n      expect(turns[0].turnId).toBe('t1');\n      expect(turns[0].user?.text).toBe('U1');\n      expect(turns[0].assistant?.text).toBe('A1');\n      expect(turns[0].starred).toBe(true);\n    });\n\n    it('keeps assistant-only selections as one grouped turn', () => {\n      const selected = [\n        { messageId: 't2:a', role: 'assistant' as const, text: 'A2', starred: false },\n      ];\n\n      const turns = groupSelectedMessagesByTurn(selected);\n\n      expect(turns).toHaveLength(1);\n      expect(turns[0].turnId).toBe('t2');\n      expect(turns[0].user).toBeUndefined();\n      expect(turns[0].assistant?.text).toBe('A2');\n    });\n\n    it('preserves visual order by first selected message occurrence', () => {\n      const selected = [\n        { messageId: 't2:a', role: 'assistant' as const, text: 'A2', starred: false },\n        { messageId: 't1:u', role: 'user' as const, text: 'U1', starred: false },\n        { messageId: 't1:a', role: 'assistant' as const, text: 'A1', starred: false },\n      ];\n\n      const turns = groupSelectedMessagesByTurn(selected);\n\n      expect(turns.map((turn) => turn.turnId)).toEqual(['t2', 't1']);\n    });\n  });\n\n  describe('resolveInitialSelectedMessageIds', () => {\n    it('returns only the preferred id when it exists in all ids', () => {\n      const allIds = ['t1:u', 't1:a', 't2:u'];\n      const selected = resolveInitialSelectedMessageIds(allIds, 't1:a');\n\n      expect(Array.from(selected)).toEqual(['t1:a']);\n    });\n\n    it('returns empty set when preferred id is missing', () => {\n      const allIds = ['t1:u', 't1:a'];\n      const selected = resolveInitialSelectedMessageIds(allIds, 't9:a');\n\n      expect(selected.size).toBe(0);\n    });\n\n    it('returns empty set when preferred id is not provided', () => {\n      const allIds = ['t1:u', 't1:a'];\n      const selected = resolveInitialSelectedMessageIds(allIds, null);\n\n      expect(selected.size).toBe(0);\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/content/export/__tests__/sidebarConversationTarget.test.ts",
    "content": "import { afterEach, describe, expect, it } from 'vitest';\n\nimport { resolveSidebarConversationTarget } from '../sidebarConversationTarget';\n\nfunction asRect(left: number, top: number, width: number, height: number): DOMRect {\n  const right = left + width;\n  const bottom = top + height;\n  return {\n    x: left,\n    y: top,\n    width,\n    height,\n    top,\n    left,\n    right,\n    bottom,\n    toJSON: () => ({}),\n  } as DOMRect;\n}\n\nfunction setRect(el: Element, left: number, top: number, width: number = 100, height: number = 28) {\n  (el as HTMLElement).getBoundingClientRect = () => asRect(left, top, width, height);\n}\n\nfunction createConversation(id: string, title: string): HTMLAnchorElement {\n  const link = document.createElement('a');\n  link.setAttribute('data-test-id', 'conversation');\n  link.href = `https://gemini.google.com/app/${id}`;\n  link.textContent = title;\n  document.body.appendChild(link);\n  return link;\n}\n\ndescribe('resolveSidebarConversationTarget', () => {\n  afterEach(() => {\n    document.body.innerHTML = '';\n  });\n\n  it('resolves target from aria-label title hint', () => {\n    const sidebar = document.createElement('div');\n    sidebar.setAttribute('data-test-id', 'overflow-container');\n    document.body.appendChild(sidebar);\n\n    createConversation('abc123', 'A股PEG估值与SEPA策略');\n    const trigger = document.createElement('button');\n    trigger.setAttribute('data-test-id', 'actions-menu-button');\n    trigger.setAttribute('aria-label', 'More options for A股PEG估值与SEPA策略');\n    sidebar.appendChild(trigger);\n\n    const target = resolveSidebarConversationTarget(trigger);\n    expect(target?.conversationId).toBe('abc123');\n  });\n\n  it('prefers nearest conversation when titles duplicate', () => {\n    const sidebar = document.createElement('div');\n    sidebar.setAttribute('data-test-id', 'overflow-container');\n    document.body.appendChild(sidebar);\n\n    const near = createConversation('near-id', 'Duplicate title');\n    const far = createConversation('far-id', 'Duplicate title');\n    const trigger = document.createElement('button');\n    trigger.setAttribute('data-test-id', 'actions-menu-button');\n    trigger.setAttribute('aria-label', 'More options for Duplicate title');\n    sidebar.appendChild(trigger);\n\n    setRect(trigger, 20, 210, 28, 28);\n    setRect(near, 40, 200, 220, 32);\n    setRect(far, 40, 420, 220, 32);\n\n    const target = resolveSidebarConversationTarget(trigger);\n    expect(target?.conversationId).toBe('near-id');\n  });\n\n  it('uses closest conversation container when trigger is nested inside it', () => {\n    const conversation = document.createElement('div');\n    conversation.setAttribute('data-test-id', 'conversation');\n    const link = document.createElement('a');\n    link.href = 'https://gemini.google.com/app/nested-id';\n    link.textContent = 'Nested title';\n    conversation.appendChild(link);\n\n    const trigger = document.createElement('button');\n    trigger.setAttribute('data-test-id', 'actions-menu-button');\n    trigger.setAttribute('aria-label', 'More options for Nested title');\n    conversation.appendChild(trigger);\n\n    document.body.appendChild(conversation);\n\n    const target = resolveSidebarConversationTarget(trigger);\n    expect(target?.conversationId).toBe('nested-id');\n  });\n\n  it('returns null when no conversation target can be inferred', () => {\n    const trigger = document.createElement('button');\n    trigger.setAttribute('data-test-id', 'actions-menu-button');\n    trigger.setAttribute('aria-label', 'More options for Missing title');\n    document.body.appendChild(trigger);\n\n    const target = resolveSidebarConversationTarget(trigger);\n    expect(target).toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/export/__tests__/sidebarExportResume.test.ts",
    "content": "import { afterEach, describe, expect, it } from 'vitest';\n\nimport {\n  consumePendingSidebarExportIntent,\n  persistPendingSidebarExportIntent,\n} from '../sidebarExportResume';\n\ndescribe('sidebarExportResume', () => {\n  afterEach(() => {\n    sessionStorage.clear();\n  });\n\n  it('persists pending sidebar export intent', () => {\n    persistPendingSidebarExportIntent('conv-123', 1000);\n\n    const raw = sessionStorage.getItem('gv_sidebar_export_pending');\n    expect(raw).toBeTruthy();\n    expect(raw).toContain('\"conversationId\":\"conv-123\"');\n  });\n\n  it('consumes pending intent when current conversation matches', () => {\n    persistPendingSidebarExportIntent('conv-123', 1000);\n\n    const shouldResume = consumePendingSidebarExportIntent('conv-123', 1000 + 20_000);\n    expect(shouldResume).toBe(true);\n    expect(sessionStorage.getItem('gv_sidebar_export_pending')).toBeNull();\n  });\n\n  it('clears pending intent when current conversation does not match', () => {\n    persistPendingSidebarExportIntent('conv-123', 1000);\n\n    const shouldResume = consumePendingSidebarExportIntent('conv-999', 1000 + 20_000);\n    expect(shouldResume).toBe(false);\n    expect(sessionStorage.getItem('gv_sidebar_export_pending')).toBeNull();\n  });\n\n  it('clears expired pending intent', () => {\n    persistPendingSidebarExportIntent('conv-123', 1000);\n\n    const shouldResume = consumePendingSidebarExportIntent('conv-123', 1000 + 90_000);\n    expect(shouldResume).toBe(false);\n    expect(sessionStorage.getItem('gv_sidebar_export_pending')).toBeNull();\n  });\n\n  it('clears malformed pending intent payload', () => {\n    sessionStorage.setItem('gv_sidebar_export_pending', '{\"conversationId\":1}');\n\n    const shouldResume = consumePendingSidebarExportIntent('conv-123', 1000);\n    expect(shouldResume).toBe(false);\n    expect(sessionStorage.getItem('gv_sidebar_export_pending')).toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/export/__tests__/topNodePreload.test.ts",
    "content": "import { afterEach, describe, expect, it, vi } from 'vitest';\n\nimport {\n  computeConversationFingerprint,\n  waitForConversationFingerprintChangeOrTimeout,\n} from '../topNodePreload';\n\nasync function flushMicrotasks(): Promise<void> {\n  await Promise.resolve();\n  await Promise.resolve();\n}\n\ndescribe('topNodePreload', () => {\n  afterEach(() => {\n    vi.useRealTimers();\n    vi.restoreAllMocks();\n    document.body.innerHTML = '';\n  });\n\n  it('detects a delayed history expansion and waits for idle', async () => {\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));\n    document.body.innerHTML = '';\n\n    const root = document.createElement('div');\n    document.body.appendChild(root);\n\n    const b = document.createElement('div');\n    b.className = 'msg';\n    b.textContent = 'B';\n    root.appendChild(b);\n\n    const c = document.createElement('div');\n    c.className = 'msg';\n    c.textContent = 'C';\n    root.appendChild(c);\n\n    const selectors = ['.msg'];\n    const before = computeConversationFingerprint(root, selectors, 5);\n\n    let resolved = false;\n    const promise = waitForConversationFingerprintChangeOrTimeout(root, selectors, before, {\n      timeoutMs: 5000,\n      idleMs: 200,\n      pollIntervalMs: 50,\n      maxSamples: 5,\n    }).then((r) => {\n      resolved = true;\n      return r;\n    });\n\n    setTimeout(() => {\n      const a = document.createElement('div');\n      a.className = 'msg';\n      a.textContent = 'A';\n      root.insertBefore(a, root.firstChild);\n    }, 100);\n\n    setTimeout(() => {\n      const first = root.querySelector('.msg');\n      if (first) first.textContent = 'A2';\n    }, 250);\n\n    vi.advanceTimersByTime(350);\n    await flushMicrotasks();\n    expect(resolved).toBe(false);\n\n    vi.advanceTimersByTime(400);\n    await flushMicrotasks();\n\n    const result = await promise;\n    expect(result.changed).toBe(true);\n    expect(result.fingerprint.count).toBe(3);\n    expect(result.fingerprint.signature).not.toBe(before.signature);\n  });\n\n  it('times out as stable when nothing changes', async () => {\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));\n    document.body.innerHTML = '';\n\n    const root = document.createElement('div');\n    document.body.appendChild(root);\n\n    const b = document.createElement('div');\n    b.className = 'msg';\n    b.textContent = 'B';\n    root.appendChild(b);\n\n    const selectors = ['.msg'];\n    const before = computeConversationFingerprint(root, selectors, 5);\n\n    const promise = waitForConversationFingerprintChangeOrTimeout(root, selectors, before, {\n      timeoutMs: 600,\n      idleMs: 100,\n      pollIntervalMs: 50,\n      maxSamples: 5,\n    });\n\n    vi.advanceTimersByTime(700);\n    await flushMicrotasks();\n\n    const result = await promise;\n    expect(result.changed).toBe(false);\n    expect(result.fingerprint.signature).toBe(before.signature);\n  });\n\n  it('resolves unchanged fingerprint within about one second by default', async () => {\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));\n    document.body.innerHTML = '';\n\n    const root = document.createElement('div');\n    document.body.appendChild(root);\n\n    const node = document.createElement('div');\n    node.className = 'msg';\n    node.textContent = 'stable';\n    root.appendChild(node);\n\n    const selectors = ['.msg'];\n    const before = computeConversationFingerprint(root, selectors, 5);\n\n    let resolved = false;\n    const promise = waitForConversationFingerprintChangeOrTimeout(root, selectors, before, {\n      timeoutMs: 3000,\n      idleMs: 120,\n      pollIntervalMs: 40,\n      maxSamples: 5,\n    }).then((result) => {\n      resolved = true;\n      return result;\n    });\n\n    await vi.advanceTimersByTimeAsync(1000);\n    expect(resolved).toBe(true);\n\n    const result = await promise;\n    expect(result.changed).toBe(false);\n    expect(result.fingerprint.signature).toBe(before.signature);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/export/conversationDom.ts",
    "content": "type ResolveConversationRootOptions = {\n  userSelectors: string[];\n  doc?: Document;\n};\n\nconst CONVERSATION_ROOT_CANDIDATES = [\n  '#chat-history',\n  'infinite-scroller.chat-history',\n  'chat-window-content',\n  'main',\n];\n\nexport function filterOutDeepResearchImmersiveNodes<T extends HTMLElement>(elements: T[]): T[] {\n  return elements.filter((element) => !element.closest('deep-research-immersive-panel'));\n}\n\nfunction hasVisibleUserTurns(root: HTMLElement, userSelectors: string[]): boolean {\n  if (userSelectors.length === 0) return false;\n  const nodes = filterOutDeepResearchImmersiveNodes(\n    Array.from(root.querySelectorAll<HTMLElement>(userSelectors.join(','))),\n  );\n  return nodes.length > 0;\n}\n\nexport function resolveConversationRoot({\n  userSelectors,\n  doc = document,\n}: ResolveConversationRootOptions): HTMLElement {\n  const body = doc.body as HTMLElement;\n  let firstCandidate: HTMLElement | null = null;\n\n  for (const selector of CONVERSATION_ROOT_CANDIDATES) {\n    const candidate = doc.querySelector(selector) as HTMLElement | null;\n    if (!candidate) continue;\n    if (!firstCandidate) firstCandidate = candidate;\n    if (hasVisibleUserTurns(candidate, userSelectors)) return candidate;\n  }\n\n  return firstCandidate || body;\n}\n"
  },
  {
    "path": "src/pages/content/export/conversationMenuInjection.ts",
    "content": "import {\n  createMenuItemFromNativeTemplate,\n  updateMenuItemTemplateLabel,\n} from '../shared/nativeMenuItemTemplate';\n\nexport type ConversationMenuExportOptions = {\n  label: string;\n  tooltip: string;\n  onClick: () => void;\n};\n\nexport type ConversationMenuType = 'top' | 'sidebar';\n\nexport type ConversationMenuContext = {\n  menuType: ConversationMenuType;\n  trigger: HTMLElement | null;\n};\n\nexport type ResponseMenuContext = {\n  trigger: HTMLElement | null;\n};\n\nconst MENU_BUTTON_CLASS = 'gv-export-conversation-menu-btn';\nconst RESPONSE_MENU_BUTTON_CLASS = 'gv-export-response-menu-btn';\nconst MENU_PANEL_SELECTOR = '.mat-mdc-menu-panel[role=\"menu\"]';\nconst SIDEBAR_CONTAINER_SELECTOR = '[data-test-id=\"overflow-container\"]';\nconst EXPANDED_MENU_TRIGGER_SELECTOR = '[aria-haspopup=\"menu\"][aria-expanded=\"true\"]';\nconst RESPONSE_MORE_MENU_TRIGGER_TEST_ID = 'more-menu-button';\n\nfunction findMenuContent(menuPanel: HTMLElement): HTMLElement | null {\n  return menuPanel.querySelector('.mat-mdc-menu-content') as HTMLElement | null;\n}\n\nfunction parseControlledIds(trigger: HTMLElement): string[] {\n  const raw = `${trigger.getAttribute('aria-controls') || ''} ${\n    trigger.getAttribute('aria-owns') || ''\n  }`;\n  return raw\n    .split(/\\s+/)\n    .map((x) => x.trim())\n    .filter(Boolean);\n}\n\nfunction resolveExpandedMenuTrigger(menuPanel: HTMLElement): HTMLElement | null {\n  const triggers = Array.from(\n    document.querySelectorAll<HTMLElement>(EXPANDED_MENU_TRIGGER_SELECTOR),\n  );\n  if (triggers.length === 0) return null;\n\n  const panelId = menuPanel.id;\n  if (panelId) {\n    const matched = triggers.find((trigger) => parseControlledIds(trigger).includes(panelId));\n    if (matched) return matched;\n  }\n\n  return triggers[triggers.length - 1] || null;\n}\n\nfunction isSidebarConversationTrigger(trigger: HTMLElement): boolean {\n  return !!trigger.closest(SIDEBAR_CONTAINER_SELECTOR);\n}\n\nfunction hasDeepResearchReportMarkers(menuContent: HTMLElement): boolean {\n  return Boolean(\n    menuContent.querySelector('[data-test-id=\"share-button-tooltip-container\"]') ||\n      menuContent.querySelector('[data-test-id=\"export-to-docs-button\"]') ||\n      menuContent.querySelector('[data-test-id=\"copy-button\"]'),\n  );\n}\n\nfunction updateButtonLabelAndTooltip(\n  button: HTMLButtonElement,\n  label: string,\n  tooltip: string,\n): void {\n  updateMenuItemTemplateLabel(button, label, tooltip);\n}\n\nfunction findMenuButtonByIcon(\n  menuContent: HTMLElement,\n  iconName: string,\n): HTMLButtonElement | null {\n  const buttons = Array.from(\n    menuContent.querySelectorAll<HTMLButtonElement>('button.mat-mdc-menu-item'),\n  );\n  return (\n    buttons.find((button) => {\n      const icon = button.querySelector('mat-icon');\n      if (!icon) return false;\n      const fontIcon = icon.getAttribute('fonticon') || icon.getAttribute('data-mat-icon-name');\n      if (fontIcon === iconName) return true;\n      return icon.textContent?.trim() === iconName;\n    }) ?? null\n  );\n}\n\nfunction closeMenuOverlay(menuPanel: HTMLElement): void {\n  const backdrops = document.querySelectorAll<HTMLElement>('.cdk-overlay-backdrop');\n  const backdrop = backdrops.length > 0 ? backdrops[backdrops.length - 1] : null;\n  if (backdrop) {\n    backdrop.click();\n    return;\n  }\n\n  try {\n    menuPanel.remove();\n  } catch {}\n}\n\nfunction createMenuItemButton({\n  label,\n  tooltip,\n  onClick,\n  menuContent,\n  menuPanel,\n  injectedClassName,\n  iconName,\n  excludedClassNames = [],\n}: ConversationMenuExportOptions & {\n  menuContent: HTMLElement;\n  menuPanel: HTMLElement;\n  injectedClassName: string;\n  iconName: string;\n  excludedClassNames?: string[];\n}): HTMLButtonElement | null {\n  const button = createMenuItemFromNativeTemplate({\n    menuContent,\n    injectedClassName,\n    iconName,\n    label,\n    tooltip,\n    excludedClassNames,\n  });\n  if (!button) return null;\n\n  button.addEventListener('click', (event) => {\n    event.preventDefault();\n    event.stopPropagation();\n    onClick();\n    closeMenuOverlay(menuPanel);\n  });\n\n  return button;\n}\n\nexport function isConversationMenuPanel(menuPanel: HTMLElement): boolean {\n  if (!menuPanel.matches(MENU_PANEL_SELECTOR)) return false;\n  if (menuPanel.classList.contains('gds-mode-switch-menu')) return false;\n  if (menuPanel.querySelector('.bard-mode-list-button')) return false;\n\n  const menuContent = findMenuContent(menuPanel);\n  if (!menuContent) return false;\n  if (hasDeepResearchReportMarkers(menuContent)) return false;\n\n  const hasConversationActions = Boolean(\n    menuContent.querySelector('[data-test-id=\"pin-button\"]') ||\n      menuContent.querySelector('[data-test-id=\"rename-button\"]') ||\n      menuContent.querySelector('[data-test-id=\"delete-button\"]'),\n  );\n  if (hasConversationActions) return true;\n\n  const hasShareButton = Boolean(menuContent.querySelector('[data-test-id=\"share-button\"]'));\n  if (!hasShareButton) return false;\n\n  const trigger = resolveExpandedMenuTrigger(menuPanel);\n  return trigger?.getAttribute('data-test-id') === 'actions-menu-button';\n}\n\nexport function getConversationMenuContext(menuPanel: HTMLElement): ConversationMenuContext | null {\n  if (!isConversationMenuPanel(menuPanel)) return null;\n  const trigger = resolveExpandedMenuTrigger(menuPanel);\n  return {\n    menuType: trigger && isSidebarConversationTrigger(trigger) ? 'sidebar' : 'top',\n    trigger,\n  };\n}\n\nfunction isResponseMenuTrigger(trigger: HTMLElement | null): boolean {\n  return trigger?.getAttribute('data-test-id') === RESPONSE_MORE_MENU_TRIGGER_TEST_ID;\n}\n\nexport function isResponseMenuPanel(menuPanel: HTMLElement): boolean {\n  if (!menuPanel.matches(MENU_PANEL_SELECTOR)) return false;\n  if (menuPanel.classList.contains('gds-mode-switch-menu')) return false;\n\n  const menuContent = findMenuContent(menuPanel);\n  if (!menuContent) return false;\n  if (hasDeepResearchReportMarkers(menuContent)) return false;\n\n  const trigger = resolveExpandedMenuTrigger(menuPanel);\n  if (isResponseMenuTrigger(trigger)) return true;\n\n  // Fallback for cases where panel/trigger linkage is not exposed during async menu rendering.\n  const hasDocsAction = !!findMenuButtonByIcon(menuContent, 'docs');\n  const hasGmailAction = !!findMenuButtonByIcon(menuContent, 'gmail');\n  const hasLegalReportAction = !!findMenuButtonByIcon(menuContent, 'flag');\n  return hasDocsAction && (hasGmailAction || hasLegalReportAction);\n}\n\nexport function getResponseMenuContext(menuPanel: HTMLElement): ResponseMenuContext | null {\n  if (!isResponseMenuPanel(menuPanel)) return null;\n  return { trigger: resolveExpandedMenuTrigger(menuPanel) };\n}\n\nexport function injectConversationMenuExportButton(\n  menuPanel: HTMLElement,\n  options: ConversationMenuExportOptions,\n): HTMLButtonElement | null {\n  if (!isConversationMenuPanel(menuPanel)) return null;\n  const menuContent = findMenuContent(menuPanel);\n  if (!menuContent) return null;\n\n  const existing = menuContent.querySelector(`.${MENU_BUTTON_CLASS}`) as HTMLButtonElement | null;\n  if (existing) {\n    updateButtonLabelAndTooltip(existing, options.label, options.tooltip);\n    return existing;\n  }\n\n  const button = createMenuItemButton({\n    ...options,\n    menuContent,\n    menuPanel,\n    injectedClassName: MENU_BUTTON_CLASS,\n    iconName: 'download',\n    excludedClassNames: ['gv-move-to-folder-btn', RESPONSE_MENU_BUTTON_CLASS],\n  });\n  if (!button) return null;\n  const pinButton = menuContent.querySelector('[data-test-id=\"pin-button\"]');\n  if (pinButton && pinButton.parentElement === menuContent) {\n    if (pinButton.nextSibling) {\n      menuContent.insertBefore(button, pinButton.nextSibling);\n    } else {\n      menuContent.appendChild(button);\n    }\n  } else if (menuContent.firstChild) {\n    menuContent.insertBefore(button, menuContent.firstChild);\n  } else {\n    menuContent.appendChild(button);\n  }\n\n  return button;\n}\n\nexport function injectResponseMenuExportButton(\n  menuPanel: HTMLElement,\n  options: ConversationMenuExportOptions,\n): HTMLButtonElement | null {\n  if (!isResponseMenuPanel(menuPanel)) return null;\n  const menuContent = findMenuContent(menuPanel);\n  if (!menuContent) return null;\n\n  const existing = menuContent.querySelector(\n    `.${RESPONSE_MENU_BUTTON_CLASS}`,\n  ) as HTMLButtonElement | null;\n  if (existing) {\n    updateButtonLabelAndTooltip(existing, options.label, options.tooltip);\n    return existing;\n  }\n\n  const button = createMenuItemButton({\n    ...options,\n    menuContent,\n    menuPanel,\n    injectedClassName: RESPONSE_MENU_BUTTON_CLASS,\n    iconName: 'download',\n    excludedClassNames: [MENU_BUTTON_CLASS, 'gv-move-to-folder-btn'],\n  });\n  if (!button) return null;\n\n  const exportToDocsButton = findMenuButtonByIcon(menuContent, 'docs');\n  if (exportToDocsButton && exportToDocsButton.parentElement === menuContent) {\n    if (exportToDocsButton.nextSibling) {\n      menuContent.insertBefore(button, exportToDocsButton.nextSibling);\n    } else {\n      menuContent.appendChild(button);\n    }\n  } else if (menuContent.firstChild) {\n    menuContent.insertBefore(button, menuContent.firstChild);\n  } else {\n    menuContent.appendChild(button);\n  }\n\n  return button;\n}\n"
  },
  {
    "path": "src/pages/content/export/index.ts",
    "content": "// Static imports to avoid CSP issues with dynamic imports in content scripts\nimport { StorageKeys } from '@/core/types/common';\nimport { isSafari } from '@/core/utils/browser';\nimport { type AppLanguage, normalizeLanguage } from '@/utils/language';\nimport { extractMessageDictionary } from '@/utils/localeMessages';\nimport type { TranslationKey } from '@/utils/translations';\n\nimport { ConversationExportService } from '../../../features/export/services/ConversationExportService';\nimport { ImageExportService } from '../../../features/export/services/ImageExportService';\nimport type {\n  ConversationMetadata,\n  ChatTurn as ExportChatTurn,\n  ExportFormat,\n} from '../../../features/export/types/export';\nimport { ExportDialog } from '../../../features/export/ui/ExportDialog';\nimport { resolveExportErrorMessage } from '../../../features/export/ui/ExportErrorMessage';\nimport { showExportToast } from '../../../features/export/ui/ExportToast';\nimport { filterOutDeepResearchImmersiveNodes, resolveConversationRoot } from './conversationDom';\nimport {\n  getConversationMenuContext,\n  getResponseMenuContext,\n  injectConversationMenuExportButton,\n  injectResponseMenuExportButton,\n} from './conversationMenuInjection';\nimport { injectResponseActionCopyImageButtons } from './responseActionImageButton';\nimport { copyImageBlobToClipboard, downloadImageBlob } from './responseImageCopy';\nimport { groupSelectedMessagesByTurn, resolveInitialSelectedMessageIds } from './selectionUtils';\nimport { resolveSidebarConversationTarget } from './sidebarConversationTarget';\nimport {\n  computeConversationFingerprint,\n  waitForConversationFingerprintChangeOrTimeout,\n} from './topNodePreload';\n\n// Storage key to persist export state across reloads (e.g. when clicking top node triggers refresh)\nconst SESSION_KEY_PENDING_EXPORT = 'gv_export_pending';\nconst CONVERSATION_MENU_SELECTOR = '.mat-mdc-menu-panel[role=\"menu\"]';\nconst CONVERSATION_MENU_TRIGGER_TEST_ID = 'actions-menu-button';\nconst RESPONSE_MENU_TRIGGER_TEST_ID = 'more-menu-button';\nconst MENU_INJECTION_RETRY_LIMIT = 8;\nconst MENU_INJECTION_RETRY_DELAY_MS = 80;\nconst EXPORT_PRELOAD_WAIT_OPTIONS = {\n  timeoutMs: 12000,\n  minWaitMs: 700,\n  idleMs: 320,\n  pollIntervalMs: 90,\n  maxSamples: 10,\n} as const;\nconst FINAL_EXPORT_PREPARE_DELAY_MS = 120;\nlet conversationMenuObserver: MutationObserver | null = null;\nlet responseActionObserver: MutationObserver | null = null;\n\ninterface PendingExportState {\n  format: ExportFormat;\n  fontSize?: number;\n  initialSelectedMessageId?: string;\n  attempt: number;\n  url: string;\n  status: 'clicking';\n  timestamp: number;\n}\n\nfunction hashString(input: string): string {\n  let h = 2166136261 >>> 0;\n  for (let i = 0; i < input.length; i++) {\n    h ^= input.charCodeAt(i);\n    h = Math.imul(h, 16777619);\n  }\n  return (h >>> 0).toString(36);\n}\n\nfunction isExportFormat(value: unknown): value is ExportFormat {\n  return value === 'json' || value === 'markdown' || value === 'pdf' || value === 'image';\n}\n\nfunction waitForElement(selector: string, timeoutMs: number = 6000): Promise<Element | null> {\n  return new Promise((resolve) => {\n    const el = document.querySelector(selector);\n    if (el) return resolve(el);\n    const obs = new MutationObserver(() => {\n      const found = document.querySelector(selector);\n      if (found) {\n        try {\n          obs.disconnect();\n        } catch {}\n        resolve(found);\n      }\n    });\n    try {\n      obs.observe(document.body, { childList: true, subtree: true });\n    } catch {}\n    if (timeoutMs > 0)\n      setTimeout(() => {\n        try {\n          obs.disconnect();\n        } catch {}\n        resolve(null);\n      }, timeoutMs);\n  });\n}\n\nfunction waitForAnyElement(\n  selectors: string[],\n  timeoutMs: number = 10000,\n): Promise<Element | null> {\n  return new Promise((resolve) => {\n    // Check first\n    for (const s of selectors) {\n      const el = document.querySelector(s);\n      if (el) return resolve(el);\n    }\n\n    const obs = new MutationObserver(() => {\n      for (const s of selectors) {\n        const found = document.querySelector(s);\n        if (found) {\n          try {\n            obs.disconnect();\n          } catch {}\n          resolve(found);\n          return;\n        }\n      }\n    });\n\n    try {\n      obs.observe(document.body, { childList: true, subtree: true });\n    } catch {}\n\n    if (timeoutMs > 0)\n      setTimeout(() => {\n        try {\n          obs.disconnect();\n        } catch {}\n        resolve(null);\n      }, timeoutMs);\n  });\n}\n\nfunction normalizeText(text: string | null): string {\n  try {\n    return String(text || '')\n      .replace(/\\s+/g, ' ')\n      .trim();\n  } catch {\n    return '';\n  }\n}\n\n// Note: cleaning of thinking toggles is handled at DOM level in extractAssistantText\n\n/**\n * querySelector variant that skips elements nested inside model-thoughts / thoughts-container.\n * When the user expands Gemini's \"thinking\" section, a second `message-content` element\n * appears *before* the real response in DOM order.  A plain `querySelector` would match\n * the thinking panel first, causing exports to grab the wrong content.\n */\nfunction queryOutsideThoughts<T extends Element = Element>(\n  root: Element,\n  selector: string,\n): T | null {\n  const candidates = root.querySelectorAll<T>(selector);\n  for (const el of Array.from(candidates)) {\n    if (!el.closest('model-thoughts, .thoughts-container, .thoughts-content')) {\n      return el;\n    }\n  }\n  return null;\n}\n\nfunction filterTopLevel(elements: Element[]): HTMLElement[] {\n  const arr = elements.map((e) => e as HTMLElement);\n  const out: HTMLElement[] = [];\n  for (let i = 0; i < arr.length; i++) {\n    const el = arr[i];\n    let isDescendant = false;\n    for (let j = 0; j < arr.length; j++) {\n      if (i === j) continue;\n      const other = arr[j];\n      if (other.contains(el)) {\n        isDescendant = true;\n        break;\n      }\n    }\n    if (!isDescendant) out.push(el);\n  }\n  return out;\n}\n\nfunction getConversationRoot(userSelectors: string[]): HTMLElement {\n  return resolveConversationRoot({ userSelectors, doc: document });\n}\n\nfunction computeConversationId(): string {\n  const raw = `${location.host}${location.pathname}${location.search}`;\n  return `gemini:${hashString(raw)}`;\n}\n\nfunction getUserSelectors(): string[] {\n  const configured = (() => {\n    try {\n      return (\n        localStorage.getItem('geminiTimelineUserTurnSelector') ||\n        localStorage.getItem('geminiTimelineUserTurnSelectorAuto') ||\n        ''\n      );\n    } catch {\n      return '';\n    }\n  })();\n  const defaults = [\n    '.user-query-bubble-with-background',\n    '.user-query-bubble-container',\n    '.user-query-container',\n    'user-query-content .user-query-bubble-with-background',\n    'div[aria-label=\"User message\"]',\n    'article[data-author=\"user\"]',\n    'article[data-turn=\"user\"]',\n    '[data-message-author-role=\"user\"]',\n    'div[role=\"listitem\"][data-user=\"true\"]',\n  ];\n  return configured ? [configured, ...defaults.filter((s) => s !== configured)] : defaults;\n}\n\nfunction getAssistantSelectors(): string[] {\n  return [\n    // Attribute-based roles\n    '[aria-label=\"Gemini response\"]',\n    '[data-message-author-role=\"assistant\"]',\n    '[data-message-author-role=\"model\"]',\n    'article[data-author=\"assistant\"]',\n    'article[data-turn=\"assistant\"]',\n    'article[data-turn=\"model\"]',\n    // Common Gemini containers\n    '.model-response, model-response',\n    '.response-container',\n    'div[role=\"listitem\"]:not([data-user=\"true\"])',\n  ];\n}\n\nfunction dedupeByTextAndOffset(elements: HTMLElement[], firstTurnOffset: number): HTMLElement[] {\n  const seen = new Set<string>();\n  const out: HTMLElement[] = [];\n  for (const el of elements) {\n    const offsetFromStart = (el.offsetTop || 0) - firstTurnOffset;\n    const key = `${normalizeText(el.textContent || '')}|${Math.round(offsetFromStart)}`;\n    if (seen.has(key)) continue;\n    seen.add(key);\n    out.push(el);\n  }\n  return out;\n}\n\nfunction ensureTurnId(el: Element, index: number): string {\n  const asEl = el as HTMLElement & { dataset?: DOMStringMap & { turnId?: string } };\n  let id = asEl.dataset?.turnId || '';\n  if (!id) {\n    const basis = normalizeText(asEl.textContent || '') || `user-${index}`;\n    id = `u-${index}-${hashString(basis)}`;\n    try {\n      if (asEl.dataset) asEl.dataset.turnId = id;\n    } catch {}\n  }\n  return id;\n}\n\nfunction readStarredSet(): Set<string> {\n  const cid = computeConversationId();\n  try {\n    const raw = localStorage.getItem(`geminiTimelineStars:${cid}`);\n    if (!raw) return new Set();\n    const arr = JSON.parse(raw);\n    if (!Array.isArray(arr)) return new Set();\n    return new Set(arr.map((x: unknown) => String(x)));\n  } catch {\n    return new Set();\n  }\n}\n\nfunction extractAssistantText(el: HTMLElement): string {\n  // Prefer direct text from message container if available (connected to DOM)\n  // Use queryOutsideThoughts to avoid matching the message-content inside\n  // the expanded thinking/reasoning panel.\n  try {\n    const mc = queryOutsideThoughts<HTMLElement>(\n      el,\n      'message-content, .markdown, .markdown-main-panel',\n    );\n    if (mc) {\n      const raw = mc.textContent || mc.innerText || '';\n      const txt = normalizeText(raw);\n      if (txt) return txt;\n    }\n  } catch {}\n\n  // Clone and remove reasoning toggles/labels before reading text (detached fallback)\n  const clone = el.cloneNode(true) as HTMLElement;\n  const matchesReasonToggle = (txt: string): boolean => {\n    const s = normalizeText(txt).toLowerCase();\n    if (!s) return false;\n    return (\n      /^(show\\s*(thinking|reasoning)|hide\\s*(thinking|reasoning))$/i.test(s) ||\n      /^(显示\\s*(思路|推理)|隐藏\\s*(思路|推理))$/u.test(s)\n    );\n  };\n  const shouldDrop = (node: HTMLElement): boolean => {\n    const role = (node.getAttribute('role') || '').toLowerCase();\n    const aria = (node.getAttribute('aria-label') || '').toLowerCase();\n    const txt = node.textContent || '';\n    if (matchesReasonToggle(txt)) return true;\n    if (role === 'button' && (/thinking|reasoning/i.test(txt) || /思路|推理/u.test(txt)))\n      return true;\n    if (/thinking|reasoning/i.test(aria) || /思路|推理/u.test(aria)) return true;\n    return false;\n  };\n  try {\n    const candidates = clone.querySelectorAll(\n      'button, [role=\"button\"], [aria-label], span, div, a',\n    );\n    candidates.forEach((n) => {\n      const eln = n as HTMLElement;\n      if (shouldDrop(eln)) eln.remove();\n    });\n  } catch {}\n  const text = normalizeText(clone.innerText || clone.textContent || '');\n  return text;\n}\n\ntype ChatTurn = {\n  turnId: string;\n  user: string;\n  assistant: string;\n  starred: boolean;\n  userElement?: HTMLElement;\n  assistantElement?: HTMLElement;\n  assistantHostElement?: HTMLElement;\n};\n\nfunction collectChatPairs(): ChatTurn[] {\n  const userSelectors = getUserSelectors();\n  const root = getConversationRoot(userSelectors);\n  const assistantSelectors = getAssistantSelectors();\n  const userNodeList = filterOutDeepResearchImmersiveNodes(\n    Array.from(root.querySelectorAll<HTMLElement>(userSelectors.join(','))),\n  );\n  if (!userNodeList || userNodeList.length === 0) return [];\n  let users = filterTopLevel(userNodeList);\n  if (users.length === 0) return [];\n\n  const firstOffset = (users[0] as HTMLElement).offsetTop || 0;\n  users = dedupeByTextAndOffset(users, firstOffset);\n  const userOffsets = users.map((el) => (el as HTMLElement).offsetTop || 0);\n\n  const assistantsAll = filterOutDeepResearchImmersiveNodes(\n    Array.from(root.querySelectorAll<HTMLElement>(assistantSelectors.join(','))),\n  );\n  const assistants = filterTopLevel(assistantsAll);\n  const assistantOffsets = assistants.map((el) => (el as HTMLElement).offsetTop || 0);\n\n  const starredSet = readStarredSet();\n  const pairs: ChatTurn[] = [];\n  for (let i = 0; i < users.length; i++) {\n    const uEl = users[i] as HTMLElement;\n    const uText = normalizeText(uEl.innerText || uEl.textContent || '');\n    const start = userOffsets[i];\n    const end = i + 1 < userOffsets.length ? userOffsets[i + 1] : Number.POSITIVE_INFINITY;\n    let aText = '';\n    let aEl: HTMLElement | null = null;\n    let bestIdx = -1;\n    let bestOff = Number.POSITIVE_INFINITY;\n    for (let k = 0; k < assistants.length; k++) {\n      const off = assistantOffsets[k];\n      if (off >= start && off < end) {\n        if (off < bestOff) {\n          bestOff = off;\n          bestIdx = k;\n        }\n      }\n    }\n    if (bestIdx >= 0) {\n      aEl = assistants[bestIdx] as HTMLElement;\n      aText = extractAssistantText(aEl);\n    } else {\n      // Fallback: search next siblings up to a small window\n      let sib: HTMLElement | null = uEl;\n      for (let step = 0; step < 8 && sib; step++) {\n        sib = sib.nextElementSibling as HTMLElement | null;\n        if (!sib) break;\n        if (sib.matches(userSelectors.join(','))) break;\n        if (sib.matches(assistantSelectors.join(','))) {\n          aEl = sib;\n          aText = extractAssistantText(sib);\n          break;\n        }\n      }\n    }\n    const turnId = ensureTurnId(uEl, i);\n    const starred = !!turnId && starredSet.has(turnId);\n    if (uText || aText) {\n      // Prefer a richer assistant container for downstream rich extraction\n      let finalAssistantEl: HTMLElement | undefined = undefined;\n      if (aEl) {\n        const pick =\n          queryOutsideThoughts<HTMLElement>(aEl, 'message-content') ||\n          queryOutsideThoughts<HTMLElement>(aEl, '.markdown, .markdown-main-panel') ||\n          (aEl.closest('.presented-response-container') as HTMLElement | null) ||\n          queryOutsideThoughts<HTMLElement>(\n            aEl,\n            '.presented-response-container, .response-content',\n          ) ||\n          queryOutsideThoughts<HTMLElement>(aEl, 'response-element') ||\n          aEl;\n        finalAssistantEl = pick || undefined;\n      }\n      pairs.push({\n        turnId,\n        user: uText,\n        assistant: aText,\n        starred,\n        userElement: uEl,\n        assistantElement: finalAssistantEl,\n        assistantHostElement: aEl || undefined,\n      });\n    }\n  }\n  return pairs;\n}\n\ntype ExportMessageRole = 'user' | 'assistant';\n\ntype ExportMessage = {\n  messageId: string;\n  role: ExportMessageRole;\n  hostElement: HTMLElement;\n  exportElement?: HTMLElement;\n  text: string;\n  starred: boolean;\n};\n\nfunction buildExportMessagesFromPairs(pairs: ChatTurn[]): ExportMessage[] {\n  const out: ExportMessage[] = [];\n  pairs.forEach((pair) => {\n    if (pair.userElement) {\n      out.push({\n        messageId: `${pair.turnId}:u`,\n        role: 'user',\n        hostElement: pair.userElement,\n        exportElement: pair.userElement,\n        text: pair.user,\n        starred: pair.starred,\n      });\n    }\n\n    const assistantHost = pair.assistantHostElement;\n    if (assistantHost) {\n      out.push({\n        messageId: `${pair.turnId}:a`,\n        role: 'assistant',\n        hostElement: assistantHost,\n        exportElement: pair.assistantElement || assistantHost,\n        text: pair.assistant,\n        starred: pair.starred,\n      });\n    }\n  });\n  return out;\n}\n\nfunction computeSortedMessages(pairsInput: ChatTurn[]): Array<ExportMessage & { absTop: number }> {\n  const msgs = buildExportMessagesFromPairs(pairsInput);\n  const withPos = msgs.map((message) => {\n    const rect = message.hostElement.getBoundingClientRect();\n    return {\n      ...message,\n      absTop: rect.top + window.scrollY,\n    };\n  });\n\n  withPos.sort((a, b) => a.absTop - b.absTop);\n  return withPos;\n}\n\nfunction buildTurnsForSelectedMessages(\n  selectedMessages: readonly ExportMessage[],\n): ExportChatTurn[] {\n  const groupedTurns = groupSelectedMessagesByTurn(selectedMessages);\n  return groupedTurns\n    .map((turn) => ({\n      user: turn.user?.text || '',\n      assistant: turn.assistant?.text || '',\n      starred: turn.starred,\n      omitEmptySections: true,\n      userElement: turn.user?.exportElement,\n      assistantElement: turn.assistant?.exportElement,\n    }))\n    .filter(\n      (turn) =>\n        turn.user.length > 0 ||\n        turn.assistant.length > 0 ||\n        !!turn.userElement ||\n        !!turn.assistantElement,\n    );\n}\n\nfunction buildTurnsForSelectedMessageIds(\n  selectedMessageIds: ReadonlySet<string>,\n  pairsInput: ChatTurn[] = collectChatPairs(),\n): ExportChatTurn[] {\n  if (selectedMessageIds.size === 0) return [];\n  const selectedMessages = computeSortedMessages(pairsInput).filter((message) =>\n    selectedMessageIds.has(message.messageId),\n  );\n  return buildTurnsForSelectedMessages(selectedMessages);\n}\n\nfunction resolveAssistantMessageIdFromMenuTrigger(trigger: HTMLElement | null): string | null {\n  if (!trigger) return null;\n\n  const assistantHost = trigger.closest(\n    '.response-container, response-container, .model-response, model-response',\n  ) as HTMLElement | null;\n  if (!assistantHost) return null;\n\n  const messages = buildExportMessagesFromPairs(collectChatPairs());\n  const target = messages.find((message) => {\n    if (message.role !== 'assistant') return false;\n    const host = message.hostElement;\n    return (\n      host === assistantHost ||\n      host.contains(assistantHost) ||\n      assistantHost.contains(host) ||\n      host.contains(trigger)\n    );\n  });\n\n  return target?.messageId || null;\n}\n\nfunction ensureDropdownInjected(logoElement: Element): HTMLButtonElement | null {\n  // Check if already injected\n  const existingWrapper = document.querySelector('.gv-logo-dropdown-wrapper');\n  if (existingWrapper) {\n    return existingWrapper.querySelector('.gv-export-dropdown-btn') as HTMLButtonElement | null;\n  }\n\n  const logo = logoElement as HTMLElement;\n  const parent = logo.parentElement;\n  if (!parent) return null;\n\n  // Create wrapper that will contain both logo and dropdown\n  const wrapper = document.createElement('div');\n  wrapper.className = 'gv-logo-dropdown-wrapper';\n\n  // Move logo into wrapper\n  parent.insertBefore(wrapper, logo);\n  wrapper.appendChild(logo);\n\n  // Create dropdown container\n  const dropdown = document.createElement('div');\n  dropdown.className = 'gv-logo-dropdown';\n\n  // Create export button inside dropdown\n  const btn = document.createElement('button');\n  btn.className = 'gv-export-dropdown-btn';\n  btn.type = 'button';\n  btn.title = 'Export chat history';\n  btn.setAttribute('aria-label', 'Export chat history');\n\n  // Export icon\n  const iconSpan = document.createElement('span');\n  iconSpan.className = 'gv-export-dropdown-icon';\n  btn.appendChild(iconSpan);\n\n  // Export text label\n  const labelSpan = document.createElement('span');\n  labelSpan.className = 'gv-export-dropdown-label';\n  labelSpan.textContent = 'Export';\n  btn.appendChild(labelSpan);\n\n  dropdown.appendChild(btn);\n  wrapper.appendChild(dropdown);\n\n  return btn;\n}\n\nasync function loadDictionaries(): Promise<Record<AppLanguage, Record<string, string>>> {\n  try {\n    const [enRaw, zhRaw, zhTWRaw, jaRaw, frRaw, esRaw, ptRaw, arRaw, ruRaw, koRaw] =\n      await Promise.all([\n        import(/* @vite-ignore */ '../../../locales/en/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/zh/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/zh_TW/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/ja/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/fr/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/es/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/pt/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/ar/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/ru/messages.json'),\n        import(/* @vite-ignore */ '../../../locales/ko/messages.json'),\n      ]);\n\n    return {\n      en: extractMessageDictionary(enRaw),\n      zh: extractMessageDictionary(zhRaw),\n      zh_TW: extractMessageDictionary(zhTWRaw),\n      ja: extractMessageDictionary(jaRaw),\n      fr: extractMessageDictionary(frRaw),\n      es: extractMessageDictionary(esRaw),\n      pt: extractMessageDictionary(ptRaw),\n      ar: extractMessageDictionary(arRaw),\n      ru: extractMessageDictionary(ruRaw),\n      ko: extractMessageDictionary(koRaw),\n    };\n  } catch {\n    return {\n      en: {},\n      zh: {},\n      zh_TW: {},\n      ja: {},\n      fr: {},\n      es: {},\n      pt: {},\n      ar: {},\n      ru: {},\n      ko: {},\n    };\n  }\n}\n\n/**\n * Extract human-readable conversation title from the current page\n * Used for JSON/Markdown metadata so all formats share the same title.\n * Mirrors the logic used by PDFPrintService.getConversationTitle.\n */\nfunction isMeaningfulConversationTitle(title: string | null | undefined): title is string {\n  const t = (title || '').trim();\n  if (!t) return false;\n  if (\n    t === 'Untitled Conversation' ||\n    t === 'Gemini' ||\n    t === 'Google Gemini' ||\n    t === 'Google AI Studio' ||\n    t === 'New chat'\n  ) {\n    return false;\n  }\n  if (t.startsWith('Gemini -') || t.startsWith('Google AI Studio -')) return false;\n  return true;\n}\n\nfunction extractConversationIdFromUrl(): string | null {\n  const appMatch = window.location.pathname.match(/\\/app\\/([^/?#]+)/);\n  if (appMatch?.[1]) return appMatch[1];\n  const gemMatch = window.location.pathname.match(/\\/gem\\/[^/]+\\/([^/?#]+)/);\n  if (gemMatch?.[1]) return gemMatch[1];\n  return null;\n}\n\nfunction extractConversationIdFromHref(href: string): string | null {\n  if (!href) return null;\n  try {\n    const parsed = new URL(href, window.location.origin);\n    const appMatch = parsed.pathname.match(/\\/app\\/([^/?#]+)/);\n    if (appMatch?.[1]) return appMatch[1];\n    const gemMatch = parsed.pathname.match(/\\/gem\\/[^/]+\\/([^/?#]+)/);\n    if (gemMatch?.[1]) return gemMatch[1];\n    return null;\n  } catch {\n    return null;\n  }\n}\n\nfunction isGemLabel(text: string | null | undefined): boolean {\n  const t = (text || '').trim().toLowerCase();\n  return t === 'gem' || t === 'gems';\n}\n\nfunction extractTitleFromLinkText(link?: HTMLAnchorElement | null): string | null {\n  if (!link) return null;\n  const text = (link.innerText || '').trim();\n  if (!text) return null;\n  const parts = text\n    .split('\\n')\n    .map((s) => s.trim())\n    .filter(Boolean)\n    .filter((s) => !isGemLabel(s))\n    .filter((s) => s.length >= 2);\n  if (parts.length === 0) return null;\n  return parts.reduce((a, b) => (b.length > a.length ? b : a), parts[0]) || null;\n}\n\nfunction extractTitleFromConversationElement(conversationEl: HTMLElement): string | null {\n  const scope =\n    (conversationEl.closest('[data-test-id=\"conversation\"]') as HTMLElement) || conversationEl;\n  const bySelector = scope.querySelector(\n    '.gds-label-l, .conversation-title-text, [data-test-id=\"conversation-title\"], h3',\n  );\n  const selectorTitle = bySelector?.textContent?.trim();\n  if (isMeaningfulConversationTitle(selectorTitle) && !isGemLabel(selectorTitle)) {\n    return selectorTitle;\n  }\n\n  const link = scope.querySelector(\n    'a[href*=\"/app/\"], a[href*=\"/gem/\"]',\n  ) as HTMLAnchorElement | null;\n  const ariaTitle = link?.getAttribute('aria-label')?.trim();\n  if (isMeaningfulConversationTitle(ariaTitle) && !isGemLabel(ariaTitle)) {\n    return ariaTitle;\n  }\n  const linkTitle = link?.getAttribute('title')?.trim();\n  if (isMeaningfulConversationTitle(linkTitle) && !isGemLabel(linkTitle)) {\n    return linkTitle;\n  }\n  const fromLinkText = extractTitleFromLinkText(link);\n  if (isMeaningfulConversationTitle(fromLinkText)) {\n    return fromLinkText;\n  }\n\n  const label = scope.querySelector('.gds-body-m, .gds-label-m, .subtitle');\n  const labelText = label?.textContent?.trim();\n  if (isMeaningfulConversationTitle(labelText) && !isGemLabel(labelText)) {\n    return labelText;\n  }\n\n  const raw = scope.textContent?.trim() || '';\n  if (!raw) return null;\n  const firstLine =\n    raw\n      .split('\\n')\n      .map((s) => s.trim())\n      .filter(Boolean)[0] || raw;\n  if (isMeaningfulConversationTitle(firstLine) && !isGemLabel(firstLine)) {\n    return firstLine.slice(0, 80);\n  }\n\n  return null;\n}\n\nfunction extractTitleFromNativeSidebarByConversationId(conversationId: string): string | null {\n  const escapedConversationId = escapeCssAttributeValue(conversationId);\n  const byJslog = document.querySelector(\n    `[data-test-id=\"conversation\"][jslog*=\"c_${escapedConversationId}\"]`,\n  ) as HTMLElement | null;\n  if (byJslog) {\n    const title = extractTitleFromConversationElement(byJslog);\n    if (title) return title;\n  }\n\n  const byHrefLink = document.querySelector(\n    `[data-test-id=\"conversation\"] a[href*=\"${escapedConversationId}\"]`,\n  ) as HTMLElement | null;\n  if (byHrefLink) {\n    const title = extractTitleFromConversationElement(byHrefLink);\n    if (title) return title;\n  }\n\n  return null;\n}\n\nfunction escapeCssAttributeValue(value: string): string {\n  const escape = globalThis.CSS?.escape;\n  if (typeof escape === 'function') {\n    return escape(value);\n  }\n  return value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"');\n}\n\nfunction getConversationTitleForExport(): string {\n  // Strategy 1: Get from active conversation in Gemini Voyager Folder UI (most accurate)\n  try {\n    const activeFolderTitle =\n      document.querySelector(\n        '.gv-folder-conversation.gv-folder-conversation-selected .gv-conversation-title',\n      ) || document.querySelector('.gv-folder-conversation-selected .gv-conversation-title');\n\n    if (activeFolderTitle?.textContent?.trim()) {\n      return activeFolderTitle.textContent.trim();\n    }\n  } catch (error) {\n    try {\n      console.debug('[Export] Failed to get title from Folder Manager:', error);\n    } catch {}\n  }\n\n  // Strategy 1b: Get from Gemini native sidebar via current conversation ID\n  try {\n    const conversationId = extractConversationIdFromUrl();\n    if (conversationId) {\n      const title = extractTitleFromNativeSidebarByConversationId(conversationId);\n      if (title) return title;\n    }\n  } catch (error) {\n    try {\n      console.debug('[Export] Failed to get title from native sidebar by conversation id:', error);\n    } catch {}\n  }\n\n  // Strategy 2: Try to get from page title\n  const titleElement = document.querySelector('title');\n  if (titleElement) {\n    const title = titleElement.textContent?.trim();\n    if (isMeaningfulConversationTitle(title)) {\n      return title;\n    }\n  }\n\n  // Strategy 3: Try to get from sidebar conversation list (Gemini / AI Studio)\n  try {\n    const selectors = [\n      'mat-list-item.mdc-list-item--activated [mat-line]',\n      'mat-list-item[aria-current=\"page\"] [mat-line]',\n      '.conversation-list-item.active .conversation-title',\n      '.active-conversation .title',\n    ];\n\n    for (const selector of selectors) {\n      const element = document.querySelector(selector);\n      const title = element?.textContent?.trim();\n      if (isMeaningfulConversationTitle(title)) {\n        return title;\n      }\n    }\n  } catch (error) {\n    try {\n      console.debug('[Export] Failed to get title from sidebar:', error);\n    } catch {}\n  }\n\n  // Strategy 4: URL fallback\n  const conversationId = extractConversationIdFromUrl();\n  if (conversationId) {\n    return `Conversation ${conversationId.slice(0, 8)}`;\n  }\n\n  return 'Untitled Conversation';\n}\n\nfunction findSidebarConversationLinkById(conversationId: string): HTMLAnchorElement | null {\n  const escapedConversationId = escapeCssAttributeValue(conversationId);\n  const byJslog = document.querySelector(\n    `[data-test-id=\"conversation\"][jslog*=\"c_${escapedConversationId}\"] a[href]`,\n  ) as HTMLAnchorElement | null;\n  if (byJslog) return byJslog;\n\n  const links = Array.from(\n    document.querySelectorAll<HTMLAnchorElement>(\n      '[data-test-id=\"conversation\"] a[href], a[data-test-id=\"conversation\"][href]',\n    ),\n  );\n  for (const link of links) {\n    if (extractConversationIdFromHref(link.href) === conversationId) {\n      return link;\n    }\n  }\n\n  return null;\n}\n\nfunction triggerNativeClick(target: HTMLElement): void {\n  const opts = { bubbles: true, cancelable: true, view: window };\n  target.dispatchEvent(new MouseEvent('pointerdown', opts));\n  target.dispatchEvent(new MouseEvent('mousedown', opts));\n  target.dispatchEvent(new MouseEvent('mouseup', opts));\n  target.dispatchEvent(new MouseEvent('click', opts));\n}\n\nasync function waitForConversationUrl(\n  conversationId: string,\n  timeoutMs: number = 10000,\n): Promise<boolean> {\n  const deadline = Date.now() + timeoutMs;\n  while (Date.now() < deadline) {\n    if (extractConversationIdFromUrl() === conversationId) return true;\n    await new Promise((resolve) => setTimeout(resolve, 120));\n  }\n  return false;\n}\n\nasync function navigateToConversationAndWait(\n  conversationId: string,\n  fallbackUrl: string,\n): Promise<boolean> {\n  const currentConversationId = extractConversationIdFromUrl();\n  if (currentConversationId === conversationId) {\n    const existing = await waitForAnyElement(getUserSelectors(), 8000);\n    return !!existing;\n  }\n\n  const link = findSidebarConversationLinkById(conversationId);\n  if (link) {\n    triggerNativeClick(link);\n  } else if (fallbackUrl) {\n    window.location.assign(fallbackUrl);\n  } else {\n    return false;\n  }\n\n  const routeReady = await waitForConversationUrl(conversationId, 12000);\n  if (!routeReady) return false;\n  const contentReady = await waitForAnyElement(getUserSelectors(), 15000);\n  return !!contentReady;\n}\n\nasync function exportFromSidebarConversationTrigger(\n  trigger: HTMLElement,\n  dict: Record<AppLanguage, Record<string, string>>,\n  getCurrentLanguage: () => AppLanguage,\n): Promise<void> {\n  const target = resolveSidebarConversationTarget(trigger);\n  if (!target) {\n    alert('Unable to locate the selected conversation. Please open it first, then export.');\n    return;\n  }\n\n  const ready = await navigateToConversationAndWait(target.conversationId, target.url);\n  if (!ready) {\n    alert('Failed to open the selected conversation for export. Please retry.');\n    return;\n  }\n\n  await showExportDialog(dict, getCurrentLanguage());\n}\n\nfunction normalizeLang(lang: string | undefined): AppLanguage {\n  return normalizeLanguage(lang);\n}\n\nasync function getLanguage(): Promise<AppLanguage> {\n  try {\n    // Add timeout to prevent hanging in Firefox\n    const stored = await Promise.race([\n      new Promise<unknown>((resolve) => {\n        try {\n          const win = window as Window & {\n            chrome?: {\n              storage?: { sync?: { get: (key: string, cb: (r: unknown) => void) => void } };\n            };\n            browser?: { storage?: { sync?: { get: (key: string) => Promise<unknown> } } };\n          };\n          if (win.chrome?.storage?.sync?.get) {\n            win.chrome.storage.sync.get(StorageKeys.LANGUAGE, resolve);\n          } else if (win.browser?.storage?.sync?.get) {\n            win.browser.storage.sync\n              .get(StorageKeys.LANGUAGE)\n              .then(resolve)\n              .catch(() => resolve({}));\n          } else {\n            resolve({});\n          }\n        } catch {\n          resolve({});\n        }\n      }),\n      new Promise<unknown>((resolve) => setTimeout(() => resolve({}), 1000)),\n    ]);\n    const rec = stored && typeof stored === 'object' ? (stored as Record<string, unknown>) : {};\n    const v =\n      typeof rec[StorageKeys.LANGUAGE] === 'string'\n        ? (rec[StorageKeys.LANGUAGE] as string)\n        : undefined;\n    return normalizeLang(v || navigator.language || 'en');\n  } catch {\n    return 'en';\n  }\n}\n\n/**\n * Finds the top-most user message element in the DOM.\n */\nfunction getTopUserElement(selectors: string[]): HTMLElement | null {\n  const root = getConversationRoot(selectors);\n  const all = filterOutDeepResearchImmersiveNodes(\n    Array.from(root.querySelectorAll<HTMLElement>(selectors.join(','))),\n  );\n  if (!all.length) return null;\n  const topLevel = filterTopLevel(all);\n  return topLevel.length > 0 ? topLevel[0] : null;\n}\n\nfunction isElementVisibleForAlignment(el: HTMLElement): boolean {\n  const rect = el.getBoundingClientRect();\n  if (rect.width < 24 || rect.height < 12) return false;\n\n  const style = window.getComputedStyle(el);\n  if (style.display === 'none' || style.visibility === 'hidden') return false;\n  const opacity = Number.parseFloat(style.opacity || '1');\n  if (Number.isFinite(opacity) && opacity <= 0.01) return false;\n\n  return true;\n}\n\nfunction isLikelySidebarElement(el: HTMLElement): boolean {\n  if (\n    el.closest(\n      [\n        '[data-test-id=\"side-nav\"]',\n        'side-navigation',\n        'mat-sidenav',\n        'aside',\n        'nav',\n        '.side-nav',\n        '.sidenav',\n        '.chat-history-nav',\n      ].join(','),\n    )\n  ) {\n    return true;\n  }\n\n  const rect = el.getBoundingClientRect();\n  const isNarrow = rect.width > 0 && rect.width <= Math.max(380, window.innerWidth * 0.45);\n  const isLeftRail = rect.left <= Math.max(40, window.innerWidth * 0.18);\n  const isTall = rect.height >= window.innerHeight * 0.35;\n  return isNarrow && isLeftRail && isTall;\n}\n\nfunction pickBestVisibleAlignmentTarget(\n  selectors: string[],\n  options?: {\n    minWidth?: number;\n    minHeight?: number;\n    allowSidebar?: boolean;\n  },\n): HTMLElement | null {\n  const candidates = Array.from(document.querySelectorAll<HTMLElement>(selectors.join(',')));\n  let best: { el: HTMLElement; score: number } | null = null;\n  const minWidth = options?.minWidth ?? 220;\n  const minHeight = options?.minHeight ?? 24;\n  const viewportCenter = window.innerWidth / 2;\n\n  for (const candidate of candidates) {\n    if (!candidate.isConnected) continue;\n    if (!isElementVisibleForAlignment(candidate)) continue;\n    if (!options?.allowSidebar && isLikelySidebarElement(candidate)) continue;\n\n    const rect = candidate.getBoundingClientRect();\n    if (rect.width < minWidth || rect.height < minHeight) continue;\n    if (rect.bottom < -16 || rect.top > window.innerHeight + 16) continue;\n\n    const center = rect.left + rect.width / 2;\n    const area = rect.width * rect.height;\n    const distancePenalty = Math.abs(center - viewportCenter) * 120;\n    const score = area - distancePenalty;\n\n    if (!best || score > best.score) {\n      best = { el: candidate, score };\n    }\n  }\n\n  return best?.el || null;\n}\n\nfunction resolveConversationCanvasCenterX(): number {\n  const viewportCenter = window.innerWidth / 2;\n\n  const canvasTarget = pickBestVisibleAlignmentTarget(\n    [\n      '#chat-history',\n      'infinite-scroller.chat-history',\n      '.chat-history-scroll-container',\n      'chat-window-content',\n      'main chat-window-content',\n    ],\n    {\n      minWidth: Math.min(420, Math.max(280, window.innerWidth * 0.42)),\n      minHeight: 80,\n    },\n  );\n  if (canvasTarget) {\n    const rect = canvasTarget.getBoundingClientRect();\n    return rect.left + rect.width / 2;\n  }\n\n  const composerTarget = pickBestVisibleAlignmentTarget(\n    [\n      'rich-textarea',\n      '[aria-label*=\"Enter a prompt\"]',\n      '[aria-label*=\"prompt\"]',\n      '[aria-label*=\"Gemini\"]',\n      '[contenteditable=\"true\"][aria-label]',\n    ],\n    {\n      minWidth: Math.min(460, Math.max(240, window.innerWidth * 0.28)),\n      minHeight: 28,\n    },\n  );\n  if (composerTarget) {\n    const rect = composerTarget.getBoundingClientRect();\n    return rect.left + rect.width / 2;\n  }\n\n  const selectors = getUserSelectors();\n  const topUser = getTopUserElement(selectors);\n  if (topUser && !isLikelySidebarElement(topUser)) {\n    const rect = topUser.getBoundingClientRect();\n    if (rect.width > 24) return rect.left + rect.width / 2;\n  }\n\n  const root = getConversationRoot(selectors);\n  if (root && !isLikelySidebarElement(root)) {\n    const rect = root.getBoundingClientRect();\n    if (rect.width > Math.max(300, window.innerWidth * 0.42)) return rect.left + rect.width / 2;\n  }\n\n  const main = document.querySelector<HTMLElement>('main');\n  if (main && !isLikelySidebarElement(main)) {\n    const rect = main.getBoundingClientRect();\n    if (rect.width > 24) return rect.left + rect.width / 2;\n  }\n\n  return viewportCenter;\n}\n\nfunction alignElementToConversationTitleCenter(element: HTMLElement): () => void {\n  const apply = () => {\n    if (window.innerWidth <= 640) {\n      element.style.removeProperty('left');\n      element.style.removeProperty('transform');\n      return;\n    }\n\n    const rawCenter = resolveConversationCanvasCenterX();\n    const safeMargin = 24;\n    const clampedCenter = Math.round(\n      Math.max(safeMargin, Math.min(window.innerWidth - safeMargin, rawCenter)),\n    );\n    element.style.left = `${clampedCenter}px`;\n    element.style.transform = 'translateX(-50%)';\n  };\n\n  apply();\n  const resizeHandler = () => apply();\n  window.addEventListener('resize', resizeHandler);\n  const timeoutId = window.setTimeout(apply, 220);\n\n  return () => {\n    window.removeEventListener('resize', resizeHandler);\n    window.clearTimeout(timeoutId);\n  };\n}\n\n/**\n * Executes the export sequence:\n * 1. Find top node and click it.\n * 2. Wait to see if refresh happens.\n * 3. If refresh -> script dies, on load we resume.\n * 4. If no refresh -> we are stable, proceed to export.\n */\nasync function executeExportSequence(\n  format: ExportFormat,\n  dict: Record<AppLanguage, Record<string, string>>,\n  lang: AppLanguage,\n  paramState?: PendingExportState,\n  fontSize?: number,\n  initialSelectedMessageId?: string,\n): Promise<void> {\n  const state: PendingExportState = paramState || {\n    format,\n    fontSize,\n    initialSelectedMessageId,\n    attempt: 0,\n    url: location.href,\n    status: 'clicking',\n    timestamp: Date.now(),\n  };\n\n  if (state.attempt > 25) {\n    console.warn('[Gemini Voyager] Export aborted: too many attempts.');\n    sessionStorage.removeItem(SESSION_KEY_PENDING_EXPORT);\n    alert('Export stopped: Too many attempts detected.');\n    return;\n  }\n\n  // 1. Find Top Node\n  if (state.attempt > 0) {\n    console.log('[Gemini Voyager] Resuming export... waiting for content load.');\n    const userSelectors = getUserSelectors();\n    await waitForAnyElement(userSelectors, 15000);\n  }\n\n  // Wait a bit if we just reloaded\n  const userSelectors = getUserSelectors();\n  let topNode = getTopUserElement(userSelectors);\n  if (!topNode) {\n    await waitForElement('body', 2000);\n    const pairs = collectChatPairs();\n    if (pairs.length > 0 && pairs[0].userElement) {\n      topNode = pairs[0].userElement;\n    }\n  }\n\n  if (!topNode) {\n    console.log('[Gemini Voyager] No top node found, proceeding to export directly.');\n    sessionStorage.removeItem(SESSION_KEY_PENDING_EXPORT);\n    await performFinalExport(format, dict, lang, state.fontSize, state.initialSelectedMessageId);\n    return;\n  }\n\n  const fingerprintSelectors = [...getUserSelectors(), ...getAssistantSelectors()];\n  const beforeFingerprint = computeConversationFingerprint(document.body, fingerprintSelectors, 10);\n\n  console.log(`[Gemini Voyager] Simulating click on top node (Attempt ${state.attempt + 1})...`);\n\n  // Update state before action to persist across potential reload\n  sessionStorage.setItem(\n    SESSION_KEY_PENDING_EXPORT,\n    JSON.stringify({ ...state, attempt: state.attempt + 1, timestamp: Date.now() }),\n  );\n\n  // Dispatch click logic\n  try {\n    topNode.scrollIntoView({ behavior: 'auto', block: 'center' });\n    const opts = { bubbles: true, cancelable: true, view: window };\n    topNode.dispatchEvent(new MouseEvent('mousedown', opts));\n    topNode.dispatchEvent(new MouseEvent('mouseup', opts));\n    topNode.click();\n  } catch (e) {\n    console.error('[Gemini Voyager] Failed to click top node:', e);\n  }\n\n  // 2. Wait for either hard refresh (page unload) OR a \"soft refresh\" that loads more history.\n  // If the page unloads, the script stops and `checkPendingExport()` resumes on next load via sessionStorage.\n  const { changed } = await waitForConversationFingerprintChangeOrTimeout(\n    document.body,\n    fingerprintSelectors,\n    beforeFingerprint,\n    EXPORT_PRELOAD_WAIT_OPTIONS,\n  );\n\n  if (changed) {\n    console.log('[Gemini Voyager] History expanded (soft refresh). Clicking top node again...');\n    await executeExportSequence(format, dict, lang, {\n      ...state,\n      attempt: state.attempt + 1,\n      timestamp: Date.now(),\n    });\n    return;\n  }\n\n  console.log('[Gemini Voyager] No refresh or update detected. Exporting...');\n  sessionStorage.removeItem(SESSION_KEY_PENDING_EXPORT);\n  await performFinalExport(format, dict, lang, state.fontSize, state.initialSelectedMessageId);\n}\n\nasync function executeExportSequenceWithProgress(\n  format: ExportFormat,\n  dict: Record<AppLanguage, Record<string, string>>,\n  lang: AppLanguage,\n  paramState?: PendingExportState,\n  fontSize?: number,\n  initialSelectedMessageId?: string,\n): Promise<void> {\n  const t = (key: TranslationKey) => dict[lang]?.[key] ?? dict.en?.[key] ?? key;\n  const hideProgress = showExportProgressOverlay(t);\n  try {\n    await executeExportSequence(format, dict, lang, paramState, fontSize, initialSelectedMessageId);\n  } finally {\n    hideProgress();\n  }\n}\n\n/**\n * Performs the actual file generation and download.\n */\nasync function performFinalExport(\n  format: ExportFormat,\n  dict: Record<AppLanguage, Record<string, string>>,\n  lang: AppLanguage,\n  fontSize?: number,\n  initialSelectedMessageId?: string,\n) {\n  const t = (key: TranslationKey) => dict[lang]?.[key] ?? dict.en?.[key] ?? key;\n\n  await new Promise((r) => setTimeout(r, FINAL_EXPORT_PREPARE_DELAY_MS));\n\n  const pairs = collectChatPairs();\n  const messages = buildExportMessagesFromPairs(pairs);\n  if (messages.length === 0) {\n    alert(t('export_dialog_warning'));\n    return;\n  }\n\n  const selectedIds = new Set<string>();\n  let allMessageIds: string[] = [];\n  const cleanupTasks: Array<() => void> = [];\n  const idToHost = new Map<string, HTMLElement>();\n  const idToCheckbox = new Map<string, HTMLButtonElement>();\n  const knownIds = new Set<string>();\n  let pendingInitialSelectionId: string | null = initialSelectedMessageId || null;\n\n  let autoSelectAll = false;\n\n  const cleanup = () => {\n    cleanupTasks.forEach((fn) => {\n      try {\n        fn();\n      } catch {}\n    });\n    cleanupTasks.length = 0;\n  };\n\n  const setSelected = (id: string, next: boolean) => {\n    if (next) selectedIds.add(id);\n    else selectedIds.delete(id);\n\n    const btn = idToCheckbox.get(id);\n    if (btn) {\n      btn.setAttribute('aria-pressed', next ? 'true' : 'false');\n      btn.dataset.selected = next ? 'true' : 'false';\n    }\n    const host = idToHost.get(id);\n    if (host) {\n      if (next) host.classList.add('gv-export-msg-selected');\n      else host.classList.remove('gv-export-msg-selected');\n    }\n  };\n\n  const updateBottomBar = (bar: HTMLElement) => {\n    const countEl = bar.querySelector(\n      '[data-gv-export-selection-count=\"true\"]',\n    ) as HTMLElement | null;\n    if (countEl) {\n      countEl.textContent = t('export_select_mode_count').replace(\n        '{count}',\n        String(selectedIds.size),\n      );\n    }\n\n    const exportBtn = bar.querySelector(\n      '[data-gv-export-action=\"export\"]',\n    ) as HTMLButtonElement | null;\n    if (exportBtn) {\n      exportBtn.disabled = selectedIds.size === 0;\n    }\n\n    const selectAllBtn = bar.querySelector(\n      '[data-gv-export-action=\"selectAll\"]',\n    ) as HTMLButtonElement | null;\n    if (selectAllBtn) {\n      const isAllSelected = allMessageIds.length > 0 && selectedIds.size === allMessageIds.length;\n      selectAllBtn.dataset.checked = isAllSelected ? 'true' : 'false';\n    }\n\n    const selectUserBtn = bar.querySelector(\n      '[data-gv-export-action=\"selectUser\"]',\n    ) as HTMLButtonElement | null;\n    if (selectUserBtn) {\n      const userMessageIds = allMessageIds.filter((id) => id.endsWith(':u'));\n      const isOnlyUserSelected =\n        userMessageIds.length > 0 &&\n        selectedIds.size === userMessageIds.length &&\n        userMessageIds.every((id) => selectedIds.has(id));\n      selectUserBtn.dataset.checked = isOnlyUserSelected ? 'true' : 'false';\n    }\n\n    const selectAIBtn = bar.querySelector(\n      '[data-gv-export-action=\"selectAI\"]',\n    ) as HTMLButtonElement | null;\n    if (selectAIBtn) {\n      const aiMessageIds = allMessageIds.filter((id) => id.endsWith(':a'));\n      const isOnlyAISelected =\n        aiMessageIds.length > 0 &&\n        selectedIds.size === aiMessageIds.length &&\n        aiMessageIds.every((id) => selectedIds.has(id));\n      selectAIBtn.dataset.checked = isOnlyAISelected ? 'true' : 'false';\n    }\n  };\n\n  const attachSelectorIfNeeded = (msg: ExportMessage) => {\n    if (knownIds.has(msg.messageId)) return;\n    knownIds.add(msg.messageId);\n\n    const host = msg.hostElement;\n    idToHost.set(msg.messageId, host);\n    host.classList.add('gv-export-msg-host');\n    cleanupTasks.push(() => host.classList.remove('gv-export-msg-host'));\n\n    const selector = document.createElement('div');\n    selector.className = 'gv-export-msg-selector';\n    selector.dataset.gvExportMessageId = msg.messageId;\n\n    const checkbox = document.createElement('button');\n    checkbox.type = 'button';\n    checkbox.className = 'gv-export-msg-checkbox';\n    checkbox.setAttribute('aria-pressed', 'false');\n    checkbox.title = t('export_select_mode_toggle');\n\n    const mark = document.createElement('span');\n    mark.className = 'gv-export-msg-checkbox-mark';\n    checkbox.appendChild(mark);\n\n    const swallow = (ev: Event) => {\n      try {\n        ev.preventDefault();\n      } catch {}\n      try {\n        ev.stopPropagation();\n      } catch {}\n    };\n\n    const toggleSelection = () => {\n      autoSelectAll = false;\n      const next = !selectedIds.has(msg.messageId);\n      setSelected(msg.messageId, next);\n      const bar = document.querySelector(\n        '[data-gv-export-select-bar=\"true\"]',\n      ) as HTMLElement | null;\n      if (bar) updateBottomBar(bar);\n    };\n\n    checkbox.addEventListener('click', (ev) => {\n      swallow(ev);\n      toggleSelection();\n    });\n\n    host.addEventListener('click', toggleSelection);\n    cleanupTasks.push(() => host.removeEventListener('click', toggleSelection));\n\n    selector.appendChild(checkbox);\n    host.appendChild(selector);\n    cleanupTasks.push(() => selector.remove());\n\n    idToCheckbox.set(msg.messageId, checkbox);\n  };\n\n  const syncMessages = (pairsInput: ChatTurn[]) => {\n    const sorted = computeSortedMessages(pairsInput);\n    allMessageIds = sorted.map((m) => m.messageId);\n\n    sorted.forEach((m) => attachSelectorIfNeeded(m));\n\n    // Auto-select new messages when a policy is active.\n    if (autoSelectAll) {\n      for (const id of allMessageIds) setSelected(id, true);\n    }\n\n    const initialSelected = resolveInitialSelectedMessageIds(\n      allMessageIds,\n      pendingInitialSelectionId,\n    );\n    if (initialSelected.size > 0) {\n      initialSelected.forEach((id) => setSelected(id, true));\n      pendingInitialSelectionId = null;\n    }\n  };\n\n  // Selection mode body class\n  document.body.classList.add('gv-export-select-mode');\n  cleanupTasks.push(() => document.body.classList.remove('gv-export-select-mode'));\n\n  // Bottom action bar\n  const bar = document.createElement('div');\n  bar.className = 'gv-export-select-bar';\n  bar.dataset.gvExportSelectBar = 'true';\n\n  const selectAllBtn = document.createElement('button');\n  selectAllBtn.type = 'button';\n  selectAllBtn.className = 'gv-export-select-all-toggle';\n  selectAllBtn.dataset.gvExportAction = 'selectAll';\n  selectAllBtn.textContent = t('export_select_mode_select_all');\n\n  const selectUserBtn = document.createElement('button');\n  selectUserBtn.type = 'button';\n  selectUserBtn.className = 'gv-export-select-role-btn';\n  selectUserBtn.dataset.gvExportAction = 'selectUser';\n  selectUserBtn.textContent = t('export_select_mode_only_user');\n\n  const selectAIBtn = document.createElement('button');\n  selectAIBtn.type = 'button';\n  selectAIBtn.className = 'gv-export-select-role-btn';\n  selectAIBtn.dataset.gvExportAction = 'selectAI';\n  selectAIBtn.textContent = t('export_select_mode_only_ai');\n\n  const count = document.createElement('div');\n  count.className = 'gv-export-select-count';\n  count.dataset.gvExportSelectionCount = 'true';\n  count.textContent = t('export_select_mode_count').replace('{count}', '0');\n\n  const exportBtn = document.createElement('button');\n  exportBtn.type = 'button';\n  exportBtn.className = 'gv-export-select-export-btn';\n  exportBtn.dataset.gvExportAction = 'export';\n  exportBtn.textContent = t('pm_export');\n  exportBtn.disabled = true;\n\n  const cancelBtn = document.createElement('button');\n  cancelBtn.type = 'button';\n  cancelBtn.className = 'gv-export-select-cancel-btn';\n  cancelBtn.title = t('pm_cancel');\n  cancelBtn.textContent = '×';\n\n  bar.appendChild(selectAllBtn);\n  bar.appendChild(selectUserBtn);\n  bar.appendChild(selectAIBtn);\n  bar.appendChild(count);\n  bar.appendChild(exportBtn);\n  bar.appendChild(cancelBtn);\n\n  document.body.appendChild(bar);\n\n  const swallow = (ev: Event) => {\n    try {\n      ev.preventDefault();\n    } catch {}\n    try {\n      ev.stopPropagation();\n    } catch {}\n  };\n\n  selectUserBtn.addEventListener('click', (ev) => {\n    swallow(ev);\n    autoSelectAll = false;\n\n    const userMessageIds = allMessageIds.filter((id) => id.endsWith(':u'));\n    const isOnlyUserSelected =\n      userMessageIds.length > 0 &&\n      selectedIds.size === userMessageIds.length &&\n      userMessageIds.every((id) => selectedIds.has(id));\n\n    if (isOnlyUserSelected) {\n      for (const id of allMessageIds) {\n        setSelected(id, false);\n      }\n    } else {\n      for (const id of allMessageIds) {\n        setSelected(id, id.endsWith(':u'));\n      }\n    }\n    updateBottomBar(bar);\n  });\n\n  selectAIBtn.addEventListener('click', (ev) => {\n    swallow(ev);\n    autoSelectAll = false;\n\n    const aiMessageIds = allMessageIds.filter((id) => id.endsWith(':a'));\n    const isOnlyAISelected =\n      aiMessageIds.length > 0 &&\n      selectedIds.size === aiMessageIds.length &&\n      aiMessageIds.every((id) => selectedIds.has(id));\n\n    if (isOnlyAISelected) {\n      for (const id of allMessageIds) {\n        setSelected(id, false);\n      }\n    } else {\n      for (const id of allMessageIds) {\n        setSelected(id, id.endsWith(':a'));\n      }\n    }\n    updateBottomBar(bar);\n  });\n  cleanupTasks.push(() => bar.remove());\n  cleanupTasks.push(alignElementToConversationTitleCenter(bar));\n\n  selectAllBtn.addEventListener('click', (ev) => {\n    swallow(ev);\n    const isAllSelected = allMessageIds.length > 0 && selectedIds.size === allMessageIds.length;\n    if (isAllSelected) {\n      selectedIds.clear();\n      autoSelectAll = false;\n      allMessageIds.forEach((id) => setSelected(id, false));\n    } else {\n      selectedIds.clear();\n      autoSelectAll = true;\n      allMessageIds.forEach((id) => setSelected(id, true));\n    }\n    updateBottomBar(bar);\n  });\n\n  const finish = () => {\n    allMessageIds.forEach((id) => setSelected(id, false));\n    selectedIds.clear();\n    autoSelectAll = false;\n    cleanup();\n  };\n\n  cancelBtn.addEventListener('click', (ev) => {\n    swallow(ev);\n    finish();\n  });\n\n  exportBtn.addEventListener('click', async (ev) => {\n    swallow(ev);\n    if (selectedIds.size === 0) {\n      alert(t('export_select_mode_empty'));\n      return;\n    }\n\n    const turnsForExport = buildTurnsForSelectedMessageIds(selectedIds, collectChatPairs());\n    if (turnsForExport.length === 0) {\n      alert(t('export_select_mode_empty'));\n      return;\n    }\n\n    // Cleanup before export so selection UI isn't captured.\n    finish();\n\n    const metadata: ConversationMetadata = {\n      url: location.href,\n      exportedAt: new Date().toISOString(),\n      count: turnsForExport.length,\n      title: getConversationTitleForExport(),\n    };\n\n    let includeImageSource = true;\n    if (format === 'markdown') {\n      const hasSearchImages = turnsForExport.some(\n        (turn) =>\n          turn.assistantElement?.querySelector('.attachment-container.search-images') != null,\n      );\n      if (hasSearchImages) {\n        includeImageSource = confirm(t('export_md_include_source_confirm'));\n      }\n    }\n\n    const hideProgress = showExportProgressOverlay(t);\n    try {\n      const resultPromise = ConversationExportService.export(turnsForExport, metadata, {\n        format,\n        fontSize,\n        includeImageSource,\n      });\n      const minVisiblePromise = new Promise((resolve) => setTimeout(resolve, 420));\n      const [result] = await Promise.all([resultPromise, minVisiblePromise]);\n\n      if (!result.success) {\n        alert(resolveExportErrorMessage(result.error, t));\n      } else if (format === 'pdf' && isSafari()) {\n        showExportToast(t('export_toast_safari_pdf_ready'), { autoDismissMs: 5000 });\n      }\n    } catch (err) {\n      console.error('[Gemini Voyager] Export error:', err);\n      alert('Export error occurred.');\n    } finally {\n      hideProgress();\n    }\n  });\n\n  // Observe new lazy-loaded messages while selection mode is active.\n  const root = getConversationRoot(getUserSelectors());\n  let refreshTimer: number | null = null;\n  const scheduleRefresh = () => {\n    if (refreshTimer) return;\n    refreshTimer = window.setTimeout(() => {\n      refreshTimer = null;\n      try {\n        syncMessages(collectChatPairs());\n        updateBottomBar(bar);\n      } catch {}\n    }, 250);\n  };\n\n  const obs = new MutationObserver(() => scheduleRefresh());\n  try {\n    obs.observe(root, { childList: true, subtree: true });\n    cleanupTasks.push(() => obs.disconnect());\n  } catch {}\n\n  // Escape to cancel\n  const onKeyDown = (e: KeyboardEvent) => {\n    if (e.key === 'Escape') {\n      finish();\n      document.removeEventListener('keydown', onKeyDown);\n    }\n  };\n  document.addEventListener('keydown', onKeyDown);\n  cleanupTasks.push(() => document.removeEventListener('keydown', onKeyDown));\n\n  // Initial sync\n  syncMessages(pairs);\n  updateBottomBar(bar);\n}\n\nfunction showExportProgressOverlay(t: (key: TranslationKey) => string): () => void {\n  const overlay = document.createElement('div');\n  overlay.className = 'gv-export-progress-overlay';\n\n  const card = document.createElement('div');\n  card.className = 'gv-export-progress-card';\n\n  const spinner = document.createElement('div');\n  spinner.className = 'gv-export-progress-spinner';\n\n  const title = document.createElement('div');\n  title.className = 'gv-export-progress-title';\n  title.textContent = `${t('pm_export')}...`;\n\n  const desc = document.createElement('div');\n  desc.className = 'gv-export-progress-desc';\n  desc.textContent = t('loading');\n\n  card.appendChild(spinner);\n  card.appendChild(title);\n  card.appendChild(desc);\n  overlay.appendChild(card);\n  document.body.appendChild(overlay);\n  const unbindAlignment = alignElementToConversationTitleCenter(overlay);\n\n  return () => {\n    unbindAlignment();\n    try {\n      overlay.remove();\n    } catch {}\n  };\n}\n\n/**\n * Check if there is a pending export operation from a previous page load.\n */\nasync function checkPendingExport() {\n  try {\n    const raw = sessionStorage.getItem(SESSION_KEY_PENDING_EXPORT);\n    if (!raw) return;\n\n    const parsed = JSON.parse(raw) as Partial<PendingExportState>;\n    if (\n      !isExportFormat(parsed.format) ||\n      typeof parsed.attempt !== 'number' ||\n      typeof parsed.url !== 'string' ||\n      parsed.status !== 'clicking' ||\n      typeof parsed.timestamp !== 'number'\n    ) {\n      sessionStorage.removeItem(SESSION_KEY_PENDING_EXPORT);\n      return;\n    }\n    const state: PendingExportState = {\n      format: parsed.format,\n      fontSize: typeof parsed.fontSize === 'number' ? parsed.fontSize : undefined,\n      initialSelectedMessageId:\n        typeof parsed.initialSelectedMessageId === 'string'\n          ? parsed.initialSelectedMessageId\n          : undefined,\n      attempt: parsed.attempt,\n      url: parsed.url,\n      status: parsed.status,\n      timestamp: parsed.timestamp,\n    };\n\n    // Validate context\n    if (state.url !== location.href) {\n      // User navigated away? Abort.\n      sessionStorage.removeItem(SESSION_KEY_PENDING_EXPORT);\n      return;\n    }\n\n    // If state exists, it means we clicked and page refreshed.\n    // So we resume the sequence.\n    console.log('[Gemini Voyager] Resuming pending export sequence...');\n\n    // We need i18n for final export/alert\n    const dict = await loadDictionaries();\n    const lang = await getLanguage();\n\n    await executeExportSequenceWithProgress(state.format, dict, lang, state);\n  } catch (e) {\n    console.error('[Gemini Voyager] Failed to resume pending export:', e);\n    sessionStorage.removeItem(SESSION_KEY_PENDING_EXPORT);\n  }\n}\n\nfunction getConversationMenuPanelsFromNode(node: HTMLElement): HTMLElement[] {\n  const panels: HTMLElement[] = [];\n  if (node.matches(CONVERSATION_MENU_SELECTOR)) {\n    panels.push(node);\n  }\n  panels.push(...Array.from(node.querySelectorAll<HTMLElement>(CONVERSATION_MENU_SELECTOR)));\n  return panels;\n}\n\nfunction parseMenuTriggerPanelIds(trigger: HTMLElement): string[] {\n  const raw = `${trigger.getAttribute('aria-controls') || ''} ${\n    trigger.getAttribute('aria-owns') || ''\n  }`;\n  return raw\n    .split(/\\s+/)\n    .map((item) => item.trim())\n    .filter(Boolean);\n}\n\ntype ResponseCopyImageTexts = {\n  label: string;\n  copied: string;\n  downloaded: string;\n  failed: string;\n  unsupported: string;\n  targetMissing: string;\n};\n\nfunction getResponseCopyImageTexts(lang: AppLanguage): ResponseCopyImageTexts {\n  if (lang === 'zh') {\n    return {\n      label: '复制回复为图片',\n      copied: '已复制回复图片',\n      downloaded: '已下载回复图片（Safari 剪贴板限制）',\n      failed: '复制回复图片失败',\n      unsupported: '当前浏览器不支持复制图片到剪贴板',\n      targetMissing: '未找到可复制的回复内容',\n    };\n  }\n\n  if (lang === 'zh_TW') {\n    return {\n      label: '複製回覆為圖片',\n      copied: '已複製回覆圖片',\n      downloaded: '已下載回覆圖片（Safari 剪貼簿限制）',\n      failed: '複製回覆圖片失敗',\n      unsupported: '目前瀏覽器不支援將圖片複製到剪貼簿',\n      targetMissing: '找不到可複製的回覆內容',\n    };\n  }\n\n  return {\n    label: 'Copy response as image',\n    copied: 'Response image copied',\n    downloaded: 'Downloaded response image (Safari clipboard limitation)',\n    failed: 'Failed to copy response image',\n    unsupported: 'Clipboard image copy is not supported in this browser',\n    targetMissing: 'Unable to locate response content',\n  };\n}\n\nfunction buildResponseImageFilename(): string {\n  const stamp = new Date().toISOString().replace(/[:.]/g, '-');\n  return `gemini-response-${stamp}.png`;\n}\n\nfunction isUnsupportedClipboardError(error: unknown): boolean {\n  if (error instanceof DOMException) {\n    const name = error.name.toLowerCase();\n    if (name === 'notallowederror' || name === 'notsupportederror' || name === 'securityerror') {\n      return true;\n    }\n  }\n\n  if (!(error instanceof Error)) return false;\n\n  if (/clipboard image copy is not supported/i.test(error.message)) {\n    return true;\n  }\n\n  const lowerMessage = error.message.toLowerCase();\n  return (\n    lowerMessage.includes('clipboard') &&\n    (lowerMessage.includes('not allowed') ||\n      lowerMessage.includes('permission') ||\n      lowerMessage.includes('gesture') ||\n      lowerMessage.includes('unsupported'))\n  );\n}\n\nasync function handleResponseCopyImageClick(\n  trigger: HTMLElement,\n  getCurrentLanguage: () => AppLanguage,\n): Promise<void> {\n  if (trigger.dataset.gvCopyImageBusy === '1') {\n    return;\n  }\n  trigger.dataset.gvCopyImageBusy = '1';\n\n  const texts = getResponseCopyImageTexts(getCurrentLanguage());\n  const messageId = resolveAssistantMessageIdFromMenuTrigger(trigger);\n  let blobForFallback: Blob | null = null;\n  try {\n    if (!messageId) {\n      showExportToast(texts.targetMissing);\n      return;\n    }\n\n    const selectedMessageIds = new Set<string>([messageId]);\n    const turnsForExport = buildTurnsForSelectedMessageIds(selectedMessageIds, collectChatPairs());\n    if (turnsForExport.length === 0) {\n      showExportToast(texts.targetMissing);\n      return;\n    }\n\n    const metadata: ConversationMetadata = {\n      url: location.href,\n      exportedAt: new Date().toISOString(),\n      count: turnsForExport.length,\n      title: getConversationTitleForExport(),\n    };\n\n    const blob = await ImageExportService.renderConversationBlob(turnsForExport, metadata, {});\n    blobForFallback = blob;\n    await copyImageBlobToClipboard(blob);\n    showExportToast(texts.copied);\n  } catch (error) {\n    if (isSafari() && blobForFallback) {\n      downloadImageBlob(blobForFallback, buildResponseImageFilename());\n      showExportToast(texts.downloaded, { autoDismissMs: 3200 });\n      return;\n    }\n    if (isUnsupportedClipboardError(error)) {\n      showExportToast(texts.unsupported, { autoDismissMs: 3200 });\n      return;\n    }\n    console.error('[Gemini Voyager] Failed to copy response image:', error);\n    showExportToast(texts.failed, { autoDismissMs: 3200 });\n  } finally {\n    delete trigger.dataset.gvCopyImageBusy;\n  }\n}\n\nfunction applyResponseActionCopyImageButtons(getCurrentLanguage: () => AppLanguage): void {\n  const texts = getResponseCopyImageTexts(getCurrentLanguage());\n  injectResponseActionCopyImageButtons(document, {\n    label: texts.label,\n    tooltip: texts.label,\n    onClick: (button) => {\n      void handleResponseCopyImageClick(button, getCurrentLanguage);\n    },\n  });\n}\n\nfunction setupResponseActionCopyImageObserver({\n  getCurrentLanguage,\n}: {\n  getCurrentLanguage: () => AppLanguage;\n}): void {\n  applyResponseActionCopyImageButtons(getCurrentLanguage);\n  if (responseActionObserver) return;\n\n  const observer = new MutationObserver((mutations) => {\n    for (const mutation of mutations) {\n      mutation.addedNodes.forEach((node) => {\n        if (!(node instanceof HTMLElement)) return;\n        const texts = getResponseCopyImageTexts(getCurrentLanguage());\n        injectResponseActionCopyImageButtons(node, {\n          label: texts.label,\n          tooltip: texts.label,\n          onClick: (button) => {\n            void handleResponseCopyImageClick(button, getCurrentLanguage);\n          },\n        });\n      });\n    }\n  });\n\n  observer.observe(document.body, { childList: true, subtree: true });\n  responseActionObserver = observer;\n\n  window.addEventListener(\n    'beforeunload',\n    () => {\n      try {\n        responseActionObserver?.disconnect();\n      } catch {}\n      responseActionObserver = null;\n    },\n    { once: true },\n  );\n}\n\nfunction setupConversationMenuExportObserver({\n  dict,\n  getCurrentLanguage,\n  onExport,\n}: {\n  dict: Record<AppLanguage, Record<string, string>>;\n  getCurrentLanguage: () => AppLanguage;\n  onExport: (context: {\n    menuType: 'top' | 'sidebar' | 'message';\n    trigger: HTMLElement | null;\n  }) => void;\n}): void {\n  if (conversationMenuObserver) return;\n\n  const tryInjectOnPanel = (\n    menuPanel: HTMLElement,\n    retriesLeft: number = MENU_INJECTION_RETRY_LIMIT,\n  ) => {\n    if (!menuPanel.isConnected) return;\n    const currentLang = getCurrentLanguage();\n    const label =\n      dict[currentLang]?.['exportChatJson'] ??\n      dict.en?.['exportChatJson'] ??\n      'Export conversation history';\n    const tooltip =\n      dict[currentLang]?.['exportChatJson'] ??\n      dict.en?.['exportChatJson'] ??\n      'Export conversation history';\n\n    const menuContext = getConversationMenuContext(menuPanel);\n    if (menuContext) {\n      const injected = injectConversationMenuExportButton(menuPanel, {\n        label,\n        tooltip,\n        onClick: () => onExport(menuContext),\n      });\n      if (!injected && retriesLeft > 0) {\n        window.setTimeout(\n          () => tryInjectOnPanel(menuPanel, retriesLeft - 1),\n          MENU_INJECTION_RETRY_DELAY_MS,\n        );\n      }\n      return;\n    }\n\n    const responseMenuContext = getResponseMenuContext(menuPanel);\n    if (responseMenuContext) {\n      const injected = injectResponseMenuExportButton(menuPanel, {\n        label,\n        tooltip,\n        onClick: () =>\n          onExport({\n            menuType: 'message',\n            trigger: responseMenuContext.trigger,\n          }),\n      });\n      if (!injected && retriesLeft > 0) {\n        window.setTimeout(\n          () => tryInjectOnPanel(menuPanel, retriesLeft - 1),\n          MENU_INJECTION_RETRY_DELAY_MS,\n        );\n      }\n      return;\n    }\n\n    if (retriesLeft > 0) {\n      window.setTimeout(\n        () => tryInjectOnPanel(menuPanel, retriesLeft - 1),\n        MENU_INJECTION_RETRY_DELAY_MS,\n      );\n    }\n  };\n\n  const observer = new MutationObserver((mutations) => {\n    for (const mutation of mutations) {\n      mutation.addedNodes.forEach((node) => {\n        if (!(node instanceof HTMLElement)) return;\n        const panelSet = new Set<HTMLElement>();\n        const panels = getConversationMenuPanelsFromNode(node);\n        panels.forEach((panel) => panelSet.add(panel));\n        const closestPanel = node.closest(CONVERSATION_MENU_SELECTOR) as HTMLElement | null;\n        if (closestPanel) panelSet.add(closestPanel);\n        panelSet.forEach((panel) => {\n          window.setTimeout(() => tryInjectOnPanel(panel), 30);\n        });\n      });\n    }\n  });\n\n  observer.observe(document.body, { childList: true, subtree: true });\n  conversationMenuObserver = observer;\n\n  const existingPanels = document.querySelectorAll<HTMLElement>(CONVERSATION_MENU_SELECTOR);\n  existingPanels.forEach((panel) => window.setTimeout(() => tryInjectOnPanel(panel), 30));\n\n  const onMenuTriggerInteraction = (event: Event) => {\n    const target = event.target;\n    if (!(target instanceof HTMLElement)) return;\n    const trigger = target.closest(\n      `[data-test-id=\"${CONVERSATION_MENU_TRIGGER_TEST_ID}\"], [data-test-id=\"${RESPONSE_MENU_TRIGGER_TEST_ID}\"]`,\n    ) as HTMLElement | null;\n    if (!trigger) return;\n\n    const panelIds = parseMenuTriggerPanelIds(trigger);\n    if (panelIds.length === 0) return;\n\n    for (let attempt = 0; attempt <= MENU_INJECTION_RETRY_LIMIT; attempt++) {\n      window.setTimeout(() => {\n        panelIds.forEach((id) => {\n          const panel = document.getElementById(id);\n          if (!(panel instanceof HTMLElement)) return;\n          if (!panel.matches(CONVERSATION_MENU_SELECTOR)) return;\n          tryInjectOnPanel(panel);\n        });\n      }, attempt * MENU_INJECTION_RETRY_DELAY_MS);\n    }\n  };\n\n  document.addEventListener('click', onMenuTriggerInteraction, true);\n  document.addEventListener('pointerdown', onMenuTriggerInteraction, true);\n\n  window.addEventListener(\n    'beforeunload',\n    () => {\n      try {\n        conversationMenuObserver?.disconnect();\n      } catch {}\n      try {\n        document.removeEventListener('click', onMenuTriggerInteraction, true);\n      } catch {}\n      try {\n        document.removeEventListener('pointerdown', onMenuTriggerInteraction, true);\n      } catch {}\n      conversationMenuObserver = null;\n    },\n    { once: true },\n  );\n}\n\nexport async function startExportButton(): Promise<void> {\n  // Check for pending export immediately\n  checkPendingExport();\n\n  // i18n setup for tooltip and label\n  const dict = await loadDictionaries();\n  let lang = await getLanguage();\n\n  setupConversationMenuExportObserver({\n    dict,\n    getCurrentLanguage: () => lang,\n    onExport: (context) => {\n      if (context.menuType === 'sidebar' && context.trigger) {\n        void exportFromSidebarConversationTrigger(context.trigger, dict, () => lang);\n        return;\n      }\n      if (context.menuType === 'message') {\n        const initialSelectedMessageId = resolveAssistantMessageIdFromMenuTrigger(context.trigger);\n        void showExportDialog(dict, lang, { initialSelectedMessageId });\n        return;\n      }\n      void showExportDialog(dict, lang);\n    },\n  });\n  setupResponseActionCopyImageObserver({\n    getCurrentLanguage: () => lang,\n  });\n\n  const logo =\n    (await waitForElement('[data-test-id=\"logo\"]', 6000)) || (await waitForElement('.logo', 2000));\n  if (!logo) return;\n  const btn = ensureDropdownInjected(logo);\n  if (!btn) return;\n  if ((btn as Element & { _gvBound?: boolean })._gvBound) return;\n  (btn as Element & { _gvBound?: boolean })._gvBound = true;\n\n  // Swallow events on the button to avoid parent navigation (logo click -> /app)\n  const swallow = (e: Event) => {\n    try {\n      e.preventDefault();\n    } catch {}\n    try {\n      e.stopPropagation();\n    } catch {}\n  };\n  // Capture low-level press events to avoid parent logo navigation, but do NOT capture 'click'\n  ['pointerdown', 'mousedown', 'pointerup', 'mouseup'].forEach((type) => {\n    try {\n      btn.addEventListener(type, swallow, true);\n    } catch {}\n  });\n\n  const t = (key: TranslationKey) => dict[lang]?.[key] ?? dict.en?.[key] ?? key;\n  const title = t('exportChatJson');\n  const labelText = t('pm_export');\n  btn.title = title;\n  btn.setAttribute('aria-label', title);\n\n  // Update label text\n  const labelEl = btn.querySelector('.gv-export-dropdown-label');\n  if (labelEl) labelEl.textContent = labelText;\n\n  // listen for runtime language changes\n  const storageChangeHandler = (\n    changes: Record<string, chrome.storage.StorageChange>,\n    area: string,\n  ) => {\n    if (area !== 'sync') return;\n    const nextRaw = changes[StorageKeys.LANGUAGE]?.newValue;\n    if (typeof nextRaw === 'string') {\n      const next = normalizeLang(nextRaw);\n      lang = next;\n      const ttl =\n        dict[next]?.['exportChatJson'] ?? dict.en?.['exportChatJson'] ?? 'Export chat history';\n      btn.title = ttl;\n      btn.setAttribute('aria-label', ttl);\n\n      // Update visible label text\n      const lbl = btn.querySelector('.gv-export-dropdown-label');\n      if (lbl) lbl.textContent = dict[next]?.['pm_export'] ?? dict.en?.['pm_export'] ?? 'Export';\n\n      applyResponseActionCopyImageButtons(() => lang);\n    }\n  };\n\n  try {\n    chrome.storage?.onChanged?.addListener(storageChangeHandler);\n\n    // Cleanup listener on page unload to prevent memory leaks\n    window.addEventListener(\n      'beforeunload',\n      () => {\n        try {\n          chrome.storage?.onChanged?.removeListener(storageChangeHandler);\n        } catch (e) {\n          console.error('[Gemini Voyager] Failed to remove storage listener on unload:', e);\n        }\n      },\n      { once: true },\n    );\n  } catch {}\n\n  btn.addEventListener('click', (ev) => {\n    // Stop parent navigation, but allow this handler to run\n    swallow(ev);\n    try {\n      // Show export dialog instead of directly exporting\n      showExportDialog(dict, lang);\n    } catch (err) {\n      try {\n        console.error('Gemini Voyager export failed', err);\n      } catch {}\n    }\n  });\n\n  // ─── DOM recovery (resize / print) ─────────────────────────────────────\n  // Gemini may re-render the logo/header area (and thus destroy the wrapper\n  // + export button) during window resize or window.print().  We use a\n  // single debounced handler that fires on resize, afterprint, and our own\n  // gv-print-cleanup event.  It checks whether the button is still attached\n  // and re-injects if not.\n  let currentBtn: HTMLButtonElement = btn;\n  let reinjectTimer: ReturnType<typeof setTimeout> | null = null;\n\n  const reinjectExportButtonIfNeeded = () => {\n    // Debounce: Gemini fires many mutations during resize; wait until it\n    // settles before we attempt re-injection.\n    if (reinjectTimer !== null) clearTimeout(reinjectTimer);\n    reinjectTimer = setTimeout(() => {\n      reinjectTimer = null;\n      try {\n        // If the button is still in the document, nothing to do.\n        if (document.body.contains(currentBtn)) return;\n\n        // Remove stale wrapper if it somehow survived but lost the button.\n        const staleWrapper = document.querySelector('.gv-logo-dropdown-wrapper');\n        if (staleWrapper) staleWrapper.remove();\n\n        // Re-find the logo element (Gemini may have created a fresh one).\n        const newLogo =\n          document.querySelector('[data-test-id=\"logo\"]') ?? document.querySelector('.logo');\n        if (!newLogo) return;\n\n        const newBtn = ensureDropdownInjected(newLogo);\n        if (!newBtn) return;\n        if ((newBtn as Element & { _gvBound?: boolean })._gvBound) return;\n        (newBtn as Element & { _gvBound?: boolean })._gvBound = true;\n\n        // Re-bind all event listeners on the fresh button.\n        ['pointerdown', 'mousedown', 'pointerup', 'mouseup'].forEach((type) => {\n          try {\n            newBtn.addEventListener(type, swallow, true);\n          } catch {}\n        });\n\n        const freshT = (key: TranslationKey) => dict[lang]?.[key] ?? dict.en?.[key] ?? key;\n        const ttl = freshT('exportChatJson');\n        const lbl = freshT('pm_export');\n        newBtn.title = ttl;\n        newBtn.setAttribute('aria-label', ttl);\n        const labelEl = newBtn.querySelector('.gv-export-dropdown-label');\n        if (labelEl) labelEl.textContent = lbl;\n\n        newBtn.addEventListener('click', (ev) => {\n          swallow(ev);\n          try {\n            showExportDialog(dict, lang);\n          } catch (err) {\n            try {\n              console.error('Gemini Voyager export failed', err);\n            } catch {}\n          }\n        });\n\n        // Update our tracking reference so the next check uses the new element.\n        currentBtn = newBtn;\n      } catch (e) {\n        try {\n          console.debug('[Gemini Voyager] Export button re-injection failed:', e);\n        } catch {}\n      }\n    }, 800);\n  };\n\n  window.addEventListener('resize', reinjectExportButtonIfNeeded);\n  window.addEventListener('gv-print-cleanup', reinjectExportButtonIfNeeded);\n  window.addEventListener('afterprint', reinjectExportButtonIfNeeded);\n}\n\nasync function showExportDialog(\n  dict: Record<AppLanguage, Record<string, string>>,\n  lang: AppLanguage,\n  options?: {\n    initialSelectedMessageId?: string | null;\n  },\n): Promise<void> {\n  const t = (key: TranslationKey) => dict[lang]?.[key] ?? dict.en?.[key] ?? key;\n\n  // We defer collection until after the export sequence (scrolling/refresh checks)\n\n  const dialog = new ExportDialog();\n\n  dialog.show({\n    onExport: async (format, fontSize) => {\n      try {\n        await executeExportSequenceWithProgress(\n          format,\n          dict,\n          lang,\n          undefined,\n          fontSize,\n          options?.initialSelectedMessageId || undefined,\n        );\n      } catch (err) {\n        console.error('[Gemini Voyager] Export error:', err);\n      }\n    },\n\n    onCancel: () => {\n      // Dialog closed\n    },\n    translations: {\n      title: t('export_dialog_title'),\n      selectFormat: t('export_dialog_select'),\n      warning: t('export_dialog_warning'),\n      safariCmdpHint: t('export_dialog_safari_cmdp_hint'),\n      safariMarkdownHint: t('export_dialog_safari_markdown_hint'),\n      cancel: t('pm_cancel'),\n      export: t('pm_export'),\n      fontSizeLabel: t('export_fontsize_label'),\n      fontSizePreview: t('export_fontsize_preview'),\n      formatDescriptions: {\n        json: t('export_format_json_description'),\n        markdown: t('export_format_markdown_description'),\n        pdf: t('export_format_pdf_description'),\n        image: t('export_format_image_description'),\n      },\n    },\n  });\n}\n\nexport default { startExportButton };\n"
  },
  {
    "path": "src/pages/content/export/responseActionImageButton.ts",
    "content": "export type ResponseActionCopyImageOptions = {\n  label: string;\n  tooltip: string;\n  onClick: (button: HTMLElement) => void;\n};\n\nconst COPY_BUTTON_TEST_ID = 'copy-button';\nconst MORE_BUTTON_TEST_ID = 'more-menu-button';\nconst COPY_IMAGE_BUTTON_TEST_ID = 'gv-copy-image-button';\nconst COPY_IMAGE_ICON_NAME = 'image';\nconst ASSISTANT_SCOPE_SELECTOR = [\n  '[data-message-author-role=\"assistant\"]',\n  '[data-message-author-role=\"model\"]',\n  'article[data-author=\"assistant\"]',\n  'article[data-turn=\"assistant\"]',\n  'article[data-turn=\"model\"]',\n  '.model-response',\n  'model-response',\n  '.response-container',\n  '.presented-response-container',\n].join(', ');\n\ntype BoundCopyImageButton = HTMLElement & {\n  __gvCopyImageHandler?: (event: Event) => void;\n};\n\nfunction updateButtonLabelAndTooltip(\n  button: HTMLElement,\n  label: string,\n  tooltip: string,\n): HTMLElement {\n  const interactive = button.matches('button')\n    ? button\n    : ((button.querySelector('button, [role=\"button\"]') as HTMLElement | null) ?? button);\n\n  interactive.setAttribute('aria-label', tooltip);\n  interactive.title = tooltip;\n  interactive.removeAttribute('aria-describedby');\n  button.setAttribute('aria-label', tooltip);\n  button.title = tooltip;\n  button.setAttribute('data-gv-copy-image-label', label);\n\n  return interactive;\n}\n\nfunction updateButtonIcon(button: HTMLElement): void {\n  const icon = button.querySelector('mat-icon');\n  if (!(icon instanceof HTMLElement)) return;\n\n  if (icon.hasAttribute('fonticon')) {\n    icon.setAttribute('fonticon', COPY_IMAGE_ICON_NAME);\n  }\n  icon.textContent = COPY_IMAGE_ICON_NAME;\n}\n\nfunction findButtonByTestId(container: HTMLElement, testId: string): HTMLElement | null {\n  const escaped =\n    typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(testId) : testId;\n  return (container.querySelector(`[data-test-id=\"${escaped}\"]`) as HTMLElement | null) ?? null;\n}\n\nfunction findActionContainerFromMoreButton(moreButton: HTMLElement): HTMLElement | null {\n  let current: HTMLElement | null = moreButton;\n  let depth = 0;\n  while (current && depth < 12) {\n    const hasCopy = !!findButtonByTestId(current, COPY_BUTTON_TEST_ID);\n    const hasMore = !!findButtonByTestId(current, MORE_BUTTON_TEST_ID);\n    if (hasCopy && hasMore) {\n      return current;\n    }\n    current = current.parentElement;\n    depth += 1;\n  }\n\n  return null;\n}\n\nfunction bindButtonClick(\n  button: HTMLElement,\n  interactive: HTMLElement,\n  onClick: (button: HTMLElement) => void,\n): void {\n  const typed = button as BoundCopyImageButton;\n  if (typed.__gvCopyImageHandler) {\n    interactive.removeEventListener('click', typed.__gvCopyImageHandler);\n  }\n\n  const handler = (event: Event) => {\n    try {\n      event.preventDefault();\n    } catch {}\n    try {\n      event.stopPropagation();\n    } catch {}\n    onClick(button);\n  };\n\n  typed.__gvCopyImageHandler = handler;\n  interactive.addEventListener('click', handler);\n}\n\nfunction isAssistantActionContainer(container: HTMLElement): boolean {\n  return !!container.closest(ASSISTANT_SCOPE_SELECTOR);\n}\n\nfunction ensureButtonPosition(\n  container: HTMLElement,\n  copyImageButton: HTMLElement,\n  moreButton: HTMLElement,\n): void {\n  if (!moreButton.parentElement) {\n    if (!container.contains(copyImageButton)) container.appendChild(copyImageButton);\n    return;\n  }\n\n  if (copyImageButton !== moreButton.previousElementSibling) {\n    moreButton.parentElement.insertBefore(copyImageButton, moreButton);\n  }\n}\n\nfunction injectIntoActionContainer(\n  container: HTMLElement,\n  options: ResponseActionCopyImageOptions,\n): HTMLElement | null {\n  if (!isAssistantActionContainer(container)) return null;\n\n  const copyButton = findButtonByTestId(container, COPY_BUTTON_TEST_ID);\n  const moreButton = findButtonByTestId(container, MORE_BUTTON_TEST_ID);\n  if (!(copyButton instanceof HTMLElement) || !(moreButton instanceof HTMLElement)) return null;\n\n  const existing = findButtonByTestId(container, COPY_IMAGE_BUTTON_TEST_ID);\n  if (existing) {\n    const interactive = updateButtonLabelAndTooltip(existing, options.label, options.tooltip);\n    updateButtonIcon(existing);\n    bindButtonClick(existing, interactive, options.onClick);\n    ensureButtonPosition(container, existing, moreButton);\n    return existing;\n  }\n\n  const cloned = copyButton.cloneNode(true) as HTMLElement;\n  cloned.setAttribute('data-test-id', COPY_IMAGE_BUTTON_TEST_ID);\n  cloned.removeAttribute('id');\n\n  const interactive = updateButtonLabelAndTooltip(cloned, options.label, options.tooltip);\n  updateButtonIcon(cloned);\n  bindButtonClick(cloned, interactive, options.onClick);\n\n  ensureButtonPosition(container, cloned, moreButton);\n  return cloned;\n}\n\nexport function injectResponseActionCopyImageButtons(\n  root: ParentNode,\n  options: ResponseActionCopyImageOptions,\n): HTMLElement[] {\n  const moreButtons: HTMLElement[] = [];\n  if (root instanceof HTMLElement && root.getAttribute('data-test-id') === MORE_BUTTON_TEST_ID) {\n    moreButtons.push(root);\n  }\n  moreButtons.push(\n    ...Array.from(root.querySelectorAll<HTMLElement>(`[data-test-id=\"${MORE_BUTTON_TEST_ID}\"]`)),\n  );\n\n  if (moreButtons.length === 0 && root instanceof HTMLElement) {\n    const directContainer = findActionContainerFromMoreButton(root);\n    if (directContainer) {\n      const maybeInjected = injectIntoActionContainer(directContainer, options);\n      return maybeInjected ? [maybeInjected] : [];\n    }\n  }\n\n  const visitedContainers = new Set<HTMLElement>();\n  const injected: HTMLElement[] = [];\n\n  for (const moreButton of moreButtons) {\n    const container = findActionContainerFromMoreButton(moreButton);\n    if (!container || visitedContainers.has(container)) continue;\n    visitedContainers.add(container);\n    const maybeInjected = injectIntoActionContainer(container, options);\n    if (maybeInjected) injected.push(maybeInjected);\n  }\n\n  return injected;\n}\n"
  },
  {
    "path": "src/pages/content/export/responseImageCopy.ts",
    "content": "import { renderElementToImageBlob } from '@/features/export/services/ImageRenderService';\n\ntype ClipboardWriteLike = Pick<Clipboard, 'write'>;\ntype ClipboardItemLike = new (items: Record<string, Blob>) => ClipboardItem;\n\nexport type CopyElementAsImageOptions = {\n  clipboard?: ClipboardWriteLike | null;\n  ClipboardItemCtor?: ClipboardItemLike | null;\n};\n\nfunction resolveClipboardDependencies(options?: CopyElementAsImageOptions): {\n  clipboard: ClipboardWriteLike | null;\n  ClipboardItemCtor: ClipboardItemLike | null;\n} {\n  const clipboard = options?.clipboard ?? navigator.clipboard ?? null;\n  const globalClipboardItem = (globalThis as unknown as { ClipboardItem?: ClipboardItemLike })\n    .ClipboardItem;\n  const ClipboardItemCtor = options?.ClipboardItemCtor ?? globalClipboardItem ?? null;\n\n  return { clipboard, ClipboardItemCtor };\n}\n\nexport async function copyImageBlobToClipboard(\n  blob: Blob,\n  options?: CopyElementAsImageOptions,\n): Promise<void> {\n  const { clipboard, ClipboardItemCtor } = resolveClipboardDependencies(options);\n  if (!clipboard?.write || !ClipboardItemCtor) {\n    throw new Error('Clipboard image copy is not supported in this browser');\n  }\n\n  const item = new ClipboardItemCtor({ [blob.type || 'image/png']: blob });\n  await clipboard.write([item]);\n}\n\nexport function downloadImageBlob(blob: Blob, filename: string): void {\n  const url = URL.createObjectURL(blob);\n  const anchor = document.createElement('a');\n  anchor.href = url;\n  anchor.download = filename.toLowerCase().endsWith('.png') ? filename : `${filename}.png`;\n  document.body.appendChild(anchor);\n  anchor.click();\n  setTimeout(() => {\n    try {\n      document.body.removeChild(anchor);\n    } catch {\n      /* ignore */\n    }\n    URL.revokeObjectURL(url);\n  }, 0);\n}\n\nexport async function copyElementAsImageToClipboard(\n  target: HTMLElement,\n  options?: CopyElementAsImageOptions,\n): Promise<void> {\n  const blob = await renderElementToImageBlob(target, {\n    enableSanitizedFallback: true,\n  });\n  await copyImageBlobToClipboard(blob, options);\n}\n"
  },
  {
    "path": "src/pages/content/export/selectionUtils.ts",
    "content": "export function filterItemsBySelectedIds<T>(\n  items: readonly T[],\n  getId: (item: T) => string | null | undefined,\n  selectedIds: ReadonlySet<string>,\n): T[] {\n  return items.filter((item) => {\n    const id = getId(item);\n    return typeof id === 'string' && id.length > 0 && selectedIds.has(id);\n  });\n}\n\nexport function selectBelowIds(allIds: readonly string[], startId: string): Set<string> {\n  const startIndex = allIds.indexOf(startId);\n  if (startIndex < 0) return new Set();\n\n  const out = new Set<string>();\n  for (let i = startIndex; i < allIds.length; i++) {\n    out.add(allIds[i]);\n  }\n  return out;\n}\n\nexport type SelectableMessageRole = 'user' | 'assistant';\n\nexport interface SelectedMessageForTurnGrouping<TElement = HTMLElement> {\n  messageId: string;\n  role: SelectableMessageRole;\n  text: string;\n  starred: boolean;\n  exportElement?: TElement;\n}\n\nexport interface GroupedSelectedTurn<TElement = HTMLElement> {\n  turnId: string;\n  starred: boolean;\n  user?: SelectedMessageForTurnGrouping<TElement>;\n  assistant?: SelectedMessageForTurnGrouping<TElement>;\n}\n\nfunction parseMessageId(messageId: string): {\n  turnId: string;\n  roleHint: SelectableMessageRole | null;\n} {\n  const match = /^(.*):(u|a)$/.exec(messageId);\n  if (!match) {\n    return { turnId: messageId, roleHint: null };\n  }\n  return {\n    turnId: match[1],\n    roleHint: match[2] === 'u' ? 'user' : 'assistant',\n  };\n}\n\nexport function groupSelectedMessagesByTurn<TElement = HTMLElement>(\n  selectedMessages: readonly SelectedMessageForTurnGrouping<TElement>[],\n): GroupedSelectedTurn<TElement>[] {\n  const grouped = new Map<string, GroupedSelectedTurn<TElement>>();\n  const order: string[] = [];\n\n  for (const message of selectedMessages) {\n    const { turnId, roleHint } = parseMessageId(message.messageId);\n    const role: SelectableMessageRole = roleHint ?? message.role;\n\n    let turn = grouped.get(turnId);\n    if (!turn) {\n      turn = { turnId, starred: false };\n      grouped.set(turnId, turn);\n      order.push(turnId);\n    }\n\n    if (role === 'user') {\n      if (!turn.user) turn.user = message;\n    } else if (!turn.assistant) {\n      turn.assistant = message;\n    }\n\n    turn.starred = turn.starred || message.starred;\n  }\n\n  return order.map((turnId) => grouped.get(turnId) as GroupedSelectedTurn<TElement>);\n}\n\nexport function findSelectionStartIdAtLine(\n  items: readonly { id: string; top: number; bottom: number }[],\n  lineY: number,\n): string | null {\n  for (const item of items) {\n    if (item.top <= lineY && item.bottom > lineY) {\n      return item.id;\n    }\n  }\n\n  for (const item of items) {\n    if (item.top > lineY) {\n      return item.id;\n    }\n  }\n\n  return null;\n}\n\nexport function resolveInitialSelectedMessageIds(\n  allMessageIds: readonly string[],\n  preferredMessageId: string | null | undefined,\n): Set<string> {\n  if (!preferredMessageId) return new Set<string>();\n  if (!allMessageIds.includes(preferredMessageId)) return new Set<string>();\n  return new Set<string>([preferredMessageId]);\n}\n"
  },
  {
    "path": "src/pages/content/export/sidebarConversationTarget.ts",
    "content": "export type SidebarConversationTarget = {\n  conversationId: string;\n  url: string;\n  title: string | null;\n};\n\ntype ConversationCandidate = SidebarConversationTarget & {\n  element: HTMLElement;\n};\n\nconst SIDEBAR_CONVERSATION_SELECTOR = '[data-test-id=\"conversation\"]';\n\nfunction normalizeText(value: string | null | undefined): string {\n  return String(value || '')\n    .replace(/\\s+/g, ' ')\n    .trim();\n}\n\nfunction normalizeConversationId(value: string | null | undefined): string | null {\n  const raw = normalizeText(value);\n  if (!raw) return null;\n  return raw.replace(/^c_/i, '');\n}\n\nfunction extractConversationIdFromPath(pathname: string): string | null {\n  const appMatch = pathname.match(/\\/app\\/([^/?#]+)/);\n  if (appMatch?.[1]) return normalizeConversationId(appMatch[1]);\n  const gemMatch = pathname.match(/\\/gem\\/[^/]+\\/([^/?#]+)/);\n  if (gemMatch?.[1]) return normalizeConversationId(gemMatch[1]);\n  return null;\n}\n\nfunction extractConversationIdFromHref(href: string | null | undefined): string | null {\n  if (!href) return null;\n  try {\n    const url = new URL(href, window.location.origin);\n    return extractConversationIdFromPath(url.pathname);\n  } catch {\n    return null;\n  }\n}\n\nfunction extractConversationIdFromJslog(value: string | null | undefined): string | null {\n  const raw = String(value || '');\n  const match = raw.match(/\\bc_([a-zA-Z0-9_-]+)/);\n  return normalizeConversationId(match?.[1] || null);\n}\n\nfunction parseTitleHintFromAriaLabel(label: string | null | undefined): string | null {\n  const raw = normalizeText(label);\n  const match = raw.match(/^More options for\\s+(.+)$/i);\n  return match?.[1] ? normalizeText(match[1]) : null;\n}\n\nfunction readTitleFromConversationScope(scope: HTMLElement): string | null {\n  const bySelector = scope.querySelector(\n    '.gds-label-l, .conversation-title-text, [data-test-id=\"conversation-title\"], h3',\n  );\n  const selectorTitle = normalizeText(bySelector?.textContent);\n  if (selectorTitle) return selectorTitle;\n\n  const link = scope.matches('a') ? (scope as HTMLAnchorElement) : scope.querySelector('a');\n  const ariaLabel = normalizeText(link?.getAttribute('aria-label'));\n  if (ariaLabel) return ariaLabel;\n  const titleAttr = normalizeText(link?.getAttribute('title'));\n  if (titleAttr) return titleAttr;\n\n  const text = normalizeText(scope.textContent);\n  return text || null;\n}\n\nfunction getCandidateFromScope(scope: HTMLElement): ConversationCandidate | null {\n  const link = (\n    scope.matches('a[href]') ? scope : scope.querySelector('a[href]')\n  ) as HTMLAnchorElement | null;\n\n  const href = link?.href || '';\n  const idFromHref = extractConversationIdFromHref(href);\n  const idFromJslog =\n    extractConversationIdFromJslog(scope.getAttribute('jslog')) ||\n    extractConversationIdFromJslog(link?.getAttribute('jslog'));\n  const conversationId = idFromHref || idFromJslog;\n  if (!conversationId) return null;\n\n  const title = readTitleFromConversationScope(scope);\n  const url = href || `${window.location.origin}/app/${conversationId}`;\n\n  return {\n    conversationId,\n    url,\n    title,\n    element: scope,\n  };\n}\n\nfunction collectConversationCandidates(): ConversationCandidate[] {\n  const scopes = Array.from(document.querySelectorAll<HTMLElement>(SIDEBAR_CONVERSATION_SELECTOR));\n  const out: ConversationCandidate[] = [];\n\n  scopes.forEach((scope) => {\n    const candidate = getCandidateFromScope(scope);\n    if (candidate) out.push(candidate);\n  });\n\n  return out;\n}\n\nfunction pickNearestByVerticalDistance(\n  trigger: HTMLElement,\n  candidates: ConversationCandidate[],\n): ConversationCandidate | null {\n  if (candidates.length === 0) return null;\n  const triggerRect = trigger.getBoundingClientRect();\n  const triggerCenterY = triggerRect.top + triggerRect.height / 2;\n\n  let best: ConversationCandidate | null = null;\n  let bestDistance = Number.POSITIVE_INFINITY;\n  for (const candidate of candidates) {\n    const rect = candidate.element.getBoundingClientRect();\n    const candidateCenterY = rect.top + rect.height / 2;\n    const distance = Math.abs(candidateCenterY - triggerCenterY);\n    if (distance < bestDistance) {\n      bestDistance = distance;\n      best = candidate;\n    }\n  }\n  return best;\n}\n\nexport function resolveSidebarConversationTarget(\n  trigger: HTMLElement,\n): SidebarConversationTarget | null {\n  const directScope = trigger.closest(SIDEBAR_CONVERSATION_SELECTOR) as HTMLElement | null;\n  if (directScope) {\n    const direct = getCandidateFromScope(directScope);\n    if (direct) {\n      return {\n        conversationId: direct.conversationId,\n        url: direct.url,\n        title: direct.title,\n      };\n    }\n  }\n\n  const candidates = collectConversationCandidates();\n  if (candidates.length === 0) return null;\n\n  const titleHint = parseTitleHintFromAriaLabel(trigger.getAttribute('aria-label'));\n  if (titleHint) {\n    const normalizedHint = titleHint.toLocaleLowerCase();\n    const titleMatched = candidates.filter((candidate) => {\n      const title = normalizeText(candidate.title).toLocaleLowerCase();\n      return title.length > 0 && title === normalizedHint;\n    });\n    const picked = pickNearestByVerticalDistance(trigger, titleMatched);\n    if (picked) {\n      return {\n        conversationId: picked.conversationId,\n        url: picked.url,\n        title: picked.title,\n      };\n    }\n  }\n\n  const nearest = pickNearestByVerticalDistance(trigger, candidates);\n  if (!nearest) return null;\n  return {\n    conversationId: nearest.conversationId,\n    url: nearest.url,\n    title: nearest.title,\n  };\n}\n"
  },
  {
    "path": "src/pages/content/export/sidebarExportResume.ts",
    "content": "const SESSION_KEY_PENDING_SIDEBAR_EXPORT = 'gv_sidebar_export_pending';\nconst MAX_PENDING_AGE_MS = 60_000;\n\ninterface PendingSidebarExportIntent {\n  conversationId: string;\n  timestamp: number;\n}\n\nfunction clearPendingSidebarExportIntent(): void {\n  sessionStorage.removeItem(SESSION_KEY_PENDING_SIDEBAR_EXPORT);\n}\n\nexport function persistPendingSidebarExportIntent(\n  conversationId: string,\n  now: number = Date.now(),\n): void {\n  if (!conversationId) return;\n  const payload: PendingSidebarExportIntent = {\n    conversationId,\n    timestamp: now,\n  };\n  sessionStorage.setItem(SESSION_KEY_PENDING_SIDEBAR_EXPORT, JSON.stringify(payload));\n}\n\nexport function consumePendingSidebarExportIntent(\n  currentConversationId: string | null | undefined,\n  now: number = Date.now(),\n): boolean {\n  const raw = sessionStorage.getItem(SESSION_KEY_PENDING_SIDEBAR_EXPORT);\n  if (!raw) return false;\n\n  let parsed: unknown;\n  try {\n    parsed = JSON.parse(raw);\n  } catch {\n    clearPendingSidebarExportIntent();\n    return false;\n  }\n\n  if (!parsed || typeof parsed !== 'object') {\n    clearPendingSidebarExportIntent();\n    return false;\n  }\n\n  const record = parsed as Partial<PendingSidebarExportIntent>;\n  if (typeof record.conversationId !== 'string' || typeof record.timestamp !== 'number') {\n    clearPendingSidebarExportIntent();\n    return false;\n  }\n\n  if (!currentConversationId || currentConversationId !== record.conversationId) {\n    clearPendingSidebarExportIntent();\n    return false;\n  }\n\n  if (now - record.timestamp > MAX_PENDING_AGE_MS) {\n    clearPendingSidebarExportIntent();\n    return false;\n  }\n\n  clearPendingSidebarExportIntent();\n  return true;\n}\n"
  },
  {
    "path": "src/pages/content/export/topNodePreload.ts",
    "content": "type Fingerprint = {\n  signature: string;\n  count: number;\n};\n\nfunction hashString(input: string): string {\n  let h = 2166136261 >>> 0;\n  for (let i = 0; i < input.length; i++) {\n    h ^= input.charCodeAt(i);\n    h = Math.imul(h, 16777619);\n  }\n  return (h >>> 0).toString(36);\n}\n\nfunction normalizeText(text: string | null): string {\n  try {\n    return String(text || '')\n      .replace(/\\s+/g, ' ')\n      .trim();\n  } catch {\n    return '';\n  }\n}\n\nfunction filterTopLevel(elements: Element[], selector: string): HTMLElement[] {\n  const out: HTMLElement[] = [];\n  for (const el of elements) {\n    const parent = el.parentElement;\n    if (parent && parent.closest(selector)) continue;\n    out.push(el as HTMLElement);\n  }\n  return out;\n}\n\nexport function computeConversationFingerprint(\n  root: ParentNode,\n  selectors: string[],\n  maxSamples: number,\n): Fingerprint {\n  const selector = selectors.join(',');\n  if (!selector) return { signature: '', count: 0 };\n  const nodes = Array.from(root.querySelectorAll(selector));\n  const topLevel = filterTopLevel(nodes, selector);\n  const texts: string[] = [];\n  for (let i = 0; i < topLevel.length && texts.length < maxSamples; i++) {\n    const t = normalizeText(topLevel[i]?.textContent ?? '');\n    if (t) texts.push(t);\n  }\n  const signature = hashString(texts.join('|'));\n  return { signature, count: topLevel.length };\n}\n\nexport type WaitForConversationChangeOptions = {\n  timeoutMs: number;\n  idleMs: number;\n  minWaitMs: number;\n  pollIntervalMs: number;\n  maxSamples: number;\n};\n\nexport type WaitForConversationChangeResult = {\n  changed: boolean;\n  fingerprint: Fingerprint;\n};\n\nconst DEFAULT_OPTIONS: WaitForConversationChangeOptions = {\n  timeoutMs: 20000,\n  idleMs: 320,\n  minWaitMs: 600,\n  pollIntervalMs: 80,\n  maxSamples: 10,\n};\n\nexport async function waitForConversationFingerprintChangeOrTimeout(\n  root: ParentNode,\n  selectors: string[],\n  before: Fingerprint,\n  options?: Partial<WaitForConversationChangeOptions>,\n): Promise<WaitForConversationChangeResult> {\n  const opt: WaitForConversationChangeOptions = { ...DEFAULT_OPTIONS, ...(options ?? {}) };\n  const selector = selectors.join(',');\n\n  const start = Date.now();\n  let lastMutationAt = Date.now();\n  let sawMutation = false;\n  let lastFingerprint = before;\n  let stableSince: number | null = null;\n\n  const observer =\n    selector && root instanceof Node\n      ? new MutationObserver(() => {\n          sawMutation = true;\n          lastMutationAt = Date.now();\n        })\n      : null;\n\n  if (observer) {\n    try {\n      observer.observe(root, { childList: true, subtree: true, characterData: true });\n    } catch {\n      // ignore\n    }\n  }\n\n  const cleanup = () => {\n    try {\n      observer?.disconnect();\n    } catch {\n      // ignore\n    }\n  };\n\n  const sleep = (ms: number) =>\n    new Promise<void>((resolve) => {\n      setTimeout(resolve, ms);\n    });\n\n  try {\n    while (Date.now() - start < opt.timeoutMs) {\n      await sleep(opt.pollIntervalMs);\n\n      const fp = computeConversationFingerprint(root, selectors, opt.maxSamples);\n      const changed = fp.signature !== before.signature || fp.count !== before.count;\n\n      if (fp.signature !== lastFingerprint.signature || fp.count !== lastFingerprint.count) {\n        lastFingerprint = fp;\n        stableSince = null;\n      } else if (stableSince === null) {\n        stableSince = Date.now();\n      }\n\n      const metMinWait = Date.now() - start >= opt.minWaitMs;\n      const stableEnough = stableSince !== null && Date.now() - stableSince >= opt.idleMs;\n\n      // No change vs \"before\": once the fingerprint stays stable for an idle window, proceed quickly.\n      if (!changed) {\n        if (metMinWait && stableEnough) {\n          cleanup();\n          return { changed: false, fingerprint: fp };\n        }\n        continue;\n      }\n\n      const idleEnough = Date.now() - lastMutationAt >= opt.idleMs;\n      if ((sawMutation && idleEnough) || stableEnough) {\n        cleanup();\n        return { changed: true, fingerprint: fp };\n      }\n    }\n\n    cleanup();\n    return {\n      changed: false,\n      fingerprint: computeConversationFingerprint(root, selectors, opt.maxSamples),\n    };\n  } finally {\n    cleanup();\n  }\n}\n"
  },
  {
    "path": "src/pages/content/folder/README.md",
    "content": "# Folder Manager\n\nThis folder contains the implementation of the conversation folder management feature for Voyager.\n\n## Overview\n\nThe folder manager allows users to:\n\n- Create and manage folders and subfolders (2-level nesting)\n- Drag and drop conversations from the sidebar into folders\n- Move conversations between folders\n- Display Gem-specific icons for different conversation types\n- Navigate to conversations without page reload (SPA-style)\n\n## File Structure\n\n- **`types.ts`** - TypeScript type definitions for folders, conversations, and drag data\n- **`manager.ts`** - Core folder management logic and UI rendering\n- **`gemConfig.ts`** - Configuration for Gem icons and metadata\n- **`index.ts`** - Entry point that initializes the folder manager\n- **`README.md`** - This file\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\n## Adding Support for New Gems\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\nTo add support for a new Gem (either official Google Gems or custom Gems):\n\n1. Open `gemConfig.ts`\n2. Add a new entry to the `GEM_CONFIG` array:\n\n```typescript\n{\n  id: 'your-gem-id',           // The ID as it appears in URLs (/gem/your-gem-id/...)\n  name: 'Your Gem Name',       // Display name\n  icon: 'material_icon_name',  // Google Material Symbols icon name\n}\n```\n\n### Finding the Gem ID\n\nThe Gem ID is the URL slug used by Gemini:\n\n- Open a conversation with the Gem\n- Check the URL: `https://gemini.google.com/app/gem/[GEM_ID]/...`\n- Use this ID in the configuration\n\n### Choosing an Icon\n\nIcons should be valid [Google Material Symbols](https://fonts.google.com/icons) icon names. Common examples:\n\n- `auto_stories` - Learning Coach\n- `lightbulb` - Brainstorm Buddy\n- `work` - Career Guide\n- `code` - Coding Partner\n- `edit_note` - Writing Editor\n- `menu_book` - Storybook\n- `chess` - Chess Champ\n- `check_circle` - Productivity Helper\n- `sports_cricket` - Cricket\n\n### Example\n\n```typescript\nexport const GEM_CONFIG: GemConfig[] = [\n  // ... existing entries ...\n  {\n    id: 'data-analyst',\n    name: 'Data Analyst',\n    icon: 'analytics',\n  },\n];\n```\n\n## Contributing\n\nIf you're adding support for a new official Google Gem, please submit a pull request with:\n\n1. The new entry in `gemConfig.ts`\n2. A brief description of the Gem in your PR\n\n## Technical Details\n\n[<img src=\"https://devin.ai/assets/askdeepwiki.png\" alt=\"Ask DeepWiki\" height=\"20\"/>](https://deepwiki.com/Nagi-ovo/gemini-voyager)\n\n### Gem Detection\n\nThe folder manager detects Gem conversations by analyzing the `jslog` attribute:\n\n- **Regular conversations**: `BardVeMetadataKey:[...,[id,null,0,1]]` (4 elements)\n- **Gem conversations**: `BardVeMetadataKey:[...,[id,null,0]]` (3 elements)\n\n### URL Generation\n\n- Regular conversations: `/app/{hex-id}`\n- Gem conversations: `/gem/{gem-id}/{hex-id}`\n- Multi-account support: `/u/{account-number}/...`\n\n### Icon Mapping\n\nThe system uses a two-way mapping:\n\n- **Gem ID → Icon**: Used when rendering conversations in folders\n- **Icon → Gem ID**: Used when detecting Gem type from DOM elements\n\n## Future Enhancements\n\nPotential improvements that could be contributed:\n\n- Custom user-defined Gems\n- Gem icon customization\n- Support for more than 2 levels of folder nesting\n- Import/export folder structure\n- Folder sharing across devices\n"
  },
  {
    "path": "src/pages/content/folder/__tests__/aistudio.test.ts",
    "content": "import { readFileSync } from 'node:fs';\nimport { resolve } from 'node:path';\nimport { afterEach, describe, expect, it, vi } from 'vitest';\n\nimport { AIStudioFolderManager, mutationAddsPromptLinks, parseDragDataPayload } from '../aistudio';\n\ntype DragDataTransferMock = {\n  effectAllowed: string;\n  setData: ReturnType<typeof vi.fn>;\n  setDragImage: ReturnType<typeof vi.fn>;\n};\n\nfunction createPromptRow(\n  promptId: string,\n  title: string,\n): {\n  root: HTMLElement;\n  row: HTMLElement;\n  host: HTMLElement;\n  anchor: HTMLAnchorElement;\n} {\n  const root = document.createElement('ms-prompt-history-v3');\n  const row = document.createElement('div');\n  row.setAttribute('data-test-id', `history-item-${promptId}`);\n  const li = document.createElement('li');\n  const anchor = document.createElement('a');\n  anchor.className = 'prompt-link';\n  anchor.setAttribute('href', `/prompts/${promptId}`);\n  anchor.textContent = title;\n  li.appendChild(anchor);\n  row.appendChild(li);\n  root.appendChild(row);\n  document.body.appendChild(root);\n  return { root, row, host: li, anchor };\n}\n\ntype AIStudioManagerInternals = {\n  data: {\n    folders: Array<{\n      id: string;\n      name: string;\n      parentId: string | null;\n      isExpanded: boolean;\n      createdAt: number;\n      updatedAt: number;\n    }>;\n    folderContents: Record<\n      string,\n      Array<{\n        conversationId: string;\n        title: string;\n        url: string;\n        addedAt: number;\n        customTitle?: boolean;\n      }>\n    >;\n  };\n  historyRoot: HTMLElement | null;\n  observePromptList: () => void;\n  syncConversationTitlesFromPromptList: () => Promise<void>;\n  save: () => Promise<void>;\n  render: () => void;\n};\n\nafterEach(() => {\n  vi.useRealTimers();\n  document.body.innerHTML = '';\n});\n\ndescribe('AIStudio prompt binding performance guards', () => {\n  it('detects prompt-link additions in mutations', () => {\n    const wrapper = document.createElement('div');\n    const promptAnchor = document.createElement('a');\n    promptAnchor.className = 'prompt-link';\n    promptAnchor.setAttribute('href', '/prompts/abc');\n    wrapper.appendChild(promptAnchor);\n\n    const hitMutation = {\n      addedNodes: [wrapper],\n    } as unknown as MutationRecord;\n    const missMutation = {\n      addedNodes: [document.createElement('span')],\n    } as unknown as MutationRecord;\n\n    expect(mutationAddsPromptLinks([hitMutation])).toBe(true);\n    expect(mutationAddsPromptLinks([missMutation])).toBe(false);\n  });\n\n  it('parses fallback URL payloads used by Firefox native drag data', () => {\n    const fromUriList = parseDragDataPayload('https://aistudio.google.com/prompts/xyz987');\n    expect(fromUriList?.conversationId).toBe('xyz987');\n\n    const fromMozUrl = parseDragDataPayload(\n      'https://aistudio.google.com/prompts/abc555\\nPrompt title from firefox',\n    );\n    expect(fromMozUrl?.conversationId).toBe('abc555');\n  });\n\n  it('binds drag handler once per host and marks anchors as bound', () => {\n    const { root, row, host, anchor } = createPromptRow('abc123', 'Prompt Title');\n    const manager = new AIStudioFolderManager();\n    const bindDraggablesInPromptList = (\n      manager as unknown as {\n        bindDraggablesInPromptList: (scope?: ParentNode | null) => void;\n      }\n    ).bindDraggablesInPromptList.bind(manager);\n\n    bindDraggablesInPromptList(root);\n    bindDraggablesInPromptList(root);\n\n    expect(anchor.dataset.gvDragBound).toBe('1');\n    expect(row.draggable).toBe(true);\n    expect(host.draggable).toBe(false);\n\n    const transfer: DragDataTransferMock = {\n      effectAllowed: '',\n      setData: vi.fn(),\n      setDragImage: vi.fn(),\n    };\n    const dragstart = new Event('dragstart') as DragEvent;\n    Object.defineProperty(dragstart, 'dataTransfer', {\n      value: transfer,\n      configurable: true,\n    });\n\n    row.dispatchEvent(dragstart);\n\n    const calls = transfer.setData.mock.calls as Array<[string, string]>;\n    expect(calls.length).toBeGreaterThanOrEqual(2);\n    expect(calls).toEqual(\n      expect.arrayContaining([\n        ['application/json', expect.stringContaining('\"conversationId\":\"abc123\"')],\n        ['text/plain', expect.stringContaining('\"conversationId\":\"abc123\"')],\n      ]),\n    );\n  });\n\n  it('uses aria-label/title fallback for drag payload titles when text is missing', () => {\n    const { root, row, anchor } = createPromptRow('abc124', '');\n    anchor.setAttribute('aria-label', 'Native prompt title');\n    anchor.setAttribute('title', 'Backup title');\n\n    const manager = new AIStudioFolderManager();\n    const bindDraggablesInPromptList = (\n      manager as unknown as {\n        bindDraggablesInPromptList: (scope?: ParentNode | null) => void;\n      }\n    ).bindDraggablesInPromptList.bind(manager);\n\n    bindDraggablesInPromptList(root);\n\n    const transfer: DragDataTransferMock = {\n      effectAllowed: '',\n      setData: vi.fn(),\n      setDragImage: vi.fn(),\n    };\n    const dragstart = new Event('dragstart') as DragEvent;\n    Object.defineProperty(dragstart, 'dataTransfer', {\n      value: transfer,\n      configurable: true,\n    });\n\n    row.dispatchEvent(dragstart);\n\n    const jsonPayload = (transfer.setData.mock.calls as Array<[string, string]>).find(\n      ([type]) => type === 'application/json',\n    )?.[1];\n    expect(jsonPayload).toBeTruthy();\n    expect(JSON.parse(jsonPayload || '{}')).toMatchObject({\n      conversationId: 'abc124',\n      title: 'Native prompt title',\n    });\n  });\n});\n\ndescribe('AIStudio theme compatibility', () => {\n  it('uses body light/dark theme selectors for folder palette variables', () => {\n    const css = readFileSync(resolve(process.cwd(), 'public/contentStyle.css'), 'utf8');\n\n    expect(css).toContain('.theme-host.dark-theme,\\nbody.dark-theme');\n    expect(css).toContain('.theme-host.light-theme,\\nbody.light-theme');\n    expect(css).toContain('body.dark-theme .gv-folder-action-btn:hover');\n  });\n\n  it('renders cloud action icons with currentColor in AI Studio', () => {\n    const code = readFileSync(\n      resolve(process.cwd(), 'src/pages/content/folder/aistudio.ts'),\n      'utf8',\n    );\n\n    expect(code).toContain('fill=\"currentColor\"');\n    expect(code).not.toContain('fill=\"#e3e3e3\"');\n  });\n});\n\ndescribe('AIStudio conversation title sync', () => {\n  it('syncs stored conversation titles from native prompt links', async () => {\n    createPromptRow('abc123', 'Renamed in AI Studio');\n    const manager = new AIStudioFolderManager();\n    const internals = manager as unknown as AIStudioManagerInternals;\n\n    internals.data = {\n      folders: [],\n      folderContents: {\n        folderA: [\n          {\n            conversationId: 'abc123',\n            title: 'Old title',\n            url: '/prompts/abc123',\n            addedAt: Date.now(),\n          },\n        ],\n      },\n    };\n\n    const saveSpy = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);\n    const renderSpy = vi.fn<() => void>();\n    internals.save = saveSpy;\n    internals.render = renderSpy;\n\n    await internals.syncConversationTitlesFromPromptList();\n\n    expect(internals.data.folderContents.folderA[0]?.title).toBe('Renamed in AI Studio');\n    expect(saveSpy).toHaveBeenCalledTimes(1);\n    expect(renderSpy).toHaveBeenCalledTimes(1);\n  });\n\n  it('does not overwrite custom titles during native sync', async () => {\n    createPromptRow('abc999', 'Native New Name');\n    const manager = new AIStudioFolderManager();\n    const internals = manager as unknown as AIStudioManagerInternals;\n\n    internals.data = {\n      folders: [],\n      folderContents: {\n        folderA: [\n          {\n            conversationId: 'abc999',\n            title: 'Manually Renamed',\n            url: '/prompts/abc999',\n            addedAt: Date.now(),\n            customTitle: true,\n          },\n        ],\n      },\n    };\n\n    const saveSpy = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);\n    const renderSpy = vi.fn<() => void>();\n    internals.save = saveSpy;\n    internals.render = renderSpy;\n\n    await internals.syncConversationTitlesFromPromptList();\n\n    expect(internals.data.folderContents.folderA[0]?.title).toBe('Manually Renamed');\n    expect(saveSpy).not.toHaveBeenCalled();\n    expect(renderSpy).not.toHaveBeenCalled();\n  });\n\n  it('observes prompt title mutations and syncs with debounce', async () => {\n    vi.useFakeTimers();\n    const { root, anchor } = createPromptRow('debounce1', 'Before Rename');\n    const manager = new AIStudioFolderManager();\n    const internals = manager as unknown as AIStudioManagerInternals;\n\n    internals.data = {\n      folders: [],\n      folderContents: {\n        folderA: [\n          {\n            conversationId: 'debounce1',\n            title: 'Before Rename',\n            url: '/prompts/debounce1',\n            addedAt: Date.now(),\n          },\n        ],\n      },\n    };\n    internals.historyRoot = root;\n\n    const saveSpy = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);\n    const renderSpy = vi.fn<() => void>();\n    internals.save = saveSpy;\n    internals.render = renderSpy;\n\n    internals.observePromptList();\n\n    anchor.textContent = 'After Rename';\n    await vi.advanceTimersByTimeAsync(350);\n\n    expect(internals.data.folderContents.folderA[0]?.title).toBe('After Rename');\n    expect(saveSpy).toHaveBeenCalledTimes(1);\n    expect(renderSpy).toHaveBeenCalledTimes(1);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/folder/__tests__/conversationSort.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { sortConversationsByPriority } from '../conversationSort';\nimport type { ConversationReference } from '../types';\n\nfunction createConversation(\n  conversationId: string,\n  options: Partial<ConversationReference> = {},\n): ConversationReference {\n  return {\n    conversationId,\n    title: conversationId,\n    url: `https://gemini.google.com/app/${conversationId}`,\n    addedAt: 0,\n    ...options,\n  };\n}\n\ndescribe('sortConversationsByPriority', () => {\n  it('keeps starred conversations ahead of non-starred conversations', () => {\n    const sorted = sortConversationsByPriority([\n      createConversation('normal-newer', { addedAt: 30 }),\n      createConversation('starred-older', { starred: true, addedAt: 10 }),\n      createConversation('starred-newer', { starred: true, addedAt: 20 }),\n    ]);\n\n    expect(sorted.map((item) => item.conversationId)).toEqual([\n      'starred-newer',\n      'starred-older',\n      'normal-newer',\n    ]);\n  });\n\n  it('sorts by lastOpenedAt (newest first) within the same starred state', () => {\n    const sorted = sortConversationsByPriority([\n      createConversation('opened-earlier', { addedAt: 999, lastOpenedAt: 100 }),\n      createConversation('opened-latest', { addedAt: 1, lastOpenedAt: 200 }),\n      createConversation('never-opened', { addedAt: 150 }),\n    ]);\n\n    expect(sorted.map((item) => item.conversationId)).toEqual([\n      'opened-latest',\n      'never-opened',\n      'opened-earlier',\n    ]);\n  });\n\n  it('falls back to addedAt when lastOpenedAt is missing (backward compatibility)', () => {\n    const sorted = sortConversationsByPriority([\n      createConversation('older', { addedAt: 100 }),\n      createConversation('newer', { addedAt: 200 }),\n      createConversation('newest', { addedAt: 300 }),\n    ]);\n\n    expect(sorted.map((item) => item.conversationId)).toEqual(['newest', 'newer', 'older']);\n  });\n\n  it('sorts by sortIndex when both items have one (within same starred group)', () => {\n    const sorted = sortConversationsByPriority([\n      createConversation('c', { sortIndex: 2, addedAt: 300 }),\n      createConversation('a', { sortIndex: 0, addedAt: 100 }),\n      createConversation('b', { sortIndex: 1, addedAt: 200 }),\n    ]);\n\n    expect(sorted.map((item) => item.conversationId)).toEqual(['a', 'b', 'c']);\n  });\n\n  it('uses sortIndex within starred group independently', () => {\n    const sorted = sortConversationsByPriority([\n      createConversation('normal-last', { sortIndex: 1, addedAt: 300 }),\n      createConversation('normal-first', { sortIndex: 0, addedAt: 100 }),\n      createConversation('starred-last', { starred: true, sortIndex: 1, addedAt: 200 }),\n      createConversation('starred-first', { starred: true, sortIndex: 0, addedAt: 300 }),\n    ]);\n\n    expect(sorted.map((item) => item.conversationId)).toEqual([\n      'starred-first',\n      'starred-last',\n      'normal-first',\n      'normal-last',\n    ]);\n  });\n\n  it('falls back to time-based sort when sortIndex is missing on either item', () => {\n    const sorted = sortConversationsByPriority([\n      createConversation('with-index', { sortIndex: 0, addedAt: 100 }),\n      createConversation('no-index-newer', { addedAt: 300 }),\n      createConversation('no-index-older', { addedAt: 200 }),\n    ]);\n\n    // When comparing items where one lacks sortIndex, time-based sort applies\n    // no-index-newer (addedAt=300) > no-index-older (addedAt=200) > with-index (addedAt=100)\n    expect(sorted.map((item) => item.conversationId)).toEqual([\n      'no-index-newer',\n      'no-index-older',\n      'with-index',\n    ]);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/folder/__tests__/folderNameInteraction.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { FolderManager } from '../manager';\nimport type { Folder } from '../types';\n\nvi.mock('@/utils/i18n', () => ({\n  getTranslationSync: (key: string) => key,\n  getTranslationSyncUnsafe: (key: string) => key,\n  initI18n: () => Promise.resolve(),\n}));\n\ntype TestableManager = {\n  createFolderElement: (folder: Folder, level?: number) => HTMLElement;\n  toggleFolder: (folderId: string) => void;\n  renameFolder: (folderId: string) => void;\n  // Expose private method via type casting for testing only\n  extractConversationData: (element: HTMLElement) => {\n    url: string;\n    isGem: boolean;\n    gemId?: string;\n  };\n};\n\nfunction createFolder(): Folder {\n  const now = Date.now();\n  return {\n    id: 'folder-1',\n    name: 'Folder 1',\n    parentId: null,\n    isExpanded: false,\n    createdAt: now,\n    updatedAt: now,\n  };\n}\n\ndescribe('folder name click/double-click interaction', () => {\n  let manager: FolderManager | null = null;\n\n  beforeEach(() => {\n    vi.useFakeTimers();\n  });\n\n  afterEach(() => {\n    manager?.destroy();\n    manager = null;\n    document.body.innerHTML = '';\n    vi.runOnlyPendingTimers();\n    vi.useRealTimers();\n  });\n\n  it('toggles folder on single click after delay', () => {\n    manager = new FolderManager();\n    const typedManager = manager as unknown as TestableManager;\n    const toggleSpy = vi.spyOn(typedManager, 'toggleFolder').mockImplementation(() => {});\n    const renameSpy = vi.spyOn(typedManager, 'renameFolder').mockImplementation(() => {});\n\n    const folderEl = typedManager.createFolderElement(createFolder());\n    document.body.appendChild(folderEl);\n\n    const folderName = folderEl.querySelector('.gv-folder-name') as HTMLElement | null;\n    expect(folderName).not.toBeNull();\n    folderName?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }));\n\n    vi.advanceTimersByTime(219);\n    expect(toggleSpy).not.toHaveBeenCalled();\n\n    vi.advanceTimersByTime(1);\n    expect(toggleSpy).toHaveBeenCalledTimes(1);\n    expect(toggleSpy).toHaveBeenCalledWith('folder-1');\n    expect(renameSpy).not.toHaveBeenCalled();\n  });\n\n  it('renames folder on double click without toggle flicker', () => {\n    manager = new FolderManager();\n    const typedManager = manager as unknown as TestableManager;\n    const toggleSpy = vi.spyOn(typedManager, 'toggleFolder').mockImplementation(() => {});\n    const renameSpy = vi.spyOn(typedManager, 'renameFolder').mockImplementation(() => {});\n\n    const folderEl = typedManager.createFolderElement(createFolder());\n    document.body.appendChild(folderEl);\n\n    const folderName = folderEl.querySelector('.gv-folder-name') as HTMLElement | null;\n    expect(folderName).not.toBeNull();\n    folderName?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }));\n    folderName?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 2 }));\n    folderName?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, detail: 2 }));\n\n    vi.runAllTimers();\n    expect(toggleSpy).not.toHaveBeenCalled();\n    expect(renameSpy).toHaveBeenCalledTimes(1);\n    expect(renameSpy).toHaveBeenCalledWith('folder-1');\n  });\n\n  it('builds conversation URL without embedding /u/{num} segment', () => {\n    manager = new FolderManager();\n    const typedManager = manager as unknown as TestableManager & {\n      accountIsolationEnabled: boolean;\n    };\n\n    // Enable hard isolation so that URL normalization logic is active\n    typedManager.accountIsolationEnabled = true;\n\n    // Simulate current URL containing /u/1/app?foo=bar (same-origin relative path)\n    window.history.pushState({}, '', '/u/1/app?foo=bar');\n\n    const convEl = document.createElement('div');\n    convEl.setAttribute('jslog', '[\"c_2b6fe5971f124c03\"]');\n\n    const data = typedManager.extractConversationData(convEl);\n\n    expect(data.url).toContain('/app/2b6fe5971f124c03');\n    expect(data.url).not.toContain('/u/1/');\n  });\n});\n"
  },
  {
    "path": "src/pages/content/folder/__tests__/moveToFolderMenuItem.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { createMoveToFolderMenuItem } from '../moveToFolderMenuItem';\n\nfunction createTemplateButton(): HTMLButtonElement {\n  const button = document.createElement('button');\n  button.className = 'mat-mdc-menu-item mat-focus-indicator';\n  button.setAttribute('data-test-id', 'pin-button');\n\n  const icon = document.createElement('mat-icon');\n  icon.className =\n    'mat-icon notranslate menu-icon google-symbols mat-ligature-font mat-icon-no-color';\n  icon.setAttribute('fonticon', 'keep');\n  icon.setAttribute('aria-hidden', 'true');\n  icon.textContent = 'keep';\n\n  const text = document.createElement('span');\n  text.className = 'mat-mdc-menu-item-text';\n  const inner = document.createElement('span');\n  inner.className = 'menu-text';\n  inner.textContent = 'Pin';\n  text.appendChild(inner);\n\n  const ripple = document.createElement('div');\n  ripple.className = 'mat-ripple mat-mdc-menu-ripple';\n  ripple.setAttribute('matripple', '');\n\n  button.append(icon, text, ripple);\n  return button;\n}\n\ndescribe('createMoveToFolderMenuItem', () => {\n  it('uses native template when available', () => {\n    const menuContent = document.createElement('div');\n    menuContent.className = 'mat-mdc-menu-content';\n    menuContent.appendChild(createTemplateButton());\n\n    const menuItem = createMoveToFolderMenuItem(menuContent, 'Move to folder', 'Move to folder');\n    expect(menuItem.classList.contains('gv-move-to-folder-btn')).toBe(true);\n    expect(menuItem.querySelector('.mat-mdc-menu-item-text .menu-text')?.textContent).toBe(\n      'Move to folder',\n    );\n  });\n\n  it('falls back to manual button when no native template exists', () => {\n    const menuContent = document.createElement('div');\n    menuContent.className = 'mat-mdc-menu-content';\n\n    const menuItem = createMoveToFolderMenuItem(menuContent, 'Move to folder', 'Move to folder');\n    expect(menuItem.classList.contains('gv-move-to-folder-btn')).toBe(true);\n    expect(menuItem.querySelector('mat-icon')?.getAttribute('fonticon')).toBe('folder_open');\n    expect(menuItem.querySelector('.mat-mdc-menu-item-text .gds-body-m')?.textContent).toBe(\n      'Move to folder',\n    );\n  });\n});\n"
  },
  {
    "path": "src/pages/content/folder/__tests__/treeIndent.test.ts",
    "content": "import { afterEach, describe, expect, it, vi } from 'vitest';\n\nimport {\n  FolderManager,\n  calculateFolderConversationPaddingLeft,\n  calculateFolderDialogPaddingLeft,\n  calculateFolderHeaderPaddingLeft,\n  clampFolderTreeIndent,\n} from '../manager';\n\nvi.mock('@/utils/i18n', () => ({\n  getTranslationSync: (key: string) => key,\n  getTranslationSyncUnsafe: (key: string) => key,\n  initI18n: () => Promise.resolve(),\n}));\n\ndescribe('folder tree indentation', () => {\n  let manager: FolderManager | null = null;\n\n  afterEach(() => {\n    manager?.destroy();\n    manager = null;\n    document.body.innerHTML = '';\n  });\n\n  it('clamps configured indent into [-8, 32] and defaults to -8 for invalid values', () => {\n    expect(clampFolderTreeIndent(-40)).toBe(-8);\n    expect(clampFolderTreeIndent(64)).toBe(32);\n    expect(clampFolderTreeIndent(0)).toBe(0);\n    expect(clampFolderTreeIndent(16)).toBe(16);\n    expect(clampFolderTreeIndent('invalid')).toBe(-8);\n  });\n\n  it('calculates folder and conversation paddings from indent and level', () => {\n    expect(calculateFolderHeaderPaddingLeft(2, 16)).toBe(40); // 2 * 16 + 8\n    expect(calculateFolderConversationPaddingLeft(2, 16)).toBe(56); // 2 * 16 + 24\n    expect(calculateFolderDialogPaddingLeft(2, 16)).toBe(44); // 2 * 16 + 12\n    expect(calculateFolderHeaderPaddingLeft(2, -16)).toBe(0);\n    expect(calculateFolderConversationPaddingLeft(3, -16)).toBe(0);\n    expect(calculateFolderDialogPaddingLeft(2, -16)).toBe(0);\n  });\n\n  it('updates indent and refreshes render when setting changes', () => {\n    manager = new FolderManager();\n    const typedManager = manager as unknown as {\n      folderEnabled: boolean;\n      containerElement: HTMLElement | null;\n      folderTreeIndent: number;\n      renderAllFolders: () => void;\n      applyFolderTreeIndentSetting: (value: unknown) => void;\n    };\n\n    typedManager.folderEnabled = true;\n    typedManager.containerElement = document.createElement('div');\n    typedManager.folderTreeIndent = 16;\n    const renderSpy = vi.spyOn(typedManager, 'renderAllFolders').mockImplementation(() => {});\n\n    typedManager.applyFolderTreeIndentSetting(28);\n    expect(typedManager.folderTreeIndent).toBe(28);\n    expect(renderSpy).toHaveBeenCalledTimes(1);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/folder/aistudio.ts",
    "content": "import browser from 'webextension-polyfill';\n\nimport {\n  type AccountScope,\n  accountIsolationService,\n  buildScopedStorageKey,\n  detectAccountContextFromDocument,\n} from '@/core/services/AccountIsolationService';\nimport { DataBackupService } from '@/core/services/DataBackupService';\nimport { getStorageMonitor } from '@/core/services/StorageMonitor';\nimport { StorageKeys } from '@/core/types/common';\nimport type { PromptItem, SyncAccountScope } from '@/core/types/sync';\nimport { isSafari } from '@/core/utils/browser';\nimport { createTranslator, initI18n } from '@/utils/i18n';\n\nimport type { ConversationReference, DragData, Folder, FolderData } from './types';\n\nfunction waitForElement<T extends Element = Element>(\n  selector: string,\n  timeoutMs = 10000,\n): Promise<T | null> {\n  return new Promise((resolve) => {\n    const found = document.querySelector(selector) as T | null;\n    if (found) return resolve(found);\n    const obs = new MutationObserver(() => {\n      const el = document.querySelector(selector) as T | null;\n      if (el) {\n        try {\n          obs.disconnect();\n        } catch {}\n        resolve(el);\n      }\n    });\n    try {\n      obs.observe(document.body, { childList: true, subtree: true });\n    } catch {}\n    if (timeoutMs > 0) {\n      setTimeout(() => {\n        try {\n          obs.disconnect();\n        } catch {}\n        resolve(null);\n      }, timeoutMs);\n    }\n  });\n}\n\nfunction normalizeText(text: string | null | undefined): string {\n  try {\n    return String(text || '')\n      .replace(/\\s+/g, ' ')\n      .trim();\n  } catch {\n    return '';\n  }\n}\n\nfunction downloadJSON(data: unknown, filename: string): void {\n  const blob = new Blob([JSON.stringify(data, null, 2)], {\n    type: 'application/json;charset=utf-8',\n  });\n  const url = URL.createObjectURL(blob);\n  const a = document.createElement('a');\n  a.href = url;\n  a.download = filename;\n  document.body.appendChild(a);\n  a.click();\n  setTimeout(() => {\n    try {\n      document.body.removeChild(a);\n    } catch {}\n    URL.revokeObjectURL(url);\n  }, 0);\n}\n\nfunction now(): number {\n  return Date.now();\n}\n\nfunction uid(): string {\n  return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);\n}\n\nconst NOTIFICATION_TIMEOUT_MS = 5000;\nconst PROMPT_LINK_SELECTOR = 'a.prompt-link[href^=\"/prompts/\"]';\nconst UNBOUND_PROMPT_LINK_SELECTOR = `${PROMPT_LINK_SELECTOR}:not([data-gv-drag-bound])`;\nconst PROMPT_LIST_BIND_DEBOUNCE_MS = 120;\nconst PROMPT_TITLE_SYNC_DEBOUNCE_MS = 280;\nconst PROMPT_DRAG_HOST_SELECTORS = [\n  '[data-test-id^=\"history-item\"]',\n  '[role=\"listitem\"]',\n  '.mat-mdc-list-item',\n  'li',\n];\n\nfunction nodeContainsPromptLink(node: Node): boolean {\n  if (!(node instanceof Element)) return false;\n  if (node.matches(PROMPT_LINK_SELECTOR)) return true;\n  return !!node.querySelector(PROMPT_LINK_SELECTOR);\n}\n\nexport function mutationAddsPromptLinks(mutations: MutationRecord[]): boolean {\n  for (const mutation of mutations) {\n    for (const node of Array.from(mutation.addedNodes)) {\n      if (nodeContainsPromptLink(node)) return true;\n    }\n  }\n  return false;\n}\n\nfunction mutationMayAffectPromptTitles(mutations: MutationRecord[]): boolean {\n  for (const mutation of mutations) {\n    if (mutation.type === 'characterData') {\n      if (mutation.target.parentElement?.closest(PROMPT_LINK_SELECTOR)) return true;\n      continue;\n    }\n\n    if (mutation.type === 'attributes') {\n      if (mutation.target instanceof Element && mutation.target.closest(PROMPT_LINK_SELECTOR))\n        return true;\n      continue;\n    }\n\n    if (mutation.type === 'childList') {\n      if (mutation.target instanceof Element && mutation.target.closest(PROMPT_LINK_SELECTOR)) {\n        return true;\n      }\n\n      for (const node of Array.from(mutation.addedNodes)) {\n        if (nodeContainsPromptLink(node)) return true;\n      }\n      for (const node of Array.from(mutation.removedNodes)) {\n        if (nodeContainsPromptLink(node)) return true;\n      }\n    }\n  }\n  return false;\n}\n\nfunction extractPromptIdFromHref(rawHref: string): string | null {\n  const href = String(rawHref || '').trim();\n  if (!href) return null;\n  const match = href.match(/\\/prompts\\/([^/?#]+)/);\n  if (match && match[1]) return match[1];\n  try {\n    const url = new URL(href, location.origin);\n    const pathMatch = url.pathname.match(/\\/prompts\\/([^/?#]+)/);\n    return pathMatch?.[1] || null;\n  } catch {\n    return null;\n  }\n}\n\nfunction normalizeDroppedUrl(raw: string): string | null {\n  const firstLine = String(raw || '')\n    .split(/\\r?\\n/, 1)[0]\n    ?.trim();\n  if (!firstLine) return null;\n  if (/^https?:\\/\\//i.test(firstLine)) return firstLine;\n  if (firstLine.startsWith('/')) return `${location.origin}${firstLine}`;\n  return null;\n}\n\nexport function parseDragDataPayload(raw: string): DragData | null {\n  const trimmed = String(raw || '').trim();\n  if (!trimmed) return null;\n\n  try {\n    const parsed = JSON.parse(trimmed) as unknown;\n    if (\n      parsed &&\n      typeof parsed === 'object' &&\n      (parsed as { type?: unknown }).type === 'conversation' &&\n      typeof (parsed as { conversationId?: unknown }).conversationId === 'string'\n    ) {\n      const data = parsed as DragData;\n      return {\n        type: 'conversation',\n        conversationId: data.conversationId,\n        title: typeof data.title === 'string' ? data.title : '',\n        url: typeof data.url === 'string' ? data.url : '',\n      };\n    }\n  } catch {}\n\n  const normalizedUrl = normalizeDroppedUrl(trimmed);\n  if (!normalizedUrl) return null;\n  const conversationId = extractPromptIdFromHref(normalizedUrl);\n  if (!conversationId) return null;\n\n  return {\n    type: 'conversation',\n    conversationId,\n    title: '',\n    url: normalizedUrl,\n  };\n}\n\n/**\n * Validate folder data structure\n */\nfunction validateFolderData(data: unknown): boolean {\n  if (typeof data !== 'object' || data === null) return false;\n  const d = data as Record<string, unknown>;\n  return Array.isArray(d.folders) && typeof d.folderContents === 'object';\n}\n\nexport class AIStudioFolderManager {\n  private t: (key: string) => string = (k) => k;\n  private data: FolderData = { folders: [], folderContents: {} };\n  private container: HTMLElement | null = null;\n  private historyRoot: HTMLElement | null = null;\n  private cleanupFns: Array<() => void> = [];\n  private promptListBindTimer: number | null = null;\n  private promptTitleSyncTimer: number | null = null;\n  private promptTitleSyncInProgress: boolean = false;\n  private readonly STORAGE_KEY = StorageKeys.FOLDER_DATA_AISTUDIO;\n  private folderEnabled: boolean = true; // Whether folder feature is enabled\n  private accountIsolationEnabled: boolean = false; // Whether hard account isolation is enabled\n  private accountScope: AccountScope | null = null; // Resolved account scope for current account\n  private activeStorageKey: string = StorageKeys.FOLDER_DATA_AISTUDIO; // Active folder data key\n  private accountContextPoller: number | null = null; // Detect account switches\n  private lastAccountContextFingerprint: string | null = null; // Debounce account scope refresh\n  private backupService!: DataBackupService<FolderData>; // Initialized in init()\n  private sidebarWidth: number = 360; // Default sidebar width (increased to reduce text truncation)\n  private readonly SIDEBAR_WIDTH_KEY = 'gvAIStudioSidebarWidth';\n  private readonly MIN_SIDEBAR_WIDTH = 240;\n  private readonly MAX_SIDEBAR_WIDTH = 600;\n  private readonly UNCATEGORIZED_KEY = '__uncategorized__'; // Special key for root-level conversations\n\n  // Helper to create a ligature icon span with a data-icon attribute\n  private createIcon(name: string): HTMLSpanElement {\n    const span = document.createElement('span');\n    span.className = 'google-symbols';\n    try {\n      span.dataset.icon = name;\n    } catch {}\n    span.textContent = name;\n    return span;\n  }\n\n  async init(): Promise<void> {\n    await initI18n();\n    this.t = createTranslator();\n\n    // Initialize backup service\n    this.backupService = new DataBackupService<FolderData>('aistudio-folders', validateFolderData);\n\n    // Setup automatic backup before page unload\n    this.backupService.setupBeforeUnloadBackup(() => this.data);\n\n    // Initialize storage quota monitor\n    const storageMonitor = getStorageMonitor({\n      checkIntervalMs: 120000, // Check every 2 minutes (less frequent for AI Studio)\n    });\n\n    // Use custom notification callback to match our style\n    storageMonitor.setNotificationCallback((message, level) => {\n      this.showNotification(message, level);\n    });\n\n    // Start monitoring\n    storageMonitor.startMonitoring();\n\n    // Migrate data from chrome.storage.sync to chrome.storage.local (one-time)\n    await this.migrateFromSyncToLocal();\n\n    // Only enable on prompts, library, or root pages\n    // Root path (/) is where the main playground is, prompts are saved chats, library is history\n    const isValidPath =\n      /^\\/(prompts|library)(\\/|$)/.test(location.pathname) || location.pathname === '/';\n    if (!isValidPath) return;\n\n    // Load folder enabled setting\n    await this.loadFolderEnabledSetting();\n\n    // Load account isolation setting/scope before reading folder data.\n    await this.loadAccountIsolationSetting();\n    await this.refreshAccountScope(true);\n\n    // Load sidebar width setting\n    await this.loadSidebarWidth();\n\n    // Set up storage change listener (always needed to respond to setting changes)\n    this.setupStorageListener();\n\n    // Keep account-scoped data aligned with current AI Studio account.\n    this.setupAccountContextPoller();\n\n    // Setup message listener for sync operations (always needed)\n    this.setupMessageListener();\n\n    // If folder feature is disabled, skip initialization\n    if (!this.folderEnabled) {\n      return;\n    }\n\n    // Initialize folder UI\n    await this.initializeFolderUI();\n  }\n\n  /**\n   * Migrate folder data from chrome.storage.sync to chrome.storage.local\n   * This is a one-time migration for users upgrading from older versions\n   * Benefits: No 100KB quota limit, consistent with Gemini storage\n   */\n  private async migrateFromSyncToLocal(): Promise<void> {\n    try {\n      // Check if there's data in chrome.storage.sync\n      const syncResult = await chrome.storage.sync.get(this.STORAGE_KEY);\n      const syncData = syncResult[this.STORAGE_KEY];\n\n      if (syncData && validateFolderData(syncData)) {\n        // Check if chrome.storage.local already has data\n        const localResult = await chrome.storage.local.get(this.STORAGE_KEY);\n        const localData = localResult[this.STORAGE_KEY];\n\n        if (!localData || !validateFolderData(localData)) {\n          // Migrate sync data to local storage\n          await chrome.storage.local.set({ [this.STORAGE_KEY]: syncData });\n          console.log('[AIStudioFolderManager] Migrated folder data from sync to local storage');\n\n          // Optionally clear sync storage after successful migration\n          // await chrome.storage.sync.remove(this.STORAGE_KEY);\n        } else {\n          // Both have data - merge them (local takes priority for conflicts)\n          const mergedFolders = this.mergeFolderData(localData, syncData);\n          await chrome.storage.local.set({ [this.STORAGE_KEY]: mergedFolders });\n          console.log('[AIStudioFolderManager] Merged sync and local folder data');\n        }\n      }\n    } catch (error) {\n      console.warn('[AIStudioFolderManager] Migration from sync to local failed:', error);\n      // Don't throw - migration failure should not block normal operation\n    }\n  }\n\n  /**\n   * Simple merge of folder data (used during migration)\n   * Local data takes priority for conflicts\n   */\n  private mergeFolderData(local: FolderData, sync: FolderData): FolderData {\n    const mergedFolders = [...local.folders];\n    const localFolderIds = new Set(local.folders.map((f) => f.id));\n\n    // Add folders from sync that don't exist in local\n    for (const folder of sync.folders) {\n      if (!localFolderIds.has(folder.id)) {\n        mergedFolders.push(folder);\n      }\n    }\n\n    // Merge folder contents\n    const mergedContents = { ...local.folderContents };\n    for (const [folderId, conversations] of Object.entries(sync.folderContents)) {\n      if (!mergedContents[folderId]) {\n        mergedContents[folderId] = conversations;\n      } else {\n        // Merge conversations, avoiding duplicates\n        const existingIds = new Set(mergedContents[folderId].map((c) => c.conversationId));\n        for (const conv of conversations) {\n          if (!existingIds.has(conv.conversationId)) {\n            mergedContents[folderId].push(conv);\n          }\n        }\n      }\n    }\n\n    return { folders: mergedFolders, folderContents: mergedContents };\n  }\n\n  private cloneFolderData(data: FolderData): FolderData {\n    const folders = data.folders.map((folder) => ({ ...folder }));\n    const folderContents = Object.fromEntries(\n      Object.entries(data.folderContents || {}).map(([folderId, conversations]) => [\n        folderId,\n        conversations.map((conversation) => ({ ...conversation })),\n      ]),\n    );\n    return { folders, folderContents };\n  }\n\n  private async migrateLegacyFolderDataToScopedStorage(): Promise<FolderData | null> {\n    try {\n      const legacyResult = await chrome.storage.local.get(this.STORAGE_KEY);\n      const legacyData = legacyResult[this.STORAGE_KEY];\n      if (!legacyData || !validateFolderData(legacyData)) {\n        return null;\n      }\n\n      const migratedData = this.cloneFolderData(legacyData);\n      await chrome.storage.local.set({ [this.activeStorageKey]: migratedData });\n      console.log(\n        '[AIStudioFolderManager] Migrated legacy AI Studio folder data to scoped storage:',\n        this.activeStorageKey,\n      );\n      return migratedData;\n    } catch (error) {\n      console.warn(\n        '[AIStudioFolderManager] Failed to migrate scoped AI Studio folder data:',\n        error,\n      );\n      return null;\n    }\n  }\n\n  private toSyncAccountScope(scope: AccountScope | null): SyncAccountScope | undefined {\n    if (!scope) return undefined;\n    return {\n      accountKey: scope.accountKey,\n      accountId: scope.accountId,\n      routeUserId: scope.routeUserId,\n    };\n  }\n\n  private buildAccountContextFingerprint(routeUserId: string | null, email: string | null): string {\n    return `${routeUserId || ''}::${email || ''}`;\n  }\n\n  private async loadAccountIsolationSetting(): Promise<void> {\n    try {\n      this.accountIsolationEnabled = await accountIsolationService.isIsolationEnabled({\n        platform: 'aistudio',\n        pageUrl: window.location.href,\n      });\n      console.log(\n        '[AIStudioFolderManager] Loaded account isolation setting:',\n        this.accountIsolationEnabled,\n      );\n    } catch (error) {\n      console.error('[AIStudioFolderManager] Failed to load account isolation setting:', error);\n      this.accountIsolationEnabled = false;\n    }\n  }\n\n  private async refreshAccountScope(force: boolean = false): Promise<boolean> {\n    if (!this.accountIsolationEnabled) {\n      const changed = this.activeStorageKey !== this.STORAGE_KEY;\n      this.accountScope = null;\n      this.activeStorageKey = this.STORAGE_KEY;\n      this.lastAccountContextFingerprint = null;\n      return changed;\n    }\n\n    try {\n      const context = detectAccountContextFromDocument(window.location.href, document);\n      const fingerprint = this.buildAccountContextFingerprint(context.routeUserId, context.email);\n      if (!force && fingerprint === this.lastAccountContextFingerprint) {\n        return false;\n      }\n      this.lastAccountContextFingerprint = fingerprint;\n\n      const resolvedScope = await accountIsolationService.resolveAccountScope({\n        pageUrl: window.location.href,\n        routeUserId: context.routeUserId,\n        email: context.email,\n      });\n      this.accountScope = resolvedScope;\n\n      const nextStorageKey = buildScopedStorageKey(this.STORAGE_KEY, resolvedScope.accountKey);\n      const changed = nextStorageKey !== this.activeStorageKey;\n      this.activeStorageKey = nextStorageKey;\n      return changed;\n    } catch (error) {\n      console.error('[AIStudioFolderManager] Failed to resolve account scope:', error);\n      const changed = this.activeStorageKey !== this.STORAGE_KEY;\n      this.accountScope = null;\n      this.activeStorageKey = this.STORAGE_KEY;\n      return changed;\n    }\n  }\n\n  private async handleAccountIsolationToggle(enabled: boolean): Promise<void> {\n    if (enabled === this.accountIsolationEnabled) return;\n\n    this.accountIsolationEnabled = enabled;\n    await this.refreshAccountScope(true);\n    await this.load();\n    if (this.folderEnabled && this.container) {\n      this.render();\n    }\n  }\n\n  private setupAccountContextPoller(): void {\n    if (this.accountContextPoller) {\n      clearInterval(this.accountContextPoller);\n      this.accountContextPoller = null;\n    }\n\n    this.accountContextPoller = window.setInterval(() => {\n      void this.refreshScopedDataOnAccountContextChange();\n    }, 1200);\n\n    this.cleanupFns.push(() => {\n      if (!this.accountContextPoller) return;\n      clearInterval(this.accountContextPoller);\n      this.accountContextPoller = null;\n    });\n  }\n\n  private async refreshScopedDataOnAccountContextChange(): Promise<void> {\n    if (!this.accountIsolationEnabled) return;\n    const changed = await this.refreshAccountScope(false);\n    if (!changed) return;\n\n    await this.load();\n    if (this.folderEnabled && this.container) {\n      this.render();\n    }\n    console.log(\n      '[AIStudioFolderManager] Switched account-scoped folder storage:',\n      this.activeStorageKey,\n    );\n  }\n\n  /**\n   * Setup message listener for sync operations\n   * Handles gv.sync.requestData and gv.folders.reload messages from popup\n   */\n  private setupMessageListener(): void {\n    browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {\n      const msg = message as Record<string, unknown>;\n      // Handle request for folder data (for cloud sync upload)\n      if (msg?.type === 'gv.sync.requestData') {\n        console.log('[AIStudioFolderManager] Received request for folder data from popup');\n        sendResponse({\n          ok: true,\n          data: this.data,\n          accountScope: this.toSyncAccountScope(this.accountScope),\n        });\n        return true;\n      }\n\n      if (msg?.type === 'gv.account.getContext') {\n        const context = detectAccountContextFromDocument(window.location.href, document);\n        sendResponse({ ok: true, context });\n        return true;\n      }\n\n      // Handle reload request (after cloud sync download)\n      if (msg?.type === 'gv.folders.reload') {\n        console.log('[AIStudioFolderManager] Received reload request from sync');\n        this.load().then(() => {\n          this.render();\n          console.log('[AIStudioFolderManager] Folder data reloaded from sync');\n        });\n        sendResponse({ ok: true });\n        return true;\n      }\n\n      // Return true for all messages to keep the channel open\n      return true;\n    });\n  }\n\n  private async initializeFolderUI(): Promise<void> {\n    // Find the prompt history component and sidebar region\n    this.historyRoot = (await waitForElement<HTMLElement>('ms-prompt-history-v3')) || null;\n\n    // On /library page, historyRoot may not exist, but we still need to load data\n    // and observe the library table for draggable elements\n    const isLibraryPage = /\\/library(\\/|$)/.test(location.pathname);\n\n    if (!this.historyRoot && !isLibraryPage) return;\n\n    try {\n      document.documentElement.classList.add('gv-aistudio-root');\n    } catch {}\n\n    await this.load();\n\n    // Only inject folder UI on prompts pages where historyRoot exists\n    if (this.historyRoot) {\n      this.injectUI();\n      this.observePromptList();\n      this.bindDraggablesInPromptList();\n      await this.syncConversationTitlesFromPromptList();\n\n      // Highlight current conversation initially and on navigation\n      this.highlightActiveConversation();\n      this.installRouteChangeListener();\n\n      // Apply initial sidebar width (force on first load)\n      this.applySidebarWidth(true);\n\n      // Add resize handle for sidebar width adjustment\n      this.addResizeHandle();\n    }\n\n    // On library page, observe and bind draggables to table rows\n    if (isLibraryPage) {\n      this.observeLibraryTable();\n      this.bindDraggablesInLibraryTable();\n      this.injectLibraryDropZone();\n    }\n  }\n\n  private async load(): Promise<void> {\n    try {\n      // Use chrome.storage.local with account-scoped key when isolation is enabled.\n      const result = await chrome.storage.local.get(this.activeStorageKey);\n      let data = result[this.activeStorageKey];\n\n      if (!data && this.accountIsolationEnabled && this.activeStorageKey !== this.STORAGE_KEY) {\n        data = await this.migrateLegacyFolderDataToScopedStorage();\n      }\n\n      if (data && validateFolderData(data)) {\n        this.data = data;\n        // Create primary backup on successful load\n        this.backupService.createPrimaryBackup(this.data);\n      } else {\n        // Don't immediately clear data - try to recover from backup\n        console.warn(\n          '[AIStudioFolderManager] Storage returned no data, attempting recovery from backup',\n        );\n        this.attemptDataRecovery(null);\n      }\n    } catch (error) {\n      console.error('[AIStudioFolderManager] Load error:', error);\n      // CRITICAL: Don't clear data on error - attempt recovery from backup\n      this.attemptDataRecovery(error);\n    }\n  }\n\n  private async save(): Promise<void> {\n    try {\n      // Create emergency backup BEFORE saving (snapshot of previous state)\n      this.backupService.createEmergencyBackup(this.data);\n\n      // Save to chrome.storage.local using active scoped key.\n      await chrome.storage.local.set({ [this.activeStorageKey]: this.data });\n\n      // Create primary backup AFTER successful save\n      this.backupService.createPrimaryBackup(this.data);\n    } catch (error) {\n      console.error('[AIStudioFolderManager] Save error:', error);\n      // Show error notification to user\n      this.showErrorNotification('Failed to save folder data. Changes may not be persisted.');\n    }\n  }\n\n  private injectUI(): void {\n    if (this.container && document.body.contains(this.container)) return;\n\n    const container = document.createElement('div');\n    // Scope aistudio-specific styles under .gv-aistudio to avoid impacting Gemini\n    container.className = 'gv-folder-container gv-aistudio';\n\n    const header = document.createElement('div');\n    header.className = 'gv-folder-header';\n\n    const title = document.createElement('div');\n    title.className = 'gv-folder-title gds-label-l';\n    title.textContent = this.t('folder_title');\n    header.appendChild(title);\n\n    const actions = document.createElement('div');\n    actions.className = 'gv-folder-header-actions';\n    header.appendChild(actions);\n\n    // For AI Studio, hide import/export for now to simplify UI\n\n    // Cloud buttons (Skip on Safari as it doesn't support cloud sync yet)\n    if (!isSafari()) {\n      // Cloud upload button\n      const cloudUploadButton = document.createElement('button');\n      cloudUploadButton.className = 'gv-folder-action-btn';\n      cloudUploadButton.innerHTML = `<svg xmlns=\"http://www.w3.org/2000/svg\" height=\"20px\" viewBox=\"0 -960 960 960\" width=\"20px\" fill=\"currentColor\"><path d=\"M260-160q-91 0-155.5-63T40-377q0-78 47-139t123-78q25-92 100-149t170-57q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 75-52.5 127.5T740-160H520q-33 0-56.5-23.5T440-240v-206l-64 62-56-56 160-160 160 160-56 56-64-62v206h220q42 0 71-29t29-71q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480-720q-83 0-141.5 58.5T280-520h-20q-58 0-99 41t-41 99q0 58 41 99t99 41h100v80H260Zm220-280Z\"/></svg>`;\n      cloudUploadButton.title = this.t('folder_cloud_upload');\n      cloudUploadButton.addEventListener('click', () => this.handleCloudUpload());\n      // Add dynamic tooltip on mouseenter\n      cloudUploadButton.addEventListener('mouseenter', async () => {\n        const tooltip = await this.getCloudUploadTooltip();\n        cloudUploadButton.title = tooltip;\n      });\n      actions.appendChild(cloudUploadButton);\n\n      // Cloud sync button\n      const cloudSyncButton = document.createElement('button');\n      cloudSyncButton.className = 'gv-folder-action-btn';\n      cloudSyncButton.innerHTML = `<svg xmlns=\"http://www.w3.org/2000/svg\" height=\"20px\" viewBox=\"0 -960 960 960\" width=\"20px\" fill=\"currentColor\"><path d=\"M260-160q-91 0-155.5-63T40-377q0-78 47-139t123-78q17-72 85-137t145-65q33 0 56.5 23.5T520-716v242l64-62 56 56-160 160-160-160 56-56 64 62v-242q-76 14-118 73.5T280-520h-20q-58 0-99 41t-41 99q0 58 41 99t99 41h480q42 0 71-29t29-71q0-42-29-71t-71-29h-60v-80q0-48-22-89.5T600-680v-93q74 35 117 103.5T760-520q69 8 114.5 59.5T920-340q0 75-52.5 127.5T740-160H260Zm220-358Z\"/></svg>`;\n      cloudSyncButton.title = this.t('folder_cloud_sync');\n      cloudSyncButton.addEventListener('click', () => this.handleCloudSync());\n      // Add dynamic tooltip on mouseenter\n      cloudSyncButton.addEventListener('mouseenter', async () => {\n        const tooltip = await this.getCloudSyncTooltip();\n        cloudSyncButton.title = tooltip;\n      });\n      actions.appendChild(cloudSyncButton);\n    }\n\n    // Add folder\n    const addBtn = document.createElement('button');\n    addBtn.className = 'gv-folder-add-btn';\n    addBtn.title = this.t('folder_create');\n    addBtn.appendChild(this.createIcon('add'));\n    addBtn.addEventListener('click', () => this.createFolder());\n    actions.appendChild(addBtn);\n\n    const list = document.createElement('div');\n    list.className = 'gv-folder-list';\n    container.appendChild(header);\n    container.appendChild(list);\n\n    // Insert before prompt history\n    const root = this.historyRoot;\n    if (!root) return;\n    const host: Element = root.parentElement ?? root;\n    host.insertAdjacentElement('beforebegin', container);\n\n    this.container = container;\n    this.injectStyles();\n    this.render();\n\n    // Apply initial folder enabled setting\n    this.applyFolderEnabledSetting();\n  }\n\n  private injectStyles(): void {\n    const styleId = 'gv-aistudio-folder-styles';\n    if (document.getElementById(styleId)) return;\n\n    const style = document.createElement('style');\n    style.id = styleId;\n    style.textContent = `\n      .gv-folder-confirm-dialog.gv-aistudio-confirm {\n        background: var(--gem-sys-color-surface, #fff);\n        border: 1px solid var(--gem-sys-color-outline-variant, #e5e7eb);\n        border-radius: 12px;\n        box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);\n        padding: 16px;\n        min-width: 280px;\n        font-family: 'Google Sans', 'Segoe UI', sans-serif;\n        animation: gv-fade-in 0.2s ease-out;\n      }\n      \n      .gv-confirm-message {\n        margin-bottom: 16px;\n        color: var(--gem-sys-color-on-surface, #1f2937);\n        font-size: 14px;\n        line-height: 1.5;\n        font-weight: 500;\n      }\n\n      .gv-confirm-actions {\n        display: flex;\n        gap: 12px;\n        justify-content: flex-end; /* Default right align, but we override order */\n      }\n\n      .gv-confirm-btn {\n        padding: 8px 16px;\n        border-radius: 8px;\n        font-size: 13px;\n        font-weight: 600;\n        cursor: pointer;\n        transition: all 0.2s;\n        border: none;\n        outline: none;\n      }\n\n      .gv-confirm-delete {\n        background-color: #ef4444; /* Red color */\n        color: white;\n        box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2);\n      }\n      \n      .gv-confirm-delete:hover {\n        background-color: #dc2626;\n        box-shadow: 0 4px 6px rgba(239, 68, 68, 0.3);\n      }\n\n      .gv-confirm-cancel {\n        background-color: transparent;\n        color: var(--gem-sys-color-on-surface-variant, #4b5563);\n        border: 1px solid var(--gem-sys-color-outline, #d1d5db);\n      }\n\n      .gv-confirm-cancel:hover {\n        background-color: var(--gem-sys-color-surface-container-high, #f3f4f6);\n        color: var(--gem-sys-color-on-surface, #111827);\n      }\n\n      /* Hover effect for remove button in list */\n      .gv-conversation-remove-btn:hover {\n        background-color: rgba(239, 68, 68, 0.1) !important;\n        color: #ef4444 !important;\n      }\n\n      .gv-conversation-remove-btn:hover span {\n        font-variation-settings: 'FILL' 1, 'wght' 600 !important;\n      }\n\n      @keyframes gv-fade-in {\n        from { opacity: 0; transform: translateY(4px); }\n        to { opacity: 1; transform: translateY(0); }\n      }\n    `;\n    document.head.appendChild(style);\n  }\n\n  private render(): void {\n    if (!this.container) return;\n    const list = this.container.querySelector('.gv-folder-list') as HTMLElement | null;\n    if (!list) return;\n    list.innerHTML = '';\n\n    // Render only root-level folders here; children are rendered recursively\n    const folders = this.data.folders.filter((f) => !f.parentId);\n    folders.sort((a, b) => {\n      const ap = a.pinned ? 1 : 0;\n      const bp = b.pinned ? 1 : 0;\n      if (ap !== bp) return bp - ap;\n      return a.createdAt - b.createdAt;\n    });\n\n    for (const f of folders) {\n      list.appendChild(this.renderFolder(f));\n    }\n\n    // Root drop zone\n    const rootDrop = document.createElement('div');\n    rootDrop.className = 'gv-folder-root-drop';\n    rootDrop.textContent = '';\n    this.bindDropZone(rootDrop, null);\n    list.appendChild(rootDrop);\n\n    // Render uncategorized conversations (dropped to root)\n    const uncategorized = this.data.folderContents[this.UNCATEGORIZED_KEY] || [];\n    if (uncategorized.length > 0) {\n      const uncatSection = document.createElement('div');\n      uncatSection.className = 'gv-folder-uncategorized';\n\n      const uncatHeader = document.createElement('div');\n      uncatHeader.className = 'gv-folder-uncategorized-header';\n      uncatHeader.innerHTML = `<span class=\"google-symbols\" data-icon=\"inbox\" style=\"margin-right: 6px;\">inbox</span>${this.t('folder_uncategorized') || 'Uncategorized'}`;\n      uncatSection.appendChild(uncatHeader);\n\n      const uncatContent = document.createElement('div');\n      uncatContent.className = 'gv-folder-uncategorized-content';\n      for (const conv of uncategorized) {\n        uncatContent.appendChild(this.renderConversation(this.UNCATEGORIZED_KEY, conv));\n      }\n      uncatSection.appendChild(uncatContent);\n      list.appendChild(uncatSection);\n    }\n\n    // After rendering, update active highlight\n    this.highlightActiveConversation();\n  }\n\n  private getCurrentPromptIdFromLocation(): string | null {\n    try {\n      const m = (location.pathname || '').match(/\\/prompts\\/([^/?#]+)/);\n      return m ? m[1] : null;\n    } catch {\n      return null;\n    }\n  }\n\n  private highlightActiveConversation(): void {\n    if (!this.container) return;\n    const currentId = this.getCurrentPromptIdFromLocation();\n    const rows = this.container.querySelectorAll(\n      '.gv-folder-conversation',\n    ) as NodeListOf<HTMLElement>;\n    rows.forEach((row) => {\n      const isActive = currentId && row.dataset.conversationId === currentId;\n      row.classList.toggle('gv-folder-conversation-selected', !!isActive);\n    });\n  }\n\n  private installRouteChangeListener(): void {\n    const update = () => setTimeout(() => this.highlightActiveConversation(), 0);\n    try {\n      window.addEventListener('popstate', update);\n    } catch {}\n    try {\n      const hist = history as History & Record<string, unknown>;\n      const wrap = (method: 'pushState' | 'replaceState') => {\n        const orig = hist[method] as (...args: unknown[]) => unknown;\n        hist[method] = function (...args: unknown[]) {\n          const ret = orig.apply(this, args);\n          try {\n            update();\n          } catch {}\n          return ret;\n        };\n      };\n      wrap('pushState');\n      wrap('replaceState');\n    } catch {}\n    // Fallback poller for routers that bypass events\n    try {\n      let last = location.pathname;\n      const id = window.setInterval(() => {\n        const now = location.pathname;\n        if (now !== last) {\n          last = now;\n          update();\n        }\n      }, 400);\n      this.cleanupFns.push(() => {\n        try {\n          clearInterval(id);\n        } catch {}\n      });\n    } catch {}\n  }\n\n  private renderFolder(folder: Folder, level: number = 0): HTMLElement {\n    const item = document.createElement('div');\n    item.className = 'gv-folder-item';\n    item.dataset.folderId = folder.id;\n    item.dataset.pinned = folder.pinned ? 'true' : 'false';\n    item.dataset.level = String(level);\n\n    const header = document.createElement('div');\n    header.className = 'gv-folder-item-header';\n    // Add left padding for nested folders\n    header.style.paddingLeft = `${level * 16 + 8}px`;\n    item.appendChild(header);\n    // Allow dropping directly on folder header\n    this.bindDropZone(header, folder.id);\n\n    const expandBtn = document.createElement('button');\n    expandBtn.className = 'gv-folder-expand-btn';\n    expandBtn.appendChild(this.createIcon(folder.isExpanded ? 'expand_more' : 'chevron_right'));\n    expandBtn.addEventListener('click', () => {\n      folder.isExpanded = !folder.isExpanded;\n      this.save().then(() => this.render());\n    });\n    header.appendChild(expandBtn);\n\n    const icon = document.createElement('span');\n    icon.className = 'gv-folder-icon google-symbols';\n    icon.dataset.icon = 'folder';\n    icon.textContent = 'folder';\n    header.appendChild(icon);\n\n    const name = document.createElement('span');\n    name.className = 'gv-folder-name gds-label-l';\n    name.textContent = folder.name;\n    name.addEventListener('dblclick', () => this.renameFolder(folder.id));\n    header.appendChild(name);\n\n    const pinBtn = document.createElement('button');\n    pinBtn.className = 'gv-folder-pin-btn';\n    pinBtn.title = folder.pinned ? this.t('folder_unpin') : this.t('folder_pin');\n    try {\n      pinBtn.dataset.state = folder.pinned ? 'pinned' : 'unpinned';\n    } catch {}\n    pinBtn.appendChild(this.createIcon('push_pin'));\n    pinBtn.addEventListener('click', () => {\n      folder.pinned = !folder.pinned;\n      this.save().then(() => this.render());\n    });\n    header.appendChild(pinBtn);\n\n    const moreBtn = document.createElement('button');\n    moreBtn.className = 'gv-folder-actions-btn';\n    moreBtn.appendChild(this.createIcon('more_vert'));\n    moreBtn.addEventListener('click', (e) => this.openFolderMenu(e, folder.id));\n    header.appendChild(moreBtn);\n\n    // Content (conversations and subfolders)\n    if (folder.isExpanded) {\n      const content = document.createElement('div');\n      content.className = 'gv-folder-content';\n      this.bindDropZone(content, folder.id);\n\n      // Render conversations in this folder\n      const convs = this.data.folderContents[folder.id] || [];\n      for (const conv of convs) {\n        const convEl = this.renderConversation(folder.id, conv);\n        // Add indentation for nested conversations\n        convEl.style.paddingLeft = `${(level + 1) * 16 + 8}px`;\n        content.appendChild(convEl);\n      }\n\n      // Render subfolders (only for root-level folders, creating 2-level hierarchy)\n      if (level === 0) {\n        const subfolders = this.data.folders.filter((f) => f.parentId === folder.id);\n        // Sort subfolders: pinned first, then by creation time\n        subfolders.sort((a, b) => {\n          const ap = a.pinned ? 1 : 0;\n          const bp = b.pinned ? 1 : 0;\n          if (ap !== bp) return bp - ap;\n          return a.createdAt - b.createdAt;\n        });\n        for (const subfolder of subfolders) {\n          content.appendChild(this.renderFolder(subfolder, level + 1));\n        }\n      }\n\n      item.appendChild(content);\n    }\n\n    return item;\n  }\n\n  private renderConversation(folderId: string, conv: ConversationReference): HTMLElement {\n    const row = document.createElement('div');\n    row.className = conv.starred ? 'gv-folder-conversation gv-starred' : 'gv-folder-conversation';\n    row.dataset.folderId = folderId;\n    row.dataset.conversationId = conv.conversationId;\n\n    const icon = document.createElement('span');\n    icon.className = 'gv-conversation-icon google-symbols';\n    icon.dataset.icon = 'chat';\n    icon.textContent = 'chat';\n    row.appendChild(icon);\n\n    const title = document.createElement('span');\n    title.className = 'gv-conversation-title gds-label-l';\n    title.textContent = conv.title || this.t('conversation_untitled');\n    row.appendChild(title);\n\n    const starBtn = document.createElement('button');\n    starBtn.className = conv.starred\n      ? 'gv-conversation-star-btn starred'\n      : 'gv-conversation-star-btn';\n    starBtn.appendChild(this.createIcon(conv.starred ? 'star' : 'star_outline'));\n    starBtn.title = conv.starred ? this.t('conversation_unstar') : this.t('conversation_star');\n    starBtn.addEventListener('click', (e) => {\n      e.stopPropagation();\n      conv.starred = !conv.starred;\n      this.save().then(() => this.render());\n    });\n    row.appendChild(starBtn);\n\n    const removeBtn = document.createElement('button');\n    removeBtn.className = 'gv-conversation-remove-btn';\n    removeBtn.appendChild(this.createIcon('close'));\n    removeBtn.title = this.t('folder_remove_conversation');\n    removeBtn.addEventListener('click', (e) => {\n      e.stopPropagation();\n      this.confirmRemoveConversation(folderId, conv.conversationId, conv.title || '', e);\n    });\n    row.appendChild(removeBtn);\n\n    row.addEventListener('click', () => this.navigateToPrompt(conv.conversationId, conv.url));\n\n    row.draggable = true;\n    row.addEventListener('dragstart', (e) => {\n      const data: DragData = {\n        type: 'conversation',\n        conversationId: conv.conversationId,\n        title: conv.title,\n        url: conv.url,\n        sourceFolderId: folderId,\n      };\n      try {\n        if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';\n        e.dataTransfer?.setData('application/json', JSON.stringify(data));\n      } catch {}\n      try {\n        e.dataTransfer?.setDragImage(row, 10, 10);\n      } catch {}\n    });\n\n    return row;\n  }\n\n  private openFolderMenu(ev: MouseEvent, folderId: string): void {\n    ev.stopPropagation();\n    const folder = this.data.folders.find((f) => f.id === folderId);\n    if (!folder) return;\n\n    const menu = document.createElement('div');\n    menu.className = 'gv-context-menu';\n\n    // Only show \"Create subfolder\" for root-level folders (to maintain 2-level hierarchy)\n    if (!folder.parentId) {\n      const createSub = document.createElement('button');\n      createSub.textContent = this.t('folder_create_subfolder') || 'Create Subfolder';\n      createSub.addEventListener('click', () => {\n        this.createFolder(folderId);\n        try {\n          document.body.removeChild(menu);\n        } catch {}\n      });\n      menu.appendChild(createSub);\n    }\n\n    const rename = document.createElement('button');\n    rename.textContent = this.t('folder_rename');\n    rename.addEventListener('click', () => {\n      this.renameFolder(folderId);\n      try {\n        document.body.removeChild(menu);\n      } catch {}\n    });\n    menu.appendChild(rename);\n\n    const del = document.createElement('button');\n    del.textContent = this.t('folder_delete');\n    del.addEventListener('click', () => {\n      this.deleteFolder(folderId);\n      try {\n        document.body.removeChild(menu);\n      } catch {}\n    });\n    menu.appendChild(del);\n\n    // Apply styles with proper typing\n    const st = menu.style;\n    st.position = 'fixed';\n    st.top = `${ev.clientY}px`;\n    st.left = `${ev.clientX}px`;\n    st.zIndex = String(2147483647);\n    st.display = 'flex';\n    st.flexDirection = 'column';\n    document.body.appendChild(menu);\n    const onClickAway = (e: MouseEvent) => {\n      if (e.target instanceof Node && !menu.contains(e.target)) {\n        try {\n          document.body.removeChild(menu);\n        } catch {}\n        window.removeEventListener('click', onClickAway, true);\n      }\n    };\n    window.addEventListener('click', onClickAway, true);\n  }\n\n  private async createFolder(parentId: string | null = null): Promise<void> {\n    const name = prompt(this.t('folder_name_prompt'));\n    if (!name) return;\n    const f: Folder = {\n      id: uid(),\n      name: name.trim(),\n      parentId: parentId || null,\n      isExpanded: true,\n      createdAt: now(),\n      updatedAt: now(),\n    };\n    this.data.folders.push(f);\n    this.data.folderContents[f.id] = [];\n    await this.save();\n    this.render();\n  }\n\n  private async renameFolder(folderId: string): Promise<void> {\n    const folder = this.data.folders.find((f) => f.id === folderId);\n    if (!folder) return;\n    const name = prompt(this.t('folder_rename_prompt'), folder.name);\n    if (!name) return;\n    folder.name = name.trim();\n    folder.updatedAt = now();\n    await this.save();\n    this.render();\n  }\n\n  private async deleteFolder(folderId: string): Promise<void> {\n    if (!confirm(this.t('folder_delete_confirm'))) return;\n\n    // Collect all folder IDs to delete (including subfolders)\n    const folderIdsToDelete: string[] = [folderId];\n    const subfolders = this.data.folders.filter((f) => f.parentId === folderId);\n    for (const subfolder of subfolders) {\n      folderIdsToDelete.push(subfolder.id);\n    }\n\n    // Delete all collected folders and their contents\n    this.data.folders = this.data.folders.filter((f) => !folderIdsToDelete.includes(f.id));\n    for (const id of folderIdsToDelete) {\n      delete this.data.folderContents[id];\n    }\n\n    await this.save();\n    this.render();\n  }\n\n  private removeConversationFromFolder(folderId: string, conversationId: string): void {\n    const arr = this.data.folderContents[folderId] || [];\n    this.data.folderContents[folderId] = arr.filter((c) => c.conversationId !== conversationId);\n    this.save().then(() => this.render());\n  }\n\n  private confirmRemoveConversation(\n    folderId: string,\n    conversationId: string,\n    title: string,\n    event: MouseEvent,\n  ): void {\n    const dialog = document.createElement('div');\n    dialog.className = 'gv-folder-confirm-dialog gv-aistudio-confirm';\n\n    // Position near the button\n    const target = event.currentTarget as HTMLElement;\n    const rect = target.getBoundingClientRect();\n\n    dialog.style.position = 'fixed';\n    dialog.style.zIndex = '2147483647';\n    // Position logic: prefer left side if space available\n    // AI Studio sidebar is on the left, so we might want to pop out to the right or below\n    // But usually context menus appear near the cursor.\n    // Let's position it below the button, aligned right\n    dialog.style.top = `${rect.bottom + 4}px`;\n    dialog.style.left = `${rect.right - 200}px`; // Align right edge roughly\n\n    // Ensure it's on screen\n    if (parseInt(dialog.style.left) < 10) dialog.style.left = '10px';\n\n    const msg = document.createElement('div');\n    msg.className = 'gv-confirm-message';\n    msg.textContent = this.t('folder_remove_conversation_confirm').replace(\n      '{title}',\n      title || this.t('conversation_untitled'),\n    );\n    dialog.appendChild(msg);\n\n    const actions = document.createElement('div');\n    actions.className = 'gv-confirm-actions';\n\n    const confirmBtn = document.createElement('button');\n    confirmBtn.className = 'gv-confirm-btn gv-confirm-delete';\n    confirmBtn.textContent = this.t('pm_delete') || 'Delete';\n    confirmBtn.addEventListener('click', () => {\n      this.removeConversationFromFolder(folderId, conversationId);\n      dialog.remove();\n      document.removeEventListener('click', closeOnOutside);\n    });\n\n    const cancelBtn = document.createElement('button');\n    cancelBtn.className = 'gv-confirm-btn gv-confirm-cancel';\n    cancelBtn.textContent = this.t('pm_cancel') || 'Cancel';\n    cancelBtn.addEventListener('click', () => {\n      dialog.remove();\n      document.removeEventListener('click', closeOnOutside);\n    });\n\n    // Delete on left, Cancel on right\n    actions.appendChild(confirmBtn);\n    actions.appendChild(cancelBtn);\n    dialog.appendChild(actions);\n\n    document.body.appendChild(dialog);\n\n    // Close when clicking outside\n    const closeOnOutside = (e: MouseEvent) => {\n      if (\n        !dialog.contains(e.target as Node) &&\n        e.target !== target &&\n        !target.contains(e.target as Node)\n      ) {\n        dialog.remove();\n        document.removeEventListener('click', closeOnOutside);\n      }\n    };\n\n    // Delay adding the listener to avoid immediate closing\n    setTimeout(() => {\n      document.addEventListener('click', closeOnOutside);\n    }, 10);\n  }\n\n  private bindDropZone(el: HTMLElement, targetFolderId: string | null): void {\n    // Use a counter to properly track nested dragenter/dragleave events\n    // This fixes the issue where child elements trigger spurious leave events\n    let dragEnterCounter = 0;\n\n    el.addEventListener('dragenter', (e) => {\n      e.preventDefault();\n      e.stopPropagation(); // Prevent bubbling to parent drop zones\n      dragEnterCounter++;\n      // Only add class on first enter\n      if (dragEnterCounter === 1) {\n        el.classList.add('gv-folder-dragover');\n      }\n    });\n    el.addEventListener('dragover', (e) => {\n      e.preventDefault();\n      e.stopPropagation(); // Prevent bubbling to parent drop zones\n      try {\n        if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';\n      } catch {}\n    });\n    el.addEventListener('dragleave', (e) => {\n      e.stopPropagation(); // Prevent bubbling to parent drop zones\n      dragEnterCounter--;\n      // Only remove class when truly leaving the container (counter reaches 0)\n      // Also check relatedTarget as a fallback\n      if (dragEnterCounter <= 0) {\n        dragEnterCounter = 0; // Prevent negative values\n        // Double-check: if relatedTarget is still inside, don't remove\n        const related = e.relatedTarget as Node | null;\n        if (!related || !el.contains(related)) {\n          el.classList.remove('gv-folder-dragover');\n        }\n      }\n    });\n    el.addEventListener('drop', (e) => {\n      e.preventDefault();\n      e.stopPropagation(); // Prevent bubbling to parent drop zones\n      dragEnterCounter = 0; // Reset counter on drop\n      el.classList.remove('gv-folder-dragover');\n      const data = this.parseDragDataFromEvent(e);\n      if (!data || data.type !== 'conversation' || !data.conversationId) return;\n      const conv: ConversationReference = {\n        conversationId: data.conversationId,\n        title: normalizeText(data.title) || this.t('conversation_untitled'),\n        url: data.url || '',\n        addedAt: now(),\n      };\n      const folderId = targetFolderId;\n      if (!folderId || folderId === this.UNCATEGORIZED_KEY) {\n        // Drop to root or uncategorized section: move to uncategorized section\n        // First remove from any existing folder\n        Object.keys(this.data.folderContents).forEach((fid) => {\n          if (fid === this.UNCATEGORIZED_KEY) return; // Don't remove from uncategorized yet\n          this.data.folderContents[fid] = (this.data.folderContents[fid] || []).filter(\n            (c) => c.conversationId !== conv.conversationId,\n          );\n        });\n        // Add to uncategorized if not already there\n        const uncatArr = this.data.folderContents[this.UNCATEGORIZED_KEY] || [];\n        const existsInUncat = uncatArr.some((c) => c.conversationId === conv.conversationId);\n        if (!existsInUncat) {\n          uncatArr.push(conv);\n          this.data.folderContents[this.UNCATEGORIZED_KEY] = uncatArr;\n        }\n      } else {\n        const arr = this.data.folderContents[folderId] || [];\n        const exists = arr.some((c) => c.conversationId === conv.conversationId);\n        if (!exists) {\n          arr.push(conv);\n          this.data.folderContents[folderId] = arr;\n        }\n        // If moving from another folder (including uncategorized), remove there\n        Object.keys(this.data.folderContents).forEach((fid) => {\n          if (fid === folderId) return;\n          this.data.folderContents[fid] = (this.data.folderContents[fid] || []).filter(\n            (c) => c.conversationId !== conv.conversationId,\n          );\n        });\n      }\n      this.save().then(() => this.render());\n    });\n  }\n\n  private observePromptList(): void {\n    const root = this.historyRoot;\n    if (!root) return;\n    const observer = new MutationObserver((mutations) => {\n      if (mutationAddsPromptLinks(mutations)) {\n        this.schedulePromptListBinding();\n      }\n      if (mutationMayAffectPromptTitles(mutations)) {\n        this.schedulePromptTitleSync();\n      }\n    });\n    try {\n      observer.observe(root, {\n        childList: true,\n        subtree: true,\n        characterData: true,\n        attributes: true,\n        attributeFilter: ['title', 'aria-label', 'href'],\n      });\n    } catch {}\n    this.cleanupFns.push(() => {\n      try {\n        observer.disconnect();\n      } catch {}\n      if (this.promptListBindTimer !== null) {\n        clearTimeout(this.promptListBindTimer);\n        this.promptListBindTimer = null;\n      }\n      if (this.promptTitleSyncTimer !== null) {\n        clearTimeout(this.promptTitleSyncTimer);\n        this.promptTitleSyncTimer = null;\n      }\n    });\n\n    // Also update on clicks within the prompt list (SPA navigation)\n    const onClick = (e: Event) => {\n      const target = e.target as HTMLElement | null;\n      if (!target) return;\n      const a = target.closest('a.prompt-link') as HTMLAnchorElement | null;\n      if (a && /\\/prompts\\//.test(a.getAttribute('href') || '')) {\n        setTimeout(() => this.highlightActiveConversation(), 0);\n      }\n    };\n    try {\n      root.addEventListener('click', onClick, true);\n    } catch {}\n    this.cleanupFns.push(() => {\n      try {\n        root.removeEventListener('click', onClick, true);\n      } catch {}\n    });\n  }\n\n  private schedulePromptListBinding(): void {\n    if (this.promptListBindTimer !== null) return;\n    this.promptListBindTimer = window.setTimeout(() => {\n      this.promptListBindTimer = null;\n      this.bindDraggablesInPromptList();\n    }, PROMPT_LIST_BIND_DEBOUNCE_MS);\n  }\n\n  private schedulePromptTitleSync(): void {\n    if (!this.hasStoredConversations()) return;\n    if (this.promptTitleSyncTimer !== null) return;\n\n    this.promptTitleSyncTimer = window.setTimeout(() => {\n      this.promptTitleSyncTimer = null;\n      void this.runPromptTitleSync();\n    }, PROMPT_TITLE_SYNC_DEBOUNCE_MS);\n  }\n\n  private async runPromptTitleSync(): Promise<void> {\n    if (this.promptTitleSyncInProgress) return;\n\n    this.promptTitleSyncInProgress = true;\n    try {\n      await this.syncConversationTitlesFromPromptList();\n    } finally {\n      this.promptTitleSyncInProgress = false;\n    }\n  }\n\n  private hasStoredConversations(): boolean {\n    return Object.values(this.data.folderContents).some(\n      (conversations) => conversations.length > 0,\n    );\n  }\n\n  private extractPromptTitle(anchor: HTMLAnchorElement | null): string | null {\n    if (!anchor) return null;\n\n    const aria = normalizeText(anchor.getAttribute('aria-label'));\n    if (aria) return aria;\n\n    const title = normalizeText(anchor.getAttribute('title'));\n    if (title) return title;\n\n    const text = normalizeText(anchor.textContent);\n    if (text) return text;\n\n    return null;\n  }\n\n  private getPromptTitleFromNative(conversationId: string): string | null {\n    const selectors = [\n      `a.prompt-link[href*=\"/prompts/${conversationId}\"]`,\n      `a[href*=\"/prompts/${conversationId}\"]`,\n      `a.name-btn[href*=\"/prompts/${conversationId}\"]`,\n    ];\n\n    for (const selector of selectors) {\n      const anchor = document.querySelector(selector) as HTMLAnchorElement | null;\n      const title = this.extractPromptTitle(anchor);\n      if (title) return title;\n    }\n\n    return null;\n  }\n\n  private async syncConversationTitlesFromPromptList(): Promise<void> {\n    if (!this.hasStoredConversations()) return;\n\n    let hasUpdates = false;\n    for (const conversations of Object.values(this.data.folderContents)) {\n      for (const conversation of conversations) {\n        if (conversation.customTitle) continue;\n        const nativeTitle = this.getPromptTitleFromNative(conversation.conversationId);\n        if (!nativeTitle || nativeTitle === conversation.title) continue;\n        conversation.title = nativeTitle;\n        conversation.updatedAt = now();\n        hasUpdates = true;\n      }\n    }\n\n    if (!hasUpdates) return;\n\n    await this.save();\n    this.render();\n  }\n\n  private resolvePromptAnchorFromHost(hostEl: HTMLElement): HTMLAnchorElement | null {\n    if (hostEl.matches(PROMPT_LINK_SELECTOR)) {\n      return hostEl as HTMLAnchorElement;\n    }\n    return hostEl.querySelector(PROMPT_LINK_SELECTOR) as HTMLAnchorElement | null;\n  }\n\n  private resolvePromptAnchorFromDragEvent(\n    event: DragEvent,\n    hostEl: HTMLElement,\n  ): HTMLAnchorElement | null {\n    const target = event.target;\n    if (target instanceof Element) {\n      const targetAnchor = target.closest(PROMPT_LINK_SELECTOR) as HTMLAnchorElement | null;\n      if (targetAnchor) return targetAnchor;\n    }\n    return this.resolvePromptAnchorFromHost(hostEl);\n  }\n\n  private resolvePromptDragHost(anchor: HTMLAnchorElement): HTMLElement {\n    for (const selector of PROMPT_DRAG_HOST_SELECTORS) {\n      const match = anchor.closest(selector) as HTMLElement | null;\n      if (match) return match;\n    }\n    return anchor.parentElement || anchor;\n  }\n\n  private setPromptDragData(e: DragEvent, data: DragData, dragImageEl: HTMLElement): void {\n    try {\n      const transfer = e.dataTransfer;\n      if (!transfer) return;\n      const json = JSON.stringify(data);\n      transfer.effectAllowed = 'move';\n      transfer.setData('application/json', json);\n      transfer.setData('text/plain', json);\n      if (data.url) {\n        transfer.setData('text/uri-list', data.url);\n        transfer.setData('text/x-moz-url', `${data.url}\\n${data.title || ''}`);\n      }\n    } catch {}\n    try {\n      e.dataTransfer?.setDragImage(dragImageEl, 10, 10);\n    } catch {}\n  }\n\n  private parseDragDataFromEvent(event: DragEvent): DragData | null {\n    const transfer = event.dataTransfer;\n    if (!transfer) return null;\n\n    const candidates = [\n      transfer.getData('application/json'),\n      transfer.getData('text/plain'),\n      transfer.getData('text/uri-list'),\n      transfer.getData('text/x-moz-url'),\n      transfer.getData('URL'),\n    ];\n\n    for (const candidate of candidates) {\n      if (!candidate) continue;\n      const parsed = parseDragDataPayload(candidate);\n      if (parsed) return parsed;\n    }\n\n    return null;\n  }\n\n  private bindDraggablesInPromptList(scope: ParentNode | null = this.historyRoot): void {\n    const root = scope ?? this.historyRoot;\n    if (!root) return;\n    const anchors = root.querySelectorAll(UNBOUND_PROMPT_LINK_SELECTOR);\n    anchors.forEach((a) => {\n      const anchor = a as HTMLAnchorElement;\n      const hostEl = this.resolvePromptDragHost(anchor);\n      anchor.dataset.gvDragBound = '1';\n      if (!(hostEl as Element & { _gvDragBound?: boolean })._gvDragBound) {\n        (hostEl as Element & { _gvDragBound?: boolean })._gvDragBound = true;\n        hostEl.draggable = true;\n        if (!hostEl.style.cursor) {\n          hostEl.style.cursor = 'grab';\n        }\n        hostEl.addEventListener('dragstart', (e) => {\n          const promptAnchor = this.resolvePromptAnchorFromDragEvent(e, hostEl);\n          if (!promptAnchor) return;\n\n          const id = this.extractPromptId(promptAnchor);\n          const title = this.extractPromptTitle(promptAnchor) || '';\n          const rawHref = promptAnchor.getAttribute('href') || promptAnchor.href || '';\n          const url = rawHref.startsWith('http')\n            ? rawHref\n            : `${location.origin}${rawHref.startsWith('/') ? '' : '/'}${rawHref}`;\n          const data: DragData = { type: 'conversation', conversationId: id, title, url };\n          this.setPromptDragData(e, data, hostEl);\n        });\n      }\n    });\n  }\n\n  /**\n   * Observe the library table for dynamic row additions\n   * This is needed because the library page loads rows dynamically\n   */\n  private observeLibraryTable(): void {\n    // The library table is within a mat-table element\n    const tableRoot = document.querySelector(\n      'table.mat-mdc-table, mat-table',\n    ) as HTMLElement | null;\n    if (!tableRoot) {\n      // Fallback: observe entire body for table appearance\n      const bodyObserver = new MutationObserver(() => {\n        const table = document.querySelector('table.mat-mdc-table, mat-table');\n        if (table) {\n          this.bindDraggablesInLibraryTable();\n        }\n      });\n      try {\n        bodyObserver.observe(document.body, { childList: true, subtree: true });\n      } catch {}\n      this.cleanupFns.push(() => {\n        try {\n          bodyObserver.disconnect();\n        } catch {}\n      });\n      return;\n    }\n\n    const observer = new MutationObserver(() => {\n      this.bindDraggablesInLibraryTable();\n    });\n    try {\n      observer.observe(tableRoot, { childList: true, subtree: true });\n    } catch {}\n    this.cleanupFns.push(() => {\n      try {\n        observer.disconnect();\n      } catch {}\n    });\n  }\n\n  /**\n   * Bind drag handlers to library table rows\n   * Each row contains an anchor with href like /prompts/{id}\n   */\n  private bindDraggablesInLibraryTable(): void {\n    // Find all table rows that contain chat prompt links\n    // The structure from user's example: <tr> > <td> > <a href=\"/prompts/...\"> title </a>\n    const rows = document.querySelectorAll('tr.mat-mdc-row, tr[mat-row]');\n    rows.forEach((row) => {\n      const tr = row as HTMLElement;\n      // Find the anchor with prompt link in this row\n      // Matches: a[href^=\"/prompts/\"] or a.name-btn with /prompts/ in href\n      const anchor = tr.querySelector(\n        'a[href^=\"/prompts/\"], a.name-btn[href*=\"/prompts/\"]',\n      ) as HTMLAnchorElement | null;\n      if (!anchor) return;\n\n      // Skip if already bound\n      if ((tr as Element & { _gvLibraryDragBound?: boolean })._gvLibraryDragBound) return;\n      (tr as Element & { _gvLibraryDragBound?: boolean })._gvLibraryDragBound = true;\n\n      tr.draggable = true;\n      tr.style.cursor = 'grab';\n\n      tr.addEventListener('dragstart', (e) => {\n        // Prevent interference from Angular Material's own drag handling if any\n        e.stopPropagation();\n\n        const id = this.extractPromptId(anchor);\n        const title = this.extractPromptTitle(anchor) || '';\n        // Ensure accurate URL construction\n        const rawHref = anchor.getAttribute('href') || anchor.href || '';\n        const url = rawHref.startsWith('http')\n          ? rawHref\n          : `${location.origin}${rawHref.startsWith('/') ? '' : '/'}${rawHref}`;\n\n        const data: DragData = { type: 'conversation', conversationId: id, title, url };\n        this.setPromptDragData(e, data, tr);\n\n        // Visual feedback\n        tr.style.opacity = '0.5';\n      });\n\n      tr.addEventListener('dragend', () => {\n        tr.style.opacity = '';\n      });\n    });\n  }\n\n  /**\n   * Inject a floating drop zone for the library page\n   * Shows available folders when user starts dragging\n   */\n  private injectLibraryDropZone(): void {\n    // Create a floating container that appears during drag\n    const floatingZone = document.createElement('div');\n    floatingZone.className = 'gv-library-drop-zone';\n    floatingZone.style.cssText = `\n      position: fixed;\n      bottom: 20px;\n      right: 20px;\n      background: rgba(32, 33, 36, 0.95);\n      border: 2px dashed rgba(138, 180, 248, 0.5);\n      border-radius: 12px;\n      padding: 16px;\n      min-width: 200px;\n      max-width: 300px;\n      max-height: 400px;\n      overflow-y: auto;\n      z-index: 2147483646;\n      opacity: 0;\n      pointer-events: none;\n      transition: opacity 0.2s, transform 0.2s;\n      transform: translateY(10px);\n      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);\n      font-family: 'Google Sans', Roboto, Arial, sans-serif;\n    `;\n\n    const title = document.createElement('div');\n    title.style.cssText = `\n      color: #e8eaed;\n      font-size: 14px;\n      font-weight: 500;\n      margin-bottom: 12px;\n      display: flex;\n      align-items: center;\n      gap: 8px;\n    `;\n    title.innerHTML = `<span class=\"google-symbols\" style=\"font-size: 18px;\">folder</span>${this.t('folder_title')}`;\n    floatingZone.appendChild(title);\n\n    const folderList = document.createElement('div');\n    folderList.className = 'gv-library-folder-list';\n    floatingZone.appendChild(folderList);\n\n    document.body.appendChild(floatingZone);\n\n    // Update folder list content\n    const updateFolderList = () => {\n      folderList.innerHTML = '';\n\n      // Add a \"Root / Uncategorized\" option at the top\n      const rootItem = document.createElement('div');\n      rootItem.className = 'gv-library-folder-item gv-library-root-item';\n      rootItem.style.cssText = `\n        padding: 10px 12px;\n        margin: 4px 0 12px 0;\n        background: rgba(138, 180, 248, 0.1);\n        border-radius: 8px;\n        color: #8ab4f8;\n        font-size: 13px;\n        font-weight: 500;\n        cursor: pointer;\n        display: flex;\n        align-items: center;\n        gap: 8px;\n        transition: background 0.15s, border-color 0.15s;\n        border: 2px dashed rgba(138, 180, 248, 0.4);\n      `;\n      rootItem.innerHTML = `<span class=\"google-symbols\" data-icon=\"inbox\">inbox</span>${this.t('folder_uncategorized') || 'Uncategorized'}`;\n\n      const onDropToRoot = (e: DragEvent) => {\n        e.preventDefault();\n        e.stopPropagation();\n        rootItem.style.background = 'rgba(138, 180, 248, 0.2)';\n        rootItem.style.borderColor = '#8ab4f8';\n\n        const data = this.parseDragDataFromEvent(e);\n        if (!data || data.type !== 'conversation' || !data.conversationId) return;\n\n        const conv: ConversationReference = {\n          conversationId: data.conversationId,\n          title: normalizeText(data.title) || this.t('conversation_untitled'),\n          url: data.url || '',\n          addedAt: now(),\n        };\n\n        // Add to uncategorized section\n        Object.keys(this.data.folderContents).forEach((fid) => {\n          if (fid === this.UNCATEGORIZED_KEY) return;\n          this.data.folderContents[fid] = (this.data.folderContents[fid] || []).filter(\n            (c) => c.conversationId !== conv.conversationId,\n          );\n        });\n\n        const uncatArr = this.data.folderContents[this.UNCATEGORIZED_KEY] || [];\n        const existsInUncat = uncatArr.some((c) => c.conversationId === conv.conversationId);\n        if (!existsInUncat) {\n          uncatArr.push(conv);\n          this.data.folderContents[this.UNCATEGORIZED_KEY] = uncatArr;\n        }\n\n        this.save();\n        this.showNotification(\n          this.t('conversation_saved_to_root') || 'Saved to Uncategorized',\n          'info',\n        );\n      };\n\n      rootItem.addEventListener('dragenter', (e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        rootItem.style.background = 'rgba(138, 180, 248, 0.3)';\n        rootItem.style.borderColor = '#8ab4f8';\n      });\n      rootItem.addEventListener('dragover', (e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        try {\n          if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';\n        } catch {}\n      });\n      rootItem.addEventListener('dragleave', (e) => {\n        e.stopPropagation();\n        rootItem.style.background = 'rgba(138, 180, 248, 0.1)';\n        rootItem.style.borderColor = 'rgba(138, 180, 248, 0.4)';\n      });\n      rootItem.addEventListener('drop', onDropToRoot);\n      folderList.appendChild(rootItem);\n\n      // Ensure at least one folder exists for the dedicated folder list section\n      if (this.data.folders.length === 0) {\n        const defaultFolder: Folder = {\n          id: uid(),\n          name: this.t('folder_default_name') || 'My Folder',\n          parentId: null,\n          isExpanded: true,\n          createdAt: now(),\n          updatedAt: now(),\n        };\n        this.data.folders.push(defaultFolder);\n        this.data.folderContents[defaultFolder.id] = [];\n        this.save();\n      }\n\n      // Render folders with proper hierarchy (root folders + their subfolders)\n      const rootFolders = this.data.folders.filter((f) => !f.parentId);\n      // Sort root folders: pinned first, then by creation time\n      rootFolders.sort((a, b) => {\n        const ap = a.pinned ? 1 : 0;\n        const bp = b.pinned ? 1 : 0;\n        if (ap !== bp) return bp - ap;\n        return a.createdAt - b.createdAt;\n      });\n\n      // Helper function to create a folder drop item\n      const createFolderDropItem = (folder: Folder, isSubfolder: boolean) => {\n        const folderItem = document.createElement('div');\n        folderItem.className = 'gv-library-folder-item';\n        folderItem.dataset.folderId = folder.id;\n        const paddingLeft = isSubfolder ? '28px' : '12px';\n        folderItem.style.cssText = `\n          padding: 10px ${paddingLeft};\n          margin: 4px 0;\n          background: rgba(255, 255, 255, 0.05);\n          border-radius: 8px;\n          color: #e8eaed;\n          font-size: 13px;\n          cursor: pointer;\n          display: flex;\n          align-items: center;\n          gap: 8px;\n          transition: background 0.15s, border-color 0.15s;\n          border: 2px solid transparent;\n        `;\n        const iconName = isSubfolder ? 'subdirectory_arrow_right' : 'folder';\n        folderItem.innerHTML = `<span class=\"google-symbols\" style=\"font-size: 16px; color: #8ab4f8;\">${iconName}</span>${folder.name}`;\n\n        // Bind drop events\n        folderItem.addEventListener('dragenter', (e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          folderItem.style.background = 'rgba(138, 180, 248, 0.2)';\n          folderItem.style.borderColor = '#8ab4f8';\n        });\n        folderItem.addEventListener('dragover', (e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          try {\n            if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';\n          } catch {}\n        });\n        folderItem.addEventListener('dragleave', (e) => {\n          e.stopPropagation();\n          folderItem.style.background = 'rgba(255, 255, 255, 0.05)';\n          folderItem.style.borderColor = 'transparent';\n        });\n        folderItem.addEventListener('drop', (e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          folderItem.style.background = 'rgba(255, 255, 255, 0.05)';\n          folderItem.style.borderColor = 'transparent';\n\n          const data = this.parseDragDataFromEvent(e);\n          if (!data || data.type !== 'conversation' || !data.conversationId) return;\n\n          const conv: ConversationReference = {\n            conversationId: data.conversationId,\n            title: normalizeText(data.title) || this.t('conversation_untitled'),\n            url: data.url || '',\n            addedAt: now(),\n          };\n\n          // Add to this folder\n          const arr = this.data.folderContents[folder.id] || [];\n          const exists = arr.some((c) => c.conversationId === conv.conversationId);\n          if (!exists) {\n            arr.push(conv);\n            this.data.folderContents[folder.id] = arr;\n          }\n\n          // Remove from other folders\n          Object.keys(this.data.folderContents).forEach((fid) => {\n            if (fid === folder.id) return;\n            this.data.folderContents[fid] = (this.data.folderContents[fid] || []).filter(\n              (c) => c.conversationId !== conv.conversationId,\n            );\n          });\n\n          this.save();\n          this.showNotification(\n            `${this.t('conversation_added_to_folder') || 'Added to'} \"${folder.name}\"`,\n            'info',\n          );\n        });\n\n        return folderItem;\n      };\n\n      // Render root folders and their subfolders\n      rootFolders.forEach((rootFolder) => {\n        folderList.appendChild(createFolderDropItem(rootFolder, false));\n\n        // Render subfolders of this root folder\n        const subfolders = this.data.folders.filter((f) => f.parentId === rootFolder.id);\n        subfolders.sort((a, b) => {\n          const ap = a.pinned ? 1 : 0;\n          const bp = b.pinned ? 1 : 0;\n          if (ap !== bp) return bp - ap;\n          return a.createdAt - b.createdAt;\n        });\n        subfolders.forEach((subfolder) => {\n          folderList.appendChild(createFolderDropItem(subfolder, true));\n        });\n      });\n    };\n\n    // Show/hide the floating zone on drag events\n    const showZone = () => {\n      updateFolderList();\n      floatingZone.style.opacity = '1';\n      floatingZone.style.pointerEvents = 'auto';\n      floatingZone.style.transform = 'translateY(0)';\n    };\n\n    const hideZone = () => {\n      floatingZone.style.opacity = '0';\n      floatingZone.style.pointerEvents = 'none';\n      floatingZone.style.transform = 'translateY(10px)';\n    };\n\n    // Listen for drag events on the document\n    const onDragStart = (e: DragEvent) => {\n      const target = e.target as HTMLElement;\n      // Check if the dragged element is or is within a library table row\n      const isLibraryRow = target.closest?.('tr.mat-mdc-row, tr[mat-row]');\n      if (isLibraryRow) {\n        // Also ensure it's not a row from some other table\n        const hasPromptLink = isLibraryRow.querySelector('a[href*=\"/prompts/\"]');\n        if (hasPromptLink) {\n          setTimeout(showZone, 0);\n        }\n      }\n    };\n\n    document.addEventListener('dragstart', onDragStart);\n\n    document.addEventListener('dragend', () => {\n      setTimeout(hideZone, 100);\n    });\n\n    this.cleanupFns.push(() => {\n      try {\n        document.removeEventListener('dragstart', onDragStart);\n        document.body.removeChild(floatingZone);\n      } catch {}\n    });\n  }\n\n  private extractPromptId(anchor: HTMLAnchorElement): string {\n    const rawHref = anchor.getAttribute('href') || anchor.href || '';\n    const id = extractPromptIdFromHref(rawHref);\n    if (id) return id;\n\n    try {\n      const u = new URL(rawHref, location.origin);\n      const parts = (u.pathname || '').split('/').filter(Boolean);\n      // Expected format: /prompts/{id} -> ['', 'prompts', '{id}']\n      if (parts.length >= 2 && parts[0] === 'prompts') {\n        return parts[1];\n      }\n      return parts[1] || rawHref;\n    } catch {\n      return rawHref;\n    }\n  }\n\n  private navigateToPrompt(promptId: string, url: string): void {\n    // Prefer clicking the native link to preserve SPA behavior\n    const selector = `ms-prompt-history-v3 a.prompt-link[href*=\"/prompts/${promptId}\"]`;\n    const a = document.querySelector(selector) as HTMLAnchorElement | null;\n    if (a) {\n      a.click();\n      setTimeout(() => this.highlightActiveConversation(), 0);\n      return;\n    }\n    try {\n      window.history.pushState({}, '', url);\n      window.dispatchEvent(new PopStateEvent('popstate'));\n      setTimeout(() => this.highlightActiveConversation(), 0);\n    } catch {\n      location.href = url;\n    }\n  }\n\n  private handleExport(): void {\n    const payload = {\n      format: 'gemini-voyager.folders.v1',\n      exportedAt: new Date().toISOString(),\n      data: this.data,\n    };\n    downloadJSON(payload, `gemini-voyager-folders-${this.timestamp()}.json`);\n  }\n\n  private handleImport(): void {\n    const input = document.createElement('input');\n    input.type = 'file';\n    input.accept = 'application/json';\n    input.addEventListener(\n      'change',\n      async () => {\n        const f = input.files && input.files[0];\n        if (!f) return;\n        try {\n          const text = await f.text();\n          const json = JSON.parse(text);\n          const next = (json && (json.data || json)) as FolderData;\n          if (!next || !Array.isArray(next.folders) || typeof next.folderContents !== 'object') {\n            alert(this.t('folder_import_invalid_format') || 'Invalid file format');\n            return;\n          }\n          // Merge mode by default: simple union without duplicates\n          const existingIds = new Set(this.data.folders.map((x) => x.id));\n          for (const f of next.folders) {\n            if (!existingIds.has(f.id)) {\n              this.data.folders.push(f);\n              this.data.folderContents[f.id] = next.folderContents[f.id] || [];\n            } else {\n              // Merge conversations\n              const base = this.data.folderContents[f.id] || [];\n              const add = next.folderContents[f.id] || [];\n              const seen = new Set(base.map((c) => c.conversationId));\n              for (const c of add) {\n                if (!seen.has(c.conversationId)) base.push(c);\n              }\n              this.data.folderContents[f.id] = base;\n            }\n          }\n          await this.save();\n          this.render();\n          alert(this.t('folder_import_success') || 'Imported');\n        } catch {\n          alert(this.t('folder_import_error') || 'Import failed');\n        }\n      },\n      { once: true },\n    );\n    input.click();\n  }\n\n  private timestamp(): string {\n    const d = new Date();\n    const pad = (n: number) => String(n).padStart(2, '0');\n    return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())} -${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())} `;\n  }\n\n  private async loadFolderEnabledSetting(): Promise<void> {\n    try {\n      const result = await browser.storage.sync.get({ geminiFolderEnabled: true });\n      this.folderEnabled = result.geminiFolderEnabled !== false;\n    } catch (error) {\n      console.error('[AIStudioFolderManager] Failed to load folder enabled setting:', error);\n      this.folderEnabled = true;\n    }\n  }\n\n  private setupStorageListener(): void {\n    browser.storage.onChanged.addListener((changes, areaName) => {\n      if (areaName === 'sync') {\n        if (changes.geminiFolderEnabled) {\n          this.folderEnabled = changes.geminiFolderEnabled.newValue !== false;\n          this.applyFolderEnabledSetting();\n        }\n        if (\n          changes[StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED] ||\n          changes[StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED_AISTUDIO]\n        ) {\n          void (async () => {\n            const nextEnabled = await accountIsolationService.isIsolationEnabled({\n              platform: 'aistudio',\n              pageUrl: window.location.href,\n            });\n            await this.handleAccountIsolationToggle(nextEnabled);\n          })();\n        }\n        if (changes[this.SIDEBAR_WIDTH_KEY]) {\n          const w = changes[this.SIDEBAR_WIDTH_KEY].newValue;\n          if (typeof w === 'number') {\n            const clamped = Math.min(\n              this.MAX_SIDEBAR_WIDTH,\n              Math.max(this.MIN_SIDEBAR_WIDTH, Math.round(w)),\n            );\n            this.sidebarWidth = clamped;\n            this.applySidebarWidth();\n          }\n        }\n      }\n    });\n  }\n\n  private applyFolderEnabledSetting(): void {\n    if (this.folderEnabled) {\n      // If folder UI doesn't exist yet, initialize it\n      if (!this.container) {\n        this.initializeFolderUI().catch((error) => {\n          console.error('[AIStudioFolderManager] Failed to initialize folder UI:', error);\n        });\n      } else {\n        // UI already exists, just show it\n        this.container.style.display = '';\n      }\n    } else {\n      // Hide the folder UI if it exists\n      if (this.container) {\n        this.container.style.display = 'none';\n      }\n    }\n  }\n\n  /**\n   * Attempt to recover data when load() fails\n   * Uses multi-layer backup system: primary > emergency > beforeUnload > in-memory\n   */\n  private attemptDataRecovery(_error: unknown): void {\n    console.warn('[AIStudioFolderManager] Attempting data recovery after load failure');\n\n    // Step 1: Try to restore from localStorage backups (primary, emergency, beforeUnload)\n    const recovered = this.backupService.recoverFromBackup();\n    if (recovered && validateFolderData(recovered)) {\n      this.data = recovered;\n      console.warn('[AIStudioFolderManager] Data recovered from localStorage backup');\n      this.showNotification('Folder data recovered from backup', 'warning');\n      // Try to save recovered data to persistent storage\n      this.save();\n      return;\n    }\n\n    // Step 2: Keep existing in-memory data if it exists and is valid\n    if (validateFolderData(this.data) && this.data.folders.length > 0) {\n      console.warn('[AIStudioFolderManager] Keeping existing in-memory data after load error');\n      this.showErrorNotification('Failed to load folder data, using cached version');\n      return;\n    }\n\n    // Step 3: Last resort - initialize empty data and notify user\n    console.error('[AIStudioFolderManager] All recovery attempts failed, initializing empty data');\n    this.data = { folders: [], folderContents: {} };\n    this.showErrorNotification('Failed to load folder data. All folders have been reset.');\n  }\n\n  /**\n   * Show an error notification to the user\n   * @deprecated Use showNotification() instead for better level support\n   */\n  private showErrorNotification(message: string): void {\n    this.showNotification(message, 'error');\n  }\n\n  /**\n   * Show a notification to the user with customizable level\n   */\n  private showNotification(message: string, level: 'info' | 'warning' | 'error' = 'error'): void {\n    try {\n      const notification = document.createElement('div');\n      notification.className = `gv - notification gv - notification - ${level} `;\n      notification.textContent = `[Gemini Voyager] ${message} `;\n\n      // Color based on level\n      const colors = {\n        info: '#2196F3',\n        warning: '#FF9800',\n        error: '#f44336',\n      };\n\n      // Apply inline styles for visibility\n      const style = notification.style;\n      style.position = 'fixed';\n      style.top = '20px';\n      style.right = '20px';\n      style.padding = '12px 20px';\n      style.background = colors[level];\n      style.color = 'white';\n      style.borderRadius = '4px';\n      style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';\n      style.zIndex = String(2147483647);\n      style.maxWidth = '400px';\n      style.fontSize = '14px';\n      style.fontFamily = 'system-ui, -apple-system, sans-serif';\n      style.lineHeight = '1.4';\n\n      document.body.appendChild(notification);\n\n      // Auto-remove after timeout (longer for errors/warnings)\n      const timeout =\n        level === 'info' ? 3000 : level === 'warning' ? 7000 : NOTIFICATION_TIMEOUT_MS;\n      setTimeout(() => {\n        try {\n          document.body.removeChild(notification);\n        } catch {\n          // Element might already be removed\n        }\n      }, timeout);\n    } catch (notificationError) {\n      console.error('[AIStudioFolderManager] Failed to show notification:', notificationError);\n    }\n  }\n\n  /**\n   * Check if extension context is valid\n   */\n  private isExtensionContextValid(): boolean {\n    try {\n      // Try to access chrome.runtime.id to check if context is valid\n      return !!(browser?.runtime?.id || chrome?.runtime?.id);\n    } catch {\n      return false;\n    }\n  }\n\n  /**\n   * Load sidebar width from storage (with localStorage fallback)\n   */\n  private async loadSidebarWidth(): Promise<void> {\n    try {\n      // Try chrome.storage.sync first\n      if (this.isExtensionContextValid()) {\n        const result = await browser.storage.sync.get({ [this.SIDEBAR_WIDTH_KEY]: 280 });\n        const width = result[this.SIDEBAR_WIDTH_KEY];\n        if (\n          typeof width === 'number' &&\n          width >= this.MIN_SIDEBAR_WIDTH &&\n          width <= this.MAX_SIDEBAR_WIDTH\n        ) {\n          this.sidebarWidth = width;\n          return;\n        }\n      }\n    } catch (error) {\n      console.warn(\n        '[AIStudioFolderManager] Failed to load from sync storage, trying localStorage:',\n        error,\n      );\n    }\n\n    // Fallback to localStorage\n    try {\n      const stored = localStorage.getItem(this.SIDEBAR_WIDTH_KEY);\n      if (stored) {\n        const width = parseInt(stored, 10);\n        if (\n          typeof width === 'number' &&\n          width >= this.MIN_SIDEBAR_WIDTH &&\n          width <= this.MAX_SIDEBAR_WIDTH\n        ) {\n          this.sidebarWidth = width;\n        }\n      }\n    } catch (error) {\n      console.error(\n        '[AIStudioFolderManager] Failed to load sidebar width from localStorage:',\n        error,\n      );\n    }\n  }\n\n  /**\n   * Save sidebar width to storage (with localStorage fallback)\n   */\n  private async saveSidebarWidth(): Promise<void> {\n    // Always save to localStorage as immediate backup\n    try {\n      localStorage.setItem(this.SIDEBAR_WIDTH_KEY, String(this.sidebarWidth));\n    } catch (error) {\n      console.error('[AIStudioFolderManager] Failed to save to localStorage:', error);\n    }\n\n    // Try to save to chrome.storage.sync if context is valid\n    if (this.isExtensionContextValid()) {\n      try {\n        await browser.storage.sync.set({ [this.SIDEBAR_WIDTH_KEY]: this.sidebarWidth });\n      } catch (error) {\n        // Silent fail if extension context is invalidated (happens during dev reload)\n        if (error instanceof Error && !error.message.includes('Extension context invalidated')) {\n          console.error('[AIStudioFolderManager] Failed to save sidebar width:', error);\n        }\n      }\n    }\n  }\n\n  /**\n   * Apply sidebar width to the navbar element (only when expanded)\n   */\n  private applySidebarWidth(force: boolean = false): void {\n    // Target the actual nav-content div, not the outer ms-navbar\n    const navContent = document.querySelector('.nav-content.v3-left-nav') as HTMLElement | null;\n    if (!navContent) return;\n\n    // Check if sidebar is expanded by looking at the 'expanded' class\n    const isExpanded = navContent.classList.contains('expanded');\n\n    if (isExpanded || force) {\n      navContent.style.width = `${this.sidebarWidth}px`;\n      navContent.style.minWidth = `${this.sidebarWidth}px`;\n      navContent.style.maxWidth = `${this.sidebarWidth}px`;\n      navContent.style.flex = `0 0 ${this.sidebarWidth}px`;\n    } else {\n      // Remove our width overrides when collapsed to allow native behavior\n      navContent.style.width = '';\n      navContent.style.minWidth = '';\n      navContent.style.maxWidth = '';\n      navContent.style.flex = '';\n    }\n  }\n\n  /**\n   * Add a draggable resize handle to adjust sidebar width\n   */\n  private addResizeHandle(): void {\n    // Target the actual nav-content div\n    const navContent = document.querySelector('.nav-content.v3-left-nav') as HTMLElement | null;\n    if (!navContent) {\n      console.warn('[AIStudioFolderManager] nav-content not found, resize handle not added');\n      return;\n    }\n\n    // Create resize handle\n    const handle = document.createElement('div');\n    handle.className = 'gv-sidebar-resize-handle';\n    handle.title = 'Drag to resize sidebar';\n\n    // Position it at the right edge of the nav-content with inline styles\n    const handleStyle = handle.style;\n    handleStyle.position = 'absolute';\n    handleStyle.top = '0';\n    handleStyle.right = '-4px'; // Position at right edge, overlapping slightly outside\n    handleStyle.width = '8px';\n    handleStyle.height = '100%';\n    handleStyle.cursor = 'ew-resize';\n    handleStyle.zIndex = '10000';\n    handleStyle.backgroundColor = 'transparent';\n    handleStyle.transition = 'background-color 0.2s';\n    handleStyle.pointerEvents = 'auto';\n\n    // Hover effect\n    handle.addEventListener('mouseenter', () => {\n      handleStyle.backgroundColor = 'rgba(66, 133, 244, 0.5)';\n    });\n    handle.addEventListener('mouseleave', () => {\n      handleStyle.backgroundColor = 'transparent';\n    });\n\n    // Dragging logic\n    let isDragging = false;\n    let startX = 0;\n    let startWidth = 0;\n\n    const handleMouseDown = (e: MouseEvent) => {\n      isDragging = true;\n      startX = e.clientX;\n      startWidth = this.sidebarWidth;\n      e.preventDefault();\n      e.stopPropagation();\n\n      // Add dragging class for visual feedback\n      document.body.style.cursor = 'ew-resize';\n      document.body.style.userSelect = 'none';\n    };\n\n    const handleMouseMove = (e: MouseEvent) => {\n      if (!isDragging) return;\n\n      const delta = e.clientX - startX;\n      const newWidth = Math.max(\n        this.MIN_SIDEBAR_WIDTH,\n        Math.min(this.MAX_SIDEBAR_WIDTH, startWidth + delta),\n      );\n\n      this.sidebarWidth = newWidth;\n      this.applySidebarWidth(true); // Force apply during drag\n\n      // Handle position is relative, no need to update during drag\n    };\n\n    const handleMouseUp = () => {\n      if (!isDragging) return;\n\n      isDragging = false;\n      document.body.style.cursor = '';\n      document.body.style.userSelect = '';\n\n      // Save the new width\n      this.saveSidebarWidth();\n    };\n\n    handle.addEventListener('mousedown', handleMouseDown);\n    document.addEventListener('mousemove', handleMouseMove);\n    document.addEventListener('mouseup', handleMouseUp);\n\n    // Ensure nav-content has position relative for absolute handle positioning\n    navContent.style.position = 'relative';\n\n    // Add to nav-content for correct positioning\n    navContent.appendChild(handle);\n\n    // Update handle visibility when sidebar state changes\n    const updateHandleVisibility = () => {\n      const isExpanded = navContent.classList.contains('expanded');\n\n      if (isExpanded) {\n        handleStyle.display = 'block';\n      } else {\n        handleStyle.display = 'none'; // Hide when collapsed\n      }\n    };\n\n    // Monitor sidebar state changes by watching the 'expanded' class\n    const observer = new MutationObserver((mutations) => {\n      for (const mutation of mutations) {\n        if (mutation.type === 'attributes' && mutation.attributeName === 'class') {\n          updateHandleVisibility();\n          this.applySidebarWidth(); // Reapply width based on current state\n          break;\n        }\n      }\n    });\n\n    try {\n      observer.observe(navContent, {\n        attributes: true,\n        attributeFilter: ['class'],\n      });\n    } catch (error) {\n      console.error('[AIStudioFolderManager] Failed to observe nav-content:', error);\n    }\n\n    // Initial visibility update\n    updateHandleVisibility();\n\n    this.cleanupFns.push(() => {\n      try {\n        observer.disconnect();\n        handle.removeEventListener('mousedown', handleMouseDown);\n        document.removeEventListener('mousemove', handleMouseMove);\n        document.removeEventListener('mouseup', handleMouseUp);\n        if (handle.parentElement) {\n          handle.parentElement.removeChild(handle);\n        }\n      } catch {}\n    });\n  }\n\n  /**\n   * Handle cloud upload - upload folder data and prompts to Google Drive\n   * This mirrors the logic in CloudSyncSettings.tsx handleSyncNow()\n   * Note: AI Studio uses its own folder file but shares prompts with Gemini\n   */\n  private async handleCloudUpload(): Promise<void> {\n    try {\n      this.showNotification(this.t('uploadInProgress'), 'info');\n\n      // Get current folder data\n      const folders = this.data;\n\n      // Get prompts from storage (shared with Gemini)\n      let prompts: PromptItem[] = [];\n      try {\n        const storageResult = await chrome.storage.local.get(['gvPromptItems']);\n        if (storageResult.gvPromptItems) {\n          prompts = storageResult.gvPromptItems as PromptItem[];\n        }\n      } catch (err) {\n        console.warn('[AIStudioFolderManager] Could not get prompts for upload:', err);\n      }\n\n      console.log(\n        `[AIStudioFolderManager] Uploading - folders: ${folders.folders?.length || 0}, prompts: ${prompts.length}`,\n      );\n\n      // Send upload request to background script\n      const response = (await browser.runtime.sendMessage({\n        type: 'gv.sync.upload',\n        payload: {\n          folders,\n          prompts,\n          platform: 'aistudio',\n          accountScope: this.toSyncAccountScope(this.accountScope),\n        },\n      })) as { ok?: boolean; error?: string } | undefined;\n\n      if (response?.ok) {\n        this.showNotification(this.t('uploadSuccess'), 'info');\n      } else {\n        const errorMsg = response?.error || 'Unknown error';\n        this.showNotification(this.t('syncError').replace('{error}', errorMsg), 'error');\n      }\n    } catch (error) {\n      const errorMsg = error instanceof Error ? error.message : 'Unknown error';\n      console.error('[AIStudioFolderManager] Cloud upload failed:', error);\n      this.showNotification(this.t('syncError').replace('{error}', errorMsg), 'error');\n    }\n  }\n\n  /**\n   * Handle cloud sync - download and merge folder data and prompts from Google Drive\n   * This mirrors the logic in CloudSyncSettings.tsx handleDownloadFromDrive()\n   * Note: AI Studio uses its own folder file but shares prompts with Gemini\n   */\n  private async handleCloudSync(): Promise<void> {\n    try {\n      this.showNotification(this.t('downloadInProgress'), 'info');\n\n      // Send download request to background script\n      const response = (await browser.runtime.sendMessage({\n        type: 'gv.sync.download',\n        payload: {\n          platform: 'aistudio',\n          accountScope: this.toSyncAccountScope(this.accountScope),\n        },\n      })) as\n        | {\n            ok?: boolean;\n            error?: string;\n            data?: {\n              folders?: { data?: FolderData };\n              prompts?: { items?: PromptItem[] };\n            };\n          }\n        | undefined;\n\n      if (!response?.ok) {\n        const errorMsg = response?.error || 'Download failed';\n        this.showNotification(this.t('syncError').replace('{error}', errorMsg), 'error');\n        return;\n      }\n\n      if (!response.data) {\n        this.showNotification(this.t('syncNoData') || 'No data in cloud', 'info');\n        return;\n      }\n\n      // Extract cloud data\n      const cloudFoldersPayload = response.data?.folders;\n      const cloudPromptsPayload = response.data?.prompts;\n      const cloudFolderData = cloudFoldersPayload?.data || { folders: [], folderContents: {} };\n      const cloudPromptItems = cloudPromptsPayload?.items || [];\n\n      console.log(\n        `[AIStudioFolderManager] Downloaded - folders: ${cloudFolderData.folders?.length || 0}, prompts: ${cloudPromptItems.length}`,\n      );\n\n      // Get local prompts for merge (shared with Gemini)\n      let localPrompts: PromptItem[] = [];\n      try {\n        const storageResult = await chrome.storage.local.get(['gvPromptItems']);\n        if (storageResult.gvPromptItems) {\n          localPrompts = storageResult.gvPromptItems as PromptItem[];\n        }\n      } catch (err) {\n        console.warn('[AIStudioFolderManager] Could not get local prompts for merge:', err);\n      }\n\n      // Merge folder data\n      const localFolders = this.data;\n      const mergedFolders = this.mergeFolderData(localFolders, cloudFolderData);\n\n      // Merge prompts (simple ID-based merge)\n      const mergedPrompts = this.mergePromptsData(localPrompts, cloudPromptItems);\n\n      console.log(\n        `[AIStudioFolderManager] Merged - folders: ${mergedFolders.folders?.length || 0}, prompts: ${mergedPrompts.length}`,\n      );\n\n      // Apply merged folder data\n      this.data = mergedFolders;\n      await this.save();\n\n      // Save merged prompts to storage (shared with Gemini)\n      try {\n        await chrome.storage.local.set({\n          gvPromptItems: mergedPrompts,\n        });\n      } catch (err) {\n        console.error('[AIStudioFolderManager] Failed to save merged prompts:', err);\n      }\n\n      this.render();\n      this.showNotification(this.t('downloadMergeSuccess'), 'info');\n    } catch (error) {\n      const errorMsg = error instanceof Error ? error.message : 'Unknown error';\n      console.error('[AIStudioFolderManager] Cloud sync failed:', error);\n      this.showNotification(this.t('syncError').replace('{error}', errorMsg), 'error');\n    }\n  }\n\n  /**\n   * Merge prompts by ID (simple deduplication)\n   */\n  private mergePromptsData(local: PromptItem[], cloud: PromptItem[]): PromptItem[] {\n    const promptMap = new Map<string, PromptItem>();\n\n    // Add local prompts first\n    local.forEach((p) => {\n      if (p?.id) promptMap.set(p.id, p);\n    });\n\n    // Add cloud prompts (cloud takes priority for newer items)\n    cloud.forEach((p) => {\n      if (!p?.id) return;\n      const existing = promptMap.get(p.id);\n      if (!existing) {\n        promptMap.set(p.id, p);\n      } else {\n        // Compare timestamps, prefer newer\n        const cloudTime = p.updatedAt || p.createdAt || 0;\n        const localTime = existing.updatedAt || existing.createdAt || 0;\n        if (cloudTime > localTime) {\n          promptMap.set(p.id, p);\n        }\n      }\n    });\n\n    return Array.from(promptMap.values());\n  }\n\n  /**\n   * Get dynamic tooltip for cloud upload button showing last upload time\n   */\n  private async getCloudUploadTooltip(): Promise<string> {\n    try {\n      const response = (await browser.runtime.sendMessage({ type: 'gv.sync.getState' })) as\n        | { ok?: boolean; state?: { lastUploadTime?: number | null } }\n        | undefined;\n      if (response?.ok && response.state) {\n        const lastUploadTime = response.state.lastUploadTime;\n        const timeStr = this.formatRelativeTime(lastUploadTime ?? null);\n        const baseTooltip = this.t('folder_cloud_upload');\n        return lastUploadTime\n          ? `${baseTooltip}\\n${this.t('lastUploaded').replace('{time}', timeStr)}`\n          : `${baseTooltip}\\n${this.t('neverUploaded')}`;\n      }\n    } catch (e) {\n      console.warn('[AIStudioFolderManager] Failed to get sync state for tooltip:', e);\n    }\n    return this.t('folder_cloud_upload');\n  }\n\n  /**\n   * Get dynamic tooltip for cloud sync button showing last sync time\n   */\n  private async getCloudSyncTooltip(): Promise<string> {\n    try {\n      const response = (await browser.runtime.sendMessage({ type: 'gv.sync.getState' })) as\n        | { ok?: boolean; state?: { lastSyncTime?: number | null } }\n        | undefined;\n      if (response?.ok && response.state) {\n        const lastSyncTime = response.state.lastSyncTime;\n        const timeStr = this.formatRelativeTime(lastSyncTime ?? null);\n        const baseTooltip = this.t('folder_cloud_sync');\n        return lastSyncTime\n          ? `${baseTooltip}\\n${this.t('lastSynced').replace('{time}', timeStr)}`\n          : `${baseTooltip}\\n${this.t('neverSynced')}`;\n      }\n    } catch (e) {\n      console.warn('[AIStudioFolderManager] Failed to get sync state for tooltip:', e);\n    }\n    return this.t('folder_cloud_sync');\n  }\n\n  /**\n   * Format a timestamp as relative time (e.g. \"5 minutes ago\")\n   */\n  private formatRelativeTime(timestamp: number | null): string {\n    if (!timestamp) return '';\n    const now = Date.now();\n    const diffMs = now - timestamp;\n    const diffMins = Math.floor(diffMs / 60000);\n    const diffHours = Math.floor(diffMs / 3600000);\n    const diffDays = Math.floor(diffMs / 86400000);\n\n    if (diffMins < 1) {\n      return this.t('justNow');\n    } else if (diffMins < 60) {\n      return `${diffMins} ${this.t('minutesAgo')}`;\n    } else if (diffHours < 24) {\n      return `${diffHours} ${this.t('hoursAgo')}`;\n    } else if (diffDays === 1) {\n      return this.t('yesterday');\n    } else {\n      return new Date(timestamp).toLocaleDateString();\n    }\n  }\n}\n\nexport async function startAIStudioFolderManager(): Promise<void> {\n  try {\n    const mgr = new AIStudioFolderManager();\n    await mgr.init();\n  } catch (e) {\n    console.error('[AIStudioFolderManager] Start error:', e);\n  }\n}\n"
  },
  {
    "path": "src/pages/content/folder/conversationSort.ts",
    "content": "import type { ConversationReference } from './types';\n\nfunction getConversationSortTime(conversation: ConversationReference): number {\n  return conversation.lastOpenedAt ?? conversation.addedAt ?? 0;\n}\n\nexport function sortConversationsByPriority(\n  conversations: ConversationReference[],\n): ConversationReference[] {\n  return [...conversations].sort((a, b) => {\n    if (a.starred && !b.starred) return -1;\n    if (!a.starred && b.starred) return 1;\n\n    // Within the same starred state, use sortIndex if both have one\n    const aIdx = a.sortIndex ?? -1;\n    const bIdx = b.sortIndex ?? -1;\n    if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx;\n\n    // Fall back to time-based sort\n    return getConversationSortTime(b) - getConversationSortTime(a);\n  });\n}\n"
  },
  {
    "path": "src/pages/content/folder/folderColors.ts",
    "content": "/**\n * Folder Color Configuration\n *\n * Provides semantic color options for folder organization.\n * Colors are designed to indicate priority/urgency levels.\n *\n * Design Principles:\n * - Limited to 8 colors to avoid choice paralysis\n * - Each color has semantic meaning for priority/urgency\n * - Supports both light and dark modes\n * - Colors are WCAG AA compliant for accessibility\n */\n\nexport interface FolderColorConfig {\n  /** Unique identifier for the color */\n  id: string;\n\n  /** Display name for the color (i18n key) */\n  nameKey: string;\n\n  /** Color value for light mode */\n  lightColor: string;\n\n  /** Color value for dark mode */\n  darkColor: string;\n\n  /** Semantic meaning/priority level */\n  priority: 'critical' | 'high' | 'medium' | 'low' | 'neutral';\n}\n\n/**\n * Available folder colors with semantic meanings:\n * - Red: Critical/Urgent\n * - Orange: High Priority/Warning\n * - Yellow: Needs Attention\n * - Green: Completed/Safe\n * - Blue: Information/Reference\n * - Purple: Creative/Ideas\n * - Pink: Personal/Special\n * - Gray: Default/Archived\n */\nexport const FOLDER_COLORS: FolderColorConfig[] = [\n  {\n    id: 'default',\n    nameKey: 'folder_color_default',\n    lightColor: '#6b7280', // gray-500\n    darkColor: '#9ca3af', // gray-400\n    priority: 'neutral',\n  },\n  {\n    id: 'red',\n    nameKey: 'folder_color_red',\n    lightColor: '#ef4444', // red-500\n    darkColor: '#f87171', // red-400\n    priority: 'critical',\n  },\n  {\n    id: 'orange',\n    nameKey: 'folder_color_orange',\n    lightColor: '#f97316', // orange-500\n    darkColor: '#fb923c', // orange-400\n    priority: 'high',\n  },\n  {\n    id: 'yellow',\n    nameKey: 'folder_color_yellow',\n    lightColor: '#eab308', // yellow-500\n    darkColor: '#fbbf24', // yellow-400\n    priority: 'high',\n  },\n  {\n    id: 'green',\n    nameKey: 'folder_color_green',\n    lightColor: '#22c55e', // green-500\n    darkColor: '#4ade80', // green-400\n    priority: 'medium',\n  },\n  {\n    id: 'blue',\n    nameKey: 'folder_color_blue',\n    lightColor: '#3b82f6', // blue-500\n    darkColor: '#60a5fa', // blue-400\n    priority: 'medium',\n  },\n  {\n    id: 'purple',\n    nameKey: 'folder_color_purple',\n    lightColor: '#a855f7', // purple-500\n    darkColor: '#c084fc', // purple-400\n    priority: 'low',\n  },\n];\n\n/**\n * Get color value for a given color ID based on theme\n * @param colorId Color identifier (e.g., 'red', 'blue') or hex code\n * @param isDarkMode Whether dark mode is active\n * @returns Hex color string\n */\nexport function getFolderColor(colorId: string | undefined, isDarkMode: boolean): string {\n  if (!colorId || colorId === 'default') {\n    return isDarkMode ? '#9ca3af' : '#6b7280';\n  }\n\n  // Support custom hex colors\n  if (colorId.startsWith('#')) {\n    return colorId;\n  }\n\n  // Legacy support for 'pink'\n  if (colorId === 'pink') {\n    return isDarkMode ? '#f472b6' : '#ec4899';\n  }\n\n  const config = FOLDER_COLORS.find((c) => c.id === colorId);\n  if (!config) {\n    // Fallback to default if color not found\n    return isDarkMode ? '#9ca3af' : '#6b7280';\n  }\n\n  return isDarkMode ? config.darkColor : config.lightColor;\n}\n\n/**\n * Get color configuration by ID\n * @param colorId Color identifier\n * @returns Color configuration or undefined if not found\n */\nexport function getFolderColorConfig(colorId: string): FolderColorConfig | undefined {\n  return FOLDER_COLORS.find((c) => c.id === colorId);\n}\n\n/**\n * Check if dark mode is currently active\n * @returns true if dark mode is active\n */\nexport function isDarkMode(): boolean {\n  // Check multiple sources for dark mode\n  // 1. Document root class\n  if (document.documentElement.classList.contains('dark-mode')) {\n    return true;\n  }\n\n  // 2. Check data attribute\n  if (document.documentElement.getAttribute('data-theme') === 'dark') {\n    return true;\n  }\n\n  // 3. Check prefers-color-scheme\n  if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {\n    return true;\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "src/pages/content/folder/gemConfig.ts",
    "content": "/**\n * Gemini Gem Configuration\n *\n * This file defines the mapping between Gem IDs and their visual representation.\n * Add new Gems here to support them in the folder system.\n *\n * Contributing:\n * - To add a new Gem, simply add a new entry to the GEM_CONFIG array\n * - The icon should be a valid Google Material Symbols icon name\n * - The id should match the URL slug used by Gemini (e.g., /gem/{id}/...)\n */\n\nexport interface GemConfig {\n  /** The Gem ID as it appears in URLs (e.g., 'learning-coach') */\n  id: string;\n\n  /** The display name of the Gem */\n  name: string;\n\n  /** The Google Material Symbols icon name */\n  icon: string;\n\n  /** Alternative icon names that might appear in the DOM */\n  aliases?: string[];\n}\n\n/**\n * Official Gemini Gems configuration\n *\n * Note: This list includes all known Gems as of the implementation date.\n * New Gems released by Google should be added here.\n */\nexport const GEM_CONFIG: GemConfig[] = [\n  {\n    id: 'learning-coach',\n    name: 'Learning Coach',\n    icon: 'auto_stories',\n  },\n  {\n    id: 'brainstormer',\n    name: 'Brainstorm Buddy',\n    icon: 'lightbulb',\n  },\n  {\n    id: 'career-guide',\n    name: 'Career Guide',\n    icon: 'work',\n  },\n  {\n    id: 'coding-partner',\n    name: 'Coding Partner',\n    icon: 'code',\n  },\n  {\n    id: 'writing-editor',\n    name: 'Writing Editor',\n    icon: 'edit_note',\n  },\n  {\n    id: 'storybook',\n    name: 'Storybook',\n    icon: 'menu_book',\n  },\n  {\n    id: 'chess-champ',\n    name: 'Chess Champ',\n    icon: 'chess_pawn',\n  },\n  {\n    id: 'productivity-helper',\n    name: 'Productivity Helper',\n    icon: 'check_circle',\n  },\n  {\n    id: 'cricket',\n    name: 'Cricket',\n    icon: 'sports_cricket',\n  },\n];\n\n/**\n * Default icon for unknown or custom Gems\n */\nexport const DEFAULT_GEM_ICON = 'stars';\n\n/**\n * Default icon for regular (non-Gem) conversations\n */\nexport const DEFAULT_CONVERSATION_ICON = 'chat_bubble';\n\n/**\n * Get Gem configuration by ID\n */\nexport function getGemConfig(gemId: string): GemConfig | undefined {\n  return GEM_CONFIG.find((gem) => gem.id === gemId);\n}\n\n/**\n * Get Gem ID from icon name (for reverse lookup)\n */\nexport function getGemIdFromIcon(iconName: string): string | undefined {\n  const gem = GEM_CONFIG.find((gem) => gem.icon === iconName);\n  return gem?.id;\n}\n\n/**\n * Get icon name for a Gem ID\n */\nexport function getGemIcon(gemId: string): string {\n  const config = getGemConfig(gemId);\n  return config?.icon || DEFAULT_GEM_ICON;\n}\n\n/**\n * Check if a Gem ID is known/configured\n */\nexport function isKnownGem(gemId: string): boolean {\n  return GEM_CONFIG.some((gem) => gem.id === gemId);\n}\n\n/**\n * Get all known Gem icons (useful for DOM searching)\n */\nexport function getAllGemIcons(): string[] {\n  return GEM_CONFIG.map((gem) => gem.icon);\n}\n\n/**\n * Create a mapping object from icon to Gem ID\n */\nexport function createIconToGemMap(): Record<string, string> {\n  const map: Record<string, string> = {};\n  GEM_CONFIG.forEach((gem) => {\n    map[gem.icon] = gem.id;\n  });\n  return map;\n}\n"
  },
  {
    "path": "src/pages/content/folder/index.ts",
    "content": "import { FolderManager } from './manager';\n\nexport async function startFolderManager(): Promise<FolderManager | null> {\n  try {\n    const manager = new FolderManager();\n    await manager.init();\n    return manager;\n  } catch (error) {\n    console.error('[FolderManager] Start error:', error);\n    return null;\n  }\n}\n"
  },
  {
    "path": "src/pages/content/folder/manager.ts",
    "content": "import browser from 'webextension-polyfill';\n\nimport {\n  type AccountScope,\n  accountIsolationService,\n  buildScopedFolderStorageKey,\n  detectAccountContextFromDocument,\n  extractRouteUserIdFromPath,\n} from '@/core/services/AccountIsolationService';\nimport { DataBackupService } from '@/core/services/DataBackupService';\nimport { getStorageMonitor } from '@/core/services/StorageMonitor';\nimport { StorageKeys } from '@/core/types/common';\nimport type { PromptItem, SyncAccountScope } from '@/core/types/sync';\nimport { isSafari } from '@/core/utils/browser';\nimport { isExtensionContextInvalidatedError } from '@/core/utils/extensionContext';\nimport { FolderImportExportService } from '@/features/folder/services/FolderImportExportService';\nimport type { ImportStrategy } from '@/features/folder/types/import-export';\nimport { getTranslationSync, getTranslationSyncUnsafe, initI18n } from '@/utils/i18n';\n\nimport { sortConversationsByPriority } from './conversationSort';\nimport { FOLDER_COLORS, getFolderColor, isDarkMode } from './folderColors';\nimport { DEFAULT_CONVERSATION_ICON, GEM_CONFIG, getGemIcon } from './gemConfig';\nimport { createMoveToFolderMenuItem } from './moveToFolderMenuItem';\nimport {\n  type IFolderStorageAdapter,\n  createFolderStorageAdapter,\n} from './storage/FolderStorageAdapter';\nimport type { ConversationReference, DragData, Folder, FolderData } from './types';\n\nconst STORAGE_KEY = 'gvFolderData';\nconst IS_DEBUG = false; // Set to true to enable debug logging\nconst ROOT_CONVERSATIONS_ID = '__root_conversations__'; // Special ID for root-level conversations\nconst NOTIFICATION_TIMEOUT_MS = 10000; // Duration to show data loss notification\nconst FOLDER_TREE_INDENT_MIN = -8;\nconst FOLDER_TREE_INDENT_MAX = 32;\nconst FOLDER_TREE_INDENT_DEFAULT = -8;\nconst FOLDER_NAME_SINGLE_CLICK_DELAY_MS = 220;\n\n// Export session backup keys for use by FolderImportExportService (deprecated, kept for compatibility)\nexport const SESSION_BACKUP_KEY = 'gvFolderBackup';\nexport const SESSION_BACKUP_TIMESTAMP_KEY = 'gvFolderBackupTimestamp';\n\nexport function clampFolderTreeIndent(value: unknown): number {\n  const numeric = typeof value === 'number' ? value : Number(value);\n  if (!Number.isFinite(numeric)) return FOLDER_TREE_INDENT_DEFAULT;\n  return Math.min(FOLDER_TREE_INDENT_MAX, Math.max(FOLDER_TREE_INDENT_MIN, Math.round(numeric)));\n}\n\nexport function calculateFolderHeaderPaddingLeft(level: number, indent: number): number {\n  return Math.max(0, level * indent + 8);\n}\n\nexport function calculateFolderConversationPaddingLeft(level: number, indent: number): number {\n  return Math.max(0, level * indent + 24);\n}\n\nexport function calculateFolderDialogPaddingLeft(level: number, indent: number): number {\n  return Math.max(0, level * indent + 12);\n}\n\n/**\n * Validate folder data structure\n */\nfunction validateFolderData(data: unknown): boolean {\n  if (typeof data !== 'object' || data === null) return false;\n  const d = data as Record<string, unknown>;\n  return Array.isArray(d.folders) && typeof d.folderContents === 'object';\n}\n\nexport class FolderManager {\n  private debug(...args: unknown[]): void {\n    if (this.isDebugEnabled()) {\n      console.log('[FolderManager]', ...args);\n    }\n  }\n\n  private debugWarn(...args: unknown[]): void {\n    if (this.isDebugEnabled()) {\n      console.warn('[FolderManager]', ...args);\n    }\n  }\n  private isDebugEnabled(): boolean {\n    try {\n      // Enable by setting localStorage.gvFolderDebug = '1'\n      return IS_DEBUG || localStorage.getItem('gvFolderDebug') === '1';\n    } catch {\n      // Ignore - localStorage may not be available in some contexts (e.g. incognito mode)\n      return IS_DEBUG;\n    }\n  }\n  private storage: IFolderStorageAdapter; // Storage adapter (Strategy Pattern)\n  private backupService: DataBackupService<FolderData>; // Multi-layer backup system\n  private data: FolderData = { folders: [], folderContents: {} };\n  private containerElement: HTMLElement | null = null;\n  private sidebarContainer: HTMLElement | null = null;\n  private recentSection: HTMLElement | null = null;\n  private tooltipElement: HTMLElement | null = null;\n  private tooltipTimeout: number | null = null;\n  private sideNavObserver: MutationObserver | null = null;\n  private conversationObserver: MutationObserver | null = null; // Observer for conversation additions/removals\n  private importInProgress: boolean = false; // Lock to prevent concurrent imports\n  private exportInProgress: boolean = false; // Lock to prevent concurrent exports\n  private selectedConversations: Set<string> = new Set(); // For multi-select support\n  private isMultiSelectMode: boolean = false; // Multi-select mode state\n  private multiSelectSource: 'folder' | 'native' | null = null; // Track where multi-select was initiated\n  private multiSelectFolderId: string | null = null; // Track which folder multi-select was initiated from\n  private longPressTimeout: number | null = null; // For long-press detection\n  private folderNameClickTimeout: number | null = null; // Distinguish single-click toggle from double-click rename\n  private longPressThreshold: number = 500; // Long-press duration in ms\n  private folderEnabled: boolean = true; // Whether folder feature is enabled\n  private hideArchivedConversations: boolean = false; // Whether to hide conversations in folders\n  private folderTreeIndent: number = FOLDER_TREE_INDENT_DEFAULT; // Tree indentation width (px)\n  private filterCurrentUserOnly: boolean = false; // Whether to show only current user's conversations\n  private accountIsolationEnabled: boolean = false; // Whether hard account isolation is enabled\n  private accountScope: AccountScope | null = null; // Resolved account scope for current page\n  private activeStorageKey: string = STORAGE_KEY; // Storage key currently used for folder data\n  private navPoller: number | null = null;\n  private lastPathname: string | null = null;\n  private saveInProgress: boolean = false; // Lock to prevent concurrent saves\n  private pendingTitleUpdates: Map<string, string> = new Map(); // Buffer title updates during render\n  private pendingRemovals: Map<string, number> = new Map(); // Pending conversation removals with timer IDs\n  private removalCheckDelay: number = 300; // Delay (ms) before confirming conversation deletion\n  private isDestroyed: boolean = false; // Flag to prevent callbacks after destruction\n  private reinitializePromise: Promise<void> | null = null; // Prevent duplicate reinitialization cascades\n  private activeColorPicker: HTMLElement | null = null; // Currently open color picker dialog\n  private activeColorPickerFolderId: string | null = null; // Folder ID of currently open color picker\n  private activeColorPickerCloseHandler: ((e: MouseEvent) => void) | null = null; // Event handler for closing color picker\n\n  // Cleanup references\n  private routeChangeCleanup: (() => void) | null = null;\n  private sidebarClickListener: ((e: Event) => void) | null = null;\n  private nativeMenuObserver: MutationObserver | null = null;\n  private outsideClickHandler: ((e: MouseEvent) => void) | null = null; // For exiting multi-select on outside click\n\n  // Batch delete related properties\n  private readonly MAX_BATCH_DELETE_COUNT = 50; // Maximum number of conversations to delete at once\n  private batchDeleteInProgress = false; // Lock to prevent concurrent batch deletes\n  private batchDeleteProgressElement: HTMLElement | null = null; // Progress indicator element\n\n  // Batch delete timing configuration (in milliseconds)\n  private readonly BATCH_DELETE_CONFIG = {\n    DELAY_BETWEEN_DELETIONS: 500, // Delay between each deletion to avoid rate limiting\n    MENU_APPEAR_DELAY: 300, // Wait for context menu to appear after clicking \"more\" button\n    DIALOG_APPEAR_DELAY: 300, // Wait for confirmation dialog to appear\n    DELETION_COMPLETE_DELAY: 500, // Wait for deletion animation/API call to complete\n    MAX_BUTTON_WAIT_TIME: 3000, // Maximum time to wait for delete/confirm button to appear\n    BUTTON_CHECK_INTERVAL: 100, // Interval for polling button appearance\n    PAGE_REFRESH_DELAY: 1500, // Delay before refreshing page after batch delete\n  } as const;\n\n  private cleanupTasks: (() => void)[] = [];\n\n  constructor() {\n    // Create storage adapter based on browser (Factory Pattern)\n    this.storage = createFolderStorageAdapter();\n    this.debug(`Using storage backend: ${this.storage.getBackendName()}`);\n\n    // Initialize backup service with localStorage\n    this.backupService = new DataBackupService<FolderData>('gemini-folders', validateFolderData);\n\n    // Note: Data loading moved to init() for async support\n    // This allows Safari to use async browser.storage API\n    this.createTooltip();\n\n    // Initialize i18n system\n    initI18n().catch((e) => {\n      this.debugWarn('Failed to initialize i18n:', e);\n    });\n  }\n\n  async init(): Promise<void> {\n    try {\n      // Initialize storage adapter (handles migration for Safari automatically)\n      await this.storage.init(STORAGE_KEY);\n\n      // Setup automatic backup before page unload\n      this.backupService.setupBeforeUnloadBackup(() => this.data);\n\n      // Initialize storage quota monitor\n      const storageMonitor = getStorageMonitor({\n        checkIntervalMs: 60000, // Check every minute for Gemini (more active)\n      });\n\n      // Use custom notification callback to match our style\n      storageMonitor.setNotificationCallback((message, level) => {\n        this.showNotificationByLevel(message, level);\n      });\n\n      // Start monitoring\n      storageMonitor.startMonitoring();\n\n      // Load account isolation setting/scope before reading folder data.\n      await this.loadAccountIsolationSetting();\n      await this.refreshAccountScope();\n\n      // Load folder data (async, works for both Safari and non-Safari)\n      await this.loadData();\n\n      // Load folder enabled setting\n      await this.loadFolderEnabledSetting();\n\n      // Load hide archived setting\n      await this.loadHideArchivedSetting();\n\n      // Load filter user setting\n      await this.loadFilterUserSetting();\n      await this.loadFolderTreeIndentSetting();\n\n      // Set up storage change listener (always needed to respond to setting changes)\n      this.setupStorageListener();\n\n      // Set up message listener (for popup communication)\n      this.setupMessageListener();\n\n      // If folder feature is disabled, skip initialization\n      if (!this.folderEnabled) {\n        this.debug('Folder feature is disabled, skipping initialization');\n        return;\n      }\n\n      // Initialize folder UI\n      await this.initializeFolderUI();\n\n      this.debug('Initialized successfully');\n    } catch (error) {\n      if (isExtensionContextInvalidatedError(error)) {\n        return;\n      }\n      console.error('[FolderManager] Initialization error:', error);\n    }\n  }\n\n  /**\n   * Cleanup method to prevent memory leaks\n   * Clears all pending deletion timers and observers\n   */\n  destroy(): void {\n    this.debug('Destroying FolderManager - cleaning up resources');\n    this.isDestroyed = true;\n\n    // Clear all pending removal timers\n    let clearedCount = 0;\n    this.pendingRemovals.forEach((timerId, conversationId) => {\n      clearTimeout(timerId);\n      clearedCount++;\n      this.debug(`Cleared pending removal timer for ${conversationId}`);\n    });\n    this.pendingRemovals.clear();\n\n    if (clearedCount > 0) {\n      this.debug(`Cleared ${clearedCount} pending removal timer(s)`);\n    }\n\n    // Clear other timers\n    if (this.longPressTimeout) {\n      clearTimeout(this.longPressTimeout);\n      this.longPressTimeout = null;\n    }\n\n    if (this.folderNameClickTimeout !== null) {\n      clearTimeout(this.folderNameClickTimeout);\n      this.folderNameClickTimeout = null;\n    }\n\n    if (this.tooltipTimeout) {\n      clearTimeout(this.tooltipTimeout);\n      this.tooltipTimeout = null;\n    }\n\n    if (this.navPoller) {\n      clearInterval(this.navPoller);\n      this.navPoller = null;\n    }\n\n    // Disconnect mutation observers\n    if (this.sideNavObserver) {\n      this.sideNavObserver.disconnect();\n      this.sideNavObserver = null;\n    }\n\n    if (this.conversationObserver) {\n      this.conversationObserver.disconnect();\n      this.conversationObserver = null;\n    }\n\n    if (this.nativeMenuObserver) {\n      this.nativeMenuObserver.disconnect();\n      this.nativeMenuObserver = null;\n    }\n\n    // Remove event listeners\n    if (this.routeChangeCleanup) {\n      this.routeChangeCleanup();\n      this.routeChangeCleanup = null;\n    }\n\n    if (this.sidebarClickListener && this.sidebarContainer) {\n      try {\n        this.sidebarContainer.removeEventListener('click', this.sidebarClickListener, true);\n      } catch {\n        // Ignore\n      }\n      this.sidebarClickListener = null;\n    }\n\n    // Remove outside click handler for multi-select\n    this.removeOutsideClickHandler();\n\n    // Remove tooltip\n    if (this.tooltipElement) {\n      this.tooltipElement.remove();\n      this.tooltipElement = null;\n    }\n\n    // Remove active color picker\n    if (this.activeColorPicker) {\n      this.activeColorPicker.remove();\n      if (this.activeColorPickerCloseHandler) {\n        document.removeEventListener('click', this.activeColorPickerCloseHandler);\n        this.activeColorPickerCloseHandler = null;\n      }\n      this.activeColorPicker = null;\n      this.activeColorPickerFolderId = null;\n    }\n\n    // Remove container\n    if (this.containerElement) {\n      this.containerElement.remove();\n      this.containerElement = null;\n    }\n\n    // Execute custom cleanup tasks\n    this.cleanupTasks.forEach((task) => task());\n    this.cleanupTasks = [];\n\n    this.debug('Cleanup complete');\n  }\n\n  private addCleanupTask(task: () => void): void {\n    this.cleanupTasks.push(task);\n  }\n\n  private async initializeFolderUI(): Promise<void> {\n    // Wait for sidebar to be available\n    await this.waitForSidebar();\n\n    // Find the Recent section\n    this.findRecentSection();\n\n    if (!this.recentSection) {\n      this.debugWarn('Could not find Recent section');\n      return;\n    }\n\n    // Create and inject folder UI\n    this.createFolderUI();\n\n    // Make conversations draggable\n    this.makeConversationsDraggable();\n\n    // Set up mutation observer to handle dynamically added conversations\n    this.setupMutationObserver();\n\n    // Set up sidebar visibility observer\n    this.setupSideNavObserver();\n\n    // Initial visibility check\n    this.updateVisibilityBasedOnSideNav();\n\n    // Set up native conversation menu injection\n    this.setupConversationClickTracking();\n    this.setupNativeConversationMenuObserver();\n\n    // ─── DOM recovery (resize / print) ─────────────────────────────────────\n    // Gemini may re-render the sidebar DOM during window resize or\n    // window.print(), detaching the folder container.  The sideNavObserver\n    // (watching `side-nav-open` on #app-root) CANNOT catch all cases because\n    // when the sidebar closes AND the DOM is rebuilt simultaneously, the\n    // observer fires with isSideNavOpen=false and skips reinitialization.\n    // A debounced resize listener provides a reliable fallback.\n    let domRecoveryTimer: ReturnType<typeof setTimeout> | null = null;\n\n    const domRecoveryCheck = () => {\n      if (domRecoveryTimer !== null) clearTimeout(domRecoveryTimer);\n      domRecoveryTimer = setTimeout(() => {\n        domRecoveryTimer = null;\n        if (this.isDestroyed) return;\n        if (\n          this.containerElement &&\n          document.body.contains(this.containerElement) &&\n          this.sidebarContainer &&\n          document.body.contains(this.sidebarContainer)\n        ) {\n          return; // Everything still attached – nothing to do.\n        }\n        // Only reinitialize if the sidebar is currently visible (open).\n        // If it is closed, the sideNavObserver will trigger reinitialization\n        // when it reopens.\n        const appRoot = document.querySelector('#app-root');\n        if (appRoot && !appRoot.classList.contains('side-nav-open')) {\n          this.debug('DOM recovery: container lost but sidebar closed, deferring');\n          return;\n        }\n        this.debug('DOM recovery: folder UI lost from DOM, reinitializing');\n        this.reinitializeFolderUI();\n      }, 800);\n    };\n\n    window.addEventListener('resize', domRecoveryCheck);\n    window.addEventListener('gv-print-cleanup', domRecoveryCheck);\n    window.addEventListener('afterprint', domRecoveryCheck);\n\n    this.addCleanupTask(() => {\n      if (domRecoveryTimer !== null) clearTimeout(domRecoveryTimer);\n      window.removeEventListener('resize', domRecoveryCheck);\n      window.removeEventListener('gv-print-cleanup', domRecoveryCheck);\n      window.removeEventListener('afterprint', domRecoveryCheck);\n    });\n  }\n\n  private async waitForSidebar(): Promise<void> {\n    return new Promise((resolve) => {\n      const checkSidebar = () => {\n        // Look for the overflow-container which holds the sidebar content\n        const container = document.querySelector('[data-test-id=\"overflow-container\"]');\n        if (container) {\n          this.sidebarContainer = container as HTMLElement;\n          resolve();\n        } else {\n          setTimeout(checkSidebar, 500);\n        }\n      };\n      checkSidebar();\n    });\n  }\n\n  private findRecentSection(): void {\n    if (!this.sidebarContainer) return;\n\n    // Find conversations-list (Recent section) by looking for the conversations container\n    // Try multiple selectors to find the Recent section\n    let conversationsList = this.sidebarContainer.querySelector(\n      '[data-test-id=\"all-conversations\"]',\n    );\n\n    if (!conversationsList) {\n      // Fallback: find by class name\n      conversationsList = this.sidebarContainer.querySelector('.chat-history');\n    }\n\n    if (!conversationsList) {\n      // Fallback: find the element that contains conversation items\n      const conversationItems = this.sidebarContainer.querySelectorAll(\n        '[data-test-id=\"conversation\"]',\n      );\n      if (conversationItems.length > 0) {\n        // Find the parent that contains these conversations\n        conversationsList = conversationItems[0].closest('.chat-history, [class*=\"conversation\"]');\n      }\n    }\n\n    if (conversationsList) {\n      this.recentSection = conversationsList as HTMLElement;\n    } else {\n      this.debugWarn('Could not find Recent section - will retry');\n      // Retry after a delay\n      setTimeout(() => {\n        this.findRecentSection();\n        if (this.recentSection && !this.containerElement) {\n          this.createFolderUI();\n          this.makeConversationsDraggable();\n          this.setupMutationObserver();\n        }\n      }, 2000);\n    }\n  }\n\n  private createFolderUI(): void {\n    if (!this.recentSection) return;\n\n    // Create folder container\n    this.containerElement = document.createElement('div');\n    this.containerElement.className = 'gv-folder-container';\n\n    // Create multi-select mode indicator\n    const indicator = this.createMultiSelectIndicator();\n    this.containerElement.appendChild(indicator);\n\n    // Create header\n    const header = this.createHeader();\n    this.containerElement.appendChild(header);\n\n    // Create folders list\n    const foldersList = this.createFoldersList();\n    this.containerElement.appendChild(foldersList);\n\n    // Insert before Recent section\n    this.recentSection.parentElement?.insertBefore(this.containerElement, this.recentSection);\n\n    // Initial active conversation highlight and route listeners\n    this.highlightActiveConversationInFolders();\n    this.installRouteChangeListener();\n    this.installSidebarClickListener();\n\n    // Apply initial folder enabled setting\n    this.applyFolderEnabledSetting();\n  }\n\n  private createMultiSelectIndicator(): HTMLElement {\n    const indicator = document.createElement('div');\n    indicator.className = 'gv-multi-select-indicator';\n    indicator.dataset.multiSelectIndicator = 'true';\n\n    // Apply floating styles\n    Object.assign(indicator.style, {\n      position: 'fixed',\n      bottom: '24px',\n      left: '50%',\n      transform: 'translateX(-50%)',\n      zIndex: '9999', // Ensure it's above everything\n      boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',\n      cursor: 'move', // Indicate it's draggable\n      transition: 'opacity 0.2s ease, transform 0.1s ease', // Only animate non-position props for performance\n      // Prevent text selection while dragging\n      userSelect: 'none',\n      // Ensure it has a background so IT covers content behind it\n      backgroundColor: 'var(--gem-sys-color-surface-container, #f0f4f9)', // Fallback color\n      borderRadius: '24px',\n      padding: '8px 16px',\n      alignItems: 'center',\n      gap: '12px',\n      border: '1px solid var(--gem-sys-color-outline-variant, rgba(0,0,0,0.1))',\n    });\n\n    // --- Draggable Logic Start ---\n    let isDragging = false;\n    let currentX: number;\n    let currentY: number;\n    let initialX: number;\n    let initialY: number;\n    let xOffset = 0;\n    let yOffset = 0;\n\n    const dragStart = (e: MouseEvent) => {\n      // Ignore if clicking buttons inside the indicator\n      if ((e.target as HTMLElement).closest('button')) return;\n\n      initialX = e.clientX - xOffset;\n      initialY = e.clientY - yOffset;\n\n      if (e.target === indicator || indicator.contains(e.target as Node)) {\n        isDragging = true;\n        indicator.style.cursor = 'grabbing';\n      }\n    };\n\n    const dragEnd = () => {\n      isDragging = false;\n      indicator.style.cursor = 'move';\n    };\n\n    const drag = (e: MouseEvent) => {\n      if (isDragging) {\n        e.preventDefault();\n        currentX = e.clientX - initialX;\n        currentY = e.clientY - initialY;\n\n        xOffset = currentX;\n        yOffset = currentY;\n\n        setTranslate(currentX, currentY, indicator);\n      }\n    };\n\n    const setTranslate = (xPos: number, yPos: number, el: HTMLElement) => {\n      el.style.transform = `translate3d(calc(-50% + ${xPos}px), ${yPos}px, 0)`;\n    };\n\n    indicator.addEventListener('mousedown', dragStart);\n    document.addEventListener('mousemove', drag);\n    document.addEventListener('mouseup', dragEnd);\n\n    // Cleanup listeners when destroyed (adding to a cleanup list if possible, or attaching to element)\n    // Since we attach to document, we MUST clean this up in destroy()\n    // We'll wrap these in a cleanup function and store it\n    this.addCleanupTask(() => {\n      indicator.removeEventListener('mousedown', dragStart);\n      document.removeEventListener('mousemove', drag);\n      document.removeEventListener('mouseup', dragEnd);\n    });\n    // --- Draggable Logic End ---\n\n    const content = document.createElement('div');\n    content.className = 'gv-multi-select-indicator-content';\n    // Ensure content (text/icon) doesn't capture drag events aggressively\n    content.style.pointerEvents = 'none';\n\n    const icon = document.createElement('mat-icon');\n    icon.className = 'mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color';\n    icon.setAttribute('role', 'img');\n    icon.setAttribute('aria-hidden', 'true');\n    icon.textContent = 'check_circle';\n\n    const text = document.createElement('span');\n    text.className = 'gv-multi-select-indicator-text';\n    text.textContent = '0 selected';\n    text.dataset.selectionCount = 'true';\n\n    content.appendChild(icon);\n    content.appendChild(text);\n    indicator.appendChild(content);\n\n    // Actions container (will be populated dynamically)\n    const actionsContainer = document.createElement('div');\n    actionsContainer.className = 'gv-multi-select-actions';\n    actionsContainer.dataset.multiSelectActions = 'true';\n    // Re-enable pointer events for buttons\n    actionsContainer.style.pointerEvents = 'auto';\n    indicator.appendChild(actionsContainer);\n\n    return indicator;\n  }\n\n  private createHeader(): HTMLElement {\n    const header = document.createElement('div');\n    header.className = 'gv-folder-header';\n\n    // Match the style of Recent section title\n    const titleContainer = document.createElement('div');\n    titleContainer.className = 'title-container';\n\n    const title = document.createElement('h1');\n    title.className = 'title gds-label-l'; // Match Recent section style\n    title.textContent = this.t('folder_title');\n    title.style.visibility = 'visible';\n\n    titleContainer.appendChild(title);\n\n    // Actions container for buttons\n    const actionsContainer = document.createElement('div');\n    actionsContainer.className = 'gv-folder-header-actions';\n\n    // Filter current user button\n    const filterUserButton = document.createElement('button');\n    filterUserButton.className = 'gv-folder-action-btn';\n    filterUserButton.innerHTML = `<mat-icon role=\"img\" class=\"mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color\" aria-hidden=\"true\">person</mat-icon>`;\n    filterUserButton.title = this.t('folder_filter_current_user');\n    // Apply active state if filter is enabled\n    if (this.filterCurrentUserOnly) {\n      filterUserButton.classList.add('gv-filter-active');\n    }\n    filterUserButton.addEventListener('click', () => this.toggleFilterCurrentUser());\n\n    // Import/Export combined button (shows dropdown menu)\n    const importExportButton = document.createElement('button');\n    importExportButton.className = 'gv-folder-action-btn';\n    importExportButton.innerHTML = `<mat-icon role=\"img\" class=\"mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color\" aria-hidden=\"true\">folder_managed</mat-icon>`;\n    importExportButton.title = this.t('folder_import_export');\n    importExportButton.addEventListener('click', (e) => this.showImportExportMenu(e));\n\n    actionsContainer.appendChild(filterUserButton);\n    actionsContainer.appendChild(importExportButton);\n\n    // Cloud buttons (Skip on Safari as it doesn't support cloud sync yet)\n    if (!isSafari()) {\n      // Cloud upload button\n      const cloudUploadButton = document.createElement('button');\n      cloudUploadButton.className = 'gv-folder-action-btn';\n      cloudUploadButton.innerHTML = `<svg xmlns=\"http://www.w3.org/2000/svg\" height=\"20px\" viewBox=\"0 -960 960 960\" width=\"20px\" fill=\"currentColor\"><path d=\"M260-160q-91 0-155.5-63T40-377q0-78 47-139t123-78q25-92 100-149t170-57q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 75-52.5 127.5T740-160H520q-33 0-56.5-23.5T440-240v-206l-64 62-56-56 160-160 160 160-56 56-64-62v206h220q42 0 71-29t29-71q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480-720q-83 0-141.5 58.5T280-520h-20q-58 0-99 41t-41 99q0 58 41 99t99 41h100v80H260Zm220-280Z\"/></svg>`;\n      cloudUploadButton.title = this.t('folder_cloud_upload');\n      cloudUploadButton.addEventListener('click', () => this.handleCloudUpload());\n      // Add dynamic tooltip on mouseenter\n      cloudUploadButton.addEventListener('mouseenter', async () => {\n        const tooltip = await this.getCloudUploadTooltip();\n        cloudUploadButton.title = tooltip;\n      });\n      actionsContainer.appendChild(cloudUploadButton);\n\n      // Cloud sync button\n      const cloudSyncButton = document.createElement('button');\n      cloudSyncButton.className = 'gv-folder-action-btn';\n      cloudSyncButton.innerHTML = `<svg xmlns=\"http://www.w3.org/2000/svg\" height=\"20px\" viewBox=\"0 -960 960 960\" width=\"20px\" fill=\"currentColor\"><path d=\"M260-160q-91 0-155.5-63T40-377q0-78 47-139t123-78q17-72 85-137t145-65q33 0 56.5 23.5T520-716v242l64-62 56 56-160 160-160-160 56-56 64 62v-242q-76 14-118 73.5T280-520h-20q-58 0-99 41t-41 99q0 58 41 99t99 41h480q42 0 71-29t29-71q0-42-29-71t-71-29h-60v-80q0-48-22-89.5T600-680v-93q74 35 117 103.5T760-520q69 8 114.5 59.5T920-340q0 75-52.5 127.5T740-160H260Zm220-358Z\"/></svg>`;\n      cloudSyncButton.title = this.t('folder_cloud_sync');\n      cloudSyncButton.addEventListener('click', () => this.handleCloudSync());\n      // Add dynamic tooltip on mouseenter\n      cloudSyncButton.addEventListener('mouseenter', async () => {\n        const tooltip = await this.getCloudSyncTooltip();\n        cloudSyncButton.title = tooltip;\n      });\n      actionsContainer.appendChild(cloudSyncButton);\n    }\n\n    // Add folder button\n    const addButton = document.createElement('button');\n    addButton.className = 'gv-folder-add-btn';\n    addButton.innerHTML = `<mat-icon role=\"img\" class=\"mat-icon notranslate gds-icon-l google-symbols mat-ligature-font mat-icon-no-color\" aria-hidden=\"true\">add</mat-icon>`;\n    addButton.title = this.t('folder_create');\n    addButton.addEventListener('click', () => this.createFolder());\n\n    actionsContainer.appendChild(addButton);\n\n    header.appendChild(titleContainer);\n    header.appendChild(actionsContainer);\n\n    // Setup root drop zone on header\n    this.setupRootDropZone(header);\n\n    return header;\n  }\n\n  private createFoldersList(): HTMLElement {\n    const list = document.createElement('div');\n    list.className = 'gv-folder-list';\n\n    // Setup root-level drop zone for dragging folders and conversations to root\n    this.setupRootDropZone(list);\n\n    // Render root-level conversations (favorites/pinned conversations)\n    const rootConversations = this.data.folderContents[ROOT_CONVERSATIONS_ID] || [];\n    const filteredRootConversations = this.filterConversationsByCurrentUser(rootConversations);\n    if (filteredRootConversations.length > 0) {\n      const sortedRootConversations = this.sortConversations(filteredRootConversations);\n      sortedRootConversations.forEach((conv, i) => {\n        const convEl = this.createConversationElement(conv, ROOT_CONVERSATIONS_ID, 0);\n        this.setupConversationReorderZone(convEl, ROOT_CONVERSATIONS_ID, i);\n        list.appendChild(convEl);\n      });\n    }\n\n    // Render root level folders (sorted)\n    const rootFolders = this.data.folders.filter((f) => f.parentId === null);\n    const sortedRootFolders = this.sortFolders(rootFolders);\n    let rootFolderIndex = 0;\n    list.appendChild(this.createReorderGap('__root__', 'folder', 0));\n    sortedRootFolders.forEach((folder) => {\n      // Filter out empty folders if \"Show current user only\" is enabled\n      if (!this.hasVisibleContent(folder.id)) return;\n\n      const folderElement = this.createFolderElement(folder);\n      list.appendChild(folderElement);\n      rootFolderIndex++;\n      list.appendChild(this.createReorderGap('__root__', 'folder', rootFolderIndex));\n    });\n\n    // If no folders and no root conversations, show empty state placeholder\n    if (rootFolders.length === 0 && rootConversations.length === 0) {\n      const emptyState = document.createElement('div');\n      emptyState.className = 'gv-folder-empty';\n      emptyState.textContent = this.t('folder_empty');\n      list.appendChild(emptyState);\n    }\n\n    return list;\n  }\n\n  private createFolderElement(folder: Folder, level = 0): HTMLElement {\n    const folderEl = document.createElement('div');\n    folderEl.className = 'gv-folder-item';\n    folderEl.dataset.folderId = folder.id;\n    folderEl.dataset.level = level.toString();\n\n    // Folder header\n    const folderHeader = document.createElement('div');\n    folderHeader.className = 'gv-folder-item-header';\n    folderHeader.style.paddingLeft = `${calculateFolderHeaderPaddingLeft(level, this.folderTreeIndent)}px`;\n\n    // Expand/collapse button\n    const expandBtn = document.createElement('button');\n    expandBtn.className = 'gv-folder-expand-btn';\n    expandBtn.innerHTML = folder.isExpanded\n      ? '<span class=\"google-symbols\">expand_more</span>'\n      : '<span class=\"google-symbols\">chevron_right</span>';\n    expandBtn.addEventListener('click', () => this.toggleFolder(folder.id));\n\n    // Folder icon\n    const folderIcon = document.createElement('span');\n    folderIcon.className = 'gv-folder-icon google-symbols';\n    folderIcon.textContent = 'folder';\n    folderIcon.style.cursor = 'pointer';\n    folderIcon.style.userSelect = 'none';\n\n    // Apply folder color if set\n    if (folder.color && folder.color !== 'default') {\n      const colorValue = getFolderColor(folder.color, isDarkMode());\n      folderIcon.style.color = colorValue;\n    }\n\n    folderIcon.addEventListener('click', (e) => {\n      e.stopPropagation(); // Prevent bubbling issues\n      this.showColorPicker(folder.id, e, true); // Allow toggle behavior\n    });\n\n    // Folder name\n    const folderName = document.createElement('span');\n    folderName.className = 'gv-folder-name gds-label-l';\n    folderName.textContent = folder.name;\n    folderName.style.cursor = 'pointer';\n    folderName.addEventListener('click', (event) => this.handleFolderNameClick(folder.id, event));\n    folderName.addEventListener('dblclick', () => this.handleFolderNameDoubleClick(folder.id));\n\n    // Add tooltip event listeners\n    folderName.addEventListener('mouseenter', () => this.showTooltip(folderName, folder.name));\n    folderName.addEventListener('mouseleave', () => this.hideTooltip());\n\n    // Pin button\n    const pinBtn = document.createElement('button');\n    pinBtn.className = 'gv-folder-pin-btn';\n    const pinIcon = document.createElement('span');\n    pinIcon.className = 'google-symbols';\n    pinIcon.textContent = 'push_pin';\n    // Add filled style for pinned folders\n    if (folder.pinned) {\n      pinIcon.style.fontVariationSettings = \"'FILL' 1\";\n    }\n    pinBtn.appendChild(pinIcon);\n    pinBtn.title = folder.pinned ? this.t('folder_unpin') : this.t('folder_pin');\n    pinBtn.addEventListener('click', (e) => {\n      e.stopPropagation();\n      this.togglePinFolder(folder.id);\n    });\n\n    // Actions menu\n    const actionsBtn = document.createElement('button');\n    actionsBtn.className = 'gv-folder-actions-btn';\n    actionsBtn.innerHTML = '<span class=\"google-symbols\">more_vert</span>';\n    actionsBtn.addEventListener('click', (e) => this.showFolderMenu(e, folder.id));\n\n    folderHeader.appendChild(expandBtn);\n    folderHeader.appendChild(folderIcon);\n    folderHeader.appendChild(folderName);\n    folderHeader.appendChild(pinBtn);\n    folderHeader.appendChild(actionsBtn);\n\n    // Setup drop zone for conversations and folders\n    this.setupDropZone(folderHeader, folder.id);\n\n    folderEl.appendChild(folderHeader);\n\n    // Apply draggable behavior dynamically based on current state\n    // This ensures draggability is always in sync with folder structure\n    this.applyFolderDraggableBehavior(folderHeader, folder);\n\n    // Folder content (conversations and subfolders)\n    if (folder.isExpanded) {\n      const content = document.createElement('div');\n      content.className = 'gv-folder-content';\n      // Fix: Allow dropping into the content area of the folder (not just the header)\n      this.setupDropZone(content, folder.id);\n\n      // Render conversations in this folder (sorted: starred first)\n      const conversations = this.data.folderContents[folder.id] || [];\n      const filteredConversations = this.filterConversationsByCurrentUser(conversations);\n      const sortedConversations = this.sortConversations(filteredConversations);\n      sortedConversations.forEach((conv, i) => {\n        const convEl = this.createConversationElement(conv, folder.id, level + 1);\n        this.setupConversationReorderZone(convEl, folder.id, i);\n        content.appendChild(convEl);\n      });\n\n      // Render subfolders (sorted)\n      const subfolders = this.data.folders.filter((f) => f.parentId === folder.id);\n      const sortedSubfolders = this.sortFolders(subfolders);\n      let subfolderIndex = 0;\n      if (sortedSubfolders.length > 0) {\n        content.appendChild(this.createReorderGap(folder.id, 'folder', 0));\n      }\n      sortedSubfolders.forEach((subfolder) => {\n        // Filter out empty folders if \"Show current user only\" is enabled\n        if (!this.hasVisibleContent(subfolder.id)) return;\n\n        const subfolderEl = this.createFolderElement(subfolder, level + 1);\n        content.appendChild(subfolderEl);\n        subfolderIndex++;\n        content.appendChild(this.createReorderGap(folder.id, 'folder', subfolderIndex));\n      });\n\n      folderEl.appendChild(content);\n    }\n\n    return folderEl;\n  }\n\n  private clearPendingFolderNameClick(): void {\n    if (this.folderNameClickTimeout === null) return;\n    clearTimeout(this.folderNameClickTimeout);\n    this.folderNameClickTimeout = null;\n  }\n\n  private handleFolderNameClick(folderId: string, event: MouseEvent): void {\n    // Double-click dispatches a second click with detail > 1; skip toggle for that sequence.\n    if (event.detail > 1) {\n      this.clearPendingFolderNameClick();\n      return;\n    }\n\n    this.clearPendingFolderNameClick();\n    this.folderNameClickTimeout = window.setTimeout(() => {\n      this.folderNameClickTimeout = null;\n      this.toggleFolder(folderId);\n    }, FOLDER_NAME_SINGLE_CLICK_DELAY_MS);\n  }\n\n  private handleFolderNameDoubleClick(folderId: string): void {\n    this.clearPendingFolderNameClick();\n    this.renameFolder(folderId);\n  }\n\n  private createConversationElement(\n    conv: ConversationReference,\n    folderId: string,\n    level: number,\n  ): HTMLElement {\n    const convEl = document.createElement('div');\n    convEl.className = conv.starred\n      ? 'gv-folder-conversation gv-starred'\n      : 'gv-folder-conversation';\n    convEl.dataset.conversationId = conv.conversationId;\n    convEl.dataset.folderId = folderId;\n    // Increase indentation for conversations under folders\n    convEl.style.paddingLeft = `${calculateFolderConversationPaddingLeft(level, this.folderTreeIndent)}px`; // More indentation for tree structure\n\n    // Try to sync title from native conversation\n    // Decide what title to display, respecting manual renames and hidden native list\n    let displayTitle = conv.title;\n    if (!conv.customTitle && !this.hideArchivedConversations) {\n      const syncedTitle = this.syncConversationTitleFromNative(conv.conversationId);\n      if (syncedTitle && syncedTitle !== conv.title) {\n        conv.title = syncedTitle;\n        displayTitle = syncedTitle;\n        // Buffer title updates during render to avoid multiple rapid saves\n        this.pendingTitleUpdates.set(conv.conversationId, syncedTitle);\n        this.debug('Buffered title update for:', conv.conversationId);\n      }\n    }\n\n    // Make conversation draggable within folders\n    convEl.draggable = true;\n    convEl.addEventListener('dragstart', (e) => {\n      e.stopPropagation();\n\n      // If this conversation is not selected, select it exclusively\n      if (!this.selectedConversations.has(conv.conversationId)) {\n        this.clearSelection();\n        this.selectConversation(conv.conversationId);\n        this.updateConversationSelectionUI();\n      }\n\n      // Cancel long press if drag starts\n      if (this.longPressTimeout) {\n        clearTimeout(this.longPressTimeout);\n        this.longPressTimeout = null;\n      }\n\n      // Include all selected conversations in the drag data\n      const selectedConvs = this.getSelectedConversationsData(folderId);\n      const dragData = {\n        type: 'conversation',\n        conversations: selectedConvs,\n        sourceFolderId: folderId, // Track where they're being dragged from\n      };\n      e.dataTransfer!.effectAllowed = 'move';\n      e.dataTransfer!.setData('application/json', JSON.stringify(dragData));\n\n      // Apply opacity to all selected conversations\n      this.selectedConversations.forEach((id) => {\n        const el = this.containerElement?.querySelector(\n          `[data-conversation-id=\"${id}\"]`,\n        ) as HTMLElement;\n        if (el) el.style.opacity = '0.5';\n      });\n    });\n\n    convEl.addEventListener('dragend', () => {\n      // Restore opacity for all selected conversations\n      this.selectedConversations.forEach((id) => {\n        const el = this.containerElement?.querySelector(\n          `[data-conversation-id=\"${id}\"]`,\n        ) as HTMLElement;\n        if (el) el.style.opacity = '1';\n      });\n\n      // If we are not in multi-select mode, clear the temporary selection\n      if (!this.isMultiSelectMode) {\n        this.clearSelection();\n        this.cleanupSelectionArtifacts();\n      }\n    });\n\n    // Conversation icon - use Gem-specific icons\n    const icon = document.createElement('mat-icon');\n    icon.className =\n      'mat-icon notranslate gv-conversation-icon google-symbols mat-ligature-font mat-icon-no-color';\n    icon.setAttribute('role', 'img');\n    icon.setAttribute('aria-hidden', 'true');\n\n    // Set icon based on conversation type\n    let iconName = DEFAULT_CONVERSATION_ICON;\n    if (conv.isGem && conv.gemId) {\n      iconName = getGemIcon(conv.gemId);\n    }\n    icon.setAttribute('fonticon', iconName);\n    icon.textContent = iconName;\n\n    // Conversation title\n    const title = document.createElement('span');\n    title.className = 'gv-conversation-title gds-label-l';\n    title.textContent = displayTitle;\n\n    // Add tooltip event listeners\n    title.addEventListener('mouseenter', () => this.showTooltip(title, displayTitle));\n    title.addEventListener('mouseleave', () => this.hideTooltip());\n\n    // Actions container for buttons\n    const actionsContainer = document.createElement('div');\n    actionsContainer.className = 'gv-conversation-actions';\n\n    // Star button\n    const starBtn = document.createElement('button');\n    starBtn.className = conv.starred\n      ? 'gv-conversation-star-btn starred'\n      : 'gv-conversation-star-btn';\n    const starIcon = conv.starred ? 'star' : 'star_outline';\n    starBtn.innerHTML = `<mat-icon role=\"img\" class=\"mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color\" aria-hidden=\"true\">${starIcon}</mat-icon>`;\n    starBtn.title = conv.starred ? this.t('conversation_unstar') : this.t('conversation_star');\n    starBtn.addEventListener('click', (e) => {\n      e.stopPropagation();\n      this.toggleConversationStar(folderId, conv.conversationId);\n    });\n\n    // Remove button\n    const removeBtn = document.createElement('button');\n    removeBtn.className = 'gv-conversation-remove-btn';\n    removeBtn.innerHTML =\n      '<mat-icon role=\"img\" class=\"mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color\" aria-hidden=\"true\">close</mat-icon>';\n    removeBtn.title = this.t('folder_remove_conversation');\n    removeBtn.addEventListener('click', (e) => {\n      e.stopPropagation();\n      this.confirmRemoveConversation(folderId, conv.conversationId, displayTitle, e);\n    });\n\n    actionsContainer.appendChild(starBtn);\n    actionsContainer.appendChild(removeBtn);\n\n    // Long-press detection for entering multi-select mode\n    let longPressTriggered = false;\n\n    convEl.addEventListener('mousedown', (e) => {\n      if (e.button !== 0) return; // Only left mouse button\n      longPressTriggered = false;\n\n      this.longPressTimeout = window.setTimeout(() => {\n        longPressTriggered = true;\n        this.enterMultiSelectMode(conv.conversationId, 'folder', folderId);\n      }, this.longPressThreshold);\n    });\n\n    convEl.addEventListener('mouseup', () => {\n      if (this.longPressTimeout) {\n        clearTimeout(this.longPressTimeout);\n        this.longPressTimeout = null;\n      }\n    });\n\n    convEl.addEventListener('mouseleave', () => {\n      if (this.longPressTimeout) {\n        clearTimeout(this.longPressTimeout);\n        this.longPressTimeout = null;\n      }\n    });\n\n    // Click to navigate or toggle selection based on mode\n    convEl.addEventListener('click', (e) => {\n      // Prevent navigation if long-press was triggered\n      if (longPressTriggered) {\n        longPressTriggered = false;\n        return;\n      }\n\n      if (this.isMultiSelectMode) {\n        // Multi-select mode: validate folder before toggling selection\n        e.preventDefault();\n        e.stopPropagation();\n\n        // Prevent cross-folder selection\n        if (\n          this.multiSelectSource === 'folder' &&\n          this.multiSelectFolderId &&\n          this.multiSelectFolderId !== folderId\n        ) {\n          // Provide visual feedback for invalid selection attempt\n          this.showInvalidSelectionFeedback(convEl);\n          return;\n        }\n\n        this.toggleConversationSelection(conv.conversationId);\n        this.updateConversationSelectionUI();\n      } else {\n        // Normal mode: navigate to conversation\n        this.navigateToConversationById(folderId, conv.conversationId);\n      }\n    });\n\n    // Double-click to rename\n    title.addEventListener('dblclick', (e) => {\n      e.stopPropagation();\n      this.renameConversation(folderId, conv.conversationId, title);\n    });\n\n    convEl.appendChild(icon);\n    convEl.appendChild(title);\n    convEl.appendChild(actionsContainer);\n\n    return convEl;\n  }\n\n  private setupDropZone(element: HTMLElement, folderId: string): void {\n    element.addEventListener('dragover', (e) => {\n      e.preventDefault();\n      e.stopPropagation(); // Prevent root drop zone from also highlighting\n      if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';\n      element.classList.add('gv-folder-dragover');\n    });\n\n    element.addEventListener('dragleave', (e) => {\n      // Only remove highlight when cursor truly leaves the element (not just entering a child)\n      const rect = element.getBoundingClientRect();\n      const x = (e as DragEvent).clientX;\n      const y = (e as DragEvent).clientY;\n\n      if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {\n        element.classList.remove('gv-folder-dragover');\n      }\n    });\n\n    element.addEventListener('drop', (e) => {\n      e.preventDefault();\n      e.stopPropagation(); // CRITICAL: Prevent event bubbling to root drop zone\n      element.classList.remove('gv-folder-dragover');\n\n      const data = e.dataTransfer?.getData('application/json');\n      if (!data) return;\n\n      try {\n        const dragData: DragData = JSON.parse(data);\n\n        // Pre-cleanup: Restore opacity immediately before processing drop\n        // This prevents visual artifacts if dragend doesn't fire properly\n        this.selectedConversations.forEach((id) => {\n          const el = this.findConversationElement(id);\n          if (el) el.style.opacity = '1';\n        });\n\n        // Handle different drag types\n        if (dragData.type === 'folder') {\n          // Handle folder drop\n          this.debug('Dropping folder into folder:', dragData.title, '→', folderId);\n          this.addFolderToFolder(folderId, dragData);\n        } else {\n          // Handle conversation drop - supports both single and multiple conversations\n          if (dragData.conversations && dragData.conversations.length > 0) {\n            // Multi-select drag\n            this.debug('Dropping multiple conversations:', dragData.conversations.length);\n            this.addConversationsToFolder(\n              folderId,\n              dragData.conversations,\n              dragData.sourceFolderId,\n            );\n          } else {\n            // Legacy single conversation drag (backward compatibility)\n            this.addConversationToFolder(folderId, dragData);\n          }\n        }\n\n        // Clear selection and exit multi-select mode after successful drop\n        this.exitMultiSelectMode();\n      } catch (error) {\n        console.error('[FolderManager] Drop error:', error);\n      }\n    });\n  }\n\n  private setupRootDropZone(element: HTMLElement): void {\n    element.addEventListener('dragover', (e) => {\n      // Allow both folder and conversation drops on the root zone\n      const data = e.dataTransfer?.types.includes('application/json');\n      if (!data) return;\n\n      e.preventDefault();\n      e.stopPropagation(); // Prevent parent handlers from firing\n      if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';\n      element.classList.add('gv-folder-list-dragover');\n    });\n\n    element.addEventListener('dragleave', (e) => {\n      // Check if we're leaving this element (not just entering a child)\n      const rect = element.getBoundingClientRect();\n      const x = (e as DragEvent).clientX;\n      const y = (e as DragEvent).clientY;\n\n      if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {\n        element.classList.remove('gv-folder-list-dragover');\n      }\n    });\n\n    element.addEventListener('drop', (e) => {\n      e.preventDefault();\n      e.stopPropagation(); // Prevent parent handlers from firing\n      element.classList.remove('gv-folder-list-dragover');\n\n      const data = e.dataTransfer?.getData('application/json');\n      if (!data) return;\n\n      try {\n        const dragData: DragData = JSON.parse(data);\n\n        // Pre-cleanup: Restore opacity immediately before processing drop\n        // This prevents visual artifacts if dragend doesn't fire properly\n        this.selectedConversations.forEach((id) => {\n          const el = this.findConversationElement(id);\n          if (el) el.style.opacity = '1';\n        });\n\n        // Handle different drag types at root level\n        if (dragData.type === 'folder') {\n          this.moveFolderToRoot(dragData);\n        } else {\n          // Handle conversation drop - supports both single and multiple conversations\n          if (dragData.conversations && dragData.conversations.length > 0) {\n            // Multi-select drag\n            this.debug(\n              'Adding multiple conversations to root level:',\n              dragData.conversations.length,\n            );\n            this.addConversationsToFolder(\n              ROOT_CONVERSATIONS_ID,\n              dragData.conversations,\n              dragData.sourceFolderId,\n            );\n          } else {\n            // Legacy single conversation drag (backward compatibility)\n            this.debug('Adding conversation to root level:', dragData.title);\n            this.addConversationToFolder(ROOT_CONVERSATIONS_ID, dragData);\n          }\n        }\n\n        // Clear selection and exit multi-select mode after successful drop\n        this.exitMultiSelectMode();\n      } catch (error) {\n        console.error('[FolderManager] Root drop error:', error);\n      }\n    });\n  }\n\n  private makeConversationsDraggable(): void {\n    if (!this.sidebarContainer) return;\n\n    const conversations = this.sidebarContainer.querySelectorAll('[data-test-id=\"conversation\"]');\n    conversations.forEach((conv) => {\n      this.makeConversationDraggable(conv as HTMLElement);\n\n      // Apply hide archived setting\n      const convId = this.extractConversationId(conv as HTMLElement);\n      const isArchived = this.isConversationInFolders(convId);\n\n      if (this.hideArchivedConversations && isArchived) {\n        (conv as HTMLElement).classList.add('gv-conversation-archived');\n      } else {\n        (conv as HTMLElement).classList.remove('gv-conversation-archived');\n      }\n    });\n  }\n\n  /**\n   * Strategy Pattern: Determine if a folder can be dragged\n   * Single Responsibility Principle: Separate logic for draggability check\n   *\n   * A folder can be dragged if and only if:\n   * - It has no subfolders (to prevent deep nesting complexity)\n   *\n   * @param folderId - The ID of the folder to check\n   * @returns true if the folder can be dragged, false otherwise\n   */\n  private canFolderBeDragged(folderId: string): boolean {\n    return !this.data.folders.some((f) => f.parentId === folderId);\n  }\n\n  /**\n   * Strategy Pattern: Apply or remove draggable behavior based on folder state\n   * Open/Closed Principle: Easy to extend with new draggable conditions\n   *\n   * This method ensures that folder draggability is always in sync with the current state.\n   * It will enable dragging if conditions are met, or disable it if not.\n   *\n   * @param element - The folder header element\n   * @param folder - The folder data object\n   */\n  private applyFolderDraggableBehavior(element: HTMLElement, folder: Folder): void {\n    if (this.canFolderBeDragged(folder.id)) {\n      this.enableFolderDragging(element, folder);\n    } else {\n      this.disableFolderDragging(element);\n    }\n  }\n\n  /**\n   * Enable dragging for a folder element\n   * Encapsulates all logic needed to make a folder draggable\n   *\n   * Uses a data attribute to track drag listeners and prevent duplicates.\n   * This ensures event listeners are only added once per element lifecycle.\n   *\n   * @param element - The folder header element\n   * @param folder - The folder data object\n   */\n  private enableFolderDragging(element: HTMLElement, folder: Folder): void {\n    // Mark element as draggable\n    element.draggable = true;\n    element.style.cursor = 'grab';\n\n    // Check if drag listeners are already attached\n    if (element.dataset.dragListenersAttached === 'true') {\n      this.debug('Drag listeners already attached for folder:', folder.name);\n      return;\n    }\n\n    // Create named event handler functions for proper cleanup\n    const handleDragStart = (e: Event) => {\n      e.stopPropagation(); // Prevent parent folder from being dragged\n\n      const dragData: DragData = {\n        type: 'folder',\n        folderId: folder.id,\n        title: folder.name,\n      };\n\n      const dt = (e as DragEvent).dataTransfer;\n      if (dt) dt.effectAllowed = 'move';\n      dt?.setData('application/json', JSON.stringify(dragData));\n      element.style.opacity = '0.5';\n\n      this.debug(\n        'Folder drag start:',\n        folder.name,\n        'canBeDragged:',\n        this.canFolderBeDragged(folder.id),\n      );\n    };\n\n    const handleDragEnd = () => {\n      element.style.opacity = '1';\n    };\n\n    // Store references for potential cleanup\n    type DragEl = Element & {\n      _dragStartHandler?: (e: Event) => void;\n      _dragEndHandler?: () => void;\n    };\n    (element as DragEl)._dragStartHandler = handleDragStart;\n    (element as DragEl)._dragEndHandler = handleDragEnd;\n\n    // Add drag event listeners\n    element.addEventListener('dragstart', handleDragStart);\n    element.addEventListener('dragend', handleDragEnd);\n\n    // Mark that listeners are attached\n    element.dataset.dragListenersAttached = 'true';\n  }\n\n  /**\n   * Disable dragging for a folder element\n   * Ensures folder cannot be dragged when it has subfolders\n   *\n   * Properly removes event listeners to prevent memory leaks.\n   *\n   * @param element - The folder header element\n   */\n  private disableFolderDragging(element: HTMLElement): void {\n    element.draggable = false;\n    element.style.cursor = '';\n\n    // Remove drag event listeners if they exist\n    if (element.dataset.dragListenersAttached === 'true') {\n      type DragEl = Element & {\n        _dragStartHandler?: (e: Event) => void;\n        _dragEndHandler?: () => void;\n      };\n      const dragStartHandler = (element as DragEl)._dragStartHandler;\n      const dragEndHandler = (element as DragEl)._dragEndHandler;\n\n      if (dragStartHandler) {\n        element.removeEventListener('dragstart', dragStartHandler);\n        delete (element as DragEl)._dragStartHandler;\n      }\n\n      if (dragEndHandler) {\n        element.removeEventListener('dragend', dragEndHandler);\n        delete (element as DragEl)._dragEndHandler;\n      }\n\n      delete element.dataset.dragListenersAttached;\n    }\n  }\n\n  private makeConversationDraggable(element: HTMLElement): void {\n    element.draggable = true;\n    element.style.cursor = 'grab';\n\n    // Long-press detection for entering multi-select mode\n    let longPressTriggered = false;\n    let longPressTimeoutId: number | null = null;\n\n    const handleMouseDown = (e: MouseEvent) => {\n      if (e.button !== 0) return; // Only left mouse button\n      longPressTriggered = false;\n\n      const conversationId = this.extractConversationId(element);\n\n      longPressTimeoutId = window.setTimeout(() => {\n        longPressTriggered = true;\n        this.enterMultiSelectMode(conversationId, 'native');\n        // Add visual feedback to this element\n        element.classList.add('gv-conversation-selected');\n      }, this.longPressThreshold);\n    };\n\n    const handleMouseUp = () => {\n      if (longPressTimeoutId) {\n        clearTimeout(longPressTimeoutId);\n        longPressTimeoutId = null;\n      }\n    };\n\n    const handleMouseLeave = () => {\n      if (longPressTimeoutId) {\n        clearTimeout(longPressTimeoutId);\n        longPressTimeoutId = null;\n      }\n    };\n\n    // Add event listeners\n    element.addEventListener('mousedown', handleMouseDown);\n    element.addEventListener('mouseup', handleMouseUp);\n    element.addEventListener('mouseleave', handleMouseLeave);\n\n    // Click handler for multi-select mode\n    element.addEventListener(\n      'click',\n      (e) => {\n        // Prevent navigation if long-press was triggered\n        if (longPressTriggered) {\n          e.preventDefault();\n          e.stopPropagation();\n          longPressTriggered = false;\n          return;\n        }\n\n        if (this.isMultiSelectMode) {\n          // Multi-select mode: toggle selection\n          e.preventDefault();\n          e.stopPropagation();\n          const conversationId = this.extractConversationId(element);\n          this.toggleConversationSelection(conversationId);\n\n          // Update visual state\n          if (this.selectedConversations.has(conversationId)) {\n            element.classList.add('gv-conversation-selected');\n          } else {\n            element.classList.remove('gv-conversation-selected');\n          }\n\n          this.updateConversationSelectionUI();\n          return;\n        }\n      },\n      true,\n    ); // Use capture phase to intercept before navigation\n\n    element.addEventListener('dragstart', (e) => {\n      const title = element.querySelector('.conversation-title')?.textContent?.trim() || 'Untitled';\n      const conversationId = this.extractConversationId(element);\n\n      // Extract URL and conversation metadata together\n      const conversationData = this.extractConversationData(element);\n\n      // Restrict to move-only to prevent Chrome from triggering split-screen/tab tiling\n      if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';\n\n      // If this conversation is not selected, select it exclusively\n      if (!this.selectedConversations.has(conversationId)) {\n        this.clearSelection();\n        this.selectConversation(conversationId);\n        element.classList.add('gv-conversation-selected');\n        this.updateConversationSelectionUI();\n      }\n\n      // Cancel long press if drag starts\n      if (longPressTimeoutId) {\n        clearTimeout(longPressTimeoutId);\n        longPressTimeoutId = null;\n      }\n\n      // Check if we have multiple selections\n      if (this.selectedConversations.size > 1) {\n        // Multi-select drag - collect all selected conversations\n        const selectedConvs: ConversationReference[] = [];\n\n        this.selectedConversations.forEach((id) => {\n          const convEl = this.findConversationElement(id);\n          if (convEl) {\n            const convTitle =\n              convEl.querySelector('.conversation-title')?.textContent?.trim() || 'Untitled';\n            const convData = this.extractConversationData(convEl);\n\n            selectedConvs.push({\n              conversationId: id,\n              title: convTitle,\n              url: convData.url,\n              addedAt: Date.now(),\n              isGem: convData.isGem,\n              gemId: convData.gemId,\n            });\n          }\n        });\n\n        const dragData: DragData = {\n          type: 'conversation',\n          title: `${selectedConvs.length} conversations`,\n          conversations: selectedConvs,\n        };\n\n        e.dataTransfer?.setData('application/json', JSON.stringify(dragData));\n\n        // Apply opacity to all selected conversations\n        this.selectedConversations.forEach((id) => {\n          const el = this.findConversationElement(id);\n          if (el) el.style.opacity = '0.5';\n        });\n      } else {\n        // Single conversation drag (legacy behavior)\n        this.debug('Drag start:', {\n          title,\n          isGem: conversationData.isGem,\n          gemId: conversationData.gemId,\n          url: conversationData.url,\n        });\n\n        const dragData: DragData = {\n          type: 'conversation',\n          conversationId,\n          title,\n          url: conversationData.url,\n          isGem: conversationData.isGem,\n          gemId: conversationData.gemId,\n        };\n\n        e.dataTransfer?.setData('application/json', JSON.stringify(dragData));\n        element.style.opacity = '0.5';\n      }\n    });\n\n    element.addEventListener('dragend', () => {\n      // Restore opacity for all selected conversations\n      if (this.selectedConversations.size > 1) {\n        this.selectedConversations.forEach((id) => {\n          const el = this.findConversationElement(id);\n          if (el) el.style.opacity = '1';\n        });\n      } else {\n        element.style.opacity = '1';\n      }\n\n      // If we are not in multi-select mode, clear the temporary selection\n      if (!this.isMultiSelectMode) {\n        this.clearSelection();\n        this.cleanupSelectionArtifacts();\n      }\n    });\n  }\n\n  // Helper method to find conversation element by ID\n  private findConversationElement(conversationId: string): HTMLElement | null {\n    // Check in folder conversations\n    const folderConv = this.containerElement?.querySelector(\n      `[data-conversation-id=\"${conversationId}\"]`,\n    ) as HTMLElement;\n    if (folderConv) return folderConv;\n\n    // Check in native conversations (Recent section)\n    const nativeConvs = this.sidebarContainer?.querySelectorAll('[data-test-id=\"conversation\"]');\n    if (nativeConvs) {\n      for (const conv of Array.from(nativeConvs)) {\n        const id = this.extractConversationId(conv as HTMLElement);\n        if (id === conversationId) {\n          return conv as HTMLElement;\n        }\n      }\n    }\n\n    return null;\n  }\n\n  private extractConversationId(element: HTMLElement): string {\n    // Strategy 1: Extract from jslog attribute\n    // This is the preferred method as it follows the internal ID format\n    const jslog = element.getAttribute('jslog');\n    if (jslog) {\n      // Match conversation ID - it appears in quotes like [\"c_3456c77162722c1a\",...]\n      const match = jslog.match(/[\",\\[]c_([a-f0-9]+)[\",\\]]/);\n      if (match) {\n        const conversationId = `c_${match[1]}`;\n        this.debug('Extracted conversation ID:', conversationId, 'from jslog:', jslog);\n        return conversationId;\n      }\n      // Fallback: match without surrounding characters\n      const simpleMatch = jslog.match(/c_[a-f0-9]+/);\n      if (simpleMatch) {\n        this.debug('Extracted conversation ID (simple):', simpleMatch[0]);\n        return simpleMatch[0];\n      }\n    }\n\n    // Strategy 2: Extract from href (fallback when jslog is missing/broken)\n    // This ensures we can still identify conversations even if Gemini UI changes traits\n    const link = element.querySelector(\n      'a[href*=\"/app/\"], a[href*=\"/gem/\"]',\n    ) as HTMLAnchorElement | null;\n    if (link) {\n      const href = link.href;\n      // Try /app/<hexId>\n      let match = href.match(/\\/app\\/([^\\/?#]+)/);\n      if (match && match[1]) {\n        // Enforce c_ prefix to match jslog format standard\n        return `c_${match[1]}`;\n      }\n      // Try /gem/<gemId>/<hexId>\n      match = href.match(/\\/gem\\/[^/]+\\/([^\\/?#]+)/);\n      if (match && match[1]) {\n        return `c_${match[1]}`;\n      }\n    }\n\n    // Fallback: generate unique ID from element attributes\n    // Use multiple attributes to ensure uniqueness\n    const title = element.querySelector('.conversation-title')?.textContent?.trim() || '';\n    const index = Array.from(element.parentElement?.children || []).indexOf(element);\n\n    // Generate unique ID combining title, index, random, and timestamp\n    const uniqueString = `${title}_${index}_${Math.random()}_${Date.now()}`;\n    const fallbackId = `conv_${this.hashString(uniqueString)}`;\n    this.debugWarn('Could not extract ID from jslog or href, using fallback:', fallbackId);\n    return fallbackId;\n  }\n\n  private extractConversationData(element: HTMLElement): {\n    url: string;\n    isGem: boolean;\n    gemId?: string;\n  } {\n    // Try to extract from jslog first\n    const jslog = element.getAttribute('jslog');\n    let hexId: string | null = null;\n\n    if (jslog) {\n      const match = jslog.match(/[\",\\[]c_([a-f0-9]+)[\",\\]]/);\n      if (match) {\n        hexId = match[1];\n        this.debug('Extracted hex ID from jslog:', hexId);\n      }\n    }\n\n    // Try to extract from href if jslog failed\n    if (!hexId) {\n      const link = element.querySelector(\n        'a[href*=\"/app/\"], a[href*=\"/gem/\"]',\n      ) as HTMLAnchorElement | null;\n      if (link) {\n        const href = link.href;\n        // Try /app/<hexId>\n        let match = href.match(/\\/app\\/([^\\/?#]+)/);\n        if (match && match[1]) {\n          hexId = match[1];\n        } else {\n          // Try /gem/<gemId>/<hexId>\n          match = href.match(/\\/gem\\/[^/]+\\/([^\\/?#]+)/);\n          if (match && match[1]) {\n            hexId = match[1];\n          }\n        }\n      }\n    }\n\n    if (!hexId) {\n      return { url: window.location.href, isGem: false };\n    }\n\n    const origin = window.location.origin;\n    const currentUrl = new URL(window.location.href);\n    const searchParams = currentUrl.searchParams.toString();\n\n    let url: string;\n\n    if (this.accountIsolationEnabled) {\n      // In hard isolation mode, intentionally do not persist the /u/{num} account index;\n      // only store the path that is intrinsic to the conversation itself.\n      // At navigation time we rebuild the correct /u/{num} segment based on the\n      // current window/account context, so that URLs stay valid even if the\n      // account index changes (e.g. saved with /u/1, later browsing under /u/2).\n      url = `${origin}/app/${hexId}`;\n    } else {\n      // Backward-compatible behavior: preserve the current /u/{num} segment\n      // when hard isolation is disabled, matching legacy URL structure.\n      const currentPath = window.location.pathname;\n      const userMatch = currentPath.match(/\\/u\\/(\\d+)\\//);\n\n      if (userMatch) {\n        url = `${origin}/u/${userMatch[1]}/app/${hexId}`;\n      } else {\n        url = `${origin}/app/${hexId}`;\n      }\n    }\n\n    if (searchParams) {\n      url += `?${searchParams}`;\n    }\n\n    this.debug('Built conversation URL:', url);\n    return { url, isGem: false, gemId: undefined };\n  }\n\n  /**\n   * Extract conversation ID from a DOM element\n   * Used for handling removed/added conversations in MutationObserver\n   *\n   * @param element - The conversation element to extract ID from\n   * @returns The conversation ID (hex only, without 'c_' prefix) or undefined if not found\n   *\n   * @remarks\n   * This method attempts two extraction strategies:\n   * 1. From jslog attribute (e.g., jslog=\"c_abc123def456\")\n   * 2. From href in anchor tags (e.g., /app/abc123def456 or /gem/xxx/abc123def456)\n   */\n  private extractConversationIdFromElement(element: Element): string | undefined {\n    // Strategy 1: Extract from jslog attribute\n    const jslog = element.getAttribute('jslog');\n    if (jslog) {\n      const match = jslog.match(/c_([a-f0-9]{8,})/i);\n      if (match && match[1]) {\n        return match[1];\n      }\n    }\n\n    // Strategy 2: Extract from href\n    const link = element.querySelector(\n      'a[href*=\"/app/\"], a[href*=\"/gem/\"]',\n    ) as HTMLAnchorElement | null;\n    if (link) {\n      const href = link.href;\n      const appMatch = href.match(/\\/app\\/([^\\/?#]+)/);\n      const gemMatch = href.match(/\\/gem\\/[^/]+\\/([^\\/?#]+)/);\n      return appMatch?.[1] || gemMatch?.[1];\n    }\n\n    return undefined;\n  }\n\n  private setupMutationObserver(): void {\n    if (!this.sidebarContainer) return;\n\n    // Disconnect existing observer to prevent duplicates\n    if (this.conversationObserver) {\n      this.conversationObserver.disconnect();\n      this.conversationObserver = null;\n    }\n\n    this.conversationObserver = new MutationObserver((mutations) => {\n      // 1. Handle added conversations (always safe)\n      mutations.forEach((mutation) => {\n        mutation.addedNodes.forEach((node) => {\n          if (node instanceof HTMLElement) {\n            // Check if the node itself is a conversation\n            if (node.matches('[data-test-id=\"conversation\"]')) {\n              this.makeConversationDraggable(node);\n              this.applyHideArchivedToConversation(node);\n              // Cancel pending removal for this conversation (it's back!)\n              this.cancelPendingRemovalForElement(node);\n            }\n            // Also check for conversations within the node\n            const conversations = node.querySelectorAll('[data-test-id=\"conversation\"]');\n            conversations.forEach((conv) => {\n              const convElement = conv as HTMLElement;\n              this.makeConversationDraggable(convElement);\n              // Apply hide archived setting to newly added conversations\n              this.applyHideArchivedToConversation(convElement);\n              // Cancel pending removal for this conversation (it's back!)\n              this.cancelPendingRemovalForElement(convElement);\n            });\n          }\n        });\n      });\n\n      // 2. Handle removed conversations with safeguards\n      // CRITICAL FIX: Prevent data loss when network disconnects or UI refreshes\n\n      // Check 1: If offline, assume removals are due to network error\n      if (!navigator.onLine) {\n        this.debug('Network offline, ignoring conversation removals to prevent data loss');\n        return;\n      }\n\n      // Check 2: Calculate total conversations being removed in this batch\n      let totalRemovedCount = 0;\n      const nodesWithRemovals: HTMLElement[] = [];\n\n      mutations.forEach((mutation) => {\n        mutation.removedNodes.forEach((node) => {\n          if (node instanceof HTMLElement) {\n            const isConv = node.matches('[data-test-id=\"conversation\"]');\n            // Check if it contains conversations (e.g. a container was removed)\n            const containedConvsCount = node.querySelectorAll(\n              '[data-test-id=\"conversation\"]',\n            ).length;\n\n            if (isConv) {\n              totalRemovedCount++;\n              nodesWithRemovals.push(node);\n            } else if (containedConvsCount > 0) {\n              totalRemovedCount += containedConvsCount;\n              nodesWithRemovals.push(node);\n            }\n          }\n        });\n      });\n\n      // If no conversations were removed, we're done\n      if (totalRemovedCount === 0) return;\n\n      // Check 3: If multiple conversations are removed at once, it's likely a UI refresh/clear\n      // Users typically delete conversations one by one.\n      // EXCEPTION: If we are in multi-select mode, the user might be performing a bulk delete.\n      if (totalRemovedCount > 1 && !this.isMultiSelectMode) {\n        this.debugWarn(\n          `Ignored bulk removal of ${totalRemovedCount} conversations - likely UI refresh`,\n        );\n        return;\n      }\n\n      // NEW: Instead of immediately removing, schedule a delayed check\n      // This prevents false positives when Gemini temporarily removes/re-adds DOM elements during UI updates\n      nodesWithRemovals.forEach((node) => {\n        const conversations = node.matches('[data-test-id=\"conversation\"]')\n          ? [node]\n          : Array.from(node.querySelectorAll('[data-test-id=\"conversation\"]'));\n\n        conversations.forEach((conv) => {\n          // Extract conversation ID from the removed element\n          const conversationId = this.extractConversationIdFromElement(conv);\n\n          if (conversationId) {\n            this.debug('Detected potential conversation removal:', conversationId);\n            // Schedule delayed removal check\n            this.scheduleConversationRemovalCheck(conversationId);\n          }\n        });\n      });\n    });\n\n    this.conversationObserver.observe(this.sidebarContainer, {\n      childList: true,\n      subtree: true,\n    });\n  }\n\n  /**\n   * Setup observer to monitor sidebar open/close state\n   * Hides folder container when sidebar is collapsed for better UX\n   */\n  private setupSideNavObserver(): void {\n    const appRoot = document.querySelector('#app-root');\n    if (!appRoot) {\n      this.debugWarn('Could not find #app-root element for sidebar monitoring');\n      return;\n    }\n\n    this.sideNavObserver = new MutationObserver((mutations) => {\n      mutations.forEach((mutation) => {\n        if (mutation.type === 'attributes' && mutation.attributeName === 'class') {\n          this.updateVisibilityBasedOnSideNav();\n        }\n      });\n    });\n\n    this.sideNavObserver.observe(appRoot, {\n      attributes: true,\n      attributeFilter: ['class'],\n    });\n\n    this.debug('Side nav observer setup complete');\n  }\n\n  /**\n   * Check if sidebar is open and update folder container visibility\n   * Sidebar is considered open when #app-root has 'side-nav-open' class\n   */\n  private updateVisibilityBasedOnSideNav(): void {\n    const appRoot = document.querySelector('#app-root');\n    if (!appRoot) return;\n\n    const isSideNavOpen = appRoot.classList.contains('side-nav-open');\n\n    // Check if containerElement exists AND is still in the DOM\n    // During screen resize (e.g., split-screen to fullscreen), Gemini may re-render the sidebar DOM,\n    // causing containerElement to become detached from the DOM tree\n    if (!this.containerElement || !document.body.contains(this.containerElement)) {\n      if (isSideNavOpen) {\n        this.debug('Container element not in DOM, reinitializing folder UI');\n        // Reinitialize the entire folder UI asynchronously\n        // This ensures sidebarContainer and recentSection are also re-found\n        this.reinitializeFolderUI();\n      }\n      return;\n    }\n\n    // Also check if sidebarContainer is still valid\n    if (!this.sidebarContainer || !document.body.contains(this.sidebarContainer)) {\n      if (isSideNavOpen) {\n        this.debug('Sidebar container not in DOM, reinitializing folder UI');\n        this.reinitializeFolderUI();\n      }\n      return;\n    }\n\n    if (isSideNavOpen) {\n      this.containerElement.style.display = '';\n      this.debug('Sidebar open - showing folder container');\n    } else {\n      this.containerElement.style.display = 'none';\n      this.debug('Sidebar closed - hiding folder container');\n    }\n  }\n\n  /**\n   * Reinitialize folder UI when DOM elements become detached\n   * This can happen during window resize or split-screen operations\n   */\n  private reinitializeFolderUI(): void {\n    if (this.reinitializePromise) {\n      this.debug('Reinitialization already in progress, skipping duplicate request');\n      return;\n    }\n\n    this.reinitializePromise = (async () => {\n      this.debug('Reinitializing folder UI...');\n\n      // Execute general cleanup tasks first (including event listeners)\n      this.cleanupTasks.forEach((task) => task());\n      this.cleanupTasks = [];\n\n      // Clean up observers/listeners tied to stale DOM nodes\n      if (this.sideNavObserver) {\n        this.sideNavObserver.disconnect();\n        this.sideNavObserver = null;\n      }\n\n      if (this.conversationObserver) {\n        this.conversationObserver.disconnect();\n        this.conversationObserver = null;\n      }\n\n      if (this.nativeMenuObserver) {\n        this.nativeMenuObserver.disconnect();\n        this.nativeMenuObserver = null;\n      }\n\n      if (this.routeChangeCleanup) {\n        try {\n          this.routeChangeCleanup();\n        } catch (error) {\n          this.debugWarn('Route change cleanup during reinit failed:', error);\n        }\n        this.routeChangeCleanup = null;\n      }\n\n      if (this.sidebarClickListener && this.sidebarContainer) {\n        try {\n          this.sidebarContainer.removeEventListener('click', this.sidebarClickListener, true);\n        } catch (error) {\n          this.debugWarn('Sidebar click listener cleanup failed:', error);\n        }\n        this.sidebarClickListener = null;\n      }\n\n      if (this.containerElement?.isConnected) {\n        try {\n          this.containerElement.remove();\n        } catch (error) {\n          this.debugWarn('Failed to remove existing folder container during reinit:', error);\n        }\n      }\n\n      // Clear existing references so initialization starts from a clean slate\n      this.containerElement = null;\n      this.sidebarContainer = null;\n      this.recentSection = null;\n\n      await this.initializeFolderUI();\n    })()\n      .catch((error) => {\n        this.debugWarn('Failed to reinitialize folder UI:', error);\n      })\n      .finally(() => {\n        this.reinitializePromise = null;\n      });\n  }\n\n  private createFolder(parentId: string | null = null): void {\n    // Create inline input for folder name\n    const inputContainer = document.createElement('div');\n    inputContainer.className = 'gv-folder-inline-input';\n\n    const input = document.createElement('input');\n    input.type = 'text';\n    input.className = 'gv-folder-name-input';\n    input.placeholder = this.t('folder_name_prompt');\n    input.maxLength = 50;\n\n    const saveBtn = document.createElement('button');\n    saveBtn.className = 'gv-folder-inline-btn gv-folder-inline-save';\n    saveBtn.innerHTML =\n      '<mat-icon role=\"img\" class=\"mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color\" aria-hidden=\"true\">check</mat-icon>';\n    saveBtn.title = this.t('pm_save');\n\n    const cancelBtn = document.createElement('button');\n    cancelBtn.className = 'gv-folder-inline-btn gv-folder-inline-cancel';\n    cancelBtn.innerHTML =\n      '<mat-icon role=\"img\" class=\"mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color\" aria-hidden=\"true\">close</mat-icon>';\n    cancelBtn.title = this.t('pm_cancel');\n\n    inputContainer.appendChild(input);\n    inputContainer.appendChild(saveBtn);\n    inputContainer.appendChild(cancelBtn);\n\n    const save = () => {\n      const name = input.value.trim();\n      if (!name) {\n        inputContainer.remove();\n        return;\n      }\n\n      const maxSortIndex = this.data.folders\n        .filter((f) => f.parentId === parentId)\n        .reduce((max, f) => Math.max(max, f.sortIndex ?? -1), -1);\n      const folder: Folder = {\n        id: this.generateId(),\n        name,\n        parentId,\n        isExpanded: true,\n        sortIndex: maxSortIndex + 1,\n        createdAt: Date.now(),\n        updatedAt: Date.now(),\n      };\n\n      this.data.folders.push(folder);\n      this.data.folderContents[folder.id] = [];\n      this.saveData();\n      this.refresh();\n    };\n\n    const cancel = () => {\n      inputContainer.remove();\n    };\n\n    saveBtn.addEventListener('click', save);\n    cancelBtn.addEventListener('click', cancel);\n    input.addEventListener('keydown', (e) => {\n      if (e.key === 'Enter') save();\n      if (e.key === 'Escape') cancel();\n    });\n\n    // Insert input into the folder list\n    const folderList = this.containerElement?.querySelector('.gv-folder-list');\n    if (folderList) {\n      if (parentId) {\n        // Insert after the parent folder\n        const parentFolder = folderList.querySelector(`[data-folder-id=\"${parentId}\"]`);\n        if (parentFolder) {\n          const parentContent = parentFolder.querySelector('.gv-folder-content');\n          if (parentContent) {\n            parentContent.insertBefore(inputContainer, parentContent.firstChild);\n          } else {\n            parentFolder.insertAdjacentElement('afterend', inputContainer);\n          }\n        } else {\n          folderList.appendChild(inputContainer);\n        }\n      } else {\n        folderList.insertBefore(inputContainer, folderList.firstChild);\n      }\n\n      input.focus();\n    }\n  }\n\n  private renameFolder(folderId: string): void {\n    this.clearPendingFolderNameClick();\n\n    const folder = this.data.folders.find((f) => f.id === folderId);\n    if (!folder) return;\n\n    // Find the folder element\n    const folderEl = this.containerElement?.querySelector(`[data-folder-id=\"${folderId}\"]`);\n    if (!folderEl) return;\n\n    const folderNameEl = folderEl.querySelector('.gv-folder-name');\n    if (!folderNameEl) return;\n\n    // Create inline input for renaming\n    const inputContainer = document.createElement('span');\n    inputContainer.className = 'gv-folder-rename-inline';\n\n    const input = document.createElement('input');\n    input.type = 'text';\n    input.className = 'gv-folder-rename-input';\n    input.value = folder.name;\n    input.maxLength = 50;\n\n    const saveBtn = document.createElement('button');\n    saveBtn.className = 'gv-folder-inline-btn gv-folder-inline-save';\n    saveBtn.innerHTML =\n      '<mat-icon role=\"img\" class=\"mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color\" aria-hidden=\"true\">check</mat-icon>';\n\n    const cancelBtn = document.createElement('button');\n    cancelBtn.className = 'gv-folder-inline-btn gv-folder-inline-cancel';\n    cancelBtn.innerHTML =\n      '<mat-icon role=\"img\" class=\"mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color\" aria-hidden=\"true\">close</mat-icon>';\n\n    inputContainer.appendChild(input);\n    inputContainer.appendChild(saveBtn);\n    inputContainer.appendChild(cancelBtn);\n\n    const save = () => {\n      const newName = input.value.trim();\n      if (!newName) {\n        restore();\n        return;\n      }\n\n      folder.name = newName;\n      folder.updatedAt = Date.now();\n      this.saveData();\n      this.refresh();\n    };\n\n    const restore = () => {\n      folderNameEl.textContent = folder.name;\n      inputContainer.remove();\n      folderNameEl.classList.remove('gv-hidden');\n    };\n\n    const cancel = () => {\n      restore();\n    };\n\n    saveBtn.addEventListener('click', save);\n    cancelBtn.addEventListener('click', cancel);\n    input.addEventListener('keydown', (e) => {\n      if (e.key === 'Enter') save();\n      if (e.key === 'Escape') cancel();\n    });\n\n    // Hide original name and show input\n    folderNameEl.classList.add('gv-hidden');\n    folderNameEl.parentElement?.insertBefore(inputContainer, folderNameEl.nextSibling);\n    input.focus();\n    input.select();\n  }\n\n  private deleteFolder(folderId: string, _event?: MouseEvent): void {\n    // Create inline confirmation using safe DOM API\n    const confirmDialog = document.createElement('div');\n    confirmDialog.className = 'gv-folder-confirm-dialog';\n\n    // Create message element safely\n    const message = document.createElement('div');\n    message.className = 'gv-folder-confirm-message';\n    message.textContent = this.t('folder_delete_confirm'); // Safe: uses textContent\n\n    // Create actions container\n    const actions = document.createElement('div');\n    actions.className = 'gv-folder-confirm-actions';\n\n    // Create buttons safely\n    const yesBtn = document.createElement('button');\n    yesBtn.className = 'gv-folder-confirm-btn gv-folder-confirm-yes';\n    yesBtn.textContent = this.t('pm_delete'); // Safe: uses textContent\n\n    const noBtn = document.createElement('button');\n    noBtn.className = 'gv-folder-confirm-btn gv-folder-confirm-no';\n    noBtn.textContent = this.t('pm_cancel'); // Safe: uses textContent\n\n    // Assemble the dialog\n    actions.appendChild(yesBtn);\n    actions.appendChild(noBtn);\n    confirmDialog.appendChild(message);\n    confirmDialog.appendChild(actions);\n\n    // Position near the folder\n    // Position near the folder header\n    const folderEl = this.containerElement?.querySelector(`[data-folder-id=\"${folderId}\"]`);\n    const headerEl = folderEl?.querySelector('.gv-folder-item-header');\n\n    if (headerEl) {\n      const rect = headerEl.getBoundingClientRect();\n      confirmDialog.style.position = 'fixed';\n      confirmDialog.style.top = `${rect.bottom + 4}px`;\n      confirmDialog.style.left = `${rect.left + 24}px`; // Align with folder name\n      confirmDialog.style.zIndex = '10002'; // Ensure it's on top\n    } else if (folderEl) {\n      const rect = folderEl.getBoundingClientRect();\n      confirmDialog.style.position = 'fixed';\n      confirmDialog.style.top = `${rect.top + 32}px`; // Fallback approximate height\n      confirmDialog.style.left = `${rect.left}px`;\n      confirmDialog.style.zIndex = '10002';\n    }\n\n    document.body.appendChild(confirmDialog);\n\n    // Cleanup function\n    const cleanup = () => {\n      confirmDialog.remove();\n    };\n\n    yesBtn?.addEventListener('click', () => {\n      // Remove folder and all subfolders recursively\n      const foldersToDelete = this.getFolderAndDescendants(folderId);\n      this.data.folders = this.data.folders.filter((f) => !foldersToDelete.includes(f.id));\n\n      // Remove folder contents\n      foldersToDelete.forEach((id) => {\n        delete this.data.folderContents[id];\n      });\n\n      this.saveData();\n      this.refresh();\n      cleanup();\n    });\n\n    noBtn?.addEventListener('click', cleanup);\n\n    // Close on click outside\n    setTimeout(() => {\n      const closeOnOutside = (e: MouseEvent) => {\n        if (!confirmDialog.contains(e.target as Node)) {\n          cleanup();\n          document.removeEventListener('click', closeOnOutside);\n        }\n      };\n      document.addEventListener('click', closeOnOutside);\n    }, 0);\n  }\n\n  private getFolderAndDescendants(folderId: string): string[] {\n    const result = [folderId];\n    const children = this.data.folders.filter((f) => f.parentId === folderId);\n    children.forEach((child) => {\n      result.push(...this.getFolderAndDescendants(child.id));\n    });\n    return result;\n  }\n\n  private toggleFolder(folderId: string): void {\n    const folder = this.data.folders.find((f) => f.id === folderId);\n    if (!folder) return;\n\n    folder.isExpanded = !folder.isExpanded;\n    folder.updatedAt = Date.now();\n    this.saveData();\n    this.refresh();\n  }\n\n  private togglePinFolder(folderId: string): void {\n    const folder = this.data.folders.find((f) => f.id === folderId);\n    if (!folder) return;\n\n    folder.pinned = !folder.pinned;\n    folder.updatedAt = Date.now();\n    this.saveData();\n    this.refresh();\n  }\n\n  /**\n   * Sort folders with pinned folders first, then by name using localized collation\n   */\n  private sortFolders(folders: Folder[]): Folder[] {\n    return [...folders].sort((a, b) => {\n      // Pinned folders always come first\n      if (a.pinned && !b.pinned) return -1;\n      if (!a.pinned && b.pinned) return 1;\n\n      // Within the same pinned state, use sortIndex if both have one\n      const aIdx = a.sortIndex ?? -1;\n      const bIdx = b.sortIndex ?? -1;\n      if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx;\n\n      // Fall back to name-based sort\n      return a.name.localeCompare(b.name, undefined, {\n        numeric: true,\n        sensitivity: 'base',\n      });\n    });\n  }\n\n  private sortConversations(conversations: ConversationReference[]): ConversationReference[] {\n    return sortConversationsByPriority(conversations);\n  }\n\n  /**\n   * Add reorder capability to a conversation element using top/bottom half detection.\n   * When dragging over the top half, an indicator line appears above; bottom half → below.\n   */\n  private setupConversationReorderZone(\n    convEl: HTMLElement,\n    folderId: string,\n    sortedIndex: number,\n  ): void {\n    convEl.addEventListener('dragover', (e) => {\n      const data = e.dataTransfer?.types.includes('application/json');\n      if (!data) return;\n\n      e.preventDefault();\n      e.stopPropagation();\n      if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';\n\n      const rect = convEl.getBoundingClientRect();\n      const midY = rect.top + rect.height / 2;\n      const isTopHalf = e.clientY < midY;\n\n      convEl.classList.remove('gv-reorder-above', 'gv-reorder-below');\n      convEl.classList.add(isTopHalf ? 'gv-reorder-above' : 'gv-reorder-below');\n    });\n\n    convEl.addEventListener('dragleave', (e) => {\n      // Only remove if truly leaving the element (not entering a child)\n      const related = e.relatedTarget as Node | null;\n      if (!related || !convEl.contains(related)) {\n        convEl.classList.remove('gv-reorder-above', 'gv-reorder-below');\n      }\n    });\n\n    convEl.addEventListener('drop', (e) => {\n      e.preventDefault();\n      e.stopPropagation();\n\n      const isAbove = convEl.classList.contains('gv-reorder-above');\n      convEl.classList.remove('gv-reorder-above', 'gv-reorder-below');\n\n      const rawData = e.dataTransfer?.getData('application/json');\n      if (!rawData) return;\n\n      try {\n        const dragData: DragData = JSON.parse(rawData);\n        if (dragData.type !== 'conversation') return;\n\n        // Restore opacity\n        this.selectedConversations.forEach((id) => {\n          const el = this.findConversationElement(id);\n          if (el) el.style.opacity = '1';\n        });\n\n        const insertIndex = isAbove ? sortedIndex : sortedIndex + 1;\n        const convs = dragData.conversations ?? [];\n        const singleId = dragData.conversationId;\n        const sourceFolderId = dragData.sourceFolderId;\n\n        // If conversation(s) are from outside any folder (native sidebar drag),\n        // add them to the folder data first so reorderOrMoveConversations can find them\n        if (!sourceFolderId) {\n          this.ensureConversationsInFolder(folderId, dragData);\n        }\n\n        const effectiveSource = sourceFolderId ?? folderId;\n\n        if (convs.length > 0) {\n          this.reorderOrMoveConversations(\n            convs.map((c) => c.conversationId),\n            effectiveSource,\n            folderId,\n            insertIndex,\n          );\n        } else if (singleId) {\n          this.reorderOrMoveConversations([singleId], effectiveSource, folderId, insertIndex);\n        }\n\n        this.exitMultiSelectMode();\n      } catch (error) {\n        console.error('[FolderManager] Conversation reorder drop error:', error);\n      }\n    });\n  }\n\n  /**\n   * Create a thin drop zone between items for drag-and-drop reordering.\n   * When an item is dragged over the gap, it expands and shows a blue indicator line.\n   * On drop, it reorders the item to the target position.\n   */\n  private createReorderGap(\n    parentId: string,\n    itemType: 'folder' | 'conversation',\n    insertIndex: number,\n  ): HTMLElement {\n    const gap = document.createElement('div');\n    gap.className = 'gv-reorder-gap';\n    gap.dataset.parentId = parentId;\n    gap.dataset.itemType = itemType;\n    gap.dataset.insertIndex = insertIndex.toString();\n\n    gap.addEventListener('dragover', (e) => {\n      const data = e.dataTransfer?.types.includes('application/json');\n      if (!data) return;\n\n      e.preventDefault();\n      e.stopPropagation();\n      if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';\n      gap.classList.add('gv-reorder-gap-active');\n    });\n\n    gap.addEventListener('dragleave', () => {\n      gap.classList.remove('gv-reorder-gap-active');\n    });\n\n    gap.addEventListener('drop', (e) => {\n      e.preventDefault();\n      e.stopPropagation();\n      gap.classList.remove('gv-reorder-gap-active');\n\n      const rawData = e.dataTransfer?.getData('application/json');\n      if (!rawData) return;\n\n      try {\n        const dragData: DragData = JSON.parse(rawData);\n\n        // Restore opacity for selected conversations\n        this.selectedConversations.forEach((id) => {\n          const el = this.findConversationElement(id);\n          if (el) el.style.opacity = '1';\n        });\n\n        if (itemType === 'folder' && dragData.type === 'folder' && dragData.folderId) {\n          this.reorderFolder(dragData.folderId, parentId, insertIndex);\n        } else if (itemType === 'conversation' && dragData.type === 'conversation') {\n          const convs = dragData.conversations ?? [];\n          const singleId = dragData.conversationId;\n          const sourceFolderId = dragData.sourceFolderId;\n\n          // If from outside any folder, add to folder data first\n          if (!sourceFolderId) {\n            this.ensureConversationsInFolder(parentId, dragData);\n          }\n\n          const effectiveSource = sourceFolderId ?? parentId;\n\n          if (convs.length > 0) {\n            this.reorderOrMoveConversations(\n              convs.map((c) => c.conversationId),\n              effectiveSource,\n              parentId,\n              insertIndex,\n            );\n          } else if (singleId) {\n            this.reorderOrMoveConversations([singleId], effectiveSource, parentId, insertIndex);\n          }\n        }\n\n        this.exitMultiSelectMode();\n      } catch (error) {\n        console.error('[FolderManager] Reorder drop error:', error);\n      }\n    });\n\n    return gap;\n  }\n\n  /**\n   * Reorder a folder within its parent (or move to a new parent at a specific position).\n   */\n  private reorderFolder(folderId: string, targetParentId: string, insertIndex: number): void {\n    const folder = this.data.folders.find((f) => f.id === folderId);\n    if (!folder) return;\n\n    const targetParent = targetParentId === '__root__' ? null : targetParentId;\n    const sourceParent = folder.parentId;\n\n    // Prevent dropping onto itself or descendants\n    if (folderId === targetParentId) return;\n    if (targetParent && this.isFolderDescendant(targetParentId, folderId)) return;\n\n    // Move to new parent if different\n    if (sourceParent !== targetParent) {\n      folder.parentId = targetParent;\n      folder.updatedAt = Date.now();\n    }\n\n    // Get siblings in target parent (same pinned group)\n    const siblings = this.data.folders.filter(\n      (f) => f.parentId === targetParent && f.id !== folderId && !!f.pinned === !!folder.pinned,\n    );\n    const sorted = this.sortFolders(siblings);\n\n    // Insert at position and reassign sortIndex\n    sorted.splice(insertIndex, 0, folder);\n    sorted.forEach((f, i) => {\n      f.sortIndex = i;\n    });\n\n    this.saveData();\n    this.refresh();\n  }\n\n  /**\n   * Silently add conversation(s) from dragData into a folder's data (no save/refresh).\n   * Used before reorderOrMoveConversations so the conversations exist in the folder.\n   */\n  private ensureConversationsInFolder(folderId: string, dragData: DragData): void {\n    if (!this.data.folderContents[folderId]) {\n      this.data.folderContents[folderId] = [];\n    }\n\n    const convs = dragData.conversations ?? [];\n    const items: { id: string; title: string; url?: string; isGem?: boolean; gemId?: string }[] =\n      convs.length > 0\n        ? convs.map((c) => ({\n            id: c.conversationId,\n            title: c.title,\n            url: c.url,\n            isGem: c.isGem,\n            gemId: c.gemId,\n          }))\n        : dragData.conversationId\n          ? [\n              {\n                id: dragData.conversationId,\n                title: dragData.title,\n                url: dragData.url,\n                isGem: dragData.isGem,\n                gemId: dragData.gemId,\n              },\n            ]\n          : [];\n\n    let maxSortIndex = this.data.folderContents[folderId].reduce(\n      (max, c) => Math.max(max, c.sortIndex ?? -1),\n      -1,\n    );\n\n    for (const item of items) {\n      const exists = this.data.folderContents[folderId].some((c) => c.conversationId === item.id);\n      if (exists) continue;\n\n      this.data.folderContents[folderId].push({\n        conversationId: item.id,\n        title: item.title,\n        url: item.url ?? '',\n        addedAt: Date.now(),\n        isGem: item.isGem,\n        gemId: item.gemId,\n        sortIndex: ++maxSortIndex,\n      });\n    }\n  }\n\n  /**\n   * Reorder conversations within a folder, or move from one folder to another at a specific position.\n   */\n  private reorderOrMoveConversations(\n    conversationIds: string[],\n    sourceParentId: string,\n    targetParentId: string,\n    insertIndex: number,\n  ): void {\n    if (!this.data.folderContents[targetParentId]) {\n      this.data.folderContents[targetParentId] = [];\n    }\n\n    const movingConvs: ConversationReference[] = [];\n\n    // Deduplicate conversation IDs to prevent duplicates from cross-folder selection\n    const uniqueIds = [...new Set(conversationIds)];\n\n    // Collect conversation references\n    for (const convId of uniqueIds) {\n      const sourceList = this.data.folderContents[sourceParentId];\n      if (!sourceList) continue;\n      const conv = sourceList.find((c) => c.conversationId === convId);\n      if (conv) movingConvs.push(conv);\n    }\n\n    if (movingConvs.length === 0) return;\n\n    // When reordering within the same folder, insertIndex is based on the original\n    // sorted list (which includes the dragged items). After removal, indices shift.\n    // Adjust by subtracting the count of dragged items that were before insertIndex.\n    if (sourceParentId === targetParentId) {\n      const isStarredGroup = movingConvs[0].starred ?? false;\n      const originalSorted = this.sortConversations(\n        (this.data.folderContents[targetParentId] ?? []).filter(\n          (c) => !!c.starred === isStarredGroup,\n        ),\n      );\n      let adjustment = 0;\n      for (const convId of conversationIds) {\n        const origIdx = originalSorted.findIndex((c) => c.conversationId === convId);\n        if (origIdx >= 0 && origIdx < insertIndex) {\n          adjustment++;\n        }\n      }\n      insertIndex -= adjustment;\n    }\n\n    // Remove from source\n    if (this.data.folderContents[sourceParentId]) {\n      const removeSet = new Set(conversationIds);\n      this.data.folderContents[sourceParentId] = this.data.folderContents[sourceParentId].filter(\n        (c) => !removeSet.has(c.conversationId),\n      );\n      // Reassign sortIndex in source if it changed\n      if (sourceParentId !== targetParentId) {\n        const sourceConvs = this.sortConversations(this.data.folderContents[sourceParentId]);\n        sourceConvs.forEach((c, i) => {\n          c.sortIndex = i;\n        });\n      }\n    }\n\n    // Get target starred group info for proper insertion\n    const isStarred = movingConvs[0].starred ?? false;\n    const targetList = this.data.folderContents[targetParentId].filter(\n      (c) => !conversationIds.includes(c.conversationId),\n    );\n    this.data.folderContents[targetParentId] = targetList;\n\n    // Get sorted siblings in the same starred group (dragged items already excluded)\n    const sameGroupSiblings = this.sortConversations(\n      targetList.filter((c) => !!c.starred === isStarred),\n    );\n    const otherGroup = targetList.filter((c) => !!c.starred !== isStarred);\n\n    // Clamp insertIndex to valid range after removal\n    const clampedIndex = Math.min(insertIndex, sameGroupSiblings.length);\n\n    // Insert at position\n    sameGroupSiblings.splice(clampedIndex, 0, ...movingConvs);\n\n    // Reassign sortIndex\n    sameGroupSiblings.forEach((c, i) => {\n      c.sortIndex = i;\n    });\n    otherGroup.forEach((c, i) => {\n      if (c.sortIndex == null) c.sortIndex = i;\n    });\n\n    // Recombine\n    this.data.folderContents[targetParentId] = [...sameGroupSiblings, ...otherGroup];\n\n    this.saveData();\n    this.refresh();\n  }\n\n  private addConversationToFolder(\n    folderId: string,\n    dragData: DragData & { sourceFolderId?: string },\n  ): void {\n    this.debug('Adding conversation to folder:', {\n      folderId,\n      dragData,\n    });\n\n    if (!this.data.folderContents[folderId]) {\n      this.data.folderContents[folderId] = [];\n    }\n\n    // Check if conversation is already in this folder\n    const exists = this.data.folderContents[folderId].some(\n      (c) => c.conversationId === dragData.conversationId,\n    );\n\n    if (exists) {\n      this.debug('Conversation already in folder:', dragData.conversationId);\n      this.debug('Existing conversations:', this.data.folderContents[folderId]);\n      return;\n    }\n\n    const maxSortIndex = this.data.folderContents[folderId].reduce(\n      (max, c) => Math.max(max, c.sortIndex ?? -1),\n      -1,\n    );\n    const conv: ConversationReference = {\n      conversationId: dragData.conversationId!,\n      title: dragData.title,\n      url: dragData.url!,\n      addedAt: Date.now(),\n      isGem: dragData.isGem,\n      gemId: dragData.gemId,\n      sortIndex: maxSortIndex + 1,\n    };\n\n    this.data.folderContents[folderId].push(conv);\n    this.debug('Conversation added. Total in folder:', this.data.folderContents[folderId].length);\n\n    // If this was dragged from another folder, remove it from the source\n    if (dragData.sourceFolderId && dragData.sourceFolderId !== folderId) {\n      this.debug('Moving from folder:', dragData.sourceFolderId);\n      this.removeConversationFromFolder(dragData.sourceFolderId, dragData.conversationId!);\n      // Note: removeConversationFromFolder calls saveData() and refresh(), so we don't need to call them again\n      return;\n    }\n\n    // Save immediately before refresh to persist data\n    this.saveData();\n    this.refresh();\n  }\n\n  // Batch add conversations to folder (for multi-select support)\n  private addConversationsToFolder(\n    folderId: string,\n    conversations: ConversationReference[],\n    sourceFolderId?: string,\n  ): void {\n    this.debug('Adding multiple conversations to folder:', {\n      folderId,\n      count: conversations.length,\n      sourceFolderId,\n    });\n\n    if (!this.data.folderContents[folderId]) {\n      this.data.folderContents[folderId] = [];\n    }\n\n    let addedCount = 0;\n    const conversationsToRemove: string[] = [];\n    let maxSortIndex = this.data.folderContents[folderId].reduce(\n      (max, c) => Math.max(max, c.sortIndex ?? -1),\n      -1,\n    );\n\n    conversations.forEach((conv) => {\n      // Check if conversation is already in this folder\n      const exists = this.data.folderContents[folderId].some(\n        (c) => c.conversationId === conv.conversationId,\n      );\n\n      if (!exists) {\n        maxSortIndex++;\n        // Create a copy with updated timestamp\n        const newConv: ConversationReference = {\n          ...conv,\n          addedAt: Date.now(),\n          sortIndex: maxSortIndex,\n        };\n\n        this.data.folderContents[folderId].push(newConv);\n        addedCount++;\n\n        // Track conversations to remove from source folder\n        if (sourceFolderId && sourceFolderId !== folderId) {\n          conversationsToRemove.push(conv.conversationId);\n        }\n      }\n    });\n\n    this.debug(\n      `Added ${addedCount} conversations. Total in folder:`,\n      this.data.folderContents[folderId].length,\n    );\n\n    // Remove from source folder if moving\n    if (sourceFolderId && sourceFolderId !== folderId && conversationsToRemove.length > 0) {\n      this.debug('Removing conversations from source folder:', sourceFolderId);\n      conversationsToRemove.forEach((convId) => {\n        this.data.folderContents[sourceFolderId] = this.data.folderContents[sourceFolderId].filter(\n          (c) => c.conversationId !== convId,\n        );\n      });\n    }\n\n    // Save immediately before refresh to persist data\n    this.saveData();\n    this.refresh();\n  }\n\n  private addFolderToFolder(targetFolderId: string, dragData: DragData): void {\n    const draggedFolderId = dragData.folderId;\n    if (!draggedFolderId) return;\n\n    this.debug('Moving folder to folder:', {\n      draggedFolderId,\n      targetFolderId,\n    });\n\n    // Prevent dropping a folder onto itself\n    if (draggedFolderId === targetFolderId) {\n      this.debug('Cannot drop folder onto itself');\n      return;\n    }\n\n    // Prevent dropping a folder onto its descendant (would create a cycle)\n    if (this.isFolderDescendant(targetFolderId, draggedFolderId)) {\n      this.debug('Cannot drop folder onto its descendant');\n      return;\n    }\n\n    // Find the dragged folder\n    const draggedFolder = this.data.folders.find((f) => f.id === draggedFolderId);\n    if (!draggedFolder) return;\n\n    // Update the parent\n    draggedFolder.parentId = targetFolderId;\n    draggedFolder.updatedAt = Date.now();\n\n    this.saveData();\n    this.refresh();\n  }\n\n  private moveFolderToRoot(dragData: DragData): void {\n    const draggedFolderId = dragData.folderId;\n    if (!draggedFolderId) return;\n\n    this.debug('Moving folder to root level:', draggedFolderId);\n\n    // Find the dragged folder\n    const draggedFolder = this.data.folders.find((f) => f.id === draggedFolderId);\n    if (!draggedFolder) return;\n\n    // If already at root level, no need to do anything\n    if (draggedFolder.parentId === null) {\n      this.debug('Folder is already at root level');\n      return;\n    }\n\n    // Update the parent to null (root level)\n    draggedFolder.parentId = null;\n    draggedFolder.updatedAt = Date.now();\n\n    this.saveData();\n    this.refresh();\n  }\n\n  private isFolderDescendant(folderId: string, potentialAncestorId: string): boolean {\n    // Check if potentialAncestorId is an ancestor of folderId\n    let currentId: string | null = folderId;\n    while (currentId) {\n      if (currentId === potentialAncestorId) {\n        return true;\n      }\n      const folder = this.data.folders.find((f) => f.id === currentId);\n      currentId = folder?.parentId || null;\n    }\n    return false;\n  }\n\n  private toggleConversationStar(folderId: string, conversationId: string): void {\n    const conversations = this.data.folderContents[folderId];\n    if (!conversations) return;\n\n    const conv = conversations.find((c) => c.conversationId === conversationId);\n    if (!conv) return;\n\n    // Toggle starred state\n    conv.starred = !conv.starred;\n\n    // Save data\n    this.saveData();\n\n    // Refresh the folder UI to update the star icon and re-sort\n    this.refresh();\n\n    this.debug('Toggled star for conversation:', conversationId, 'starred:', conv.starred);\n  }\n\n  private confirmRemoveConversation(\n    folderId: string,\n    conversationId: string,\n    title: string,\n    event: MouseEvent,\n  ): void {\n    // Create inline confirmation dialog using safe DOM API\n    const confirmDialog = document.createElement('div');\n    confirmDialog.className = 'gv-folder-confirm-dialog';\n\n    // Create message element safely with user-provided title\n    const message = document.createElement('div');\n    message.className = 'gv-folder-confirm-message';\n    // Safe: textContent prevents XSS even with user-controlled title\n    message.textContent = this.t('folder_remove_conversation_confirm').replace('{title}', title);\n\n    // Create actions container\n    const actions = document.createElement('div');\n    actions.className = 'gv-folder-confirm-actions';\n\n    // Create buttons safely\n    const yesBtn = document.createElement('button');\n    yesBtn.className = 'gv-folder-confirm-btn gv-folder-confirm-yes';\n    yesBtn.textContent = this.t('pm_delete'); // Safe: uses textContent\n\n    const noBtn = document.createElement('button');\n    noBtn.className = 'gv-folder-confirm-btn gv-folder-confirm-no';\n    noBtn.textContent = this.t('pm_cancel'); // Safe: uses textContent\n\n    // Assemble the dialog\n    actions.appendChild(yesBtn);\n    actions.appendChild(noBtn);\n    confirmDialog.appendChild(message);\n    confirmDialog.appendChild(actions);\n\n    // Position near the clicked element\n    const rect = (event.target as HTMLElement).getBoundingClientRect();\n    confirmDialog.style.position = 'fixed';\n    confirmDialog.style.top = `${rect.bottom + 4}px`;\n    confirmDialog.style.left = `${Math.min(rect.left, window.innerWidth - 280)}px`;\n\n    document.body.appendChild(confirmDialog);\n\n    // Cleanup function\n    const cleanup = () => {\n      confirmDialog.remove();\n    };\n\n    yesBtn?.addEventListener('click', () => {\n      this.removeConversationFromFolder(folderId, conversationId);\n      cleanup();\n    });\n\n    noBtn?.addEventListener('click', cleanup);\n\n    // Close on click outside\n    setTimeout(() => {\n      const closeOnOutside = (e: MouseEvent) => {\n        if (!confirmDialog.contains(e.target as Node)) {\n          cleanup();\n          document.removeEventListener('click', closeOnOutside);\n        }\n      };\n      document.addEventListener('click', closeOnOutside);\n    }, 0);\n  }\n\n  private removeConversationFromFolder(folderId: string, conversationId: string): void {\n    if (!this.data.folderContents[folderId]) return;\n\n    this.data.folderContents[folderId] = this.data.folderContents[folderId].filter(\n      (c) => c.conversationId !== conversationId,\n    );\n\n    this.saveData();\n    this.refresh();\n  }\n\n  private batchDeleteConversations(): void {\n    if (!this.multiSelectFolderId || this.selectedConversations.size === 0) return;\n\n    const count = this.selectedConversations.size;\n    const confirmed = confirm(\n      `Delete ${count} selected conversation${count > 1 ? 's' : ''} from this folder?`,\n    );\n\n    if (!confirmed) return;\n\n    // Remove all selected conversations from the folder\n    const folderId = this.multiSelectFolderId;\n    if (!this.data.folderContents[folderId]) return;\n\n    this.data.folderContents[folderId] = this.data.folderContents[folderId].filter(\n      (c) => !this.selectedConversations.has(c.conversationId),\n    );\n\n    this.saveData();\n\n    // Exit multi-select mode and refresh\n    this.exitMultiSelectMode();\n    this.refresh();\n\n    this.debug(`Batch deleted ${count} conversations from folder ${folderId}`);\n  }\n\n  /**\n   * Batch delete native Gemini conversations by simulating user clicks\n   * This triggers the actual deletion on Gemini's servers\n   */\n  private async batchDeleteNativeConversations(): Promise<void> {\n    if (this.batchDeleteInProgress) {\n      this.debug('Batch delete already in progress');\n      return;\n    }\n\n    const count = this.selectedConversations.size;\n    if (count === 0) return;\n\n    // Show confirmation dialog\n    const confirmMessage = this.t('batch_delete_confirm').replace('{count}', String(count));\n    const confirmed = confirm(confirmMessage);\n    if (!confirmed) return;\n\n    this.batchDeleteInProgress = true;\n    const conversationIds = Array.from(this.selectedConversations);\n    let successCount = 0;\n    let failedCount = 0;\n\n    try {\n      // Show progress indicator\n      this.showBatchDeleteProgress(0, count);\n\n      for (let i = 0; i < conversationIds.length; i++) {\n        const conversationId = conversationIds[i];\n        this.debug(`Deleting conversation ${i + 1}/${count}: ${conversationId}`);\n\n        // Update progress\n        this.updateBatchDeleteProgress(i + 1, count);\n\n        try {\n          const success = await this.triggerNativeDeleteForConversation(conversationId);\n          if (success) {\n            successCount++;\n          } else {\n            failedCount++;\n            this.debugWarn(`Failed to delete conversation: ${conversationId}`);\n          }\n        } catch (error) {\n          failedCount++;\n          console.error(`[FolderManager] Error deleting conversation ${conversationId}:`, error);\n        }\n\n        // Add delay between deletions to avoid rate limiting\n        if (i < conversationIds.length - 1) {\n          await this.delay(this.BATCH_DELETE_CONFIG.DELAY_BETWEEN_DELETIONS);\n        }\n      }\n\n      // Hide progress indicator\n      this.hideBatchDeleteProgress();\n\n      // Show result summary\n      if (failedCount === 0) {\n        const successMessage = this.t('batch_delete_success').replace(\n          '{count}',\n          String(successCount),\n        );\n        this.showNotification(successMessage, 'success');\n      } else {\n        const partialMessage = this.t('batch_delete_partial')\n          .replace('{success}', String(successCount))\n          .replace('{failed}', String(failedCount));\n        this.showNotification(partialMessage, 'info');\n      }\n\n      // Exit multi-select mode\n      this.exitMultiSelectMode();\n\n      // Refresh page after deletion\n      if (successCount > 0) {\n        this.debug('Refreshing page after batch delete');\n        setTimeout(() => {\n          window.location.reload();\n        }, this.BATCH_DELETE_CONFIG.PAGE_REFRESH_DELAY);\n      }\n    } finally {\n      this.batchDeleteInProgress = false;\n    }\n  }\n\n  /**\n   * Trigger native delete for a single conversation by simulating UI interactions\n   */\n  private async triggerNativeDeleteForConversation(conversationId: string): Promise<boolean> {\n    try {\n      // Step 1: Find the conversation element in the sidebar\n      const conversationEl = this.findNativeConversationElement(conversationId);\n      if (!conversationEl) {\n        this.debugWarn(`Could not find conversation element for: ${conversationId}`);\n        return false;\n      }\n\n      // Step 2: Find and click the more options button\n      const moreButton = await this.findAndClickMoreButton(conversationEl);\n      if (!moreButton) {\n        this.debugWarn(`Could not find more button for: ${conversationId}`);\n        return false;\n      }\n\n      // Wait for menu to appear\n      await this.delay(this.BATCH_DELETE_CONFIG.MENU_APPEAR_DELAY);\n\n      // Step 3: Find and click the delete button in the menu\n      const deleteSuccess = await this.waitForDeleteButtonAndClick();\n      if (!deleteSuccess) {\n        this.debugWarn(`Could not click delete button for: ${conversationId}`);\n        // Try to close the menu by clicking the backdrop\n        this.clickBackdropToCloseMenu();\n        return false;\n      }\n\n      // Wait for confirmation dialog (if any)\n      await this.delay(this.BATCH_DELETE_CONFIG.DIALOG_APPEAR_DELAY);\n\n      // Step 4: Confirm deletion if confirmation dialog appears\n      await this.confirmDeleteIfNeeded();\n\n      // Wait for deletion to complete\n      await this.delay(this.BATCH_DELETE_CONFIG.DELETION_COMPLETE_DELAY);\n\n      return true;\n    } catch (error) {\n      console.error(`[FolderManager] Error in triggerNativeDeleteForConversation:`, error);\n      return false;\n    }\n  }\n\n  /**\n   * Find native conversation element by conversation ID\n   */\n  private findNativeConversationElement(conversationId: string): HTMLElement | null {\n    // Try multiple strategies to find the conversation\n    const allConversations = this.sidebarContainer?.querySelectorAll(\n      '[data-test-id=\"conversation\"]',\n    );\n    if (!allConversations) return null;\n\n    for (const conv of allConversations) {\n      const id = this.extractConversationId(conv as HTMLElement);\n      if (id === conversationId) {\n        return conv as HTMLElement;\n      }\n    }\n\n    return null;\n  }\n\n  /**\n   * Find and click the more options button for a conversation\n   */\n  private async findAndClickMoreButton(conversationEl: HTMLElement): Promise<HTMLElement | null> {\n    // The more button might be in the actions container which is a sibling\n    let moreButton: HTMLElement | null = null;\n\n    // Strategy 1: Look for actions container as a sibling\n    const parent = conversationEl.parentElement;\n    if (parent) {\n      const actionsContainer = parent.querySelector('.conversation-actions-container');\n      if (actionsContainer) {\n        moreButton = actionsContainer.querySelector(\n          '[data-test-id=\"actions-menu-button\"]',\n        ) as HTMLElement;\n      }\n    }\n\n    // Strategy 2: Look within the conversation element\n    if (!moreButton) {\n      moreButton = conversationEl.querySelector(\n        '[data-test-id=\"actions-menu-button\"]',\n      ) as HTMLElement;\n    }\n\n    // Strategy 3: Look for any visible button with the actions-menu-button test id near this element\n    if (!moreButton) {\n      // Find the closest list item that contains both the conversation and actions\n      const listItem = conversationEl.closest('li');\n      if (listItem) {\n        moreButton = listItem.querySelector('[data-test-id=\"actions-menu-button\"]') as HTMLElement;\n      }\n    }\n\n    if (moreButton) {\n      moreButton.click();\n      this.debug('Clicked more button');\n      return moreButton;\n    }\n\n    return null;\n  }\n\n  /**\n   * Wait for delete button to appear in the menu and click it\n   * Uses multiple strategies to find the delete button for resilience to UI changes\n   */\n  private async waitForDeleteButtonAndClick(): Promise<boolean> {\n    const maxWaitTime = this.BATCH_DELETE_CONFIG.MAX_BUTTON_WAIT_TIME;\n    const checkInterval = this.BATCH_DELETE_CONFIG.BUTTON_CHECK_INTERVAL;\n    let elapsed = 0;\n\n    const keywords = this.getDeleteKeywords();\n\n    while (elapsed < maxWaitTime) {\n      // Strategy 1: Look for delete button by data-test-id (primary method)\n      const deleteByTestId = document.querySelector(\n        '[data-test-id=\"delete-button\"]',\n      ) as HTMLElement;\n      if (deleteByTestId && this.isVisibleElement(deleteByTestId)) {\n        deleteByTestId.click();\n        this.debug('Clicked delete button (by test-id)');\n        return true;\n      }\n\n      // Strategy 2: Look for menu items containing delete text (supports translations)\n      const menuItems = document.querySelectorAll(\n        '.cdk-overlay-container button, ' +\n          '.cdk-overlay-container [role=\"menuitem\"], ' +\n          '.mat-mdc-menu-content button, ' +\n          '.mat-menu-content button',\n      );\n\n      for (const item of menuItems) {\n        if (!this.isVisibleElement(item as HTMLElement)) continue;\n\n        const text = item.textContent?.toLowerCase().trim() || '';\n        // Match keywords from i18n\n        if (\n          text &&\n          keywords.some(\n            (keyword: string) => text === keyword || (text.includes(keyword) && text.length < 20),\n          )\n        ) {\n          (item as HTMLElement).click();\n          this.debug('Clicked delete button (by text):', text);\n          return true;\n        }\n      }\n\n      // Strategy 3: Look for button with delete icon (mat-icon containing 'delete')\n      const deleteIcons = document.querySelectorAll(\n        '.cdk-overlay-container mat-icon, .cdk-overlay-container .material-icons',\n      );\n\n      for (const icon of deleteIcons) {\n        const iconText = icon.textContent?.toLowerCase().trim() || '';\n        if (\n          iconText === 'delete' ||\n          iconText === 'delete_forever' ||\n          iconText === 'delete_outline'\n        ) {\n          // Find the parent button and click it\n          const parentButton = icon.closest('button, [role=\"menuitem\"]') as HTMLElement;\n          if (parentButton && this.isVisibleElement(parentButton)) {\n            parentButton.click();\n            this.debug('Clicked delete button (by icon)');\n            return true;\n          }\n        }\n      }\n\n      await this.delay(checkInterval);\n      elapsed += checkInterval;\n    }\n\n    return false;\n  }\n\n  /**\n   * Check for and confirm the delete confirmation dialog if it appears\n   */\n  private async confirmDeleteIfNeeded(): Promise<void> {\n    // Look for confirmation dialog buttons\n    // Gemini typically uses a dialog with confirm/cancel buttons\n    const maxWaitTime = this.BATCH_DELETE_CONFIG.MAX_BUTTON_WAIT_TIME;\n    const checkInterval = this.BATCH_DELETE_CONFIG.BUTTON_CHECK_INTERVAL;\n    let elapsed = 0;\n\n    const keywords = this.getDeleteKeywords();\n\n    while (elapsed < maxWaitTime) {\n      // Strategy 1: Look for button with data-test-id containing \"confirm\" or \"delete\"\n      const confirmByTestId = document.querySelector(\n        '[data-test-id*=\"confirm\"], [data-test-id*=\"delete\"]:not([data-test-id=\"delete-button\"])',\n      ) as HTMLElement;\n      if (confirmByTestId && this.isVisibleElement(confirmByTestId)) {\n        confirmByTestId.click();\n        this.debug('Clicked confirmation button (by test-id)');\n        return;\n      }\n\n      // Strategy 2: Look for primary/action buttons in dialogs\n      const primaryButtons = document.querySelectorAll(`\n        .mat-mdc-dialog-container button.mat-primary,\n        .mat-mdc-dialog-container button.mat-accent,\n        .mat-mdc-dialog-container .mat-mdc-dialog-actions button:last-child,\n        .cdk-overlay-container .mat-mdc-dialog-actions button:last-child,\n        .cdk-overlay-container button[color=\"primary\"],\n        .cdk-overlay-container button[color=\"warn\"]\n      `);\n\n      for (const btn of primaryButtons) {\n        if (this.isVisibleElement(btn as HTMLElement)) {\n          const text = btn.textContent?.toLowerCase().trim() || '';\n          // Match keywords from i18n\n          if (\n            text &&\n            keywords.some((keyword: string) => text.includes(keyword) || text === keyword)\n          ) {\n            (btn as HTMLElement).click();\n            this.debug('Clicked confirmation button (primary button):', text);\n            return;\n          }\n        }\n      }\n\n      // Strategy 3: Look for any button in overlay with delete/confirm text\n      const allOverlayButtons = document.querySelectorAll(\n        '.cdk-overlay-container button, .mat-mdc-dialog-container button',\n      );\n\n      for (const btn of allOverlayButtons) {\n        if (!this.isVisibleElement(btn as HTMLElement)) continue;\n\n        const text = btn.textContent?.toLowerCase().trim() || '';\n        // Be more specific - look for exact match or simple inclusion for keywords\n        if (text && keywords.some((keyword: string) => text === keyword)) {\n          (btn as HTMLElement).click();\n          this.debug('Clicked confirmation button (overlay button):', text);\n          return;\n        }\n      }\n\n      // Strategy 4: Look for the second/right button in a two-button dialog (usually the confirm button)\n      const dialogActions = document.querySelector(\n        '.mat-mdc-dialog-actions, .cdk-overlay-container .mat-dialog-actions',\n      );\n      if (dialogActions) {\n        const buttons = dialogActions.querySelectorAll('button');\n        if (buttons.length >= 2) {\n          // The last button is typically the confirm/destructive action\n          const confirmBtn = buttons[buttons.length - 1] as HTMLElement;\n          if (this.isVisibleElement(confirmBtn)) {\n            confirmBtn.click();\n            this.debug('Clicked last button in dialog actions');\n            return;\n          }\n        }\n      }\n\n      await this.delay(checkInterval);\n      elapsed += checkInterval;\n    }\n\n    // No confirmation dialog found, which is fine\n    this.debug('No confirmation dialog detected after', maxWaitTime, 'ms');\n  }\n\n  /**\n   * Get delete/confirm keywords from i18n settings to avoid hardcoding\n   */\n  private getDeleteKeywords(): string[] {\n    const rawPatterns = this.t('batch_delete_match_patterns') || '';\n    return rawPatterns\n      .split(',')\n      .map((s: string) => s.trim().toLowerCase())\n      .filter((s: string) => s.length > 0);\n  }\n\n  /**\n   * Check if an element is visible\n   */\n  private isVisibleElement(el: HTMLElement): boolean {\n    if (!el) return false;\n    const style = window.getComputedStyle(el);\n    return (\n      style.display !== 'none' &&\n      style.visibility !== 'hidden' &&\n      style.opacity !== '0' &&\n      el.offsetParent !== null\n    );\n  }\n\n  /**\n   * Click backdrop to close any open menu\n   */\n  private clickBackdropToCloseMenu(): void {\n    const backdrop = document.querySelector('.cdk-overlay-backdrop') as HTMLElement;\n    if (backdrop) {\n      backdrop.click();\n      this.debug('Clicked backdrop to close menu');\n    }\n  }\n\n  /**\n   * Show batch delete progress indicator\n   */\n  private showBatchDeleteProgress(current: number, total: number): void {\n    // Remove existing progress element if any\n    this.hideBatchDeleteProgress();\n\n    const progress = document.createElement('div');\n    progress.className = 'gv-batch-delete-progress';\n    progress.style.cssText = `\n      position: fixed;\n      bottom: 20px;\n      right: 20px;\n      background: rgba(32, 33, 36, 0.95);\n      color: #e8eaed;\n      padding: 16px 24px;\n      border-radius: 8px;\n      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);\n      z-index: 2147483647;\n      display: flex;\n      align-items: center;\n      gap: 12px;\n      font-family: 'Google Sans', Roboto, Arial, sans-serif;\n      font-size: 14px;\n    `;\n\n    const spinner = document.createElement('div');\n    spinner.style.cssText = `\n      width: 20px;\n      height: 20px;\n      border: 2px solid #8ab4f8;\n      border-top-color: transparent;\n      border-radius: 50%;\n      animation: gv-spin 1s linear infinite;\n    `;\n\n    // Add spinner animation if not already present\n    if (!document.querySelector('#gv-batch-delete-styles')) {\n      const style = document.createElement('style');\n      style.id = 'gv-batch-delete-styles';\n      style.textContent = `\n        @keyframes gv-spin {\n          to { transform: rotate(360deg); }\n        }\n      `;\n      document.head.appendChild(style);\n    }\n\n    const text = document.createElement('span');\n    text.className = 'gv-batch-delete-progress-text';\n    text.textContent = this.t('batch_delete_in_progress')\n      .replace('{current}', String(current))\n      .replace('{total}', String(total));\n\n    progress.appendChild(spinner);\n    progress.appendChild(text);\n    document.body.appendChild(progress);\n\n    this.batchDeleteProgressElement = progress;\n  }\n\n  /**\n   * Update batch delete progress indicator\n   */\n  private updateBatchDeleteProgress(current: number, total: number): void {\n    if (this.batchDeleteProgressElement) {\n      const textEl = this.batchDeleteProgressElement.querySelector(\n        '.gv-batch-delete-progress-text',\n      );\n      if (textEl) {\n        textEl.textContent = this.t('batch_delete_in_progress')\n          .replace('{current}', String(current))\n          .replace('{total}', String(total));\n      }\n    }\n  }\n\n  /**\n   * Hide batch delete progress indicator\n   */\n  private hideBatchDeleteProgress(): void {\n    if (this.batchDeleteProgressElement) {\n      this.batchDeleteProgressElement.remove();\n      this.batchDeleteProgressElement = null;\n    }\n  }\n\n  /**\n   * Helper function to create a delay\n   */\n  private delay(ms: number): Promise<void> {\n    return new Promise((resolve) => setTimeout(resolve, ms));\n  }\n\n  // Multi-select helper methods\n  private clearSelection(): void {\n    this.selectedConversations.clear();\n  }\n\n  private selectConversation(conversationId: string): void {\n    this.selectedConversations.add(conversationId);\n  }\n\n  private toggleConversationSelection(conversationId: string): void {\n    if (this.selectedConversations.has(conversationId)) {\n      this.selectedConversations.delete(conversationId);\n\n      // Auto-exit multi-select mode when all selections are cleared\n      if (this.selectedConversations.size === 0 && this.isMultiSelectMode) {\n        this.exitMultiSelectMode();\n        return;\n      }\n    } else {\n      // Check if we've reached the maximum selection limit\n      if (this.selectedConversations.size >= this.MAX_BATCH_DELETE_COUNT) {\n        const message = this.t('batch_delete_limit_reached').replace(\n          '{max}',\n          String(this.MAX_BATCH_DELETE_COUNT),\n        );\n        this.showNotification(message, 'info');\n        return;\n      }\n      this.selectedConversations.add(conversationId);\n    }\n  }\n\n  private updateConversationSelectionUI(): void {\n    // Only update UI for the source where multi-select was initiated\n    if (this.multiSelectSource === 'folder') {\n      // Only update folder conversation elements\n      const allConvEls = this.containerElement?.querySelectorAll('.gv-folder-conversation');\n      allConvEls?.forEach((el) => {\n        const convId = (el as HTMLElement).dataset.conversationId;\n        const elFolderId = (el as HTMLElement).dataset.folderId;\n\n        // Only update conversations in the same folder where multi-select started\n        if (convId && (!this.multiSelectFolderId || elFolderId === this.multiSelectFolderId)) {\n          if (this.selectedConversations.has(convId)) {\n            el.classList.add('gv-folder-conversation-selected');\n          } else {\n            el.classList.remove('gv-folder-conversation-selected');\n          }\n        }\n      });\n    } else if (this.multiSelectSource === 'native') {\n      // Only update native conversation elements (Recent section)\n      const nativeConvs = this.sidebarContainer?.querySelectorAll('[data-test-id=\"conversation\"]');\n      nativeConvs?.forEach((el) => {\n        const convId = this.extractConversationId(el as HTMLElement);\n        if (convId) {\n          if (this.selectedConversations.has(convId)) {\n            el.classList.add('gv-conversation-selected');\n          } else {\n            el.classList.remove('gv-conversation-selected');\n          }\n        }\n      });\n    }\n\n    // Update the selection count\n    this.updateMultiSelectModeUI();\n  }\n\n  private enterMultiSelectMode(\n    initialConversationId?: string,\n    source: 'folder' | 'native' = 'native',\n    folderId?: string,\n  ): void {\n    this.debug('Entering multi-select mode', { source, folderId });\n    this.isMultiSelectMode = true;\n    this.multiSelectSource = source;\n    this.multiSelectFolderId = folderId || null;\n\n    // Select the conversation that triggered the long-press\n    if (initialConversationId) {\n      this.selectConversation(initialConversationId);\n    }\n\n    this.updateMultiSelectModeUI();\n    this.updateConversationSelectionUI();\n\n    // Add visual feedback (vibration on mobile)\n    if ('vibrate' in navigator) {\n      navigator.vibrate(50);\n    }\n\n    // Add click-outside listener to exit multi-select mode\n    this.setupOutsideClickHandler();\n  }\n\n  private exitMultiSelectMode(): void {\n    this.debug('Exiting multi-select mode');\n    this.isMultiSelectMode = false;\n    this.multiSelectSource = null;\n    this.multiSelectFolderId = null;\n\n    // Remove click-outside listener\n    this.removeOutsideClickHandler();\n\n    // First update UI to remove selection styles\n    this.updateConversationSelectionUI();\n\n    // Then clear the selection set\n    this.clearSelection();\n\n    // Update mode UI\n    this.updateMultiSelectModeUI();\n\n    // Force cleanup of any remaining visual artifacts\n    this.cleanupSelectionArtifacts();\n  }\n\n  /**\n   * Setup a document-level click handler to exit multi-select mode when clicking outside the sidebar\n   */\n  private setupOutsideClickHandler(): void {\n    // Remove any existing handler first\n    this.removeOutsideClickHandler();\n\n    this.outsideClickHandler = (e: MouseEvent) => {\n      const target = e.target as HTMLElement;\n\n      // Check if click is inside the sidebar or folder container\n      const isInsideSidebar = this.sidebarContainer?.contains(target);\n      const isInsideFolderContainer = this.containerElement?.contains(target);\n\n      // Check if click is on an overlay (menus, dialogs, etc.)\n      const isOnOverlay = target.closest('.cdk-overlay-container, .mat-mdc-dialog-container');\n\n      // If click is outside all relevant areas, exit multi-select mode\n      if (!isInsideSidebar && !isInsideFolderContainer && !isOnOverlay) {\n        this.debug('Click outside sidebar detected, exiting multi-select mode');\n        this.exitMultiSelectMode();\n      }\n    };\n\n    // Use setTimeout to avoid the current click event from triggering the handler\n    setTimeout(() => {\n      document.addEventListener('click', this.outsideClickHandler!, true);\n    }, 0);\n  }\n\n  /**\n   * Remove the outside click handler\n   */\n  private removeOutsideClickHandler(): void {\n    if (this.outsideClickHandler) {\n      document.removeEventListener('click', this.outsideClickHandler, true);\n      this.outsideClickHandler = null;\n    }\n  }\n\n  private cleanupSelectionArtifacts(): void {\n    // Remove selection classes from all native conversations\n    const nativeConvs = this.sidebarContainer?.querySelectorAll('[data-test-id=\"conversation\"]');\n    nativeConvs?.forEach((el) => {\n      (el as HTMLElement).classList.remove('gv-conversation-selected');\n      (el as HTMLElement).style.opacity = '1';\n    });\n    // Remove selection classes from all folder conversations\n    const folderConvs = this.containerElement?.querySelectorAll('.gv-folder-conversation');\n    folderConvs?.forEach((el) => {\n      (el as HTMLElement).classList.remove('gv-folder-conversation-selected');\n      (el as HTMLElement).style.opacity = '1';\n    });\n\n    // Restore active conversation highlight in folders\n    // This ensures that the currently active conversation remains highlighted\n    // after drag-and-drop or multi-select operations\n    this.highlightActiveConversationInFolders();\n  }\n\n  /**\n   * Provides visual feedback when user attempts to select conversations from different folders.\n   * Uses a subtle shake animation to indicate invalid selection.\n   *\n   * @param element - The conversation element to apply feedback to\n   *\n   * Note: Uses animationend event instead of setTimeout to ensure cleanup happens\n   * exactly when the CSS animation finishes, making it resilient to animation timing changes.\n   */\n  private showInvalidSelectionFeedback(element: HTMLElement): void {\n    // Remove existing class (if any) to allow animation restart on rapid clicks\n    element.classList.remove('gv-invalid-selection');\n\n    // Force reflow to ensure animation restarts (see: CSS Triggers)\n    void element.offsetWidth;\n\n    // Add invalid selection class to trigger animation\n    element.classList.add('gv-invalid-selection');\n\n    // Listen for animation end to clean up the class automatically\n    // Using { once: true } ensures the listener is removed after first invocation\n    element.addEventListener(\n      'animationend',\n      () => {\n        element.classList.remove('gv-invalid-selection');\n      },\n      { once: true },\n    );\n\n    // Optional: Haptic feedback on mobile devices\n    if ('vibrate' in navigator) {\n      navigator.vibrate([30, 20, 30]); // Two short vibrations\n    }\n  }\n\n  private updateMultiSelectModeUI(): void {\n    // Add or remove multi-select mode class from container\n    if (this.isMultiSelectMode) {\n      this.containerElement?.classList.add('gv-multi-select-mode');\n    } else {\n      this.containerElement?.classList.remove('gv-multi-select-mode');\n    }\n\n    // Update selection count in indicator\n    const countElement = this.containerElement?.querySelector('[data-selection-count=\"true\"]');\n    if (countElement) {\n      const count = this.selectedConversations.size;\n      countElement.textContent = `${count} selected`;\n    }\n\n    // Update action buttons based on source\n    const actionsContainer = this.containerElement?.querySelector(\n      '[data-multi-select-actions=\"true\"]',\n    );\n    if (actionsContainer && this.isMultiSelectMode) {\n      actionsContainer.innerHTML = ''; // Clear existing buttons\n\n      if (this.multiSelectSource === 'folder') {\n        // Delete button for folder multi-select (removes from folder only)\n        const deleteBtn = document.createElement('button');\n        deleteBtn.className = 'gv-multi-select-action-btn gv-multi-select-delete-btn';\n        deleteBtn.innerHTML =\n          '<mat-icon role=\"img\" class=\"mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color\" aria-hidden=\"true\">delete</mat-icon>';\n        deleteBtn.title = this.t('batch_delete_button');\n        deleteBtn.addEventListener('click', () => this.batchDeleteConversations());\n        actionsContainer.appendChild(deleteBtn);\n      } else if (this.multiSelectSource === 'native') {\n        // Delete button for native multi-select (deletes from Gemini)\n        const deleteBtn = document.createElement('button');\n        deleteBtn.className = 'gv-multi-select-action-btn gv-multi-select-delete-btn';\n        deleteBtn.innerHTML =\n          '<mat-icon role=\"img\" class=\"mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color\" aria-hidden=\"true\">delete</mat-icon>';\n        deleteBtn.title = this.t('batch_delete_button');\n        deleteBtn.addEventListener('click', () => this.batchDeleteNativeConversations());\n        actionsContainer.appendChild(deleteBtn);\n      }\n\n      // Exit button (always present)\n      const exitBtn = document.createElement('button');\n      exitBtn.className = 'gv-multi-select-action-btn gv-multi-select-exit-btn';\n      exitBtn.innerHTML =\n        '<mat-icon role=\"img\" class=\"mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color\" aria-hidden=\"true\">close</mat-icon>';\n      exitBtn.title = 'Exit multi-select mode';\n      exitBtn.addEventListener('click', () => this.exitMultiSelectMode());\n      actionsContainer.appendChild(exitBtn);\n    } else if (actionsContainer) {\n      actionsContainer.innerHTML = ''; // Clear buttons when exiting\n    }\n  }\n\n  private getSelectedConversationsData(_folderId: string): ConversationReference[] {\n    const result: ConversationReference[] = [];\n    const seen = new Set<string>();\n\n    // Collect from all folders since selection can span folders\n    for (const fId in this.data.folderContents) {\n      const conversations = this.data.folderContents[fId];\n      conversations.forEach((conv) => {\n        if (this.selectedConversations.has(conv.conversationId) && !seen.has(conv.conversationId)) {\n          seen.add(conv.conversationId);\n          result.push(conv);\n        }\n      });\n    }\n\n    return result;\n  }\n\n  private renameConversation(\n    folderId: string,\n    conversationId: string,\n    titleElement: HTMLElement,\n  ): void {\n    // Get current title\n    const conv = this.data.folderContents[folderId]?.find(\n      (c) => c.conversationId === conversationId,\n    );\n    if (!conv) return;\n\n    const currentTitle = conv.title;\n\n    // Create inline input for renaming\n    const input = document.createElement('input');\n    input.type = 'text';\n    input.className = 'gv-folder-name-input gv-conversation-rename-input';\n    input.value = currentTitle;\n    input.style.width = '100%';\n\n    // Replace title with input\n    const parent = titleElement.parentElement;\n    if (!parent) return;\n\n    titleElement.style.display = 'none';\n    parent.insertBefore(input, titleElement);\n    input.focus();\n    input.select();\n\n    let finished = false;\n    const cleanup = () => {\n      try {\n        input.removeEventListener('blur', onBlur);\n      } catch (e) {\n        this.debug('Failed to remove blur listener:', e);\n      }\n      try {\n        input.removeEventListener('keydown', onKeyDown);\n      } catch (e) {\n        this.debug('Failed to remove keydown listener:', e);\n      }\n    };\n    const finalize = (commit: boolean) => {\n      if (finished) return;\n      finished = true;\n      cleanup();\n      try {\n        if (commit) {\n          const newTitle = input.value.trim();\n          if (newTitle && newTitle !== currentTitle) {\n            conv.title = newTitle;\n            conv.customTitle = true; // mark as manually renamed, don't auto-sync from native\n            conv.updatedAt = Date.now(); // record update time for sync conflict resolution\n            this.saveData();\n          }\n        }\n      } catch (e) {\n        this.debug('Failed to save renamed conversation:', e);\n      }\n      // Restore title element gracefully even if DOM re-rendered\n      try {\n        if (input.isConnected) input.remove();\n      } catch (e) {\n        this.debug('Failed to remove input:', e);\n      }\n      try {\n        titleElement.style.display = '';\n      } catch (e) {\n        this.debug('Failed to restore title display:', e);\n      }\n      try {\n        titleElement.textContent = conv.title;\n      } catch (e) {\n        this.debug('Failed to restore title text:', e);\n      }\n    };\n    const onBlur = () => {\n      // Defer finalize to let Angular/SPA navigation settle\n      requestAnimationFrame(() => finalize(true));\n    };\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        finalize(true);\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        finalize(false);\n      }\n    };\n\n    input.addEventListener('blur', onBlur);\n    input.addEventListener('keydown', onKeyDown);\n  }\n\n  private showFolderMenu(event: MouseEvent, folderId: string): void {\n    event.stopPropagation();\n\n    const folder = this.data.folders.find((f) => f.id === folderId);\n    if (!folder) return;\n\n    // Create context menu\n    const menu = document.createElement('div');\n    menu.className = 'gv-folder-menu';\n    menu.style.position = 'fixed';\n    menu.style.left = `${event.clientX}px`;\n    menu.style.top = `${event.clientY}px`;\n\n    const menuItems = [\n      {\n        label: folder.pinned ? this.t('folder_unpin') : this.t('folder_pin'),\n        action: () => this.togglePinFolder(folderId),\n      },\n      { label: this.t('folder_create_subfolder'), action: () => this.createFolder(folderId) },\n      { label: this.t('folder_rename'), action: () => this.renameFolder(folderId) },\n      { label: this.t('folder_change_color'), action: () => this.showColorPicker(folderId, event) },\n      { label: this.t('folder_delete'), action: () => this.deleteFolder(folderId) },\n    ];\n\n    menuItems.forEach((item) => {\n      const menuItem = document.createElement('button');\n      menuItem.className = 'gv-folder-menu-item';\n      menuItem.textContent = item.label;\n      menuItem.addEventListener('click', () => {\n        item.action();\n        menu.remove();\n      });\n      menu.appendChild(menuItem);\n    });\n\n    document.body.appendChild(menu);\n\n    // Close menu on click outside\n    const closeMenu = (e: MouseEvent) => {\n      if (!menu.contains(e.target as Node)) {\n        menu.remove();\n        document.removeEventListener('click', closeMenu);\n      }\n    };\n    setTimeout(() => document.addEventListener('click', closeMenu), 0);\n  }\n\n  /**\n   * Show color picker dialog for a folder\n   * @param folderId The folder ID to change color\n   * @param sourceEvent The source mouse event (for positioning)\n   */\n  private showColorPicker(\n    folderId: string,\n    sourceEvent: MouseEvent,\n    allowToggle: boolean = true,\n  ): void {\n    const folder = this.data.folders.find((f) => f.id === folderId);\n    if (!folder) return;\n\n    // If a color picker is already open, close it first\n    if (this.activeColorPicker) {\n      const wasSameFolder = this.activeColorPickerFolderId === folderId;\n      this.activeColorPicker.remove();\n      // Clean up the old event listener to prevent memory leak\n      if (this.activeColorPickerCloseHandler) {\n        document.removeEventListener('click', this.activeColorPickerCloseHandler);\n        this.activeColorPickerCloseHandler = null;\n      }\n      this.activeColorPicker = null;\n      this.activeColorPickerFolderId = null;\n      // If clicking the same folder icon again and toggle is allowed, just close the picker\n      if (allowToggle && wasSameFolder) {\n        return;\n      }\n    }\n\n    // Create color picker dialog\n    const dialog = document.createElement('div');\n    dialog.className = 'gv-color-picker-dialog';\n\n    // Position near the menu click (slightly offset to avoid overlap)\n    dialog.style.position = 'fixed';\n    dialog.style.left = `${sourceEvent.clientX + 10}px`;\n    dialog.style.top = `${sourceEvent.clientY}px`;\n    dialog.style.zIndex = '10001';\n\n    // Create color options\n    FOLDER_COLORS.forEach((colorConfig) => {\n      const colorBtn = document.createElement('button');\n      colorBtn.className = 'gv-color-picker-item';\n      colorBtn.title = this.t(colorConfig.nameKey);\n\n      // Apply color based on current theme\n      const colorValue = getFolderColor(colorConfig.id, isDarkMode());\n      colorBtn.style.backgroundColor = colorValue;\n\n      // Mark current color as selected\n      if (folder.color === colorConfig.id || (!folder.color && colorConfig.id === 'default')) {\n        colorBtn.classList.add('selected');\n      }\n\n      colorBtn.addEventListener('click', () => {\n        this.changeFolderColor(folderId, colorConfig.id);\n        dialog.remove();\n        if (this.activeColorPickerCloseHandler) {\n          document.removeEventListener('click', this.activeColorPickerCloseHandler);\n          this.activeColorPickerCloseHandler = null;\n        }\n        this.activeColorPicker = null;\n        this.activeColorPickerFolderId = null;\n      });\n\n      dialog.appendChild(colorBtn);\n    });\n\n    // Add Custom Color Picker Button\n    const customBtn = document.createElement('button');\n    customBtn.className = 'gv-color-picker-item gv-color-picker-custom';\n    customBtn.title = this.t('folder_color_custom');\n\n    // Create hidden color input\n    const colorInput = document.createElement('input');\n    colorInput.type = 'color';\n    // Style to be invisible but functional\n    Object.assign(colorInput.style, {\n      position: 'absolute',\n      opacity: '0',\n      width: '100%',\n      height: '100%',\n      top: '0',\n      left: '0',\n      cursor: 'pointer',\n    });\n\n    // Set initial state\n    if (folder.color && folder.color.startsWith('#')) {\n      colorInput.value = folder.color;\n      customBtn.classList.add('selected');\n      customBtn.style.background = folder.color;\n    } else {\n      // Rainbow gradient to indicate color picker\n      customBtn.style.background =\n        'conic-gradient(from 180deg at 50% 50%, #D9231E 0deg, #F06800 66.47deg, #E6A300 125.68deg, #2D9CDB 195.91deg, #9B51E0 262.24deg, #D9231E 360deg)';\n    }\n\n    // Handle color change\n    colorInput.addEventListener('change', (e) => {\n      const hex = (e.target as HTMLInputElement).value;\n      this.changeFolderColor(folderId, hex);\n      dialog.remove(); // Close picker dialog\n      if (this.activeColorPickerCloseHandler) {\n        document.removeEventListener('click', this.activeColorPickerCloseHandler);\n        this.activeColorPickerCloseHandler = null;\n      }\n      this.activeColorPicker = null;\n      this.activeColorPickerFolderId = null;\n    });\n\n    // Prevent button click from closing the dialog immediately (if bubbling)\n    customBtn.addEventListener('click', (e) => {\n      e.stopPropagation();\n      // Trigger the input (if not clicked directly via the overlay input)\n      // Since input covers the button, this might not be strictly needed, but good for safety\n      if (e.target === customBtn) {\n        colorInput.click();\n      }\n    });\n\n    customBtn.appendChild(colorInput);\n    dialog.appendChild(customBtn);\n\n    document.body.appendChild(dialog);\n    this.activeColorPicker = dialog;\n    this.activeColorPickerFolderId = folderId;\n\n    // Close dialog on click outside\n    const closeDialog = (e: MouseEvent) => {\n      if (!dialog.contains(e.target as Node)) {\n        dialog.remove();\n        this.activeColorPicker = null;\n        this.activeColorPickerFolderId = null;\n        if (this.activeColorPickerCloseHandler) {\n          document.removeEventListener('click', this.activeColorPickerCloseHandler);\n          this.activeColorPickerCloseHandler = null;\n        }\n      }\n    };\n    this.activeColorPickerCloseHandler = closeDialog;\n    setTimeout(() => document.addEventListener('click', closeDialog), 0);\n  }\n\n  /**\n   * Change folder color\n   * @param folderId The folder ID to change\n   * @param colorId The new color ID\n   */\n  private changeFolderColor(folderId: string, colorId: string): void {\n    const folder = this.data.folders.find((f) => f.id === folderId);\n    if (!folder) return;\n\n    folder.color = colorId;\n    folder.updatedAt = Date.now();\n\n    this.saveData();\n    this.refresh();\n  }\n\n  private showMoveToFolderDialog(\n    conversationId: string,\n    conversationTitle: string,\n    url: string,\n    isGem?: boolean,\n    gemId?: string,\n  ): void {\n    // Create dialog overlay\n    const overlay = document.createElement('div');\n    overlay.className = 'gv-folder-dialog-overlay';\n\n    // Create dialog\n    const dialog = document.createElement('div');\n    dialog.className = 'gv-folder-dialog';\n\n    // Dialog title\n    const dialogTitle = document.createElement('div');\n    dialogTitle.className = 'gv-folder-dialog-title';\n    dialogTitle.textContent = this.t('conversation_move_to_folder_title');\n\n    // Folder list\n    const folderList = document.createElement('div');\n    folderList.className = 'gv-folder-dialog-list';\n\n    // Helper function to add folder options recursively\n    const addFolderOptions = (parentId: string | null, level: number = 0) => {\n      const folders = this.data.folders.filter((f) => f.parentId === parentId);\n      const sortedFolders = this.sortFolders(folders); // Apply same sorting as sidebar\n      sortedFolders.forEach((folder) => {\n        const folderItem = document.createElement('button');\n        folderItem.className = 'gv-folder-dialog-item';\n        folderItem.style.paddingLeft = `${calculateFolderDialogPaddingLeft(level, this.folderTreeIndent)}px`;\n\n        // Folder icon\n        const icon = document.createElement('mat-icon');\n        icon.className = 'mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color';\n        icon.setAttribute('role', 'img');\n        icon.setAttribute('aria-hidden', 'true');\n        icon.textContent = 'folder';\n\n        // Folder name\n        const name = document.createElement('span');\n        name.textContent = folder.name;\n\n        folderItem.appendChild(icon);\n        folderItem.appendChild(name);\n\n        folderItem.addEventListener('click', () => {\n          this.addConversationToFolderFromNative(\n            folder.id,\n            conversationId,\n            conversationTitle,\n            url,\n            isGem,\n            gemId,\n          );\n          overlay.remove();\n        });\n\n        folderList.appendChild(folderItem);\n\n        // Add subfolders recursively\n        addFolderOptions(folder.id, level + 1);\n      });\n    };\n\n    // Add root folders and their children\n    addFolderOptions(null);\n\n    // Cancel button\n    const cancelBtn = document.createElement('button');\n    cancelBtn.className = 'gv-folder-dialog-cancel';\n    cancelBtn.textContent = this.t('pm_cancel');\n    cancelBtn.addEventListener('click', () => overlay.remove());\n\n    // Assemble dialog\n    dialog.appendChild(dialogTitle);\n    dialog.appendChild(folderList);\n    dialog.appendChild(cancelBtn);\n    overlay.appendChild(dialog);\n\n    // Add to body\n    document.body.appendChild(overlay);\n\n    // Close on overlay click\n    overlay.addEventListener('click', (e) => {\n      if (e.target === overlay) {\n        overlay.remove();\n      }\n    });\n  }\n\n  private moveConversationToFolder(\n    sourceFolderId: string,\n    targetFolderId: string,\n    conv: ConversationReference,\n  ): void {\n    // Remove from source folder\n    if (this.data.folderContents[sourceFolderId]) {\n      this.data.folderContents[sourceFolderId] = this.data.folderContents[sourceFolderId].filter(\n        (c) => c.conversationId !== conv.conversationId,\n      );\n    }\n\n    // Add to target folder\n    if (!this.data.folderContents[targetFolderId]) {\n      this.data.folderContents[targetFolderId] = [];\n    }\n\n    // Check if conversation already exists in target folder\n    const existingIndex = this.data.folderContents[targetFolderId].findIndex(\n      (c) => c.conversationId === conv.conversationId,\n    );\n\n    if (existingIndex === -1) {\n      // Add with updated timestamp\n      this.data.folderContents[targetFolderId].push({\n        ...conv,\n        addedAt: Date.now(),\n      });\n    }\n\n    this.saveData();\n    this.refresh();\n  }\n\n  private addConversationToFolderFromNative(\n    folderId: string,\n    conversationId: string,\n    title: string,\n    url: string,\n    isGem?: boolean,\n    gemId?: string,\n  ): void {\n    // Add to folder\n    if (!this.data.folderContents[folderId]) {\n      this.data.folderContents[folderId] = [];\n    }\n\n    // Check if conversation already exists in folder\n    const existingIndex = this.data.folderContents[folderId].findIndex(\n      (c) => c.conversationId === conversationId,\n    );\n\n    if (existingIndex === -1) {\n      // Add new conversation\n      this.data.folderContents[folderId].push({\n        conversationId,\n        title,\n        url,\n        addedAt: Date.now(),\n        isGem,\n        gemId,\n      });\n    }\n\n    this.saveData();\n    this.refresh();\n  }\n\n  private setupNativeConversationMenuObserver(): void {\n    // Disconnect existing observer if any\n    if (this.nativeMenuObserver) {\n      this.nativeMenuObserver.disconnect();\n    }\n\n    // Observe the document for menu appearance and disappearance\n    this.nativeMenuObserver = new MutationObserver((mutations) => {\n      if (this.isDestroyed) return;\n      mutations.forEach((mutation) => {\n        // Handle added nodes (menu opening)\n        mutation.addedNodes.forEach((node) => {\n          if (node instanceof HTMLElement) {\n            // Check if this is the native conversation menu\n            const menuContent = node.querySelector('.mat-mdc-menu-content');\n            if (menuContent && !menuContent.querySelector('.gv-move-to-folder-btn')) {\n              // Check if this is a conversation menu (not model selection menu or other menus)\n              if (this.isConversationMenu(node)) {\n                this.debug('Observer: conversation menu detected, preparing to inject');\n                this.injectMoveToFolderButton(menuContent as HTMLElement);\n              } else {\n                this.debug('Observer: non-conversation menu detected, skipping injection');\n              }\n            } else if (menuContent) {\n              this.debug('Observer: menu content detected but button already present');\n            }\n          }\n        });\n\n        // Handle removed nodes (menu closing)\n        mutation.removedNodes.forEach((node) => {\n          if (node instanceof HTMLElement) {\n            // Check if a menu panel was removed\n            const isMenuPanel =\n              node.classList?.contains('mat-mdc-menu-panel') ||\n              node.querySelector('.mat-mdc-menu-panel');\n            if (isMenuPanel) {\n              this.debug('Observer: menu closed, clearing conversation state');\n              this.lastClickedConversation = null;\n              this.lastClickedConversationInfo = null;\n            }\n          }\n        });\n      });\n    });\n\n    this.nativeMenuObserver.observe(document.body, {\n      childList: true,\n      subtree: true,\n    });\n  }\n\n  private isConversationMenu(menuElement: HTMLElement): boolean {\n    // Check if this is NOT a model selection menu or other non-conversation menus\n    const menuPanel = menuElement.querySelector('.mat-mdc-menu-panel');\n\n    // Exclude model selection menu (has gds-mode-switch-menu class)\n    if (menuPanel?.classList.contains('gds-mode-switch-menu')) {\n      this.debug('isConversationMenu: detected model selection menu');\n      return false;\n    }\n\n    // Exclude menus with bard-mode-list-button (model selection)\n    if (menuElement.querySelector('.bard-mode-list-button')) {\n      this.debug('isConversationMenu: detected bard mode list menu');\n      return false;\n    }\n\n    // Check for conversation-specific elements\n    const menuContent = menuElement.querySelector('.mat-mdc-menu-content');\n    if (!menuContent) return false;\n\n    // Look for conversation menu indicators:\n    // 1. Pin button (common in conversation menus)\n    // 2. Rename/delete conversation buttons\n    // 3. Share conversation button\n    const hasPinButton = menuContent.querySelector('[data-test-id=\"pin-button\"]');\n    const hasRenameButton = menuContent.querySelector('[data-test-id=\"rename-button\"]');\n    const hasShareButton = menuContent.querySelector('[data-test-id=\"share-button\"]');\n    const hasDeleteButton = menuContent.querySelector('[data-test-id=\"delete-button\"]');\n\n    // If any conversation-specific button exists, it's a conversation menu\n    if (hasPinButton || hasRenameButton || hasShareButton || hasDeleteButton) {\n      this.debug('isConversationMenu: found conversation-specific buttons');\n      return true;\n    }\n\n    // If we have a lastClickedConversation, we can assume it's a conversation menu\n    if (this.lastClickedConversation) {\n      this.debug('isConversationMenu: lastClickedConversation exists');\n      return true;\n    }\n\n    // Default to false if we can't determine\n    this.debug('isConversationMenu: could not determine menu type, defaulting to false');\n    return false;\n  }\n\n  private injectMoveToFolderButton(menuContent: HTMLElement): void {\n    this.debug('injectMoveToFolderButton: begin');\n\n    // First, try to use pre-extracted conversation info (most reliable)\n    let conversationId: string | null = null;\n    let title: string | null = null;\n    let url: string | null = null;\n\n    if (this.lastClickedConversationInfo) {\n      this.debug('Using pre-extracted conversation info');\n      conversationId = this.lastClickedConversationInfo.id;\n      title = this.lastClickedConversationInfo.title;\n      url = this.lastClickedConversationInfo.url;\n    } else {\n      // Fallback: try to extract from conversation element\n      this.debug('No pre-extracted info, falling back to extraction from element');\n      const conversationEl = this.findConversationElementFromMenu();\n      if (!conversationEl) {\n        this.debug('No conversation element found from menu');\n        return;\n      }\n\n      conversationId = this.extractNativeConversationId(conversationEl);\n      title = this.extractNativeConversationTitle(conversationEl);\n      url = this.extractNativeConversationUrl(conversationEl);\n    }\n\n    // Additional fallbacks when info is still missing\n    if (!conversationId) {\n      // Try to parse hex id from the overlay menu itself\n      const hexFromMenu = this.extractHexIdFromMenu(menuContent);\n      if (hexFromMenu) {\n        conversationId = hexFromMenu;\n        this.debug('injectMoveToFolderButton: using id from menu jslog', conversationId);\n      } else if (this.lastClickedConversation) {\n        // Try from jslog on the conversation element tree\n        const hexFromJslog = this.extractHexIdFromJslog(this.lastClickedConversation);\n        if (hexFromJslog) {\n          conversationId = hexFromJslog;\n          this.debug('injectMoveToFolderButton: using id from conversation jslog', conversationId);\n        }\n      }\n    }\n\n    // If URL is missing but we have an id, synthesize a best-effort URL\n    if (!url && conversationId) {\n      url = this.buildConversationUrlFromId(conversationId);\n      this.debug('injectMoveToFolderButton: built fallback URL from id', url);\n    }\n\n    // Title fallback\n    if ((!title || title.trim() === '') && this.lastClickedConversation) {\n      title = this.extractFallbackTitle(this.lastClickedConversation) || 'Untitled';\n      this.debug('injectMoveToFolderButton: using fallback title', title);\n    }\n\n    this.debug('Extracted conversation info:', { conversationId, title, url });\n\n    if (!conversationId || !title || !url) {\n      this.debugWarn('Missing conversation info:', { conversationId, title, url });\n      return;\n    }\n\n    const moveToFolderLabel = this.t('conversation_move_to_folder');\n    const menuItem = createMoveToFolderMenuItem(menuContent, moveToFolderLabel, moveToFolderLabel);\n\n    // Add click handler\n    menuItem.addEventListener('click', (e) => {\n      e.stopPropagation();\n      this.showMoveToFolderDialog(conversationId, title, url);\n\n      // Close the native menu properly\n      // Strategy 1: Simulate click on backdrop to trigger Angular's native cleanup\n      // We look for the last backdrop as it's likely the one covering the screen for the current menu\n      const backdrops = document.querySelectorAll('.cdk-overlay-backdrop');\n      const backdrop = backdrops.length > 0 ? backdrops[backdrops.length - 1] : null;\n\n      if (backdrop instanceof HTMLElement) {\n        this.debug('Closing menu by clicking backdrop');\n        backdrop.click();\n      } else {\n        // Strategy 2: Fallback manual cleanup if backdrop logic fails\n        this.debug('Backdrop not found, performing manual cleanup');\n        const menu = menuContent.closest('.mat-mdc-menu-panel');\n        if (menu) {\n          menu.remove();\n        }\n\n        // Also try to remove any orphaned backdrop that might be blocking the screen\n        const orphanedBackdrop = document.querySelector('.cdk-overlay-backdrop');\n        if (orphanedBackdrop) {\n          orphanedBackdrop.remove();\n        }\n      }\n    });\n\n    // Insert after the pin button if it exists, otherwise insert at the beginning\n    const pinButton = menuContent.querySelector('[data-test-id=\"pin-button\"]');\n    if (pinButton && pinButton.nextSibling) {\n      this.debug('injectMoveToFolderButton: inserting after pin-button');\n      menuContent.insertBefore(menuItem, pinButton.nextSibling);\n    } else {\n      this.debug('injectMoveToFolderButton: inserting at beginning of menu');\n      menuContent.insertBefore(menuItem, menuContent.firstChild);\n    }\n  }\n\n  private findConversationElementFromMenu(): HTMLElement | null {\n    // Use the element captured on click\n    if (this.lastClickedConversation) {\n      this.debug('findConversationElementFromMenu: using lastClickedConversation');\n      return this.lastClickedConversation;\n    }\n\n    // No fallback - if we don't have the clicked conversation element, we should not guess\n    // The previous fallback logic using '.conversation-actions-container.selected' was incorrect\n    // as it would select the currently focused conversation instead of the one user clicked\n    this.debugWarn(\n      'findConversationElementFromMenu: no conversation element found (lastClickedConversation is null)',\n    );\n    return null;\n  }\n\n  private lastClickedConversation: HTMLElement | null = null;\n  private lastClickedConversationInfo: { id: string; title: string; url: string } | null = null;\n\n  private setupConversationClickTracking(): void {\n    // Track clicks on conversation more buttons\n    document.addEventListener(\n      'click',\n      (e) => {\n        const target = e.target as HTMLElement;\n        const moreButton = target.closest('[data-test-id=\"actions-menu-button\"]');\n        if (moreButton) {\n          this.debug('More button clicked:', moreButton);\n\n          let conversationEl: HTMLElement | null = null;\n\n          // Strategy 1: In Gemini's new UI, the conversation div and actions-menu-button are siblings!\n          // Find the actions container first, then look for sibling conversation div\n          const actionsContainer = moreButton.closest('.conversation-actions-container');\n          if (actionsContainer) {\n            this.debug('Found actions container, looking for sibling conversation...');\n            // Look for previous sibling with data-test-id=\"conversation\"\n            let sibling = actionsContainer.previousElementSibling;\n            while (sibling) {\n              if (sibling.getAttribute('data-test-id') === 'conversation') {\n                conversationEl = sibling as HTMLElement;\n                this.debug('Found conversation as sibling:', conversationEl);\n                break;\n              }\n              sibling = sibling.previousElementSibling;\n            }\n          }\n\n          // Strategy 2: Try traditional closest approach (for older UI patterns)\n          if (!conversationEl) {\n            this.debug('Trying closest with conversation selector...');\n            conversationEl = moreButton.closest(\n              '[data-test-id=\"conversation\"]',\n            ) as HTMLElement | null;\n          }\n\n          if (!conversationEl) {\n            this.debug('Trying history-item selector...');\n            conversationEl = moreButton.closest(\n              '[data-test-id^=\"history-item\"]',\n            ) as HTMLElement | null;\n          }\n\n          if (!conversationEl) {\n            this.debug('Trying conversation-card selector...');\n            conversationEl = moreButton.closest('.conversation-card') as HTMLElement | null;\n          }\n\n          // Strategy 3: Check parent container for conversation children\n          if (!conversationEl && actionsContainer && actionsContainer.parentElement) {\n            this.debug('Trying to find conversation in parent container...');\n            const parentContainer = actionsContainer.parentElement;\n            const conversationInParent = parentContainer.querySelector(\n              '[data-test-id=\"conversation\"]',\n            ) as HTMLElement | null;\n            if (conversationInParent) {\n              // Verify this is the right conversation by checking it's close to the actions container\n              const actionsIndex = Array.from(parentContainer.children).indexOf(actionsContainer);\n              const convIndex = Array.from(parentContainer.children).indexOf(conversationInParent);\n              if (Math.abs(actionsIndex - convIndex) <= 1) {\n                conversationEl = conversationInParent;\n                this.debug('Found conversation in parent container');\n              }\n            }\n          }\n\n          // Last resort fallback\n          if (!conversationEl) {\n            this.debugWarn('Could not find precise conversation element, using broader fallback');\n            conversationEl = moreButton.closest('[jslog]') as HTMLElement | null;\n          }\n\n          if (conversationEl) {\n            this.lastClickedConversation = conversationEl as HTMLElement;\n\n            // Debug: verify this element and show its attributes\n            const linkCount = conversationEl.querySelectorAll(\n              'a[href*=\"/app/\"], a[href*=\"/gem/\"]',\n            ).length;\n            const jslogAttr = conversationEl.getAttribute('jslog');\n            const dataTestId = conversationEl.getAttribute('data-test-id');\n            this.debug('Tracked conversation element:', {\n              element: conversationEl,\n              linkCount,\n              jslog: jslogAttr,\n              dataTestId,\n            });\n\n            // Extract conversation info immediately to avoid issues with multiple links later\n            const conversationId = this.extractNativeConversationId(conversationEl);\n            const title = this.extractNativeConversationTitle(conversationEl);\n            const url = this.extractNativeConversationUrl(conversationEl);\n\n            if (conversationId && title && url) {\n              this.lastClickedConversationInfo = { id: conversationId, title, url };\n              this.debug(\n                '✅ Extracted conversation info on click:',\n                this.lastClickedConversationInfo,\n              );\n            } else {\n              this.debugWarn('⚠️ Failed to extract complete conversation info on click', {\n                conversationId,\n                title,\n                url,\n              });\n              this.lastClickedConversationInfo = null;\n            }\n\n            // Fallback: after the click, the Angular Material menu is rendered\n            // into a global overlay container. Poll briefly to inject our item\n            // even if the mutation observer misses the insertion.\n            let attempts = 0;\n            const maxAttempts = 20; // ~1s at 50ms intervals\n            const timer = window.setInterval(() => {\n              attempts++;\n              const menuContent = document.querySelector(\n                '.mat-mdc-menu-panel .mat-mdc-menu-content',\n              ) as HTMLElement | null;\n              if (menuContent) {\n                this.debug('Overlay poll: menu content found on attempt', attempts);\n                if (!menuContent.querySelector('.gv-move-to-folder-btn')) {\n                  this.debug('Overlay poll: injecting Move to Folder');\n                  this.injectMoveToFolderButton(menuContent);\n                }\n                window.clearInterval(timer);\n              } else if (attempts >= maxAttempts) {\n                this.debugWarn('Overlay poll: menu not found within attempts', maxAttempts);\n                window.clearInterval(timer);\n              }\n            }, 50);\n          }\n        }\n      },\n      true,\n    );\n  }\n\n  private extractNativeConversationId(conversationEl: HTMLElement): string | null {\n    // Support both /app/<hexId> and /gem/<gemId>/<hexId>\n    const scope =\n      (conversationEl.closest('[data-test-id=\"conversation\"]') as HTMLElement) || conversationEl;\n\n    // Get all conversation links\n    const links = scope.querySelectorAll('a[href*=\"/app/\"], a[href*=\"/gem/\"]');\n\n    if (links.length === 0) {\n      this.debugWarn('extractId: no conversation link found under scope');\n      // Fallback to jslog parsing on the conversation element tree\n      const hex = this.extractHexIdFromJslog(scope);\n      if (hex) return hex;\n      return null;\n    }\n\n    // If there are multiple links, try to find the most specific one\n    let link: Element;\n    if (links.length > 1) {\n      this.debugWarn(\n        `extractId: found ${links.length} links, attempting to select the most appropriate one`,\n      );\n\n      // Strategy 1: Find the link with the smallest bounding box (most likely the actual conversation item)\n      let minArea = Infinity;\n      let bestLink = links[0];\n\n      for (const l of Array.from(links)) {\n        const rect = l.getBoundingClientRect();\n        const area = rect.width * rect.height;\n        if (area > 0 && area < minArea) {\n          minArea = area;\n          bestLink = l;\n        }\n      }\n\n      // If all links have the same size, fall back to the first one\n      link = minArea < Infinity ? bestLink : links[0];\n      this.debug('extractId: selected link with area', minArea);\n    } else {\n      link = links[0];\n    }\n\n    const href = link.getAttribute('href') || '';\n    this.debug('extractId: found link href', href);\n\n    // Try /app/<hexId>\n    let match = href.match(/\\/app\\/([^\\/?#]+)/);\n    if (match && match[1]) {\n      this.debug('extractId: extracted from /app/', match[1]);\n      return match[1];\n    }\n    // Try /gem/<gemId>/<hexId>\n    match = href.match(/\\/gem\\/[^/]+\\/([^\\/?#]+)/);\n    if (match && match[1]) {\n      this.debug('extractId: extracted from /gem/', match[1]);\n      return match[1];\n    }\n    this.debugWarn('extractId: failed to extract id from href');\n    return null;\n  }\n\n  private extractNativeConversationTitle(conversationEl: HTMLElement): string | null {\n    const scope =\n      (conversationEl.closest('[data-test-id=\"conversation\"]') as HTMLElement) || conversationEl;\n    // 1) Known title selectors\n    const titleEl = scope.querySelector(\n      '.gds-label-l, .conversation-title-text, [data-test-id=\"conversation-title\"], h3',\n    );\n    let title = titleEl?.textContent?.trim() || null;\n    if (title && !this.isGemLabel(title)) {\n      this.debug('extractTitle(selectors):', title);\n      return title;\n    }\n\n    // 2) Link attributes\n    const link = scope.querySelector(\n      'a[href*=\"/app/\"], a[href*=\"/gem/\"]',\n    ) as HTMLAnchorElement | null;\n    const aria = link?.getAttribute('aria-label')?.trim();\n    if (aria && !this.isGemLabel(aria)) {\n      this.debug('extractTitle(link aria-label):', aria);\n      return aria;\n    }\n    const linkTitle = link?.getAttribute('title')?.trim();\n    if (linkTitle && !this.isGemLabel(linkTitle)) {\n      this.debug('extractTitle(link title attr):', linkTitle);\n      return linkTitle;\n    }\n\n    // 3) Parse visible text from link (ignore icons and gem labels)\n    const fromLinkText = this.extractTitleFromLinkText(link || undefined);\n    if (fromLinkText) {\n      this.debug('extractTitle(link text):', fromLinkText);\n      return fromLinkText;\n    }\n\n    // 4) Fallbacks on common labels\n    title = this.extractFallbackTitle(scope);\n    if (title && !this.isGemLabel(title)) {\n      this.debug('extractTitle(fallback):', title);\n      return title;\n    }\n\n    this.debug('extractTitle: null');\n    return null;\n  }\n\n  private syncConversationTitleFromNative(conversationId: string): string | null {\n    try {\n      // Try to find the conversation in the native sidebar by its ID\n      const conversations = document.querySelectorAll('[data-test-id=\"conversation\"]');\n      for (const convEl of Array.from(conversations)) {\n        // Check if this conversation matches the ID\n        const jslog = convEl.getAttribute('jslog');\n        if (jslog && jslog.includes(conversationId)) {\n          // Found the matching conversation, extract its current title\n          const currentTitle = this.extractNativeConversationTitle(convEl as HTMLElement);\n          if (currentTitle) {\n            this.debug('Synced title from native:', currentTitle);\n            return currentTitle;\n          }\n        }\n\n        // Also check by href\n        const link = convEl.querySelector(\n          'a[href*=\"/app/\"], a[href*=\"/gem/\"]',\n        ) as HTMLAnchorElement | null;\n        if (link && link.href.includes(conversationId)) {\n          const currentTitle = this.extractNativeConversationTitle(convEl as HTMLElement);\n          if (currentTitle) {\n            this.debug('Synced title from native (by href):', currentTitle);\n            return currentTitle;\n          }\n        }\n      }\n    } catch (e) {\n      this.debug('Error syncing title from native:', e);\n    }\n    return null;\n  }\n\n  private updateConversationTitle(conversationId: string, newTitle: string): void {\n    // Update the title for all instances of this conversation across all folders\n    let updated = false;\n\n    for (const folderId in this.data.folderContents) {\n      const conversations = this.data.folderContents[folderId];\n      for (const conv of conversations) {\n        // Match by conversation ID (check both direct match and URL match)\n        if (\n          (conv.conversationId === conversationId || conv.url.includes(conversationId)) &&\n          !conv.customTitle\n        ) {\n          conv.title = newTitle;\n          updated = true;\n          this.debug(`Updated title for conversation ${conversationId} in folder ${folderId}`);\n        }\n      }\n    }\n\n    if (updated) {\n      this.saveData();\n      // Re-render folders to show updated title\n      this.renderAllFolders();\n    }\n  }\n\n  /**\n   * Schedule a delayed check to confirm conversation deletion\n   * This prevents false positives when Gemini UI temporarily removes/re-adds elements\n   */\n  private scheduleConversationRemovalCheck(conversationId: string): void {\n    // Cancel any existing timer for this conversation\n    const existingTimer = this.pendingRemovals.get(conversationId);\n    if (existingTimer) {\n      clearTimeout(existingTimer);\n      this.debug(`Cancelled previous removal timer for ${conversationId}`);\n    }\n\n    // Schedule a new check after delay\n    const timerId = window.setTimeout(() => {\n      this.confirmConversationRemoval(conversationId);\n    }, this.removalCheckDelay);\n\n    this.pendingRemovals.set(conversationId, timerId);\n    this.debug(\n      `Scheduled removal check for ${conversationId} (delay: ${this.removalCheckDelay}ms)`,\n    );\n  }\n\n  /**\n   * Cancel pending removal for a conversation element that was re-added\n   */\n  private cancelPendingRemovalForElement(element: HTMLElement): void {\n    // Extract conversation ID from the element\n    const conversationId = this.extractConversationIdFromElement(element);\n\n    if (conversationId) {\n      const timerId = this.pendingRemovals.get(conversationId);\n      if (timerId) {\n        clearTimeout(timerId);\n        this.pendingRemovals.delete(conversationId);\n        this.debug(`Cancelled removal for ${conversationId} (conversation re-added to DOM)`);\n      }\n    }\n  }\n\n  /**\n   * Check if conversation still exists in DOM\n   * Returns true if conversation found, false if definitely deleted\n   * In case of errors, conservatively returns true to avoid false deletions\n   */\n  private isConversationInDOM(conversationId: string): boolean {\n    if (!this.sidebarContainer) {\n      this.debugWarn('Sidebar container not available for DOM check');\n      return true; // Conservative: assume conversation exists if we can't check\n    }\n\n    try {\n      // Check by jslog attribute\n      const byJslog = this.sidebarContainer.querySelector(\n        `[data-test-id=\"conversation\"][jslog*=\"c_${conversationId}\"]`,\n      );\n      if (byJslog) {\n        this.debug(`Found conversation ${conversationId} in DOM by jslog`);\n        return true;\n      }\n\n      // Check by href\n      const byHref = this.sidebarContainer.querySelector(\n        `[data-test-id=\"conversation\"] a[href*=\"${conversationId}\"]`,\n      );\n      if (byHref) {\n        this.debug(`Found conversation ${conversationId} in DOM by href`);\n        return true;\n      }\n\n      // Not found in DOM\n      this.debug(`Conversation ${conversationId} not found in DOM`);\n      return false;\n    } catch (error) {\n      this.debugWarn(`DOM check failed for ${conversationId}:`, error);\n      // Conservative approach: if we can't check, assume it still exists\n      // This prevents accidental deletion during DOM reconstruction\n      return true;\n    }\n  }\n\n  /**\n   * Get the conversation ID from current URL\n   */\n  private getCurrentConversationId(): string | null {\n    const url = window.location.href;\n    const appMatch = url.match(/\\/app\\/([^\\/?#]+)/);\n    const gemMatch = url.match(/\\/gem\\/[^/]+\\/([^\\/?#]+)/);\n    return appMatch?.[1] || gemMatch?.[1] || null;\n  }\n\n  /**\n   * Confirm conversation removal after delay\n   * Only removes if conversation is truly deleted (not in DOM and not current conversation)\n   */\n  private confirmConversationRemoval(conversationId: string): void {\n    // Remove from pending list\n    this.pendingRemovals.delete(conversationId);\n\n    this.debug(`\\n═══ Confirming removal for conversation ${conversationId} ═══`);\n    this.debug(`  Delay elapsed: ${this.removalCheckDelay}ms`);\n\n    // Check 1: Is this the currently active conversation?\n    const currentConvId = this.getCurrentConversationId();\n    const currentUrl = window.location.href;\n\n    if (currentConvId === conversationId) {\n      this.debug(`  ✓ SKIPPED: Currently active conversation`);\n      this.debug(`    Current URL: ${currentUrl}`);\n      this.debug(`    Matched ID: ${currentConvId}`);\n      this.debug(`════════════════════════════════════════════════\\n`);\n      return;\n    }\n\n    // Check 2: Is conversation still in DOM?\n    if (this.isConversationInDOM(conversationId)) {\n      this.debug(`  ✓ SKIPPED: Conversation still exists in DOM`);\n      this.debug(`    Likely a UI refresh, not a deletion`);\n      this.debug(`════════════════════════════════════════════════\\n`);\n      return;\n    }\n\n    // Conversation is truly deleted - remove from folders\n    this.debug(`  ✗ CONFIRMED DELETION: Removing from all folders`);\n    this.debug(`    Reason: Not in current URL and not found in DOM`);\n    this.debug(`    Current URL: ${currentUrl}`);\n    this.debug(`════════════════════════════════════════════════\\n`);\n\n    this.removeConversationFromAllFolders(conversationId);\n  }\n\n  private removeConversationFromAllFolders(conversationId: string): void {\n    // Remove this conversation from all folders when the original conversation is deleted\n    let removed = false;\n\n    for (const folderId in this.data.folderContents) {\n      const conversations = this.data.folderContents[folderId];\n      const initialLength = conversations.length;\n\n      // Filter out the deleted conversation\n      this.data.folderContents[folderId] = conversations.filter(\n        (conv) => conv.conversationId !== conversationId && !conv.url.includes(conversationId),\n      );\n\n      if (this.data.folderContents[folderId].length < initialLength) {\n        removed = true;\n        this.debug(`Removed deleted conversation ${conversationId} from folder ${folderId}`);\n      }\n    }\n\n    if (removed) {\n      this.saveData();\n      // Re-render folders to reflect the removal\n      this.renderAllFolders();\n    }\n  }\n\n  private extractHexIdFromJslog(scope: HTMLElement): string | null {\n    try {\n      const tryParse = (val: string | null | undefined): string | null => {\n        if (!val) return null;\n        // Typical pattern inside jslog: c_<hex>\n        const m = val.match(/c_([a-f0-9]{8,})/i);\n        return m?.[1] || null;\n      };\n\n      // Check on scope itself\n      const fromSelf = tryParse(scope.getAttribute('jslog'));\n      if (fromSelf) {\n        this.debug('extractId(jslog self):', fromSelf);\n        return fromSelf;\n      }\n\n      // Search descendants with jslog\n      const nodes = scope.querySelectorAll('[jslog]');\n      for (const n of Array.from(nodes)) {\n        const found = tryParse(n.getAttribute('jslog'));\n        if (found) {\n          this.debug('extractId(jslog descendant):', found);\n          return found;\n        }\n      }\n    } catch (e) {\n      this.debugWarn('extractHexIdFromJslog error:', e);\n    }\n    this.debugWarn('extractId(jslog): not found');\n    return null;\n  }\n\n  private extractHexIdFromMenu(menuContent: HTMLElement): string | null {\n    try {\n      const nodes = menuContent.querySelectorAll('[jslog]');\n      for (const n of Array.from(nodes)) {\n        const val = n.getAttribute('jslog');\n        if (!val) continue;\n        const m = val.match(/c_([a-f0-9]{8,})/i);\n        if (m && m[1]) {\n          this.debug('extractId(menu jslog):', m[1]);\n          return m[1];\n        }\n      }\n    } catch (e) {\n      this.debugWarn('extractHexIdFromMenu error:', e);\n    }\n    this.debugWarn('extractId(menu): not found');\n    return null;\n  }\n\n  private buildConversationUrlFromId(hexId: string): string {\n    try {\n      const path = window.location.pathname;\n      const gemMatch = path.match(/\\/gem\\/([^\\/]+)/);\n      if (gemMatch && gemMatch[1]) {\n        const gemId = gemMatch[1];\n        return `https://gemini.google.com/gem/${gemId}/${hexId}`;\n      }\n    } catch (e) {\n      this.debug('Failed to extract gem URL:', e);\n    }\n    return `https://gemini.google.com/app/${hexId}`;\n  }\n\n  private extractFallbackTitle(conversationEl: HTMLElement): string | null {\n    try {\n      const scope =\n        (conversationEl.closest('[data-test-id=\"conversation\"]') as HTMLElement) || conversationEl;\n      // Prefer explicit attributes if present\n      const aria = scope.getAttribute('aria-label');\n      if (aria && aria.trim()) {\n        this.debug('fallbackTitle(aria-label):', aria.trim());\n        return aria.trim();\n      }\n      const titleAttr = scope.getAttribute('title');\n      if (titleAttr && titleAttr.trim()) {\n        this.debug('fallbackTitle(title attr):', titleAttr.trim());\n        return titleAttr.trim();\n      }\n      // Try a common inner label\n      const label = scope.querySelector('.gds-body-m, .gds-label-m, .subtitle');\n      const labelText = label?.textContent?.trim();\n      if (labelText && !this.isGemLabel(labelText)) {\n        this.debug('fallbackTitle(label-ish):', labelText);\n        return labelText;\n      }\n      // Fall back to trimmed text content (first line, clipped)\n      const raw = scope.textContent?.trim() || '';\n      if (raw) {\n        const firstLine =\n          raw\n            .split('\\n')\n            .map((s) => s.trim())\n            .filter(Boolean)[0] || raw;\n        const clipped = firstLine.slice(0, 80);\n        this.debug('fallbackTitle(textContent):', clipped);\n        return clipped;\n      }\n    } catch (e) {\n      this.debugWarn('extractFallbackTitle error:', e);\n    }\n    return null;\n  }\n\n  private isGemLabel(text: string): boolean {\n    const t = (text || '').trim();\n    if (!t) return false;\n    const simple = t.toLowerCase();\n    // Generic labels we want to ignore\n    if (simple === 'gem' || simple === 'gems') return true;\n    // Known Gem names (English)\n    for (const g of GEM_CONFIG) {\n      if (simple === g.name.toLowerCase()) return true;\n    }\n    return false;\n  }\n\n  private extractTitleFromLinkText(link?: HTMLAnchorElement | null): string | null {\n    if (!link) return null;\n    // Get visible textual lines from the link\n    const text = (link.innerText || '').trim();\n    if (!text) return null;\n    const parts = text\n      .split('\\n')\n      .map((s) => s.trim())\n      .filter(Boolean)\n      .filter((s) => !this.isGemLabel(s))\n      .filter((s) => s.length >= 2);\n    this.debug('extractTitleFromLinkText parts:', parts);\n    if (parts.length === 0) return null;\n    // Heuristic: pick the longest part\n    const best = parts.reduce((a, b) => (b.length > a.length ? b : a), parts[0]);\n    return best || null;\n  }\n\n  private extractNativeConversationUrl(conversationEl: HTMLElement): string | null {\n    const scope =\n      (conversationEl.closest('[data-test-id=\"conversation\"]') as HTMLElement) || conversationEl;\n    const link = scope.querySelector('a[href*=\"/app/\"], a[href*=\"/gem/\"]');\n    if (!link) {\n      this.debugWarn('extractUrl: no conversation link found under scope');\n      // Fallback: construct from extracted id (via jslog) if possible\n      const hex = this.extractHexIdFromJslog(scope);\n      if (hex) {\n        const fullFromJslog = this.buildConversationUrlFromId(hex);\n        this.debug('extractUrl(jslog fallback):', fullFromJslog);\n        return fullFromJslog;\n      }\n      return null;\n    }\n    const href = link.getAttribute('href');\n    if (!href) {\n      this.debugWarn('extractUrl: link has no href');\n      return null;\n    }\n    const full = href.startsWith('http') ? href : `https://gemini.google.com${href}`;\n    this.debug('extractUrl:', full);\n    return full;\n  }\n\n  private refresh(): void {\n    if (!this.containerElement) return;\n\n    // Find and update the folders list\n    const oldList = this.containerElement.querySelector('.gv-folder-list');\n    if (oldList) {\n      const newList = this.createFoldersList();\n      oldList.replaceWith(newList);\n    }\n\n    // Re-apply hide archived setting after refresh\n    this.applyHideArchivedSetting();\n\n    // Update active highlight after re-render\n    this.highlightActiveConversationInFolders();\n\n    // Flush any pending title updates collected during rendering\n    if (this.pendingTitleUpdates.size > 0) {\n      this.debug(`Flushing ${this.pendingTitleUpdates.size} pending title updates`);\n      // Save once after all title updates are applied (async, fire-and-forget)\n      this.saveData()\n        .then((saved) => {\n          // Only clear after confirmed successful save to avoid losing updates\n          if (saved) {\n            this.pendingTitleUpdates.clear();\n          } else {\n            this.debugWarn('Save failed, retaining pending title updates for next attempt');\n          }\n        })\n        .catch((error) => {\n          console.error('[FolderManager] Failed to save pending title updates:', error);\n        });\n    }\n  }\n\n  private getCurrentHexIdFromLocation(): string | null {\n    try {\n      const path = window.location.pathname || '';\n      // Match /app/<hex> or /gem/<gemId>/<hex>\n      const m = path.match(/\\/(?:app|gem\\/[^/]+)\\/([a-f0-9]+)/i);\n      return m ? m[1] : null;\n    } catch (e) {\n      this.debug('Failed to get current hex ID from location:', e);\n      return null;\n    }\n  }\n\n  private highlightActiveConversationInFolders(): void {\n    if (!this.containerElement) return;\n    const hex = this.getCurrentHexIdFromLocation();\n    const currentId = hex ? `c_${hex}` : null;\n    const rows = this.containerElement.querySelectorAll('.gv-folder-conversation');\n    rows.forEach((el) => {\n      const row = el as HTMLElement;\n      const isActive = currentId && row.dataset.conversationId === currentId;\n      row.classList.toggle('gv-folder-conversation-selected', !!isActive);\n    });\n  }\n\n  /**\n   * Ensures data integrity by validating and repairing the folder data structure.\n   * This method is called by both loadData() and saveData() to maintain consistency.\n   */\n  private ensureDataIntegrity(): void {\n    // Ensure folderContents object exists\n    if (!this.data.folderContents) {\n      this.data.folderContents = {};\n      this.debugWarn('folderContents was missing, initialized');\n    }\n\n    // Ensure folders array exists\n    if (!this.data.folders) {\n      this.data.folders = [];\n      this.debugWarn('folders was missing, initialized');\n    }\n\n    // Ensure all folders have a folderContents entry (even if empty)\n    // This is critical for empty folders to persist correctly\n    this.data.folders.forEach((folder) => {\n      if (!this.data.folderContents[folder.id]) {\n        this.data.folderContents[folder.id] = [];\n        this.debugWarn(`Initialized missing folderContents for folder: ${folder.name}`);\n      }\n    });\n\n    // Deduplicate conversations within each folder\n    for (const folderId of Object.keys(this.data.folderContents)) {\n      const convs = this.data.folderContents[folderId];\n      const seen = new Set<string>();\n      const deduped = convs.filter((c) => {\n        if (seen.has(c.conversationId)) return false;\n        seen.add(c.conversationId);\n        return true;\n      });\n      if (deduped.length < convs.length) {\n        this.debugWarn(\n          `Removed ${convs.length - deduped.length} duplicate conversations in folder: ${folderId}`,\n        );\n        this.data.folderContents[folderId] = deduped;\n      }\n    }\n\n    // Ensure all items have sortIndex for manual ordering\n    this.ensureSortIndices();\n  }\n\n  /**\n   * Assign sortIndex to folders and conversations that don't have one yet.\n   * Uses current sort order so existing users see no change on upgrade.\n   */\n  private ensureSortIndices(): void {\n    // Group folders by parent\n    const foldersByParent = new Map<string, Folder[]>();\n    for (const folder of this.data.folders) {\n      const parentKey = folder.parentId ?? '__root__';\n      if (!foldersByParent.has(parentKey)) foldersByParent.set(parentKey, []);\n      foldersByParent.get(parentKey)!.push(folder);\n    }\n\n    // Assign sortIndex to folders missing it, preserving current name-based order\n    for (const siblings of foldersByParent.values()) {\n      const needsIndex = siblings.some((f) => f.sortIndex == null);\n      if (!needsIndex) continue;\n\n      // Sort by current logic (pinned state ignored here — sortIndex is within same pinned group)\n      const sorted = [...siblings].sort((a, b) =>\n        a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),\n      );\n      sorted.forEach((folder, i) => {\n        if (folder.sortIndex == null) folder.sortIndex = i;\n      });\n    }\n\n    // Assign sortIndex to conversations missing it, preserving current time-based order\n    for (const [, conversations] of Object.entries(this.data.folderContents)) {\n      const needsIndex = conversations.some((c) => c.sortIndex == null);\n      if (!needsIndex) continue;\n\n      const sorted = [...conversations].sort((a, b) => {\n        const aTime = a.lastOpenedAt ?? a.addedAt ?? 0;\n        const bTime = b.lastOpenedAt ?? b.addedAt ?? 0;\n        return bTime - aTime;\n      });\n      sorted.forEach((conv, i) => {\n        const original = conversations.find((c) => c.conversationId === conv.conversationId);\n        if (original && original.sortIndex == null) original.sortIndex = i;\n      });\n    }\n  }\n\n  /**\n   * Load folder data from storage (async, browser-agnostic)\n   * Uses storage adapter for automatic Safari/non-Safari handling\n   */\n  private async loadData(): Promise<void> {\n    try {\n      let loadedData = await this.storage.loadData(this.activeStorageKey);\n\n      if (!loadedData && this.accountIsolationEnabled && this.activeStorageKey !== STORAGE_KEY) {\n        loadedData = await this.migrateLegacyFolderDataToScopedStorage();\n      }\n\n      if (loadedData && validateFolderData(loadedData)) {\n        this.data = loadedData;\n\n        // Validate and repair data integrity\n        this.ensureDataIntegrity();\n\n        // Clean up orphaned folderContents (folders that no longer exist)\n        const validFolderIds = new Set(this.data.folders.map((f) => f.id));\n        validFolderIds.add(ROOT_CONVERSATIONS_ID); // Keep root conversations\n        Object.keys(this.data.folderContents).forEach((folderId) => {\n          if (!validFolderIds.has(folderId)) {\n            this.debugWarn(`Removing orphaned folderContents for: ${folderId}`);\n            delete this.data.folderContents[folderId];\n          }\n        });\n\n        // Create primary backup on successful load\n        this.backupService.createPrimaryBackup(this.data);\n\n        this.debug('Data loaded and validated successfully');\n      } else if (loadedData) {\n        // Data exists but validation failed - this is a real corruption case\n        console.warn(\n          '[FolderManager] Storage returned invalid data structure, attempting recovery from backup',\n        );\n        this.attemptDataRecovery({ reason: 'corrupted', originalData: loadedData });\n      } else {\n        // No data found - likely a first-time user\n        console.log(\n          '[FolderManager] No folder data found, initializing empty state (likely first-time user)',\n        );\n        this.data = { folders: [], folderContents: {} };\n        // No notification needed - this is expected for new users\n      }\n    } catch (error) {\n      console.error('[FolderManager] Load data error:', error);\n\n      // CRITICAL: Do NOT clear data on error - this causes data loss!\n      // Instead, try to recover from backup or keep existing data\n      this.attemptDataRecovery(error);\n    }\n  }\n\n  private cloneFolderData(data: FolderData): FolderData {\n    const folders = data.folders.map((folder) => ({ ...folder }));\n    const folderContents = Object.fromEntries(\n      Object.entries(data.folderContents || {}).map(([folderId, conversations]) => [\n        folderId,\n        conversations.map((conversation) => ({ ...conversation })),\n      ]),\n    );\n    return { folders, folderContents };\n  }\n\n  private filterLegacyFolderDataByCurrentAccount(data: FolderData): FolderData {\n    const routeUserId = this.accountScope?.routeUserId;\n    if (!routeUserId) {\n      return this.cloneFolderData(data);\n    }\n\n    const folderById = new Map(data.folders.map((folder) => [folder.id, folder]));\n    const visibleFolderIds = new Set<string>();\n    const nextContents: Record<string, ConversationReference[]> = {};\n\n    for (const [folderId, conversations] of Object.entries(data.folderContents || {})) {\n      const filtered = conversations.filter((conversation) => {\n        const conversationUserId = this.getUserIdFromUrl(conversation.url);\n        return conversationUserId === null || conversationUserId === routeUserId;\n      });\n      if (filtered.length === 0) continue;\n\n      nextContents[folderId] = filtered.map((conversation) => ({ ...conversation }));\n      if (folderId !== ROOT_CONVERSATIONS_ID) {\n        visibleFolderIds.add(folderId);\n      }\n    }\n\n    const stack = [...visibleFolderIds];\n    while (stack.length > 0) {\n      const currentId = stack.pop();\n      if (!currentId) continue;\n\n      const folder = folderById.get(currentId);\n      if (!folder?.parentId) continue;\n      if (visibleFolderIds.has(folder.parentId)) continue;\n      visibleFolderIds.add(folder.parentId);\n      stack.push(folder.parentId);\n    }\n\n    const folders = data.folders\n      .filter((folder) => visibleFolderIds.has(folder.id))\n      .map((folder) => ({ ...folder }));\n\n    for (const folder of folders) {\n      if (!nextContents[folder.id]) {\n        nextContents[folder.id] = [];\n      }\n    }\n\n    if (!nextContents[ROOT_CONVERSATIONS_ID]) {\n      nextContents[ROOT_CONVERSATIONS_ID] = [];\n    }\n\n    return {\n      folders,\n      folderContents: nextContents,\n    };\n  }\n\n  private async migrateLegacyFolderDataToScopedStorage(): Promise<FolderData | null> {\n    try {\n      const legacyData = await this.storage.loadData(STORAGE_KEY);\n      if (!legacyData || !validateFolderData(legacyData)) {\n        return null;\n      }\n\n      const migratedData = this.filterLegacyFolderDataByCurrentAccount(legacyData);\n      const saved = await this.storage.saveData(this.activeStorageKey, migratedData);\n      if (!saved) {\n        console.warn('[FolderManager] Failed to persist scoped migration data');\n      }\n      this.debug(\n        'Migrated legacy folder data to scoped storage:',\n        this.activeStorageKey,\n        migratedData.folders.length,\n      );\n      return migratedData;\n    } catch (error) {\n      console.error('[FolderManager] Failed to migrate legacy folder data:', error);\n      return null;\n    }\n  }\n\n  /**\n   * Attempt to recover data when loadData() encounters corrupted data or errors.\n   * This method is only called when there's an actual problem (not for first-time users).\n   * Priority: localStorage backup (primary/emergency/beforeUnload) > keep existing data > initialize empty\n   */\n  private attemptDataRecovery(error: unknown): void {\n    console.warn('[FolderManager] Attempting data recovery after load failure');\n\n    // Step 1: Try to restore from localStorage backups (primary, emergency, beforeUnload)\n    const recovered = this.backupService.recoverFromBackup();\n    if (recovered && validateFolderData(recovered)) {\n      this.data = recovered;\n      this.ensureDataIntegrity();\n      console.warn('[FolderManager] Data recovered from localStorage backup');\n      this.showNotificationByLevel('Folder data has been recovered from a backup.', 'warning');\n      // Save recovered data to persistent storage\n      this.saveData();\n      return; // Successfully recovered, no need to continue\n    }\n\n    // Step 2: If current this.data already has valid structure, keep it\n    if (validateFolderData(this.data) && this.data.folders.length > 0) {\n      console.warn('[FolderManager] Keeping existing in-memory data after load error');\n      this.ensureDataIntegrity();\n      return;\n    }\n\n    // Step 3: Last resort - initialize empty data and log critical error\n    console.error('[FolderManager] CRITICAL: Unable to recover data, initializing empty state');\n    console.error('[FolderManager] Original error:', error);\n    this.data = { folders: [], folderContents: {} };\n\n    // Show user notification about data loss\n    this.showDataLossNotification();\n  }\n\n  /**\n   * Show notification to user about potential data loss\n   */\n  private showDataLossNotification(): void {\n    this.showNotificationByLevel(\n      getTranslationSync('folderManager_dataLossWarning') ||\n        'Warning: Failed to load folder data. Please check your browser console for details.',\n      'error',\n    );\n  }\n\n  /**\n   * Show a notification to the user with customizable level\n   */\n  private showNotificationByLevel(\n    message: string,\n    level: 'info' | 'warning' | 'error' = 'error',\n  ): void {\n    try {\n      // Color based on level\n      const colors = {\n        info: '#2196F3',\n        warning: '#FF9800',\n        error: '#f44336',\n      };\n\n      // Create a visible notification\n      const notification = document.createElement('div');\n      notification.style.cssText = `\n        position: fixed;\n        top: 20px;\n        right: 20px;\n        background: ${colors[level]};\n        color: white;\n        padding: 16px 24px;\n        border-radius: 8px;\n        box-shadow: 0 4px 12px rgba(0,0,0,0.3);\n        z-index: 10000;\n        font-family: system-ui, -apple-system, sans-serif;\n        font-size: 14px;\n        max-width: 400px;\n        line-height: 1.4;\n      `;\n      notification.textContent = message;\n      document.body.appendChild(notification);\n\n      // Auto-remove after timeout (longer for errors/warnings)\n      const timeout =\n        level === 'info' ? 3000 : level === 'warning' ? 7000 : NOTIFICATION_TIMEOUT_MS;\n      setTimeout(() => {\n        try {\n          document.body.removeChild(notification);\n        } catch {\n          // Ignore - notification may have already been removed\n        }\n      }, timeout);\n    } catch (notificationError) {\n      console.error('[FolderManager] Failed to show notification:', notificationError);\n    }\n  }\n\n  /**\n   * Save folder data to storage (async, browser-agnostic)\n   * Uses storage adapter for automatic Safari/non-Safari handling\n   */\n  private async saveData(): Promise<boolean> {\n    // Prevent concurrent saves to avoid race conditions\n    if (this.saveInProgress) {\n      this.debug('Save already in progress, skipping duplicate call');\n      return false;\n    }\n\n    this.saveInProgress = true;\n    let success = false;\n\n    try {\n      // Validate data integrity before saving\n      this.ensureDataIntegrity();\n\n      // CRITICAL: Create emergency backup BEFORE saving (snapshot of previous state)\n      this.backupService.createEmergencyBackup(this.data);\n\n      // Additional safety check: warn if saving empty data\n      if (this.data.folders.length === 0 && Object.keys(this.data.folderContents).length === 0) {\n        // Check if we're about to overwrite non-empty data\n        const existingData = await this.storage.loadData(this.activeStorageKey);\n        if (\n          existingData &&\n          (existingData.folders.length > 0 || Object.keys(existingData.folderContents).length > 0)\n        ) {\n          console.warn(\n            '[FolderManager] WARNING: Attempting to save empty data over existing non-empty data',\n          );\n          console.warn('[FolderManager] This may indicate a bug.');\n          // Still proceed, but log it prominently\n        }\n      }\n\n      // Save via storage adapter (handles both Safari and non-Safari)\n      success = await this.storage.saveData(this.activeStorageKey, this.data);\n\n      // Retry once if the first attempt fails (for transient errors)\n      if (!success) {\n        console.warn('[FolderManager] Save failed, retrying once...');\n        success = await this.storage.saveData(this.activeStorageKey, this.data);\n      }\n\n      if (success) {\n        // Create primary backup AFTER successful save\n        this.backupService.createPrimaryBackup(this.data);\n        this.debug('Data saved successfully');\n      } else {\n        console.error('[FolderManager] Save failed after retry');\n      }\n    } catch (error) {\n      console.error('[FolderManager] Save data error:', error);\n      success = false;\n    } finally {\n      this.saveInProgress = false;\n    }\n\n    return success;\n  }\n\n  private async loadFolderEnabledSetting(): Promise<void> {\n    try {\n      const result = await browser.storage.sync.get({ geminiFolderEnabled: true });\n      this.folderEnabled = result.geminiFolderEnabled !== false;\n      this.debug('Loaded folder enabled setting:', this.folderEnabled);\n    } catch (error) {\n      console.error('[FolderManager] Failed to load folder enabled setting:', error);\n      this.folderEnabled = true;\n    }\n  }\n\n  private async loadAccountIsolationSetting(): Promise<void> {\n    try {\n      this.accountIsolationEnabled = await accountIsolationService.isIsolationEnabled({\n        platform: 'gemini',\n        pageUrl: window.location.href,\n      });\n      this.debug('Loaded account isolation setting:', this.accountIsolationEnabled);\n    } catch (error) {\n      console.error('[FolderManager] Failed to load account isolation setting:', error);\n      this.accountIsolationEnabled = false;\n    }\n  }\n\n  private async refreshAccountScope(): Promise<void> {\n    if (!this.accountIsolationEnabled) {\n      this.accountScope = null;\n      this.activeStorageKey = STORAGE_KEY;\n      return;\n    }\n\n    try {\n      const context = detectAccountContextFromDocument(window.location.href, document);\n      const resolvedScope = await accountIsolationService.resolveAccountScope({\n        pageUrl: window.location.href,\n        routeUserId: context.routeUserId,\n        email: context.email,\n      });\n      this.accountScope = resolvedScope;\n      this.activeStorageKey = buildScopedFolderStorageKey(resolvedScope.accountKey);\n      await this.storage.init(this.activeStorageKey);\n    } catch (error) {\n      console.error('[FolderManager] Failed to resolve account scope:', error);\n      this.accountScope = null;\n      this.activeStorageKey = STORAGE_KEY;\n    }\n  }\n\n  private toSyncAccountScope(scope: AccountScope | null): SyncAccountScope | undefined {\n    if (!scope) return undefined;\n    return {\n      accountKey: scope.accountKey,\n      accountId: scope.accountId,\n      routeUserId: scope.routeUserId,\n    };\n  }\n\n  private async loadHideArchivedSetting(): Promise<void> {\n    try {\n      const result = await browser.storage.sync.get({\n        geminiFolderHideArchivedConversations: false,\n      });\n      this.hideArchivedConversations = !!result.geminiFolderHideArchivedConversations;\n      this.debug('Loaded hide archived setting:', this.hideArchivedConversations);\n    } catch (error) {\n      console.error('[FolderManager] Failed to load hide archived setting:', error);\n      this.hideArchivedConversations = false;\n    }\n  }\n\n  private async loadFilterUserSetting(): Promise<void> {\n    try {\n      const result = await browser.storage.sync.get({\n        [StorageKeys.GV_FOLDER_FILTER_USER_ONLY]: false,\n      });\n      this.filterCurrentUserOnly = !!result[StorageKeys.GV_FOLDER_FILTER_USER_ONLY];\n      this.debug('Loaded filter user setting:', this.filterCurrentUserOnly);\n    } catch (error) {\n      console.error('[FolderManager] Failed to load filter user setting:', error);\n      this.filterCurrentUserOnly = false;\n    }\n  }\n\n  private async loadFolderTreeIndentSetting(): Promise<void> {\n    try {\n      const result = await browser.storage.sync.get({\n        [StorageKeys.GV_FOLDER_TREE_INDENT]: FOLDER_TREE_INDENT_DEFAULT,\n      });\n      this.folderTreeIndent = clampFolderTreeIndent(result[StorageKeys.GV_FOLDER_TREE_INDENT]);\n      this.debug('Loaded folder tree indent setting:', this.folderTreeIndent);\n    } catch (error) {\n      console.error('[FolderManager] Failed to load folder tree indent setting:', error);\n      this.folderTreeIndent = FOLDER_TREE_INDENT_DEFAULT;\n    }\n  }\n\n  private applyFolderTreeIndentSetting(value: unknown): void {\n    const nextIndent = clampFolderTreeIndent(value);\n    if (nextIndent === this.folderTreeIndent) return;\n\n    this.folderTreeIndent = nextIndent;\n    this.debug('Folder tree indent changed:', this.folderTreeIndent);\n\n    if (this.folderEnabled && this.containerElement) {\n      this.renderAllFolders();\n    }\n  }\n\n  private async handleAccountIsolationToggle(enabled: boolean): Promise<void> {\n    if (enabled === this.accountIsolationEnabled) return;\n\n    this.accountIsolationEnabled = enabled;\n    await this.refreshAccountScope();\n    await this.loadData();\n\n    if (this.folderEnabled) {\n      this.refresh();\n    }\n  }\n\n  private setupStorageListener(): void {\n    // Listen for sync settings changes\n    browser.storage.onChanged.addListener((changes, areaName) => {\n      if (areaName === 'sync') {\n        if (changes.geminiFolderEnabled) {\n          this.folderEnabled = changes.geminiFolderEnabled.newValue !== false;\n          this.debug('Folder enabled setting changed:', this.folderEnabled);\n          // Apply the change to folder visibility\n          this.applyFolderEnabledSetting();\n        }\n        if (changes.geminiFolderHideArchivedConversations) {\n          this.hideArchivedConversations = !!changes.geminiFolderHideArchivedConversations.newValue;\n          this.debug('Hide archived setting changed:', this.hideArchivedConversations);\n          // Apply the change to all conversations\n          this.applyHideArchivedSetting();\n        }\n        if (changes[StorageKeys.GV_FOLDER_TREE_INDENT]) {\n          this.applyFolderTreeIndentSetting(changes[StorageKeys.GV_FOLDER_TREE_INDENT].newValue);\n        }\n        if (\n          changes[StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED] ||\n          changes[StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED_GEMINI]\n        ) {\n          void (async () => {\n            const nextEnabled = await accountIsolationService.isIsolationEnabled({\n              platform: 'gemini',\n              pageUrl: window.location.href,\n            });\n            await this.handleAccountIsolationToggle(nextEnabled);\n          })();\n        }\n        // Listen for language changes and update UI text\n        if (changes[StorageKeys.LANGUAGE]) {\n          this.debug('Language changed, updating UI text...');\n          this.updateHeaderLanguageText();\n        }\n      }\n      // Also listen for language changes from local storage (fallback)\n      if (areaName === 'local' && changes[StorageKeys.LANGUAGE]) {\n        this.debug('Language changed (local), updating UI text...');\n        this.updateHeaderLanguageText();\n      }\n      // Listen for folder data changes from cloud sync\n      if (areaName === 'local' && changes[this.activeStorageKey]) {\n        this.debug('Folder data changed in chrome.storage.local, reloading...');\n        this.reloadFoldersFromStorage();\n      }\n    });\n\n    // Listen for reload message from popup after sync\n    chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {\n      if (message?.type === 'gv.folders.reload') {\n        this.debug('Received folder reload message');\n        this.reloadFoldersFromStorage();\n        sendResponse({ ok: true });\n      }\n      return true;\n    });\n\n    // Perform migration from legacy settings\n    this.performMigration();\n  }\n\n  /**\n   * Reload folder data from chrome.storage.local and refresh UI\n   */\n  private async reloadFoldersFromStorage(): Promise<void> {\n    try {\n      await this.loadData();\n      this.renderAllFolders();\n      this.debug('Folders reloaded from storage');\n    } catch (error) {\n      console.error('[FolderManager] Failed to reload folders:', error);\n    }\n  }\n\n  /**\n   * Migrate legacy settings\n   */\n  private async performMigration(): Promise<void> {\n    try {\n      const result = await chrome.storage.local.get('gvSyncMode');\n      // Migration: Auto sync is deprecated, switch to manual\n      if (result.gvSyncMode === 'auto') {\n        console.log('[FolderManager] Migrating legacy \"auto\" sync mode to \"manual\"');\n        await chrome.storage.local.set({ gvSyncMode: 'manual' });\n      }\n    } catch (error) {\n      console.error('[FolderManager] Migration failed:', error);\n    }\n  }\n\n  /**\n   * Merge folder data for auto-sync (same logic as popup's mergeFolderData)\n   */\n  private mergeFolderDataForAutoSync(local: FolderData, cloud: FolderData): FolderData {\n    // Merge folders list\n    const folderMap = new Map<string, Folder>();\n\n    // Add all local folders first\n    local.folders.forEach((folder) => {\n      folderMap.set(folder.id, folder);\n    });\n\n    // Merge cloud folders\n    cloud.folders.forEach((cloudFolder) => {\n      const localFolder = folderMap.get(cloudFolder.id);\n      if (!localFolder) {\n        // New folder from cloud\n        folderMap.set(cloudFolder.id, cloudFolder);\n      } else {\n        // Conflict: compare timestamps\n        const cloudTime = cloudFolder.updatedAt || cloudFolder.createdAt || 0;\n        const localTime = localFolder.updatedAt || localFolder.createdAt || 0;\n        if (cloudTime > localTime) {\n          folderMap.set(cloudFolder.id, cloudFolder);\n        }\n        // If local is newer or equal, keep local\n      }\n    });\n\n    // Merge folder contents\n    const mergedContents: Record<string, ConversationReference[]> = { ...local.folderContents };\n\n    const allFolderIds = new Set([\n      ...Object.keys(local.folderContents),\n      ...Object.keys(cloud.folderContents),\n    ]);\n\n    allFolderIds.forEach((folderId) => {\n      const localConvos = local.folderContents[folderId] || [];\n      const cloudConvos = cloud.folderContents[folderId] || [];\n\n      const convoMap = new Map<string, ConversationReference>();\n      // Add cloud first, then local overwrites (local preferred)\n      cloudConvos.forEach((c) => convoMap.set(c.conversationId, c));\n      localConvos.forEach((c) => convoMap.set(c.conversationId, c));\n\n      mergedContents[folderId] = Array.from(convoMap.values());\n    });\n\n    return {\n      folders: Array.from(folderMap.values()),\n      folderContents: mergedContents,\n    };\n  }\n\n  private applyFolderEnabledSetting(): void {\n    if (this.folderEnabled) {\n      // If folder UI doesn't exist yet, initialize it\n      if (!this.containerElement) {\n        this.debug('Folder feature enabled, initializing UI');\n        this.initializeFolderUI().catch((error) => {\n          console.error('[FolderManager] Failed to initialize folder UI:', error);\n        });\n      } else {\n        // UI already exists, just show it\n        this.containerElement.style.display = '';\n        this.debug('Folder feature enabled');\n      }\n    } else {\n      // Hide the folder UI if it exists\n      if (this.containerElement) {\n        this.containerElement.style.display = 'none';\n        this.debug('Folder feature disabled');\n      }\n    }\n  }\n\n  private applyHideArchivedSetting(): void {\n    if (!this.sidebarContainer) return;\n\n    const conversations = this.sidebarContainer.querySelectorAll('[data-test-id=\"conversation\"]');\n    conversations.forEach((conv) => {\n      this.applyHideArchivedToConversation(conv as HTMLElement);\n    });\n  }\n\n  /**\n   * Apply hide archived setting to a single conversation element\n   */\n  private applyHideArchivedToConversation(conv: HTMLElement): void {\n    const convId = this.extractConversationId(conv);\n    const isArchived = this.isConversationInFolders(convId);\n\n    if (this.hideArchivedConversations && isArchived) {\n      conv.classList.add('gv-conversation-archived');\n    } else {\n      conv.classList.remove('gv-conversation-archived');\n    }\n  }\n\n  private isConversationInFolders(conversationId: string): boolean {\n    // Check if conversation exists in any folder\n    for (const folderId in this.data.folderContents) {\n      const conversations = this.data.folderContents[folderId];\n      if (\n        conversations.some((c) => {\n          // Direct ID match\n          if (c.conversationId === conversationId) return true;\n\n          // Robustness fallback: check if one ID contains the other (e.g. c_ prefix mismatch)\n          // or if URL contains the ID (common if one is hex and other is full ID)\n          const cleanId = conversationId.replace(/^c_/, '');\n          const cleanStoredId = c.conversationId.replace(/^c_/, '');\n\n          if (cleanId && cleanId === cleanStoredId) return true;\n\n          // Check if URL contains the hex ID\n          if (cleanId && cleanId.length > 8 && c.url.includes(cleanId)) return true;\n\n          return false;\n        })\n      ) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  private generateId(): string {\n    return `folder_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;\n  }\n\n  private hashString(str: string): string {\n    let hash = 0;\n    for (let i = 0; i < str.length; i++) {\n      const char = str.charCodeAt(i);\n      hash = (hash << 5) - hash + char;\n      hash = hash & hash;\n    }\n    return Math.abs(hash).toString(36);\n  }\n\n  private navigateToConversationById(folderId: string, conversationId: string): void {\n    // Look up the latest conversation data from storage\n    const conv = this.data.folderContents[folderId]?.find(\n      (c) => c.conversationId === conversationId,\n    );\n    if (!conv) {\n      console.error('[FolderManager] Conversation not found:', conversationId);\n      return;\n    }\n\n    this.debug('Navigating to conversation:', {\n      title: conv.title,\n      url: conv.url,\n      isGem: conv.isGem,\n      gemId: conv.gemId,\n    });\n\n    this.markConversationAsRecentlyOpened(conversationId);\n    this.navigateToConversation(conv.url, conv);\n  }\n\n  private isSameConversation(targetId: string, conversation: ConversationReference): boolean {\n    if (conversation.conversationId === targetId) return true;\n\n    const cleanId = targetId.replace(/^c_/, '');\n    const cleanStoredId = conversation.conversationId.replace(/^c_/, '');\n\n    if (cleanId && cleanId === cleanStoredId) return true;\n\n    if (cleanId && cleanId.length > 8 && conversation.url.includes(cleanId)) return true;\n\n    return false;\n  }\n\n  private markConversationAsRecentlyOpened(conversationId: string): void {\n    const now = Date.now();\n    let changed = false;\n\n    for (const folderId in this.data.folderContents) {\n      const conversations = this.data.folderContents[folderId];\n      conversations.forEach((conversation) => {\n        if (!this.isSameConversation(conversationId, conversation)) return;\n\n        // De-duplicate near-simultaneous route/listener updates.\n        if (conversation.lastOpenedAt && now - conversation.lastOpenedAt < 1000) return;\n\n        conversation.lastOpenedAt = now;\n        conversation.updatedAt = now;\n        changed = true;\n      });\n    }\n\n    if (!changed) return;\n\n    void this.saveData();\n\n    if (this.folderEnabled && this.containerElement) {\n      this.renderAllFolders();\n    }\n  }\n\n  private navigateToConversation(url: string, conversation?: ConversationReference): void {\n    // Use History API to navigate without page reload (SPA-style)\n    // This mimics how Gemini's original conversation links work\n    try {\n      // Try to find and click the original conversation element in the sidebar\n      // This is the most reliable way to trigger Gemini's navigation\n      const targetUrl = new URL(url);\n      const pathParts = targetUrl.pathname.split('/');\n      const hexId = pathParts[pathParts.length - 1]; // Get the hex ID part\n\n      let effectivePath: string | null = null;\n      let effectiveUrl: string | null = null;\n\n      if (this.accountIsolationEnabled) {\n        // In hard isolation mode, build a navigation URL that matches the\n        // current account context:\n        // - If the current path contains /u/{num}/, reuse that {num}\n        // - Otherwise navigate to /app/{hexId} directly\n        // This prevents us from reusing stale /u/{num} segments from previously\n        // saved URLs when the active account index has changed.\n        const currentPath = window.location.pathname;\n        const currentUserMatch = currentPath.match(/\\/u\\/(\\d+)\\//);\n        if (currentUserMatch) {\n          effectivePath = `/u/${currentUserMatch[1]}/app/${hexId}`;\n        } else {\n          effectivePath = `/app/${hexId}`;\n        }\n        effectiveUrl = `${window.location.origin}${effectivePath}${targetUrl.search}`;\n      }\n\n      const conversations = document.querySelectorAll('[data-test-id=\"conversation\"]');\n      for (const conv of Array.from(conversations)) {\n        const jslog = conv.getAttribute('jslog');\n        if (jslog && jslog.includes(hexId)) {\n          // Found the matching conversation, click it\n          // This will trigger SPA navigation, even if there's a brief redirect for gems\n          (conv as HTMLElement).click();\n          this.debug('Navigated by clicking sidebar element');\n          setTimeout(() => this.highlightActiveConversationInFolders(), 0);\n\n          // After navigation, sync title and check for gem updates\n          setTimeout(() => {\n            // Sync title from native conversation\n            if (conversation) {\n              const syncedTitle = this.syncConversationTitleFromNative(hexId);\n              if (syncedTitle && syncedTitle !== conversation.title) {\n                this.updateConversationTitle(hexId, syncedTitle);\n                this.debug('Updated conversation title after navigation:', syncedTitle);\n              }\n            }\n\n            // Check if URL changed (Gemini auto-redirected to /gem/)\n            if (conversation && !conversation.gemId) {\n              this.checkAndUpdateGemId(hexId);\n            } else if (conversation?.gemId) {\n              this.debug('Known gem conversation:', conversation.gemId);\n            }\n          }, 300);\n\n          return;\n        }\n      }\n\n      // If we can't find the sidebar element, try pushState + popstate\n      const navigationUrl = this.accountIsolationEnabled && effectiveUrl ? effectiveUrl : url;\n      const navigationPath =\n        this.accountIsolationEnabled && effectivePath ? effectivePath : targetUrl.pathname;\n\n      this.debug('Sidebar element not found, trying pushState');\n      window.history.pushState({}, '', navigationUrl);\n      const popStateEvent = new PopStateEvent('popstate', { state: {} });\n      window.dispatchEvent(popStateEvent);\n      setTimeout(() => this.highlightActiveConversationInFolders(), 0);\n\n      // If that doesn't work, fall back to page reload\n      setTimeout(() => {\n        if (window.location.pathname !== navigationPath) {\n          this.debug('Falling back to page reload');\n          window.location.href = navigationUrl;\n        }\n      }, 200);\n    } catch (error) {\n      console.error('[FolderManager] Navigation error:', error);\n      // Fallback to regular navigation\n      window.location.href = url;\n    }\n  }\n\n  private checkAndUpdateGemId(hexId: string): void {\n    // Wait for navigation to complete and check if URL changed\n    setTimeout(() => {\n      const currentPath = window.location.pathname;\n      this.debug('Checking URL after navigation:', currentPath);\n\n      // If URL changed from /app/ to /gem/, update the stored gemId\n      if (currentPath.includes('/gem/')) {\n        const gemMatch = currentPath.match(/\\/gem\\/([^\\/]+)/);\n        if (gemMatch) {\n          const gemId = gemMatch[1];\n          this.debug('Detected Gem after navigation:', gemId);\n\n          // Update all instances of this conversation in folders\n          let updated = false;\n\n          for (const folderId in this.data.folderContents) {\n            const conversations = this.data.folderContents[folderId];\n            for (const conv of conversations) {\n              // Match by hex ID in URL\n              if (conv.url.includes(hexId)) {\n                const oldUrl = conv.url;\n                conv.isGem = true;\n                conv.gemId = gemId;\n                // Update URL to use /gem/ instead of /app/\n                conv.url = conv.url.replace(/\\/app\\/([^/?]+)/, `/gem/${gemId}/$1`);\n                updated = true;\n                this.debug('Updated conversation:', conv.title);\n                this.debug('Old URL:', oldUrl);\n                this.debug('New URL:', conv.url);\n                this.debug('Gem ID:', gemId);\n              }\n            }\n          }\n\n          if (updated) {\n            this.saveData();\n            // Re-render folders to show correct icon\n            this.renderAllFolders();\n          }\n        }\n      }\n    }, 500); // Wait 500ms for navigation to complete\n  }\n\n  private renderAllFolders(): void {\n    if (!this.containerElement) return;\n\n    // Find the existing folders list\n    const existingList = this.containerElement.querySelector('.gv-folder-list');\n    if (!existingList) return;\n\n    // Create a new folders list\n    const newList = this.createFoldersList();\n\n    // Replace the old list with the new one\n    existingList.replaceWith(newList);\n\n    this.debug('Re-rendered all folders');\n\n    // Ensure active conversation remains highlighted after full re-render\n    this.highlightActiveConversationInFolders();\n  }\n\n  private async reloadScopedDataOnAccountRouteChange(): Promise<void> {\n    if (!this.accountIsolationEnabled) return;\n\n    const routeUserId = extractRouteUserIdFromPath(window.location.pathname);\n    if (routeUserId === this.accountScope?.routeUserId) return;\n\n    const previousStorageKey = this.activeStorageKey;\n    await this.refreshAccountScope();\n    if (this.activeStorageKey === previousStorageKey) return;\n\n    await this.loadData();\n    this.renderAllFolders();\n    this.debug('Switched account-scoped folder storage:', this.activeStorageKey);\n  }\n\n  private installRouteChangeListener(): void {\n    const update = () => {\n      if (this.isDestroyed) return;\n      setTimeout(() => {\n        void this.reloadScopedDataOnAccountRouteChange();\n        this.highlightActiveConversationInFolders();\n        const currentConversationId = this.getCurrentConversationId();\n        if (currentConversationId) {\n          this.markConversationAsRecentlyOpened(currentConversationId);\n        }\n      }, 0);\n    };\n\n    const cleanupFns: (() => void)[] = [];\n\n    try {\n      window.addEventListener('popstate', update);\n      cleanupFns.push(() => window.removeEventListener('popstate', update));\n    } catch (e) {\n      this.debug('Failed to add popstate listener:', e);\n    }\n\n    try {\n      const hist = history as History & Record<string, unknown>;\n      const originalPushState = hist.pushState;\n      const originalReplaceState = hist.replaceState;\n\n      const wrap = (\n        method: 'pushState' | 'replaceState',\n        original: (...args: unknown[]) => unknown,\n      ) => {\n        hist[method] = function (...args: unknown[]) {\n          const ret = original.apply(this, args);\n          try {\n            update();\n          } catch {\n            /* Ignore - update is non-critical */\n          }\n          return ret;\n        };\n      };\n      wrap('pushState', originalPushState as (...args: unknown[]) => unknown);\n      wrap('replaceState', originalReplaceState as (...args: unknown[]) => unknown);\n\n      cleanupFns.push(() => {\n        hist.pushState = originalPushState;\n        hist.replaceState = originalReplaceState;\n      });\n    } catch (e) {\n      this.debug('Failed to wrap history methods:', e);\n    }\n\n    // Fallback poller for routers/flows that don't emit events\n    try {\n      this.lastPathname = window.location.pathname;\n      this.navPoller = window.setInterval(() => {\n        if (this.isDestroyed) {\n          if (this.navPoller) clearInterval(this.navPoller);\n          return;\n        }\n        const now = window.location.pathname;\n        if (now !== this.lastPathname) {\n          this.lastPathname = now;\n          update();\n        }\n      }, 400);\n    } catch (e) {\n      this.debug('Failed to setup navigation poller:', e);\n    }\n\n    this.routeChangeCleanup = () => {\n      cleanupFns.forEach((fn) => fn());\n      if (this.navPoller) {\n        clearInterval(this.navPoller);\n        this.navPoller = null;\n      }\n    };\n  }\n\n  private installSidebarClickListener(): void {\n    // Capture clicks in Gemini's native sidebar and update highlight after navigation happens\n    const root = this.sidebarContainer;\n    if (!root) return;\n\n    this.sidebarClickListener = (e: Event) => {\n      if (this.isDestroyed) return;\n      const target = e.target as HTMLElement | null;\n      if (!target) return;\n      const a = target.closest('a[href*=\"/app/\"], a[href*=\"/gem/\"]') as HTMLAnchorElement | null;\n      if (a) {\n        setTimeout(() => this.highlightActiveConversationInFolders(), 0);\n      }\n    };\n\n    try {\n      root.addEventListener('click', this.sidebarClickListener, true);\n    } catch (e) {\n      this.debug('Failed to add sidebar click listener:', e);\n    }\n  }\n\n  private t(key: string): string {\n    // Use the centralized i18n system that respects user's language preference\n    return getTranslationSyncUnsafe(key);\n  }\n\n  /**\n   * Update all translatable text in the folder header when language changes\n   */\n  private updateHeaderLanguageText(): void {\n    if (!this.containerElement) return;\n\n    // Update folder title\n    const title = this.containerElement.querySelector('.gv-folder-header .title');\n    if (title) {\n      title.textContent = this.t('folder_title');\n    }\n\n    // Update button tooltips in header actions\n    const actionsContainer = this.containerElement.querySelector('.gv-folder-header-actions');\n    if (actionsContainer) {\n      const buttons = actionsContainer.querySelectorAll('button');\n      buttons.forEach((btn) => {\n        // Identify buttons by their class or icon content\n        if (btn.classList.contains('gv-folder-add-btn')) {\n          btn.title = this.t('folder_create');\n        } else if (btn.classList.contains('gv-folder-action-btn')) {\n          // Check icon to identify button type\n          const icon = btn.querySelector('mat-icon');\n          if (icon?.textContent === 'person') {\n            btn.title = this.t('folder_filter_current_user');\n          } else if (icon?.textContent === 'folder_managed') {\n            btn.title = this.t('folder_import_export');\n          }\n          // Cloud buttons use SVG, check for SVG content\n          const svg = btn.querySelector('svg');\n          if (svg) {\n            const path = svg.querySelector('path')?.getAttribute('d') || '';\n            // Cloud upload icon contains specific path pattern\n            if (path.includes('520q-33 0-56.5-23.5')) {\n              btn.title = this.t('folder_cloud_upload');\n            } else if (path.includes('520-716v242')) {\n              btn.title = this.t('folder_cloud_sync');\n            }\n          }\n        }\n      });\n    }\n\n    // Update empty state text if present\n    const emptyState = this.containerElement.querySelector('.gv-folder-empty');\n    if (emptyState) {\n      emptyState.textContent = this.t('folder_empty');\n    }\n\n    this.debug('Header language text updated');\n  }\n\n  private setupMessageListener(): void {\n    browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {\n      const msg = message as Record<string, unknown>;\n      // Handle request for current folder data\n      if (msg.type === 'gv.sync.requestData') {\n        this.debug('Received request for folder data from popup');\n        sendResponse({\n          ok: true,\n          data: this.data,\n          accountScope: this.toSyncAccountScope(this.accountScope),\n        });\n        // Return true to indicate we might respond asynchronously (though we responded synchronously above)\n        // This is good practice in some browser implementations or if we change logic later\n        return true;\n      }\n\n      // Handle reload request (existing functionality might be handled elsewhere, but safe to add log)\n      if (msg.type === 'gv.folders.reload') {\n        this.debug('Received reload request');\n        this.loadData().then(() => {\n          this.refresh();\n          // We can't easily respond to reload since it's fire-and-forget in some contexts,\n          // but if sendResponse is provided we can use it\n          try {\n            sendResponse({ ok: true });\n          } catch {\n            /* ignore */\n          }\n        });\n        return true;\n      }\n\n      if (msg.type === 'gv.account.getContext') {\n        const context = detectAccountContextFromDocument(window.location.href, document);\n        sendResponse({ ok: true, context });\n        return true;\n      }\n\n      // Handle request to collect all conversations and folder structure for AI organization\n      if (msg.type === 'gv.folders.getStructureForAI') {\n        this.debug('Received AI structure request');\n        const sidebarConversations = this.collectAllSidebarConversations();\n        sendResponse({\n          ok: true,\n          sidebarConversations,\n          folderData: this.data,\n        });\n        return true;\n      }\n\n      // Return true for all messages to keep the channel open\n      return true;\n    });\n  }\n\n  /**\n   * Collect all conversation titles and URLs from the native sidebar DOM\n   */\n  private collectAllSidebarConversations(): Array<{\n    id: string;\n    title: string;\n    url: string;\n  }> {\n    const results: Array<{ id: string; title: string; url: string }> = [];\n    const conversationEls = document.querySelectorAll('[data-test-id=\"conversation\"]');\n\n    for (const el of Array.from(conversationEls)) {\n      const htmlEl = el as HTMLElement;\n      const id = this.extractNativeConversationId(htmlEl);\n      const title = this.extractNativeConversationTitle(htmlEl);\n      const url = this.extractNativeConversationUrl(htmlEl);\n      if (id && title && url) {\n        results.push({ id, title, url });\n      }\n    }\n\n    return results;\n  }\n\n  // Tooltip methods\n  private createTooltip(): void {\n    this.tooltipElement = document.createElement('div');\n    this.tooltipElement.className = 'gv-tooltip';\n    document.body.appendChild(this.tooltipElement);\n  }\n\n  private showTooltip(element: HTMLElement, text: string): void {\n    if (!this.tooltipElement) return;\n\n    // Clear any existing timeout\n    if (this.tooltipTimeout) {\n      clearTimeout(this.tooltipTimeout);\n    }\n\n    // Check if text is truncated\n    const isTruncated = element.scrollWidth > element.clientWidth;\n    if (!isTruncated) return;\n\n    // Show tooltip after a short delay (200ms)\n    this.tooltipTimeout = window.setTimeout(() => {\n      if (!this.tooltipElement) return;\n\n      this.tooltipElement.textContent = text;\n\n      // Position tooltip\n      const rect = element.getBoundingClientRect();\n      const tooltipRect = this.tooltipElement.getBoundingClientRect();\n\n      let left = rect.left;\n      let top = rect.bottom + 8;\n\n      // Adjust if tooltip goes off screen\n      if (left + tooltipRect.width > window.innerWidth) {\n        left = window.innerWidth - tooltipRect.width - 10;\n      }\n      if (top + tooltipRect.height > window.innerHeight) {\n        top = rect.top - tooltipRect.height - 8;\n      }\n\n      this.tooltipElement.style.left = `${left}px`;\n      this.tooltipElement.style.top = `${top}px`;\n\n      // Trigger reflow for animation\n      this.tooltipElement.offsetHeight;\n      this.tooltipElement.classList.add('show');\n    }, 200);\n  }\n\n  private hideTooltip(): void {\n    if (this.tooltipTimeout) {\n      clearTimeout(this.tooltipTimeout);\n      this.tooltipTimeout = null;\n    }\n    if (this.tooltipElement) {\n      this.tooltipElement.classList.remove('show');\n    }\n  }\n\n  // Export/Import methods\n  private exportFolders(): void {\n    // Prevent concurrent exports\n    if (this.exportInProgress) {\n      this.showNotification(\n        this.t('folder_export_in_progress') || 'Export already in progress',\n        'info',\n      );\n      return;\n    }\n\n    this.exportInProgress = true;\n\n    try {\n      // Type assertion to match the service's expected type\n      const payload = FolderImportExportService.exportToPayload(\n        this.data as unknown as Parameters<typeof FolderImportExportService.exportToPayload>[0],\n      );\n      FolderImportExportService.downloadJSON(payload);\n      this.showNotification(this.t('folder_export_success'), 'success');\n      this.debug('Folders exported successfully');\n    } catch (error) {\n      console.error('[FolderManager] Export error:', error);\n      this.showNotification(\n        this.t('folder_import_error').replace('{error}', String(error)),\n        'error',\n      );\n    } finally {\n      // Always release the lock\n      this.exportInProgress = false;\n    }\n  }\n\n  private showImportDialog(): void {\n    // Create dialog overlay\n    const overlay = document.createElement('div');\n    overlay.className = 'gv-folder-dialog-overlay';\n\n    // Create dialog\n    const dialog = document.createElement('div');\n    dialog.className = 'gv-folder-import-dialog';\n\n    // Dialog title\n    const dialogTitle = document.createElement('div');\n    dialogTitle.className = 'gv-folder-dialog-title';\n    dialogTitle.textContent = this.t('folder_import_title');\n\n    // Strategy selection\n    const strategyContainer = document.createElement('div');\n    strategyContainer.className = 'gv-folder-import-strategy';\n\n    const strategyLabel = document.createElement('div');\n    strategyLabel.className = 'gv-folder-import-strategy-label';\n    strategyLabel.textContent = this.t('folder_import_strategy');\n\n    const strategyOptions = document.createElement('div');\n    strategyOptions.className = 'gv-folder-import-strategy-options';\n\n    const mergeOption = this.createRadioOption('merge', this.t('folder_import_merge'), true);\n    const overwriteOption = this.createRadioOption(\n      'overwrite',\n      this.t('folder_import_overwrite'),\n      false,\n    );\n\n    strategyOptions.appendChild(mergeOption);\n    strategyOptions.appendChild(overwriteOption);\n\n    strategyContainer.appendChild(strategyLabel);\n    strategyContainer.appendChild(strategyOptions);\n\n    // File input\n    const fileInputContainer = document.createElement('div');\n    fileInputContainer.className = 'gv-folder-import-file-input';\n\n    const fileInput = document.createElement('input');\n    fileInput.type = 'file';\n    fileInput.accept = '.json,application/json';\n    fileInput.style.display = 'none';\n\n    const fileButton = document.createElement('button');\n    fileButton.className = 'gv-folder-import-file-button';\n    fileButton.textContent = this.t('folder_import_select_file');\n    fileButton.addEventListener('click', () => fileInput.click());\n\n    const fileName = document.createElement('div');\n    fileName.className = 'gv-folder-import-file-name';\n    fileName.textContent = '';\n\n    fileInput.addEventListener('change', () => {\n      if (fileInput.files && fileInput.files[0]) {\n        fileName.textContent = fileInput.files[0].name;\n      }\n    });\n\n    fileInputContainer.appendChild(fileInput);\n    fileInputContainer.appendChild(fileButton);\n    fileInputContainer.appendChild(fileName);\n\n    // Paste JSON section\n    const pasteContainer = document.createElement('div');\n    pasteContainer.className = 'gv-folder-import-paste-container';\n\n    const pasteToggleBtn = document.createElement('button');\n    pasteToggleBtn.className = 'gv-folder-import-paste-toggle';\n    pasteToggleBtn.textContent = this.t('folder_import_paste_json');\n    let pasteExpanded = false;\n\n    const pasteArea = document.createElement('textarea');\n    pasteArea.className = 'gv-folder-import-paste-area';\n    pasteArea.placeholder = this.t('folder_import_paste_placeholder');\n    pasteArea.style.display = 'none';\n\n    pasteToggleBtn.addEventListener('click', () => {\n      pasteExpanded = !pasteExpanded;\n      pasteArea.style.display = pasteExpanded ? 'block' : 'none';\n      pasteToggleBtn.classList.toggle('gv-folder-import-paste-toggle-active', pasteExpanded);\n    });\n\n    pasteContainer.appendChild(pasteToggleBtn);\n    pasteContainer.appendChild(pasteArea);\n\n    // Buttons\n    const buttonsContainer = document.createElement('div');\n    buttonsContainer.className = 'gv-folder-dialog-buttons';\n\n    const importBtn = document.createElement('button');\n    importBtn.className = 'gv-folder-dialog-btn gv-folder-dialog-btn-primary';\n    importBtn.textContent = this.t('pm_import');\n    importBtn.addEventListener('click', async () => {\n      const strategy = (mergeOption.querySelector('input') as HTMLInputElement).checked\n        ? 'merge'\n        : 'overwrite';\n      const pasteText = pasteArea.value.trim();\n      if (pasteText) {\n        await this.handleImportFromText(pasteText, strategy);\n      } else {\n        await this.handleImport(fileInput, strategy);\n      }\n      overlay.remove();\n    });\n\n    const cancelBtn = document.createElement('button');\n    cancelBtn.className = 'gv-folder-dialog-btn gv-folder-dialog-btn-secondary';\n    cancelBtn.textContent = this.t('pm_cancel');\n    cancelBtn.addEventListener('click', () => overlay.remove());\n\n    buttonsContainer.appendChild(cancelBtn);\n    buttonsContainer.appendChild(importBtn);\n\n    // Assemble dialog\n    dialog.appendChild(dialogTitle);\n    dialog.appendChild(strategyContainer);\n    dialog.appendChild(fileInputContainer);\n    dialog.appendChild(pasteContainer);\n    dialog.appendChild(buttonsContainer);\n    overlay.appendChild(dialog);\n\n    // Add to body\n    document.body.appendChild(overlay);\n\n    // Close on overlay click\n    overlay.addEventListener('click', (e) => {\n      if (e.target === overlay) {\n        overlay.remove();\n      }\n    });\n  }\n\n  private createRadioOption(value: string, label: string, checked: boolean): HTMLElement {\n    const container = document.createElement('label');\n    container.className = 'gv-folder-import-radio-option';\n\n    const radio = document.createElement('input');\n    radio.type = 'radio';\n    radio.name = 'import-strategy';\n    radio.value = value;\n    radio.checked = checked;\n\n    const labelText = document.createElement('span');\n    labelText.textContent = label;\n\n    container.appendChild(radio);\n    container.appendChild(labelText);\n\n    return container;\n  }\n\n  private async handleImport(fileInput: HTMLInputElement, strategy: ImportStrategy): Promise<void> {\n    // Prevent concurrent imports to avoid data corruption\n    if (this.importInProgress) {\n      this.showNotification(\n        this.t('folder_import_in_progress') || 'Import already in progress',\n        'info',\n      );\n      return;\n    }\n\n    this.importInProgress = true;\n\n    try {\n      if (!fileInput.files || fileInput.files.length === 0) {\n        this.showNotification(this.t('folder_import_select_file'), 'error');\n        return;\n      }\n\n      const file = fileInput.files[0];\n\n      // Confirm overwrite if strategy is overwrite\n      if (strategy === 'overwrite') {\n        const confirmed = confirm(this.t('folder_import_confirm_overwrite'));\n        if (!confirmed) {\n          return;\n        }\n      }\n\n      // Read and parse file\n      const readResult = await FolderImportExportService.readJSONFile(file);\n      if (!readResult.success) {\n        this.showNotification(this.t('folder_import_invalid_format'), 'error');\n        return;\n      }\n\n      // Validate payload\n      const validationResult = FolderImportExportService.validatePayload(readResult.data);\n      if (!validationResult.success) {\n        this.showNotification(\n          this.t('folder_import_invalid_format') + ': ' + validationResult.error.message,\n          'error',\n        );\n        return;\n      }\n\n      // Import data (now async with concurrency protection)\n      const importResult = await FolderImportExportService.importFromPayload(\n        validationResult.data,\n        this.data as unknown as Parameters<typeof FolderImportExportService.importFromPayload>[1],\n        { strategy, createBackup: true },\n      );\n\n      if (!importResult.success) {\n        this.showNotification(\n          this.t('folder_import_error').replace('{error}', String(importResult.error)),\n          'error',\n        );\n        return;\n      }\n\n      // Update data and save\n      this.data = importResult.data.data;\n      this.saveData();\n      this.refresh();\n\n      // Show success message\n      const stats = importResult.data.stats;\n      let message = this.t('folder_import_success')\n        .replace('{folders}', String(stats.foldersImported))\n        .replace('{conversations}', String(stats.conversationsImported));\n\n      if (\n        strategy === 'merge' &&\n        (stats.duplicatesFoldersSkipped || stats.duplicatesConversationsSkipped)\n      ) {\n        const totalSkipped =\n          (stats.duplicatesFoldersSkipped || 0) + (stats.duplicatesConversationsSkipped || 0);\n        message = this.t('folder_import_success_skipped')\n          .replace('{folders}', String(stats.foldersImported))\n          .replace('{conversations}', String(stats.conversationsImported))\n          .replace('{skipped}', String(totalSkipped));\n      }\n\n      this.showNotification(message, 'success');\n      this.debug('Import successful:', stats);\n    } catch (error) {\n      console.error('[FolderManager] Import error:', error);\n      this.showNotification(\n        this.t('folder_import_error').replace('{error}', String(error)),\n        'error',\n      );\n    } finally {\n      // Always release the lock, even if an error occurred\n      this.importInProgress = false;\n    }\n  }\n\n  /**\n   * Import folder data from pasted JSON text\n   */\n  private async handleImportFromText(jsonText: string, strategy: ImportStrategy): Promise<void> {\n    if (this.importInProgress) {\n      this.showNotification(\n        this.t('folder_import_in_progress') || 'Import already in progress',\n        'info',\n      );\n      return;\n    }\n\n    this.importInProgress = true;\n\n    try {\n      let parsed: unknown;\n      try {\n        parsed = JSON.parse(jsonText);\n      } catch {\n        this.showNotification(this.t('folder_import_invalid_format'), 'error');\n        return;\n      }\n\n      if (strategy === 'overwrite') {\n        const confirmed = confirm(this.t('folder_import_confirm_overwrite'));\n        if (!confirmed) return;\n      }\n\n      const validationResult = FolderImportExportService.validatePayload(parsed);\n      if (!validationResult.success) {\n        this.showNotification(\n          this.t('folder_import_invalid_format') + ': ' + validationResult.error.message,\n          'error',\n        );\n        return;\n      }\n\n      const importResult = await FolderImportExportService.importFromPayload(\n        validationResult.data,\n        this.data as unknown as Parameters<typeof FolderImportExportService.importFromPayload>[1],\n        { strategy, createBackup: true },\n      );\n\n      if (!importResult.success) {\n        this.showNotification(\n          this.t('folder_import_error').replace('{error}', String(importResult.error)),\n          'error',\n        );\n        return;\n      }\n\n      this.data = importResult.data.data;\n      this.saveData();\n      this.refresh();\n\n      const stats = importResult.data.stats;\n      let message = this.t('folder_import_success')\n        .replace('{folders}', String(stats.foldersImported))\n        .replace('{conversations}', String(stats.conversationsImported));\n\n      if (\n        strategy === 'merge' &&\n        (stats.duplicatesFoldersSkipped || stats.duplicatesConversationsSkipped)\n      ) {\n        const totalSkipped =\n          (stats.duplicatesFoldersSkipped || 0) + (stats.duplicatesConversationsSkipped || 0);\n        message = this.t('folder_import_success_skipped')\n          .replace('{folders}', String(stats.foldersImported))\n          .replace('{conversations}', String(stats.conversationsImported))\n          .replace('{skipped}', String(totalSkipped));\n      }\n\n      this.showNotification(message, 'success');\n      this.debug('Import from text successful:', stats);\n    } catch (error) {\n      console.error('[FolderManager] Import from text error:', error);\n      this.showNotification(\n        this.t('folder_import_error').replace('{error}', String(error)),\n        'error',\n      );\n    } finally {\n      this.importInProgress = false;\n    }\n  }\n\n  /**\n   * Check if a folder has any visible content for the current user.\n   * - If filter is disabled, always returns true (show everything).\n   * - If filter is enabled:\n   *   - Returns true if folder has any conversations matching current user.\n   *   - Returns true if any subfolder has visible content.\n   *   - Returns false otherwise.\n   */\n  private hasVisibleContent(folderId: string): boolean {\n    if (!this.filterCurrentUserOnly) return true;\n\n    // Check direct conversations\n    const conversations = this.data.folderContents[folderId] || [];\n    const userConversations = this.filterConversationsByCurrentUser(conversations);\n    if (userConversations.length > 0) return true;\n\n    // Check subfolders recursively\n    const subfolders = this.data.folders.filter((f) => f.parentId === folderId);\n    for (const subfolder of subfolders) {\n      if (this.hasVisibleContent(subfolder.id)) return true;\n    }\n\n    return false;\n  }\n\n  /**\n   * Filter conversations to show only those belonging to the current user.\n   * If filterCurrentUserOnly is false, returns all conversations.\n   */\n  private filterConversationsByCurrentUser(\n    conversations: ConversationReference[],\n  ): ConversationReference[] {\n    if (!this.filterCurrentUserOnly) {\n      return conversations;\n    }\n    const currentUserId = this.getCurrentUserId();\n    return conversations.filter((conv) => {\n      const convUserId = this.getUserIdFromUrl(conv.url);\n      // Always show conversations with unspecified user (e.g. /app/...) as they might redirect to current user\n      if (convUserId === null) return true;\n      return convUserId === currentUserId;\n    });\n  }\n\n  /**\n   * Get the current user ID from the URL.\n   * URL patterns:\n   * - /u/0/app/xxx → user \"0\"\n   * - /u/1/app/xxx → user \"1\"\n   * - /app?hl=zh&pageId=none → user \"0\" (default)\n   */\n  private getCurrentUserId(): string {\n    try {\n      const path = window.location.pathname;\n      const match = path.match(/^\\/u\\/(\\d+)\\//);\n      return match ? match[1] : '0';\n    } catch {\n      return '0';\n    }\n  }\n\n  /**\n   * Extract user ID from a conversation URL.\n   * @param url The conversation URL\n   * @returns User ID string, or null if unspecified (e.g. /app/...)\n   */\n  private getUserIdFromUrl(url: string): string | null {\n    try {\n      const urlObj = new URL(url);\n      const match = urlObj.pathname.match(/^\\/u\\/(\\d+)\\//);\n      return match ? match[1] : null;\n    } catch {\n      return null;\n    }\n  }\n\n  /**\n   * Toggle the \"show only current user\" filter and refresh the UI.\n   */\n  private toggleFilterCurrentUser(): void {\n    this.filterCurrentUserOnly = !this.filterCurrentUserOnly;\n    this.debug('Filter current user only:', this.filterCurrentUserOnly);\n\n    // Save setting to storage\n    browser.storage.sync\n      .set({\n        [StorageKeys.GV_FOLDER_FILTER_USER_ONLY]: this.filterCurrentUserOnly,\n      })\n      .catch((e) => console.error('Failed to save filter user setting:', e));\n\n    // Refresh the entire folder container to update button state and list\n    if (this.containerElement) {\n      // Update the filter button state\n      const filterBtn = this.containerElement.querySelector(\n        '.gv-folder-header-actions button:first-child',\n      );\n      if (filterBtn) {\n        if (this.filterCurrentUserOnly) {\n          filterBtn.classList.add('gv-filter-active');\n        } else {\n          filterBtn.classList.remove('gv-filter-active');\n        }\n      }\n    }\n\n    // Refresh the folders list to apply the filter\n    this.refresh();\n  }\n\n  private showNotification(message: string, type: 'success' | 'error' | 'info' = 'info'): void {\n    // Create notification element\n    const notification = document.createElement('div');\n    notification.className = `gv-notification gv-notification-${type}`;\n    notification.textContent = message;\n\n    // Add to body\n    document.body.appendChild(notification);\n\n    // Trigger animation\n    setTimeout(() => notification.classList.add('show'), 10);\n\n    // Remove after 3 seconds\n    setTimeout(() => {\n      notification.classList.remove('show');\n      setTimeout(() => notification.remove(), 300);\n    }, 3000);\n  }\n\n  /**\n   * Show import/export dropdown menu\n   */\n  private showImportExportMenu(event: MouseEvent): void {\n    event.stopPropagation();\n\n    // Create context menu\n    const menu = document.createElement('div');\n    menu.className = 'gv-folder-menu';\n    menu.style.position = 'fixed';\n    menu.style.left = `${event.clientX}px`;\n    menu.style.top = `${event.clientY}px`;\n\n    const menuItems = [\n      {\n        label: this.t('folder_import'),\n        icon: 'upload',\n        action: () => this.showImportDialog(),\n      },\n      {\n        label: this.t('folder_export'),\n        icon: 'download',\n        action: () => this.exportFolders(),\n      },\n    ];\n\n    menuItems.forEach((item) => {\n      const menuItem = document.createElement('button');\n      menuItem.className = 'gv-folder-menu-item';\n\n      menuItem.innerHTML = `<mat-icon role=\"img\" class=\"mat-icon notranslate google-symbols mat-ligature-font mat-icon-no-color\" aria-hidden=\"true\" style=\"font-size: 18px; line-height: 1; margin-right: 8px;\">${item.icon}</mat-icon>${item.label}`;\n      menuItem.addEventListener('click', () => {\n        item.action();\n        menu.remove();\n      });\n      menu.appendChild(menuItem);\n    });\n\n    document.body.appendChild(menu);\n\n    // Close menu on click outside\n    const closeMenu = (e: MouseEvent) => {\n      if (!menu.contains(e.target as Node)) {\n        menu.remove();\n        document.removeEventListener('click', closeMenu);\n      }\n    };\n    setTimeout(() => document.addEventListener('click', closeMenu), 0);\n  }\n\n  /**\n   * Handle cloud upload - upload folder data, prompts, and starred messages to Google Drive\n   * This mirrors the logic in CloudSyncSettings.tsx handleSyncNow()\n   */\n  private async handleCloudUpload(): Promise<void> {\n    try {\n      this.showNotification(this.t('uploadInProgress'), 'info');\n\n      // Get current folder data\n      const folders = this.data;\n\n      // Get prompts from storage\n      let prompts: PromptItem[] = [];\n      try {\n        const storageResult = await chrome.storage.local.get(['gvPromptItems']);\n        if (storageResult.gvPromptItems) {\n          prompts = storageResult.gvPromptItems as PromptItem[];\n        }\n      } catch (err) {\n        console.warn('[FolderManager] Could not get prompts for upload:', err);\n      }\n\n      this.debug(\n        `Uploading - folders: ${folders.folders?.length || 0}, prompts: ${prompts.length}`,\n      );\n\n      // Send upload request to background script\n      // Background script will also fetch starred messages for Gemini platform\n      const response = (await browser.runtime.sendMessage({\n        type: 'gv.sync.upload',\n        payload: {\n          folders,\n          prompts,\n          platform: 'gemini',\n          accountScope: this.toSyncAccountScope(this.accountScope),\n        },\n      })) as { ok?: boolean; error?: string } | undefined;\n\n      if (response?.ok) {\n        this.showNotification(this.t('uploadSuccess'), 'success');\n      } else {\n        const errorMsg = response?.error || 'Unknown error';\n        this.showNotification(this.t('syncError').replace('{error}', errorMsg), 'error');\n      }\n    } catch (error) {\n      const errorMsg = error instanceof Error ? error.message : 'Unknown error';\n      console.error('[FolderManager] Cloud upload failed:', error);\n      this.showNotification(this.t('syncError').replace('{error}', errorMsg), 'error');\n    }\n  }\n\n  /**\n   * Handle cloud sync - download and merge folder data, prompts, and starred messages from Google Drive\n   * This mirrors the logic in CloudSyncSettings.tsx handleDownloadFromDrive()\n   */\n  private async handleCloudSync(): Promise<void> {\n    try {\n      this.showNotification(this.t('downloadInProgress'), 'info');\n\n      // Send download request to background script\n      const response = (await browser.runtime.sendMessage({\n        type: 'gv.sync.download',\n        payload: {\n          platform: 'gemini',\n          accountScope: this.toSyncAccountScope(this.accountScope),\n        },\n      })) as\n        | {\n            ok?: boolean;\n            error?: string;\n            data?: {\n              folders?: { data?: FolderData };\n              prompts?: { items?: PromptItem[] };\n              starred?: { data?: { messages: Record<string, unknown[]> } };\n            };\n          }\n        | undefined;\n\n      if (!response?.ok) {\n        const errorMsg = response?.error || 'Download failed';\n        this.showNotification(this.t('syncError').replace('{error}', errorMsg), 'error');\n        return;\n      }\n\n      if (!response.data) {\n        this.showNotification(this.t('syncNoData') || 'No data in cloud', 'info');\n        return;\n      }\n\n      // Extract cloud data\n      const cloudFoldersPayload = response.data?.folders;\n      const cloudPromptsPayload = response.data?.prompts;\n      const cloudStarredPayload = response.data?.starred;\n      const cloudFolderData = cloudFoldersPayload?.data || { folders: [], folderContents: {} };\n      const cloudPromptItems = cloudPromptsPayload?.items || [];\n      const cloudStarredData = cloudStarredPayload?.data || { messages: {} };\n\n      this.debug(\n        `Downloaded - folders: ${cloudFolderData.folders?.length || 0}, prompts: ${cloudPromptItems.length}, starred conversations: ${Object.keys(cloudStarredData.messages || {}).length}`,\n      );\n\n      // Get local prompts for merge\n      let localPrompts: PromptItem[] = [];\n      try {\n        const storageResult = await chrome.storage.local.get(['gvPromptItems']);\n        if (storageResult.gvPromptItems) {\n          localPrompts = storageResult.gvPromptItems as PromptItem[];\n        }\n      } catch (err) {\n        console.warn('[FolderManager] Could not get local prompts for merge:', err);\n      }\n\n      // Get local starred messages for merge\n      let localStarred = { messages: {} as Record<string, unknown[]> };\n      try {\n        const starredResult = await chrome.storage.local.get(['geminiTimelineStarredMessages']);\n        if (starredResult.geminiTimelineStarredMessages) {\n          localStarred = starredResult.geminiTimelineStarredMessages;\n        }\n      } catch (err) {\n        console.warn('[FolderManager] Could not get local starred messages for merge:', err);\n      }\n\n      // Merge folder data\n      const localFolders = this.data;\n      const mergedFolders = this.mergeFolderData(localFolders, cloudFolderData);\n\n      // Merge prompts (simple ID-based merge)\n      const mergedPrompts = this.mergePrompts(localPrompts, cloudPromptItems);\n\n      // Merge starred messages\n      const mergedStarred = this.mergeStarredMessages(localStarred, cloudStarredData);\n\n      this.debug(\n        `Merged - folders: ${mergedFolders.folders?.length || 0}, prompts: ${mergedPrompts.length}, starred conversations: ${Object.keys(mergedStarred.messages || {}).length}`,\n      );\n\n      // Apply merged folder data\n      this.data = mergedFolders;\n      await this.saveData();\n\n      // Save merged prompts and starred to storage\n      try {\n        await chrome.storage.local.set({\n          gvPromptItems: mergedPrompts,\n          geminiTimelineStarredMessages: mergedStarred,\n        });\n      } catch (err) {\n        console.error('[FolderManager] Failed to save merged prompts/starred:', err);\n      }\n\n      this.refresh();\n      this.showNotification(this.t('downloadMergeSuccess'), 'success');\n    } catch (error) {\n      const errorMsg = error instanceof Error ? error.message : 'Unknown error';\n      console.error('[FolderManager] Cloud sync failed:', error);\n      this.showNotification(this.t('syncError').replace('{error}', errorMsg), 'error');\n    }\n  }\n\n  /**\n   * Merge prompts by ID (simple deduplication)\n   */\n  private mergePrompts(local: PromptItem[], cloud: PromptItem[]): PromptItem[] {\n    const promptMap = new Map<string, PromptItem>();\n\n    // Add local prompts first\n    local.forEach((p) => {\n      if (p?.id) promptMap.set(p.id, p);\n    });\n\n    // Add cloud prompts (cloud takes priority for newer items)\n    cloud.forEach((p) => {\n      if (!p?.id) return;\n      const existing = promptMap.get(p.id);\n      if (!existing) {\n        promptMap.set(p.id, p);\n      } else {\n        // Compare timestamps, prefer newer\n        const cloudTime = p.updatedAt || p.createdAt || 0;\n        const localTime = existing.updatedAt || existing.createdAt || 0;\n        if (cloudTime > localTime) {\n          promptMap.set(p.id, p);\n        }\n      }\n    });\n\n    return Array.from(promptMap.values());\n  }\n\n  /**\n   * Merge starred messages by conversationId and turnId\n   */\n  private mergeStarredMessages(\n    local: { messages: Record<string, unknown[]> },\n    cloud: { messages: Record<string, unknown[]> },\n  ): { messages: Record<string, unknown[]> } {\n    const localMessages = local?.messages || {};\n    const cloudMessages = cloud?.messages || {};\n\n    const allConversationIds = new Set([\n      ...Object.keys(localMessages),\n      ...Object.keys(cloudMessages),\n    ]);\n\n    const mergedMessages: Record<string, unknown[]> = {};\n\n    allConversationIds.forEach((conversationId) => {\n      const localConvoMessages = localMessages[conversationId] || [];\n      const cloudConvoMessages = cloudMessages[conversationId] || [];\n\n      type StarredMsg = { turnId?: string; starredAt?: number };\n      const messageMap = new Map<string, unknown>();\n\n      // Add cloud messages first\n      cloudConvoMessages.forEach((m) => {\n        const msg = m as StarredMsg;\n        if (msg?.turnId) messageMap.set(msg.turnId, m);\n      });\n\n      // Merge local messages - prefer newer starredAt\n      localConvoMessages.forEach((m) => {\n        const localMsg = m as StarredMsg;\n        if (!localMsg?.turnId) return;\n        const existingMsg = messageMap.get(localMsg.turnId) as StarredMsg | undefined;\n        if (!existingMsg) {\n          messageMap.set(localMsg.turnId, m);\n        } else if ((localMsg.starredAt || 0) >= (existingMsg.starredAt || 0)) {\n          messageMap.set(localMsg.turnId, m);\n        }\n      });\n\n      const mergedArray = Array.from(messageMap.values());\n      if (mergedArray.length > 0) {\n        mergedMessages[conversationId] = mergedArray;\n      }\n    });\n\n    return { messages: mergedMessages };\n  }\n\n  /**\n   * Merge two FolderData objects (local + cloud)\n   * Uses folder/conversation IDs to deduplicate\n   */\n  private mergeFolderData(local: FolderData, cloud: FolderData): FolderData {\n    // Merge folders by ID\n    const folderMap = new Map<string, Folder>();\n    local.folders.forEach((f) => folderMap.set(f.id, f));\n    cloud.folders.forEach((f) => {\n      if (!folderMap.has(f.id)) {\n        folderMap.set(f.id, f);\n      }\n      // If exists, keep local version (local takes priority)\n    });\n\n    // Merge folderContents\n    const mergedContents: FolderData['folderContents'] = { ...local.folderContents };\n    Object.entries(cloud.folderContents).forEach(([folderId, conversations]) => {\n      if (!mergedContents[folderId]) {\n        mergedContents[folderId] = conversations;\n      } else {\n        // Merge conversations in folder by conversationId\n        const existingIds = new Set(mergedContents[folderId].map((c) => c.conversationId));\n        conversations.forEach((conv) => {\n          if (!existingIds.has(conv.conversationId)) {\n            mergedContents[folderId].push(conv);\n          }\n        });\n      }\n    });\n\n    return {\n      folders: Array.from(folderMap.values()),\n      folderContents: mergedContents,\n    };\n  }\n\n  /**\n   * Get dynamic tooltip for cloud upload button showing last upload time\n   */\n  private async getCloudUploadTooltip(): Promise<string> {\n    try {\n      const response = (await browser.runtime.sendMessage({ type: 'gv.sync.getState' })) as\n        | { ok?: boolean; state?: { lastUploadTime?: number | null } }\n        | undefined;\n      if (response?.ok && response.state) {\n        const lastUploadTime = response.state.lastUploadTime;\n        const timeStr = this.formatRelativeTime(lastUploadTime ?? null);\n        const baseTooltip = this.t('folder_cloud_upload');\n        return lastUploadTime\n          ? `${baseTooltip}\\n${this.t('lastUploaded').replace('{time}', timeStr)}`\n          : `${baseTooltip}\\n${this.t('neverUploaded')}`;\n      }\n    } catch (e) {\n      console.warn('[FolderManager] Failed to get sync state for tooltip:', e);\n    }\n    return this.t('folder_cloud_upload');\n  }\n\n  /**\n   * Get dynamic tooltip for cloud sync button showing last sync time\n   */\n  private async getCloudSyncTooltip(): Promise<string> {\n    try {\n      const response = (await browser.runtime.sendMessage({ type: 'gv.sync.getState' })) as\n        | { ok?: boolean; state?: { lastSyncTime?: number | null } }\n        | undefined;\n      if (response?.ok && response.state) {\n        const lastSyncTime = response.state.lastSyncTime;\n        const timeStr = this.formatRelativeTime(lastSyncTime ?? null);\n        const baseTooltip = this.t('folder_cloud_sync');\n        return lastSyncTime\n          ? `${baseTooltip}\\n${this.t('lastSynced').replace('{time}', timeStr)}`\n          : `${baseTooltip}\\n${this.t('neverSynced')}`;\n      }\n    } catch (e) {\n      console.warn('[FolderManager] Failed to get sync state for tooltip:', e);\n    }\n    return this.t('folder_cloud_sync');\n  }\n\n  /**\n   * Format a timestamp as relative time (e.g. \"5 minutes ago\")\n   */\n  private formatRelativeTime(timestamp: number | null): string {\n    if (!timestamp) return '';\n    const now = Date.now();\n    const diffMs = now - timestamp;\n    const diffMins = Math.floor(diffMs / 60000);\n    const diffHours = Math.floor(diffMs / 3600000);\n    const diffDays = Math.floor(diffMs / 86400000);\n\n    if (diffMins < 1) {\n      return this.t('justNow');\n    } else if (diffMins < 60) {\n      return `${diffMins} ${this.t('minutesAgo')}`;\n    } else if (diffHours < 24) {\n      return `${diffHours} ${this.t('hoursAgo')}`;\n    } else if (diffDays === 1) {\n      return this.t('yesterday');\n    } else {\n      return new Date(timestamp).toLocaleDateString();\n    }\n  }\n}\n"
  },
  {
    "path": "src/pages/content/folder/moveToFolderMenuItem.ts",
    "content": "import { createMenuItemFromNativeTemplate } from '../shared/nativeMenuItemTemplate';\n\nfunction createMoveToFolderMenuItemFallback(label: string): HTMLButtonElement {\n  const menuItem = document.createElement('button');\n  menuItem.className = 'mat-mdc-menu-item mat-focus-indicator gv-move-to-folder-btn';\n  menuItem.setAttribute('role', 'menuitem');\n  menuItem.setAttribute('tabindex', '0');\n  menuItem.setAttribute('aria-disabled', 'false');\n\n  const icon = document.createElement('mat-icon');\n  icon.className =\n    'mat-icon notranslate gds-icon-l google-symbols mat-ligature-font mat-icon-no-color';\n  icon.setAttribute('role', 'img');\n  icon.setAttribute('fonticon', 'folder_open');\n  icon.setAttribute('aria-hidden', 'true');\n  icon.textContent = 'folder_open';\n\n  const textSpan = document.createElement('span');\n  textSpan.className = 'mat-mdc-menu-item-text';\n  const innerSpan = document.createElement('span');\n  innerSpan.className = 'gds-body-m';\n  innerSpan.textContent = label;\n  textSpan.appendChild(innerSpan);\n\n  const ripple = document.createElement('div');\n  ripple.className = 'mat-ripple mat-mdc-menu-ripple';\n  ripple.setAttribute('matripple', '');\n\n  menuItem.appendChild(icon);\n  menuItem.appendChild(textSpan);\n  menuItem.appendChild(ripple);\n  return menuItem;\n}\n\nexport function createMoveToFolderMenuItem(\n  menuContent: HTMLElement,\n  label: string,\n  tooltip: string,\n): HTMLButtonElement {\n  return (\n    createMenuItemFromNativeTemplate({\n      menuContent,\n      injectedClassName: 'gv-move-to-folder-btn',\n      iconName: 'folder_open',\n      label,\n      tooltip,\n      excludedClassNames: ['gv-export-conversation-menu-btn'],\n    }) ?? createMoveToFolderMenuItemFallback(label)\n  );\n}\n"
  },
  {
    "path": "src/pages/content/folder/storage/FolderStorageAdapter.ts",
    "content": "/**\n * Folder Storage Adapter\n *\n * Enterprise-grade storage abstraction using Strategy Pattern\n * Provides unified interface for different storage backends\n *\n * Design Patterns:\n * - Strategy Pattern: Different storage implementations (localStorage vs browser.storage)\n * - Factory Pattern: Automatic strategy selection based on browser\n * - Adapter Pattern: Converts different storage APIs to unified interface\n *\n * Benefits:\n * - Single Responsibility: Each adapter handles one storage type\n * - Open/Closed: Easy to add new storage backends without modifying existing code\n * - Dependency Inversion: FolderManager depends on interface, not implementation\n * - Testability: Easy to mock storage in unit tests\n */\nimport { isSafari } from '@/core/utils/browser';\nimport { safariStorage } from '@/core/utils/safariStorage';\n\nimport type { FolderData } from '../types';\n\n/**\n * Unified storage interface for folder data\n * All implementations must provide async methods\n */\nexport interface IFolderStorageAdapter {\n  /**\n   * Initialize the storage adapter\n   * Used for adapter-specific setup like data migration\n   * @param key Storage key\n   */\n  init(key: string): Promise<void>;\n\n  /**\n   * Load folder data from storage\n   * @returns FolderData or null if no data exists\n   */\n  loadData(key: string): Promise<FolderData | null>;\n\n  /**\n   * Save folder data to storage\n   * @param key Storage key\n   * @param data Folder data to save\n   * @returns true if save succeeded\n   */\n  saveData(key: string, data: FolderData): Promise<boolean>;\n\n  /**\n   * Remove folder data from storage\n   * @param key Storage key\n   */\n  removeData(key: string): Promise<void>;\n\n  /**\n   * Get storage backend name for debugging\n   */\n  getBackendName(): string;\n}\n\n/**\n * LocalStorage implementation for Chrome/Firefox/Edge\n * Synchronous localStorage API wrapped in async interface for consistency\n */\nexport class LocalStorageFolderAdapter implements IFolderStorageAdapter {\n  /**\n   * Initialize and migrate existing data to chrome.storage.local\n   * This enables popup/sync to access folder data\n   */\n  async init(key: string): Promise<void> {\n    try {\n      // Check if we need to migrate localStorage data to chrome.storage.local\n      const localData = localStorage.getItem(key);\n      if (localData) {\n        const result = await chrome.storage.local.get(key);\n        if (!result[key]) {\n          // Migrate localStorage data to chrome.storage.local\n          const data = JSON.parse(localData) as FolderData;\n          await chrome.storage.local.set({ [key]: data });\n          console.log('[LocalStorageFolderAdapter] Migrated folder data to chrome.storage.local');\n        }\n      }\n    } catch (error) {\n      console.warn('[LocalStorageFolderAdapter] Migration check failed:', error);\n    }\n  }\n\n  async loadData(key: string): Promise<FolderData | null> {\n    try {\n      // First check chrome.storage.local (for synced data from popup/download)\n      const chromeResult = await chrome.storage.local.get(key);\n      if (chromeResult[key]) {\n        console.log('[LocalStorageFolderAdapter] Loaded data from chrome.storage.local');\n        // Also sync to localStorage for consistency\n        localStorage.setItem(key, JSON.stringify(chromeResult[key]));\n        return chromeResult[key] as FolderData;\n      }\n\n      // Fallback to localStorage\n      const stored = localStorage.getItem(key);\n      if (!stored) {\n        return null;\n      }\n      return JSON.parse(stored) as FolderData;\n    } catch (error) {\n      console.error('[LocalStorageFolderAdapter] Failed to load data:', error);\n      return null;\n    }\n  }\n\n  async saveData(key: string, data: FolderData): Promise<boolean> {\n    try {\n      const dataString = JSON.stringify(data);\n      localStorage.setItem(key, dataString);\n\n      // Verify the save was successful\n      const verification = localStorage.getItem(key);\n      if (verification !== dataString) {\n        throw new Error('Save verification failed - data mismatch');\n      }\n\n      // Also mirror to chrome.storage.local for popup/sync access\n      try {\n        await chrome.storage.local.set({ [key]: data });\n      } catch (storageError) {\n        console.warn(\n          '[LocalStorageFolderAdapter] Failed to mirror to chrome.storage.local:',\n          storageError,\n        );\n      }\n\n      return true;\n    } catch (error) {\n      console.error('[LocalStorageFolderAdapter] Failed to save data:', error);\n      return false;\n    }\n  }\n\n  async removeData(key: string): Promise<void> {\n    try {\n      localStorage.removeItem(key);\n    } catch (error) {\n      console.error('[LocalStorageFolderAdapter] Failed to remove data:', error);\n    }\n  }\n\n  getBackendName(): string {\n    return 'localStorage';\n  }\n}\n\n/**\n * BrowserStorage implementation for Safari\n * Uses browser.storage.local for reliable persistence\n *\n * Why Safari needs this:\n * - Safari's localStorage has 7-day deletion policy\n * - Random data loss on iOS 13+\n * - Private mode quota exceeded errors\n * - browser.storage.local is more reliable (10MB quota, persistent)\n */\nexport class SafariFolderAdapter implements IFolderStorageAdapter {\n  /**\n   * Initialize Safari adapter with data migration\n   * Migrates data from localStorage to browser.storage.local (one-time)\n   */\n  async init(key: string): Promise<void> {\n    await this.migrateFromLocalStorage(key);\n  }\n\n  async loadData(key: string): Promise<FolderData | null> {\n    try {\n      const stored = await safariStorage.getItem(key);\n      if (!stored) {\n        return null;\n      }\n      return JSON.parse(stored) as FolderData;\n    } catch (error) {\n      console.error('[SafariFolderAdapter] Failed to load data:', error);\n      return null;\n    }\n  }\n\n  async saveData(key: string, data: FolderData): Promise<boolean> {\n    try {\n      const dataString = JSON.stringify(data);\n      await safariStorage.setItem(key, dataString);\n\n      // Verify the save was successful for robustness\n      const verification = await safariStorage.getItem(key);\n      if (verification !== dataString) {\n        throw new Error('Save verification failed - data mismatch');\n      }\n\n      return true;\n    } catch (error) {\n      console.error('[SafariFolderAdapter] Failed to save data:', error);\n      return false;\n    }\n  }\n\n  async removeData(key: string): Promise<void> {\n    try {\n      await safariStorage.removeItem(key);\n    } catch (error) {\n      console.error('[SafariFolderAdapter] Failed to remove data:', error);\n    }\n  }\n\n  getBackendName(): string {\n    return 'browser.storage.local (Safari)';\n  }\n\n  /**\n   * Migrate data from localStorage to browser.storage.local\n   * Should be called once during initialization\n   */\n  async migrateFromLocalStorage(key: string): Promise<boolean> {\n    try {\n      return await safariStorage.migrateFromLocalStorage(key);\n    } catch (error) {\n      console.error('[SafariFolderAdapter] Migration failed:', error);\n      return false;\n    }\n  }\n}\n\n/**\n * Factory function to create appropriate storage adapter\n * Automatically selects based on browser detection\n *\n * Strategy Selection:\n * - Safari → SafariFolderAdapter (browser.storage.local)\n * - Others → LocalStorageFolderAdapter (localStorage)\n *\n * @returns Storage adapter instance\n */\nexport function createFolderStorageAdapter(): IFolderStorageAdapter {\n  if (isSafari()) {\n    console.log('[FolderStorage] Using SafariFolderAdapter (browser.storage.local)');\n    return new SafariFolderAdapter();\n  }\n\n  console.log('[FolderStorage] Using LocalStorageFolderAdapter (localStorage)');\n  return new LocalStorageFolderAdapter();\n}\n"
  },
  {
    "path": "src/pages/content/folder/types.ts",
    "content": "export interface Folder {\n  id: string;\n  name: string;\n  parentId: string | null; // null for root-level folders\n  isExpanded: boolean;\n  pinned?: boolean; // Whether folder is pinned to the top\n  color?: string; // Optional folder color identifier\n  sortIndex?: number; // Manual sort order within the same parent group\n  createdAt: number;\n  updatedAt: number;\n}\n\nexport interface ConversationReference {\n  conversationId: string; // The unique ID of the conversation\n  title: string; // The conversation title\n  url: string; // The conversation URL\n  addedAt: number; // When it was added to the folder\n  lastOpenedAt?: number; // Timestamp when the conversation was last opened\n  updatedAt?: number; // Timestamp when the reference was last updated (e.g., renamed)\n  isGem?: boolean; // Whether this is a Gem conversation\n  gemId?: string; // Gem identifier if applicable\n  starred?: boolean; // Whether this conversation is starred in the folder\n  customTitle?: boolean; // Whether title was manually renamed in folder (don't auto-sync from native)\n  sortIndex?: number; // Manual sort order within the same folder group\n}\n\nexport interface FolderData {\n  folders: Folder[];\n  // Maps folder ID to conversation references in that folder\n  folderContents: Record<string, ConversationReference[]>;\n}\n\nexport interface DragData {\n  type?: 'conversation' | 'folder'; // Type of dragged item\n  conversationId?: string;\n  folderId?: string; // For folder dragging\n  title: string;\n  url?: string;\n  isGem?: boolean;\n  gemId?: string;\n  conversations?: ConversationReference[]; // For multi-select dragging\n  sourceFolderId?: string; // Track where conversations are being dragged from\n}\n"
  },
  {
    "path": "src/pages/content/folderSpacing/__tests__/folderSpacing.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nconst STYLE_ID = 'gv-folder-spacing-style';\nconst GEMINI_KEY = 'gvFolderSpacing';\nconst AISTUDIO_KEY = 'gvAIStudioFolderSpacing';\n\ndescribe('folderSpacing', () => {\n  let storageChangeListeners: Array<\n    (changes: Record<string, chrome.storage.StorageChange>, area: string) => void\n  >;\n\n  beforeEach(() => {\n    vi.resetModules();\n    vi.clearAllMocks();\n    document.head.innerHTML = '';\n    document.body.innerHTML = '';\n\n    storageChangeListeners = [];\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ [GEMINI_KEY]: 2, [AISTUDIO_KEY]: 2 });\n      },\n    );\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation(\n      (listener: (changes: Record<string, chrome.storage.StorageChange>, area: string) => void) => {\n        storageChangeListeners.push(listener);\n      },\n    );\n  });\n\n  afterEach(() => {\n    window.dispatchEvent(new Event('beforeunload'));\n  });\n\n  // ===== Gemini platform tests =====\n\n  describe('gemini platform', () => {\n    it('injects a style element with the default spacing', async () => {\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('gemini');\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      expect(style).not.toBeNull();\n      expect(style.textContent).toContain('gap: 2px');\n    });\n\n    it('uses base selectors without .gv-aistudio prefix', async () => {\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('gemini');\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      expect(style.textContent).toContain('.gv-folder-list');\n      expect(style.textContent).toContain('.gv-folder-content');\n      expect(style.textContent).toContain('.gv-folder-item-header');\n      expect(style.textContent).toContain('.gv-folder-conversation');\n      expect(style.textContent).not.toContain('.gv-aistudio');\n    });\n\n    it('reads from gvFolderSpacing storage key', async () => {\n      (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n        (\n          _defaults: Record<string, unknown>,\n          callback: (result: Record<string, unknown>) => void,\n        ) => {\n          callback({ [GEMINI_KEY]: 10 });\n        },\n      );\n\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('gemini');\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      expect(style.textContent).toContain('gap: 10px');\n    });\n\n    it('responds to gvFolderSpacing storage changes', async () => {\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('gemini');\n\n      storageChangeListeners[0]({ [GEMINI_KEY]: { newValue: 8, oldValue: 2 } }, 'sync');\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      expect(style.textContent).toContain('gap: 8px');\n    });\n\n    it('ignores gvAIStudioFolderSpacing changes', async () => {\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('gemini');\n\n      const contentBefore = (document.getElementById(STYLE_ID) as HTMLStyleElement).textContent;\n\n      storageChangeListeners[0]({ [AISTUDIO_KEY]: { newValue: 12, oldValue: 2 } }, 'sync');\n\n      expect((document.getElementById(STYLE_ID) as HTMLStyleElement).textContent).toBe(\n        contentBefore,\n      );\n    });\n\n    it('clamps spacing below minimum to 0', async () => {\n      (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n        (\n          _defaults: Record<string, unknown>,\n          callback: (result: Record<string, unknown>) => void,\n        ) => {\n          callback({ [GEMINI_KEY]: -5 });\n        },\n      );\n\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('gemini');\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      expect(style.textContent).toContain('gap: 0px');\n    });\n\n    it('clamps spacing above maximum to 16', async () => {\n      (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n        (\n          _defaults: Record<string, unknown>,\n          callback: (result: Record<string, unknown>) => void,\n        ) => {\n          callback({ [GEMINI_KEY]: 50 });\n        },\n      );\n\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('gemini');\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      expect(style.textContent).toContain('gap: 16px');\n    });\n\n    it('scales vertical padding proportionally', async () => {\n      (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n        (\n          _defaults: Record<string, unknown>,\n          callback: (result: Record<string, unknown>) => void,\n        ) => {\n          callback({ [GEMINI_KEY]: 12 });\n        },\n      );\n\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('gemini');\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      // max(4, 4 + 12 * 0.5) = 10px\n      expect(style.textContent).toContain('padding-top: 10px');\n      expect(style.textContent).toContain('padding-bottom: 10px');\n    });\n\n    it('ensures minimum vertical padding of 4px', async () => {\n      (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n        (\n          _defaults: Record<string, unknown>,\n          callback: (result: Record<string, unknown>) => void,\n        ) => {\n          callback({ [GEMINI_KEY]: 0 });\n        },\n      );\n\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('gemini');\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      expect(style.textContent).toContain('padding-top: 4px');\n      expect(style.textContent).toContain('padding-bottom: 4px');\n    });\n  });\n\n  // ===== AI Studio platform tests =====\n\n  describe('aistudio platform', () => {\n    it('injects AI Studio-specific selectors', async () => {\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('aistudio');\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      expect(style).not.toBeNull();\n      expect(style.textContent).toContain('.gv-aistudio .gv-folder-list');\n      expect(style.textContent).toContain('.gv-aistudio .gv-folder-content');\n      expect(style.textContent).toContain('.gv-aistudio .gv-folder-item-header');\n      expect(style.textContent).toContain('.gv-aistudio .gv-folder-conversation');\n      expect(style.textContent).toContain('.gv-aistudio .gv-folder-uncategorized-content');\n    });\n\n    it('reads from gvAIStudioFolderSpacing storage key', async () => {\n      (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n        (\n          _defaults: Record<string, unknown>,\n          callback: (result: Record<string, unknown>) => void,\n        ) => {\n          callback({ [AISTUDIO_KEY]: 8 });\n        },\n      );\n\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('aistudio');\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      expect(style.textContent).toContain('gap: 8px');\n    });\n\n    it('responds to gvAIStudioFolderSpacing storage changes', async () => {\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('aistudio');\n\n      storageChangeListeners[0]({ [AISTUDIO_KEY]: { newValue: 6, oldValue: 2 } }, 'sync');\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      expect(style.textContent).toContain('gap: 6px');\n    });\n\n    it('ignores gvFolderSpacing changes', async () => {\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('aistudio');\n\n      const contentBefore = (document.getElementById(STYLE_ID) as HTMLStyleElement).textContent;\n\n      storageChangeListeners[0]({ [GEMINI_KEY]: { newValue: 12, oldValue: 2 } }, 'sync');\n\n      expect((document.getElementById(STYLE_ID) as HTMLStyleElement).textContent).toBe(\n        contentBefore,\n      );\n    });\n\n    it('uses more compact padding than Gemini at same spacing', async () => {\n      (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n        (\n          _defaults: Record<string, unknown>,\n          callback: (result: Record<string, unknown>) => void,\n        ) => {\n          callback({ [AISTUDIO_KEY]: 12 });\n        },\n      );\n\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('aistudio');\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      // max(3, round(3 + 12 * 0.45)) = max(3, 8) = 8px (vs Gemini's 10px)\n      expect(style.textContent).toContain('padding-top: 8px');\n      expect(style.textContent).toContain('padding-bottom: 8px');\n    });\n\n    it('ensures minimum padding of 3px at zero spacing', async () => {\n      (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n        (\n          _defaults: Record<string, unknown>,\n          callback: (result: Record<string, unknown>) => void,\n        ) => {\n          callback({ [AISTUDIO_KEY]: 0 });\n        },\n      );\n\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('aistudio');\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      expect(style.textContent).toContain('padding-top: 3px');\n      expect(style.textContent).toContain('padding-bottom: 3px');\n    });\n  });\n\n  // ===== Shared behavior tests =====\n\n  describe('shared behavior', () => {\n    it('removes style element on beforeunload', async () => {\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('gemini');\n\n      expect(document.getElementById(STYLE_ID)).not.toBeNull();\n\n      window.dispatchEvent(new Event('beforeunload'));\n\n      expect(document.getElementById(STYLE_ID)).toBeNull();\n    });\n\n    it('falls back to default spacing for non-numeric values', async () => {\n      (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n        (\n          _defaults: Record<string, unknown>,\n          callback: (result: Record<string, unknown>) => void,\n        ) => {\n          callback({ [GEMINI_KEY]: 'invalid' });\n        },\n      );\n\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('gemini');\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      expect(style.textContent).toContain('gap: 2px');\n    });\n\n    it('ignores storage changes from non-sync areas', async () => {\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster('gemini');\n\n      const contentBefore = (document.getElementById(STYLE_ID) as HTMLStyleElement).textContent;\n\n      storageChangeListeners[0]({ [GEMINI_KEY]: { newValue: 10, oldValue: 2 } }, 'local');\n\n      expect((document.getElementById(STYLE_ID) as HTMLStyleElement).textContent).toBe(\n        contentBefore,\n      );\n    });\n\n    it('defaults to gemini platform when no argument provided', async () => {\n      const { startFolderSpacingAdjuster } = await import('../index');\n      startFolderSpacingAdjuster();\n\n      const style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n      // Should use base selectors (no .gv-aistudio)\n      expect(style.textContent).not.toContain('.gv-aistudio');\n      expect(style.textContent).toContain('.gv-folder-list');\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/content/folderSpacing/index.ts",
    "content": "/**\n * Adjusts the spacing (gap) between folders and conversations in the sidebar\n * based on user settings stored in chrome.storage.sync.\n *\n * Gemini and AI Studio use separate storage keys so users can configure\n * each platform independently (similar to sidebar width).\n *\n * Platform differences:\n * - Gemini: folder-item-header padding 8px 12px, conversation 8px 6px\n * - AI Studio: folder-item-header padding 6px 10px, more compact sidebar\n */\n\ntype FolderSpacingPlatform = 'gemini' | 'aistudio';\n\nconst STYLE_ID = 'gv-folder-spacing-style';\nconst STORAGE_KEYS: Record<FolderSpacingPlatform, string> = {\n  gemini: 'gvFolderSpacing',\n  aistudio: 'gvAIStudioFolderSpacing',\n};\nconst DEFAULT_SPACING = 2;\nconst MIN_SPACING = 0;\nconst MAX_SPACING = 16;\n\nfunction clamp(value: number): number {\n  if (!Number.isFinite(value)) return DEFAULT_SPACING;\n  return Math.min(MAX_SPACING, Math.max(MIN_SPACING, Math.round(value)));\n}\n\nfunction applyGeminiSpacing(clamped: number, style: HTMLStyleElement) {\n  // Gemini defaults: header 8px, conversation 8px\n  //   At spacing 0 → 4px, spacing 2 → 5px, spacing 16 → 12px\n  const vPad = Math.max(4, Math.round(4 + clamped * 0.5));\n\n  style.textContent = `\n    .gv-folder-list {\n      gap: ${clamped}px !important;\n    }\n    .gv-folder-content {\n      gap: ${clamped}px !important;\n    }\n    .gv-folder-item-header {\n      padding-top: ${vPad}px !important;\n      padding-bottom: ${vPad}px !important;\n    }\n    .gv-folder-conversation {\n      padding-top: ${vPad}px !important;\n      padding-bottom: ${vPad}px !important;\n    }\n  `;\n}\n\nfunction applyAIStudioSpacing(clamped: number, style: HTMLStyleElement) {\n  // AI Studio defaults: header 6px, more compact sidebar\n  //   At spacing 0 → 3px, spacing 2 → 4px, spacing 16 → 10px\n  const vPad = Math.max(3, Math.round(3 + clamped * 0.45));\n\n  style.textContent = `\n    .gv-aistudio .gv-folder-list {\n      gap: ${clamped}px !important;\n    }\n    .gv-aistudio .gv-folder-content {\n      gap: ${clamped}px !important;\n    }\n    .gv-aistudio .gv-folder-item-header {\n      padding-top: ${vPad}px !important;\n      padding-bottom: ${vPad}px !important;\n    }\n    .gv-aistudio .gv-folder-conversation {\n      padding-top: ${vPad}px !important;\n      padding-bottom: ${vPad}px !important;\n    }\n    .gv-aistudio .gv-folder-uncategorized-content {\n      gap: ${clamped}px !important;\n    }\n  `;\n}\n\nfunction applySpacing(spacing: number, platform: FolderSpacingPlatform) {\n  const clamped = clamp(spacing);\n\n  let style = document.getElementById(STYLE_ID) as HTMLStyleElement;\n  if (!style) {\n    style = document.createElement('style');\n    style.id = STYLE_ID;\n    document.head.appendChild(style);\n  }\n\n  if (platform === 'aistudio') {\n    applyAIStudioSpacing(clamped, style);\n  } else {\n    applyGeminiSpacing(clamped, style);\n  }\n}\n\nfunction removeStyles() {\n  const style = document.getElementById(STYLE_ID);\n  if (style) style.remove();\n}\n\n/**\n * Start the folder spacing adjuster for a specific platform.\n * Each platform reads/writes its own storage key so settings are independent.\n */\nexport function startFolderSpacingAdjuster(platform: FolderSpacingPlatform = 'gemini') {\n  const storageKey = STORAGE_KEYS[platform];\n  let currentSpacing = DEFAULT_SPACING;\n\n  // Load initial spacing from storage\n  chrome.storage?.sync?.get({ [storageKey]: DEFAULT_SPACING }, (res) => {\n    const stored = res?.[storageKey];\n    if (typeof stored === 'number') {\n      currentSpacing = clamp(stored);\n    }\n    applySpacing(currentSpacing, platform);\n  });\n\n  // Listen for changes from popup or other sources\n  const storageChangeHandler = (\n    changes: Record<string, chrome.storage.StorageChange>,\n    area: string,\n  ) => {\n    if (area === 'sync' && changes[storageKey]) {\n      const newValue = changes[storageKey].newValue;\n      if (typeof newValue === 'number') {\n        currentSpacing = clamp(newValue);\n        applySpacing(currentSpacing, platform);\n      }\n    }\n  };\n\n  chrome.storage?.onChanged?.addListener(storageChangeHandler);\n\n  // Cleanup on page unload\n  window.addEventListener(\n    'beforeunload',\n    () => {\n      removeStyles();\n      try {\n        chrome.storage?.onChanged?.removeListener(storageChangeHandler);\n      } catch {\n        // Ignore errors during cleanup\n      }\n    },\n    { once: true },\n  );\n}\n"
  },
  {
    "path": "src/pages/content/fork/ForkNodesService.ts",
    "content": "/**\n * ForkNodesService - communicates with background script for fork node persistence\n * Follows the same pattern as StarredMessagesService\n */\nimport { eventBus } from '../timeline/EventBus';\nimport type { ForkNode, ForkNodesData } from './forkTypes';\n\nasync function sendMessage<T>(type: string, payload?: unknown): Promise<T> {\n  return new Promise((resolve, reject) => {\n    try {\n      chrome.runtime.sendMessage({ type, payload }, (response) => {\n        if (chrome.runtime.lastError) {\n          reject(new Error(chrome.runtime.lastError.message));\n          return;\n        }\n        if (response?.ok) {\n          resolve(response as T);\n        } else {\n          reject(new Error(response?.error || 'Unknown error'));\n        }\n      });\n    } catch (error) {\n      reject(error);\n    }\n  });\n}\n\nexport class ForkNodesService {\n  static async addForkNode(node: ForkNode): Promise<boolean> {\n    const response = await sendMessage<{ ok: boolean; added: boolean }>('gv.fork.add', node);\n    if (response.added) {\n      eventBus.emit('fork:added', {\n        conversationId: node.conversationId,\n        turnId: node.turnId,\n        forkGroupId: node.forkGroupId,\n      });\n    }\n    return response.added;\n  }\n\n  static async removeForkNode(\n    conversationId: string,\n    turnId: string,\n    forkGroupId: string,\n  ): Promise<boolean> {\n    const response = await sendMessage<{ ok: boolean; removed: boolean }>('gv.fork.remove', {\n      conversationId,\n      turnId,\n      forkGroupId,\n    });\n    if (response.removed) {\n      eventBus.emit('fork:removed', { conversationId, turnId, forkGroupId });\n    }\n    return response.removed;\n  }\n\n  static async getAllForkNodes(): Promise<ForkNodesData> {\n    const response = await sendMessage<{ ok: boolean; data: ForkNodesData }>('gv.fork.getAll');\n    return response.data;\n  }\n\n  static async getForConversation(conversationId: string): Promise<ForkNode[]> {\n    const response = await sendMessage<{ ok: boolean; nodes: ForkNode[] }>(\n      'gv.fork.getForConversation',\n      { conversationId },\n    );\n    return response.nodes;\n  }\n\n  static async getGroup(forkGroupId: string): Promise<ForkNode[]> {\n    const response = await sendMessage<{ ok: boolean; nodes: ForkNode[] }>('gv.fork.getGroup', {\n      forkGroupId,\n    });\n    return response.nodes;\n  }\n}\n"
  },
  {
    "path": "src/pages/content/fork/__tests__/ForkNodesService.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { eventBus } from '../../timeline/EventBus';\nimport { ForkNodesService } from '../ForkNodesService';\nimport type { ForkNode, ForkNodesData } from '../forkTypes';\n\nfunction createForkNode(overrides: Partial<ForkNode> = {}): ForkNode {\n  return {\n    turnId: 'u-0',\n    conversationId: 'conv1',\n    conversationUrl: 'https://gemini.google.com/app/conv1',\n    conversationTitle: 'Test Conversation',\n    forkGroupId: 'fork-group-1',\n    forkIndex: 0,\n    createdAt: 1000,\n    ...overrides,\n  };\n}\n\ndescribe('ForkNodesService', () => {\n  let sendMessageMock: ReturnType<typeof vi.fn>;\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n\n    sendMessageMock = vi.fn();\n    chrome.runtime.sendMessage = sendMessageMock as unknown as typeof chrome.runtime.sendMessage;\n    Object.defineProperty(chrome.runtime, 'lastError', { value: null, configurable: true });\n  });\n\n  describe('addForkNode', () => {\n    it('should send gv.fork.add message and emit event when added', async () => {\n      const node = createForkNode();\n      const emitSpy = vi.spyOn(eventBus, 'emit');\n\n      sendMessageMock.mockImplementation(\n        (_message: unknown, callback: (response: unknown) => void) => {\n          callback({ ok: true, added: true });\n        },\n      );\n\n      const result = await ForkNodesService.addForkNode(node);\n\n      expect(result).toBe(true);\n      expect(sendMessageMock).toHaveBeenCalledWith(\n        { type: 'gv.fork.add', payload: node },\n        expect.any(Function),\n      );\n      expect(emitSpy).toHaveBeenCalledWith('fork:added', {\n        conversationId: 'conv1',\n        turnId: 'u-0',\n        forkGroupId: 'fork-group-1',\n      });\n\n      emitSpy.mockRestore();\n    });\n\n    it('should not emit event when node was not added', async () => {\n      const node = createForkNode();\n      const emitSpy = vi.spyOn(eventBus, 'emit');\n\n      sendMessageMock.mockImplementation(\n        (_message: unknown, callback: (response: unknown) => void) => {\n          callback({ ok: true, added: false });\n        },\n      );\n\n      const result = await ForkNodesService.addForkNode(node);\n\n      expect(result).toBe(false);\n      expect(emitSpy).not.toHaveBeenCalled();\n\n      emitSpy.mockRestore();\n    });\n\n    it('should reject on runtime error', async () => {\n      const node = createForkNode();\n\n      sendMessageMock.mockImplementation(\n        (_message: unknown, callback: (response: unknown) => void) => {\n          Object.defineProperty(chrome.runtime, 'lastError', {\n            value: { message: 'Extension context invalidated' },\n            configurable: true,\n          });\n          callback(undefined);\n        },\n      );\n\n      await expect(ForkNodesService.addForkNode(node)).rejects.toThrow(\n        'Extension context invalidated',\n      );\n\n      // Restore\n      Object.defineProperty(chrome.runtime, 'lastError', {\n        value: null,\n        configurable: true,\n      });\n    });\n  });\n\n  describe('removeForkNode', () => {\n    it('should send gv.fork.remove message and emit event when removed', async () => {\n      const emitSpy = vi.spyOn(eventBus, 'emit');\n\n      sendMessageMock.mockImplementation(\n        (_message: unknown, callback: (response: unknown) => void) => {\n          callback({ ok: true, removed: true });\n        },\n      );\n\n      const result = await ForkNodesService.removeForkNode('conv1', 'u-0', 'fork-group-1');\n\n      expect(result).toBe(true);\n      expect(sendMessageMock).toHaveBeenCalledWith(\n        {\n          type: 'gv.fork.remove',\n          payload: { conversationId: 'conv1', turnId: 'u-0', forkGroupId: 'fork-group-1' },\n        },\n        expect.any(Function),\n      );\n      expect(emitSpy).toHaveBeenCalledWith('fork:removed', {\n        conversationId: 'conv1',\n        turnId: 'u-0',\n        forkGroupId: 'fork-group-1',\n      });\n\n      emitSpy.mockRestore();\n    });\n  });\n\n  describe('getAllForkNodes', () => {\n    it('should return all fork nodes data', async () => {\n      const mockData: ForkNodesData = {\n        nodes: {\n          conv1: [createForkNode()],\n        },\n        groups: {\n          'fork-group-1': ['conv1:u-0'],\n        },\n      };\n\n      sendMessageMock.mockImplementation(\n        (_message: unknown, callback: (response: unknown) => void) => {\n          callback({ ok: true, data: mockData });\n        },\n      );\n\n      const result = await ForkNodesService.getAllForkNodes();\n\n      expect(result).toEqual(mockData);\n      expect(sendMessageMock).toHaveBeenCalledWith(\n        { type: 'gv.fork.getAll', payload: undefined },\n        expect.any(Function),\n      );\n    });\n  });\n\n  describe('getForConversation', () => {\n    it('should return fork nodes for a specific conversation', async () => {\n      const nodes = [createForkNode()];\n\n      sendMessageMock.mockImplementation(\n        (_message: unknown, callback: (response: unknown) => void) => {\n          callback({ ok: true, nodes });\n        },\n      );\n\n      const result = await ForkNodesService.getForConversation('conv1');\n\n      expect(result).toEqual(nodes);\n      expect(sendMessageMock).toHaveBeenCalledWith(\n        { type: 'gv.fork.getForConversation', payload: { conversationId: 'conv1' } },\n        expect.any(Function),\n      );\n    });\n  });\n\n  describe('getGroup', () => {\n    it('should return all nodes in a fork group', async () => {\n      const nodes = [\n        createForkNode({ forkIndex: 0 }),\n        createForkNode({ conversationId: 'conv2', forkIndex: 1 }),\n      ];\n\n      sendMessageMock.mockImplementation(\n        (_message: unknown, callback: (response: unknown) => void) => {\n          callback({ ok: true, nodes });\n        },\n      );\n\n      const result = await ForkNodesService.getGroup('fork-group-1');\n\n      expect(result).toEqual(nodes);\n      expect(sendMessageMock).toHaveBeenCalledWith(\n        { type: 'gv.fork.getGroup', payload: { forkGroupId: 'fork-group-1' } },\n        expect.any(Function),\n      );\n    });\n  });\n\n  describe('error handling', () => {\n    it('should reject when response is not ok', async () => {\n      sendMessageMock.mockImplementation(\n        (_message: unknown, callback: (response: unknown) => void) => {\n          callback({ ok: false, error: 'Storage full' });\n        },\n      );\n\n      await expect(ForkNodesService.getAllForkNodes()).rejects.toThrow('Storage full');\n    });\n\n    it('should reject with unknown error when no error message provided', async () => {\n      sendMessageMock.mockImplementation(\n        (_message: unknown, callback: (response: unknown) => void) => {\n          callback({ ok: false });\n        },\n      );\n\n      await expect(ForkNodesService.getAllForkNodes()).rejects.toThrow('Unknown error');\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/content/fork/__tests__/branching.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { buildBranchDisplayNodes, resolveForkPlan } from '../branching';\nimport type { ForkNode } from '../forkTypes';\n\nfunction createNode(overrides: Partial<ForkNode> = {}): ForkNode {\n  return {\n    turnId: 'u-0',\n    conversationId: 'conv-1',\n    conversationUrl: 'https://gemini.google.com/app/conv-1',\n    conversationTitle: 'Conversation 1',\n    forkGroupId: 'group-1',\n    forkIndex: 0,\n    createdAt: 100,\n    ...overrides,\n  };\n}\n\ndescribe('resolveForkPlan', () => {\n  it('should create new group when turn has no existing forks', () => {\n    const plan = resolveForkPlan('conv-1', 'u-2', [], {}, () => 'new-group');\n    expect(plan).toEqual({\n      forkGroupId: 'new-group',\n      sourceForkIndex: 0,\n      nextForkIndex: 1,\n    });\n  });\n\n  it('should reuse existing group and increment index', () => {\n    const convNodes = [createNode({ turnId: 'u-0', forkGroupId: 'group-1', forkIndex: 0 })];\n    const groups = {\n      'group-1': [\n        createNode({ conversationId: 'conv-1', forkIndex: 0 }),\n        createNode({ conversationId: 'conv-2', forkIndex: 1 }),\n      ],\n    };\n\n    const plan = resolveForkPlan('conv-1', 'u-0', convNodes, groups, () => 'new-group');\n    expect(plan).toEqual({\n      forkGroupId: 'group-1',\n      sourceForkIndex: 0,\n      nextForkIndex: 2,\n    });\n  });\n});\n\ndescribe('buildBranchDisplayNodes', () => {\n  it('should merge nodes from duplicate groups into a single 1..N sequence basis', () => {\n    const groupA = [\n      createNode({\n        conversationId: 'conv-1',\n        forkGroupId: 'group-a',\n        forkIndex: 0,\n        createdAt: 100,\n      }),\n      createNode({\n        conversationId: 'conv-2',\n        forkGroupId: 'group-a',\n        forkIndex: 1,\n        createdAt: 200,\n      }),\n    ];\n    const groupB = [\n      createNode({\n        conversationId: 'conv-1',\n        forkGroupId: 'group-b',\n        forkIndex: 0,\n        createdAt: 100,\n      }),\n      createNode({\n        conversationId: 'conv-3',\n        forkGroupId: 'group-b',\n        forkIndex: 1,\n        createdAt: 300,\n      }),\n    ];\n\n    const nodes = buildBranchDisplayNodes([groupA, groupB]);\n    expect(nodes.map((node) => node.conversationId)).toEqual(['conv-1', 'conv-2', 'conv-3']);\n  });\n\n  it('should keep linked branches even when turnId differs between conversations', () => {\n    const group = [\n      createNode({ conversationId: 'conv-1', turnId: 'u-5', forkIndex: 0 }),\n      createNode({ conversationId: 'conv-2', turnId: 'u-0', forkIndex: 1 }),\n    ];\n\n    const nodes = buildBranchDisplayNodes([group]);\n    expect(nodes.map((node) => node.conversationId)).toEqual(['conv-1', 'conv-2']);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/fork/__tests__/chatPairs.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { collectForkChatPairs } from '../chatPairs';\n\ndescribe('collectForkChatPairs', () => {\n  it('should collect user and assistant pairs from chat DOM', () => {\n    document.body.innerHTML = `\n      <main>\n        <div class=\"user-query-container\">\n          <div class=\"user-query-bubble-with-background\">user-1</div>\n        </div>\n        <div class=\"response-container\">\n          <div class=\"markdown-main-panel\">assistant-1</div>\n        </div>\n        <div class=\"user-query-container\">\n          <div class=\"user-query-bubble-with-background\">user-2</div>\n        </div>\n        <div class=\"response-container\">\n          <div class=\"markdown-main-panel\">assistant-2</div>\n        </div>\n      </main>\n    `;\n\n    const userContainers = document.querySelectorAll<HTMLElement>('.user-query-container');\n    const responseContainers = document.querySelectorAll<HTMLElement>('.response-container');\n    Object.defineProperty(userContainers[0], 'offsetTop', { value: 0, configurable: true });\n    Object.defineProperty(responseContainers[0], 'offsetTop', { value: 100, configurable: true });\n    Object.defineProperty(userContainers[1], 'offsetTop', { value: 200, configurable: true });\n    Object.defineProperty(responseContainers[1], 'offsetTop', { value: 300, configurable: true });\n\n    const pairs = collectForkChatPairs();\n    expect(pairs).toHaveLength(2);\n    expect(pairs[0].user).toContain('user-1');\n    expect(pairs[0].assistant).toContain('assistant-1');\n    expect(pairs[1].user).toContain('user-2');\n    expect(pairs[1].assistant).toContain('assistant-2');\n    expect(pairs[0].turnId).toBe('u-0');\n    expect(pairs[1].turnId).toBe('u-1');\n  });\n});\n"
  },
  {
    "path": "src/pages/content/fork/__tests__/featureFlag.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { isForkFeatureEnabledValue } from '../featureFlag';\n\ndescribe('isForkFeatureEnabledValue', () => {\n  it('should return true only for boolean true', () => {\n    expect(isForkFeatureEnabledValue(true)).toBe(true);\n    expect(isForkFeatureEnabledValue(false)).toBe(false);\n    expect(isForkFeatureEnabledValue(undefined)).toBe(false);\n    expect(isForkFeatureEnabledValue('true')).toBe(false);\n    expect(isForkFeatureEnabledValue(1)).toBe(false);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/fork/__tests__/forkContext.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { composeForkInputWithContext } from '../forkContext';\n\ndescribe('composeForkInputWithContext', () => {\n  it('should use Chinese context when language is zh', () => {\n    const output = composeForkInputWithContext('# title\\n\\n### 👤 User\\n\\nhello', 'zh');\n    expect(output).toContain('# 分支上下文');\n    expect(output).toContain('# Conversation History');\n  });\n\n  it('should fallback to English for unknown language', () => {\n    const output = composeForkInputWithContext('history', 'xx');\n    expect(output).toContain('# Branch Context');\n    expect(output).toContain('history');\n  });\n});\n"
  },
  {
    "path": "src/pages/content/fork/__tests__/index.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type { ForkNode } from '../forkTypes';\nimport { startFork } from '../index';\n\nvi.mock('webextension-polyfill', () => ({\n  default: {\n    storage: {\n      sync: { get: vi.fn().mockResolvedValue({}) },\n      local: {\n        get: vi.fn().mockResolvedValue({}),\n        remove: vi.fn().mockResolvedValue(undefined),\n        set: vi.fn().mockResolvedValue(undefined),\n      },\n      onChanged: { addListener: vi.fn(), removeListener: vi.fn() },\n    },\n  },\n}));\n\ndescribe('startFork style injection', () => {\n  let cleanup: (() => void) | null = null;\n  let sendMessageMock: ReturnType<typeof vi.fn>;\n\n  const flushMicrotasks = async (): Promise<void> => {\n    await Promise.resolve();\n    await Promise.resolve();\n  };\n\n  beforeEach(() => {\n    vi.useFakeTimers();\n    document.head.innerHTML = '';\n    document.body.innerHTML = '';\n    sessionStorage.clear();\n    window.history.replaceState({}, '', '/');\n\n    sendMessageMock = vi.fn();\n    chrome.runtime.sendMessage = sendMessageMock as unknown as typeof chrome.runtime.sendMessage;\n    Object.defineProperty(chrome.runtime, 'lastError', { value: null, configurable: true });\n  });\n\n  afterEach(() => {\n    if (cleanup) {\n      cleanup();\n      cleanup = null;\n    }\n\n    vi.clearAllTimers();\n    vi.useRealTimers();\n    document.head.innerHTML = '';\n    document.body.innerHTML = '';\n    sessionStorage.clear();\n  });\n\n  it('uses non-layout-shifting visibility transitions for fork button reveal', () => {\n    cleanup = startFork();\n\n    const style = document.getElementById('gemini-voyager-fork-style');\n    expect(style).not.toBeNull();\n\n    const css = style?.textContent ?? '';\n\n    expect(css).toMatch(/\\.gv-fork-btn\\s*\\{[\\s\\S]*display:\\s*inline-flex;/);\n    expect(css).toMatch(/\\.gv-fork-btn\\s*\\{[\\s\\S]*position:\\s*absolute;/);\n    expect(css).toMatch(/\\.gv-fork-btn\\s*\\{[\\s\\S]*opacity:\\s*0;/);\n    expect(css).toMatch(/\\.gv-fork-btn\\s*\\{[\\s\\S]*visibility:\\s*hidden;/);\n    expect(css).toMatch(/\\.gv-fork-btn\\s*\\{[\\s\\S]*pointer-events:\\s*none;/);\n    expect(css).toMatch(/\\.gv-fork-btn\\s*\\{[\\s\\S]*right:\\s*calc\\(100%\\s*\\+\\s*8px\\);/);\n    expect(css).not.toMatch(/\\.gv-fork-btn\\s*\\{[\\s\\S]*display:\\s*none;/);\n\n    const revealRule = css.match(\n      /\\.user-query-bubble-with-background:hover \\.gv-fork-btn,[\\s\\S]*?\\.gv-fork-btn:focus-visible\\s*\\{([\\s\\S]*?)\\}/,\n    );\n    expect(revealRule).not.toBeNull();\n    const revealDeclarations = revealRule?.[1] ?? '';\n    expect(revealDeclarations).toContain('opacity: 1;');\n    expect(revealDeclarations).toContain('pointer-events: auto;');\n    expect(revealDeclarations).not.toContain('display:');\n\n    expect(css).toMatch(/body\\.gv-rtl \\.gv-fork-btn[\\s\\S]*left:\\s*calc\\(100%\\s*\\+\\s*8px\\);/);\n  });\n\n  it('anchors fork button beside the native copy button when available', () => {\n    document.body.innerHTML = `\n      <main>\n        <div class=\"user-query-container\">\n          <div class=\"user-query-bubble-with-background\">user-1</div>\n          <div class=\"actions\">\n            <div id=\"copy-anchor\">\n              <button data-test-id=\"copy-button\" class=\"action-button\" aria-label=\"Copy prompt\">\n                <mat-icon fonticon=\"content_copy\"></mat-icon>\n              </button>\n            </div>\n          </div>\n        </div>\n        <div class=\"response-container\">\n          <div class=\"markdown-main-panel\">assistant-1</div>\n        </div>\n      </main>\n    `;\n\n    const userContainer = document.querySelector<HTMLElement>('.user-query-container');\n    const responseContainer = document.querySelector<HTMLElement>('.response-container');\n    expect(userContainer).not.toBeNull();\n    expect(responseContainer).not.toBeNull();\n\n    Object.defineProperty(userContainer!, 'offsetTop', { value: 0, configurable: true });\n    Object.defineProperty(responseContainer!, 'offsetTop', { value: 100, configurable: true });\n\n    cleanup = startFork();\n    vi.advanceTimersByTime(1000);\n\n    const forkButton = document.querySelector<HTMLElement>('.gv-fork-btn');\n    expect(forkButton).not.toBeNull();\n    expect(forkButton?.parentElement?.id).toBe('copy-anchor');\n  });\n\n  it('avoids duplicate branch indicator groups when concurrent refreshes happen', async () => {\n    window.history.replaceState({}, '', '/app/conv-source');\n    document.body.innerHTML = `\n      <main>\n        <a href=\"/app/conv-source\">source</a>\n        <a href=\"/app/conv-fork\">fork</a>\n        <div class=\"user-query-container\">\n          <div class=\"user-query-bubble-with-background\">user-1</div>\n          <div class=\"actions\">\n            <div id=\"copy-anchor\">\n              <button data-test-id=\"copy-button\" class=\"action-button\" aria-label=\"Copy prompt\">\n                <mat-icon fonticon=\"content_copy\"></mat-icon>\n              </button>\n            </div>\n          </div>\n        </div>\n        <div class=\"response-container\">\n          <div class=\"markdown-main-panel\">assistant-1</div>\n        </div>\n      </main>\n    `;\n\n    const userContainer = document.querySelector<HTMLElement>('.user-query-container');\n    const responseContainer = document.querySelector<HTMLElement>('.response-container');\n    const host = document.querySelector<HTMLElement>('.user-query-bubble-with-background');\n    if (!userContainer || !responseContainer || !host) {\n      throw new Error('test DOM setup failed');\n    }\n\n    Object.defineProperty(userContainer, 'offsetTop', { value: 0, configurable: true });\n    Object.defineProperty(responseContainer, 'offsetTop', { value: 100, configurable: true });\n\n    const sourceNode: ForkNode = {\n      turnId: 'u-0',\n      conversationId: 'conv-source',\n      conversationUrl: 'https://gemini.google.com/app/conv-source',\n      conversationTitle: 'Source',\n      forkGroupId: 'group-1',\n      forkIndex: 0,\n      createdAt: 1,\n    };\n    const forkNode: ForkNode = {\n      ...sourceNode,\n      conversationId: 'conv-fork',\n      conversationUrl: 'https://gemini.google.com/app/conv-fork',\n      conversationTitle: 'Fork',\n      forkIndex: 1,\n      createdAt: 2,\n    };\n\n    sendMessageMock.mockImplementation(\n      (\n        rawMessage: unknown,\n        callback: (response: { ok: boolean; [key: string]: unknown }) => void,\n      ) => {\n        const message = rawMessage as { type?: string };\n        if (message.type === 'gv.fork.getForConversation') {\n          callback({ ok: true, nodes: [sourceNode] });\n          return;\n        }\n        if (message.type === 'gv.fork.getGroup') {\n          setTimeout(() => {\n            callback({ ok: true, nodes: [sourceNode, forkNode] });\n          }, 1200);\n          return;\n        }\n        callback({ ok: true });\n      },\n    );\n\n    cleanup = startFork();\n\n    // Initial setup injection.\n    vi.advanceTimersByTime(1000);\n    await flushMicrotasks();\n\n    // Trigger a second concurrent refresh via mutation observer debounce.\n    document.body.appendChild(document.createElement('div'));\n    await flushMicrotasks();\n    vi.advanceTimersByTime(500);\n    await flushMicrotasks();\n\n    // Allow both async indicator fetch rounds to finish.\n    vi.advanceTimersByTime(3000);\n    for (let i = 0; i < 6; i++) {\n      await flushMicrotasks();\n    }\n\n    const sentTypes = sendMessageMock.mock.calls.map(\n      ([rawMessage]) => (rawMessage as { type?: string }).type,\n    );\n    expect(sentTypes).toContain('gv.fork.getForConversation');\n    expect(sentTypes).toContain('gv.fork.getGroup');\n\n    expect(host.querySelectorAll('.gv-fork-indicator-group')).toHaveLength(1);\n    expect(host.querySelectorAll('.gv-fork-indicator')).toHaveLength(2);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/fork/__tests__/markdown.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { buildForkMarkdown } from '../markdown';\n\ndescribe('buildForkMarkdown', () => {\n  it('should include all prior assistant responses and drop the last one', () => {\n    const markdown = buildForkMarkdown(\n      'Fork Test',\n      [\n        { user: 'u1', assistant: 'a1' },\n        { user: 'u2', assistant: 'a2' },\n      ],\n      true,\n    );\n\n    expect(markdown).toContain('# Fork Test');\n    expect(markdown).toContain('u1');\n    expect(markdown).toContain('a1');\n    expect(markdown).toContain('u2');\n    expect(markdown).not.toContain('a2');\n  });\n\n  it('should keep the last assistant when dropLastAssistant is false', () => {\n    const markdown = buildForkMarkdown('Fork Test', [{ user: 'u1', assistant: 'a1' }], false);\n\n    expect(markdown).toContain('a1');\n  });\n});\n"
  },
  {
    "path": "src/pages/content/fork/__tests__/turnId.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { makeStableTurnId, normalizeTurnId } from '../turnId';\n\ndescribe('turnId', () => {\n  it('should create stable turn IDs from index', () => {\n    expect(makeStableTurnId(0)).toBe('u-0');\n    expect(makeStableTurnId(3)).toBe('u-3');\n  });\n\n  it('should normalize legacy hashed turn IDs', () => {\n    expect(normalizeTurnId('u-0-abcd')).toBe('u-0');\n    expect(normalizeTurnId('u-12-xyz')).toBe('u-12');\n  });\n\n  it('should keep non-user turn IDs unchanged', () => {\n    expect(normalizeTurnId('custom-id')).toBe('custom-id');\n  });\n});\n"
  },
  {
    "path": "src/pages/content/fork/branching.ts",
    "content": "import type { ForkNode } from './forkTypes';\nimport { normalizeTurnId } from './turnId';\n\nexport interface ForkPlan {\n  forkGroupId: string;\n  sourceForkIndex: number;\n  nextForkIndex: number;\n}\n\nexport function resolveForkPlan(\n  conversationId: string,\n  turnId: string,\n  conversationNodes: ForkNode[],\n  groups: Record<string, ForkNode[]>,\n  createForkGroupId: () => string,\n): ForkPlan {\n  const normalizedTurnId = normalizeTurnId(turnId);\n  const sameTurnNodes = conversationNodes.filter(\n    (node) => normalizeTurnId(node.turnId) === normalizedTurnId,\n  );\n\n  if (sameTurnNodes.length === 0) {\n    return {\n      forkGroupId: createForkGroupId(),\n      sourceForkIndex: 0,\n      nextForkIndex: 1,\n    };\n  }\n\n  const groupIds = Array.from(new Set(sameTurnNodes.map((node) => node.forkGroupId)));\n  let bestGroupId = groupIds[0];\n  let bestGroupNodes = groups[bestGroupId] || [];\n\n  for (const groupId of groupIds) {\n    const groupNodes = groups[groupId] || [];\n    if (groupNodes.length > bestGroupNodes.length) {\n      bestGroupId = groupId;\n      bestGroupNodes = groupNodes;\n    }\n  }\n\n  const sourceNode = bestGroupNodes.find(\n    (node) =>\n      node.conversationId === conversationId && normalizeTurnId(node.turnId) === normalizedTurnId,\n  );\n  const maxForkIndex = bestGroupNodes.reduce((max, node) => Math.max(max, node.forkIndex), 0);\n\n  return {\n    forkGroupId: bestGroupId,\n    sourceForkIndex: sourceNode?.forkIndex ?? 0,\n    nextForkIndex: maxForkIndex + 1,\n  };\n}\n\nexport function buildBranchDisplayNodes(groupNodesList: ForkNode[][]): ForkNode[] {\n  const dedupedByConversation = new Map<string, ForkNode>();\n\n  for (const node of groupNodesList.flat()) {\n    const existing = dedupedByConversation.get(node.conversationId);\n    if (!existing) {\n      dedupedByConversation.set(node.conversationId, node);\n      continue;\n    }\n\n    if (node.forkIndex < existing.forkIndex) {\n      dedupedByConversation.set(node.conversationId, node);\n      continue;\n    }\n\n    if (node.forkIndex === existing.forkIndex && node.createdAt < existing.createdAt) {\n      dedupedByConversation.set(node.conversationId, node);\n    }\n  }\n\n  return Array.from(dedupedByConversation.values()).sort((a, b) => {\n    if (a.forkIndex !== b.forkIndex) return a.forkIndex - b.forkIndex;\n    return a.createdAt - b.createdAt;\n  });\n}\n"
  },
  {
    "path": "src/pages/content/fork/chatPairs.ts",
    "content": "import { DOMContentExtractor } from '@/features/export/services/DOMContentExtractor';\n\nimport {\n  filterOutDeepResearchImmersiveNodes,\n  resolveConversationRoot,\n} from '../export/conversationDom';\nimport { makeStableTurnId } from './turnId';\n\nexport interface ForkChatPair {\n  turnId: string;\n  user: string;\n  assistant: string;\n  userElement: HTMLElement;\n}\n\nfunction normalizeText(text: string | null): string {\n  if (!text) return '';\n  return text.replace(/\\s+/g, ' ').trim();\n}\n\nfunction queryOutsideThoughts<T extends Element = Element>(\n  root: Element,\n  selector: string,\n): T | null {\n  const candidates = root.querySelectorAll<T>(selector);\n  for (const el of Array.from(candidates)) {\n    if (!el.closest('model-thoughts, .thoughts-container, .thoughts-content')) {\n      return el;\n    }\n  }\n  return null;\n}\n\nfunction filterTopLevel(elements: Element[]): HTMLElement[] {\n  const arr = elements.map((element) => element as HTMLElement);\n  const out: HTMLElement[] = [];\n  for (let i = 0; i < arr.length; i++) {\n    const el = arr[i];\n    let isDescendant = false;\n    for (let j = 0; j < arr.length; j++) {\n      if (i === j) continue;\n      const other = arr[j];\n      if (other.contains(el)) {\n        isDescendant = true;\n        break;\n      }\n    }\n    if (!isDescendant) out.push(el);\n  }\n  return out;\n}\n\nfunction dedupeByTextAndOffset(elements: HTMLElement[], firstTurnOffset: number): HTMLElement[] {\n  const seen = new Set<string>();\n  const out: HTMLElement[] = [];\n  for (const el of elements) {\n    const offsetFromStart = (el.offsetTop || 0) - firstTurnOffset;\n    const key = `${normalizeText(el.textContent || '')}|${Math.round(offsetFromStart)}`;\n    if (seen.has(key)) continue;\n    seen.add(key);\n    out.push(el);\n  }\n  return out;\n}\n\nfunction getUserSelectors(): string[] {\n  const configured = (() => {\n    try {\n      return (\n        localStorage.getItem('geminiTimelineUserTurnSelector') ||\n        localStorage.getItem('geminiTimelineUserTurnSelectorAuto') ||\n        ''\n      );\n    } catch {\n      return '';\n    }\n  })();\n\n  const defaults = [\n    '.user-query-bubble-with-background',\n    '.user-query-bubble-container',\n    '.user-query-container',\n    'user-query-content .user-query-bubble-with-background',\n    'div[aria-label=\"User message\"]',\n    'article[data-author=\"user\"]',\n    'article[data-turn=\"user\"]',\n    '[data-message-author-role=\"user\"]',\n    'div[role=\"listitem\"][data-user=\"true\"]',\n  ];\n  return configured\n    ? [configured, ...defaults.filter((selector) => selector !== configured)]\n    : defaults;\n}\n\nfunction getAssistantSelectors(): string[] {\n  return [\n    '[aria-label=\"Gemini response\"]',\n    '[data-message-author-role=\"assistant\"]',\n    '[data-message-author-role=\"model\"]',\n    'article[data-author=\"assistant\"]',\n    'article[data-turn=\"assistant\"]',\n    'article[data-turn=\"model\"]',\n    '.model-response, model-response',\n    '.response-container',\n    'div[role=\"listitem\"]:not([data-user=\"true\"])',\n  ];\n}\n\nfunction pickAssistantExportElement(assistantHost: HTMLElement): HTMLElement {\n  return (\n    queryOutsideThoughts<HTMLElement>(assistantHost, 'message-content') ||\n    queryOutsideThoughts<HTMLElement>(assistantHost, '.markdown, .markdown-main-panel') ||\n    (assistantHost.closest('.presented-response-container') as HTMLElement | null) ||\n    queryOutsideThoughts<HTMLElement>(\n      assistantHost,\n      '.presented-response-container, .response-content',\n    ) ||\n    queryOutsideThoughts<HTMLElement>(assistantHost, 'response-element') ||\n    assistantHost\n  );\n}\n\nexport function collectForkChatPairs(): ForkChatPair[] {\n  const userSelectors = getUserSelectors();\n  const assistantSelectors = getAssistantSelectors();\n  const root = resolveConversationRoot({ userSelectors, doc: document });\n\n  const userNodesRaw = filterOutDeepResearchImmersiveNodes(\n    Array.from(root.querySelectorAll<HTMLElement>(userSelectors.join(','))),\n  );\n  if (userNodesRaw.length === 0) return [];\n\n  let users = filterTopLevel(userNodesRaw);\n  if (users.length === 0) return [];\n\n  const firstOffset = users[0].offsetTop || 0;\n  users = dedupeByTextAndOffset(users, firstOffset);\n  const userOffsets = users.map((el) => el.offsetTop || 0);\n\n  const assistantNodesRaw = filterOutDeepResearchImmersiveNodes(\n    Array.from(root.querySelectorAll<HTMLElement>(assistantSelectors.join(','))),\n  );\n  const assistants = filterTopLevel(assistantNodesRaw);\n  const assistantOffsets = assistants.map((el) => el.offsetTop || 0);\n\n  const pairs: ForkChatPair[] = [];\n\n  for (let i = 0; i < users.length; i++) {\n    const userEl = users[i];\n    const turnId = makeStableTurnId(i);\n    userEl.dataset.turnId = turnId;\n\n    const userExtracted = DOMContentExtractor.extractUserContent(userEl).text;\n    const userText = userExtracted || normalizeText(userEl.innerText || userEl.textContent || '');\n\n    const start = userOffsets[i];\n    const end = i + 1 < userOffsets.length ? userOffsets[i + 1] : Number.POSITIVE_INFINITY;\n\n    let assistantHost: HTMLElement | null = null;\n    let bestOff = Number.POSITIVE_INFINITY;\n\n    for (let k = 0; k < assistants.length; k++) {\n      const off = assistantOffsets[k];\n      if (off >= start && off < end && off < bestOff) {\n        bestOff = off;\n        assistantHost = assistants[k];\n      }\n    }\n\n    if (!assistantHost) {\n      let sib: HTMLElement | null = userEl;\n      for (let step = 0; step < 8 && sib; step++) {\n        sib = sib.nextElementSibling as HTMLElement | null;\n        if (!sib) break;\n        if (sib.matches(userSelectors.join(','))) break;\n        if (sib.matches(assistantSelectors.join(','))) {\n          assistantHost = sib;\n          break;\n        }\n      }\n    }\n\n    let assistantText = '';\n    if (assistantHost) {\n      const assistantExportEl = pickAssistantExportElement(assistantHost);\n      const extracted = DOMContentExtractor.extractAssistantContent(assistantExportEl).text;\n      assistantText =\n        extracted ||\n        normalizeText(assistantExportEl.innerText || assistantExportEl.textContent || '');\n    }\n\n    if (userText || assistantText) {\n      pairs.push({\n        turnId,\n        user: userText,\n        assistant: assistantText,\n        userElement: userEl,\n      });\n    }\n  }\n\n  return pairs;\n}\n"
  },
  {
    "path": "src/pages/content/fork/featureFlag.ts",
    "content": "export function isForkFeatureEnabledValue(value: unknown): boolean {\n  return value === true;\n}\n"
  },
  {
    "path": "src/pages/content/fork/forkContext.ts",
    "content": "export type ForkLanguage = 'en' | 'ar' | 'es' | 'fr' | 'ja' | 'ko' | 'pt' | 'ru' | 'zh' | 'zh_TW';\n\nfunction normalizeLanguage(raw: string | undefined): ForkLanguage {\n  if (!raw) return 'en';\n  const value = raw.trim();\n  if (value === 'zh_TW' || value.toLowerCase() === 'zh-tw') return 'zh_TW';\n  if (value.startsWith('zh')) return 'zh';\n  if (value.startsWith('ar')) return 'ar';\n  if (value.startsWith('es')) return 'es';\n  if (value.startsWith('fr')) return 'fr';\n  if (value.startsWith('ja')) return 'ja';\n  if (value.startsWith('ko')) return 'ko';\n  if (value.startsWith('pt')) return 'pt';\n  if (value.startsWith('ru')) return 'ru';\n  return 'en';\n}\n\nconst CONTEXT_PREFIX: Record<ForkLanguage, string> = {\n  en: `# Branch Context\nYou are continuing a branched conversation.\n- The section below is the conversation history up to the fork point.\n- Continue from the final \"User\" message as a new branch.\n- Do not rewrite the history; only provide the next assistant response.\n`,\n  ar: `# سياق التفرع\nأنت تتابع محادثة متفرعة.\n- القسم أدناه هو سجل المحادثة حتى نقطة التفرع.\n- تابع من آخر رسالة \"المستخدم\" كفرع جديد.\n- لا تعِد كتابة السجل؛ قدّم رد المساعد التالي فقط.\n`,\n  es: `# Contexto de Rama\nEstás continuando una conversación en rama.\n- La sección de abajo es el historial hasta el punto de bifurcación.\n- Continúa desde el último mensaje de \"Usuario\" como una nueva rama.\n- No reescribas el historial; entrega solo la siguiente respuesta del asistente.\n`,\n  fr: `# Contexte de Branche\nVous poursuivez une conversation branchée.\n- La section ci-dessous est l'historique jusqu'au point de branchement.\n- Continuez à partir du dernier message \"Utilisateur\" comme une nouvelle branche.\n- Ne réécrivez pas l'historique; fournissez uniquement la prochaine réponse de l'assistant.\n`,\n  ja: `# 分岐コンテキスト\nこれは分岐した会話の続きです。\n- 以下は分岐点までの会話履歴です。\n- 最後の「User」メッセージから新しい分岐として続けてください。\n- 履歴を書き直さず、次のアシスタント返信のみを返してください。\n`,\n  ko: `# 분기 컨텍스트\n현재 분기된 대화를 이어서 처리합니다.\n- 아래 섹션은 분기 지점까지의 대화 기록입니다.\n- 마지막 \"User\" 메시지부터 새 분기로 이어서 답변하세요.\n- 기록을 다시 쓰지 말고, 다음 어시스턴트 응답만 제공하세요.\n`,\n  pt: `# Contexto de Ramificação\nVocê está continuando uma conversa ramificada.\n- A seção abaixo é o histórico até o ponto de ramificação.\n- Continue a partir da última mensagem de \"User\" como uma nova ramificação.\n- Não reescreva o histórico; forneça apenas a próxima resposta do assistente.\n`,\n  ru: `# Контекст Ветвления\nВы продолжаете разговор в новой ветке.\n- Раздел ниже — история диалога до точки ветвления.\n- Продолжайте от последнего сообщения \"User\" как новую ветку.\n- Не переписывайте историю; дайте только следующий ответ ассистента.\n`,\n  zh: `# 分支上下文\n你正在继续一个“分支对话”。\n- 下方内容是到分叉点为止的历史对话。\n- 请从最后一条“User”消息继续，生成这个新分支的后续回复。\n- 不要重写历史，只输出下一条助手回复。\n`,\n  zh_TW: `# 分支上下文\n你正在延續一個「分支對話」。\n- 下方內容是到分叉點為止的歷史對話。\n- 請從最後一條「User」訊息繼續，產生這個新分支的後續回覆。\n- 不要重寫歷史，只輸出下一條助手回覆。\n`,\n};\n\nexport function composeForkInputWithContext(historyMarkdown: string, rawLanguage?: string): string {\n  const language = normalizeLanguage(rawLanguage);\n  const prefix = CONTEXT_PREFIX[language] || CONTEXT_PREFIX.en;\n  const normalizedHistory = historyMarkdown.trim();\n  return `${prefix}\\n# Conversation History\\n${normalizedHistory}\\n`;\n}\n"
  },
  {
    "path": "src/pages/content/fork/forkTypes.ts",
    "content": "/**\n * Types for conversation fork feature\n */\n\nexport interface ForkNode {\n  /** The user turn where the fork happened */\n  turnId: string;\n  /** Conversation ID (computed hash) */\n  conversationId: string;\n  /** Full URL of this conversation */\n  conversationUrl: string;\n  /** Title of this conversation */\n  conversationTitle?: string;\n  /** Shared ID linking all forks from the same point */\n  forkGroupId: string;\n  /** 0-based index within the fork group */\n  forkIndex: number;\n  /** Timestamp when the fork was created */\n  createdAt: number;\n}\n\nexport interface ForkNodesData {\n  /** conversationId -> array of fork nodes in that conversation */\n  nodes: Record<string, ForkNode[]>;\n  /** forkGroupId -> array of \"conversationId:turnId\" keys */\n  groups: Record<string, string[]>;\n}\n"
  },
  {
    "path": "src/pages/content/fork/index.ts",
    "content": "/**\n * Conversation Fork Feature\n *\n * Allows users to fork/branch a conversation at any user message.\n * When forking, the conversation up to that point is exported as markdown\n * and pasted into a new Gemini conversation. Both conversations are linked\n * via fork indicators for easy navigation between branches.\n */\nimport browser from 'webextension-polyfill';\n\nimport { StorageKeys } from '@/core/types/common';\nimport { isExtensionContextInvalidatedError } from '@/core/utils/extensionContext';\nimport { generateUniqueId } from '@/core/utils/hash';\n\nimport { getTranslationSync } from '../../../utils/i18n';\nimport { ForkNodesService } from './ForkNodesService';\nimport { buildBranchDisplayNodes, resolveForkPlan } from './branching';\nimport { collectForkChatPairs } from './chatPairs';\nimport { composeForkInputWithContext } from './forkContext';\nimport type { ForkNode } from './forkTypes';\nimport { type ForkExtractedTurn, buildForkMarkdown } from './markdown';\nimport { makeStableTurnId, normalizeTurnId } from './turnId';\n\n// ============================================================================\n// Constants\n// ============================================================================\n\nconst STYLE_ID = 'gemini-voyager-fork-style';\nconst FORK_BTN_CLASS = 'gv-fork-btn';\nconst FORK_CONFIRM_CLASS = 'gv-fork-confirm';\nconst FORK_INDICATOR_CLASS = 'gv-fork-indicator';\nconst FORK_INDICATOR_GROUP_CLASS = 'gv-fork-indicator-group';\nconst FORK_INDICATOR_ITEM_CLASS = 'gv-fork-indicator-item';\nconst FORK_INDICATOR_DELETE_CLASS = 'gv-fork-indicator-delete';\nconst PENDING_FORK_KEY = 'gvPendingFork';\n\nconst FORK_ICON = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"18\" r=\"3\"/><circle cx=\"6\" cy=\"6\" r=\"3\"/><circle cx=\"18\" cy=\"6\" r=\"3\"/><path d=\"M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9\"/><path d=\"M12 12v3\"/></svg>`;\n\nconst OBSERVER_DEBOUNCE_MS = 500;\nconst CONVERSATION_VERIFY_TIMEOUT_MS = 4000;\nconst CONVERSATION_EXISTENCE_CACHE_TTL_MS = 30000;\n\nconst conversationExistenceCache = new Map<string, { exists: boolean; checkedAt: number }>();\n\n// ============================================================================\n// Styles\n// ============================================================================\n\nfunction injectStyles(): void {\n  if (document.getElementById(STYLE_ID)) return;\n  const style = document.createElement('style');\n  style.id = STYLE_ID;\n  style.textContent = `\n    .${FORK_BTN_CLASS} {\n      display: inline-flex;\n      align-items: center;\n      gap: 4px;\n      padding: 4px 8px;\n      background: transparent;\n      color: var(--gv-fork-btn-color, #5f6368);\n      border: none;\n      border-radius: 4px;\n      cursor: pointer;\n      font-size: 12px;\n      font-family: 'Google Sans', Roboto, Arial, sans-serif;\n      opacity: 0;\n      visibility: hidden;\n      pointer-events: none;\n      transition: opacity 0.15s, transform 0.15s, background-color 0.15s;\n      position: absolute;\n      top: 9px;\n      right: calc(100% + 8px);\n      z-index: 1;\n      white-space: nowrap;\n      height: 22px;\n      box-sizing: border-box;\n    }\n    .${FORK_BTN_CLASS}:hover {\n      opacity: 1;\n      background-color: var(--gv-fork-btn-hover-bg, rgba(0, 0, 0, 0.06));\n    }\n    .${FORK_BTN_CLASS} svg {\n      width: 14px;\n      height: 14px;\n      flex-shrink: 0;\n    }\n\n    /* Reveal on hover/focus without affecting message layout */\n    .user-query-bubble-with-background:hover .${FORK_BTN_CLASS},\n    .user-query-container:hover .${FORK_BTN_CLASS},\n    user-query:hover .${FORK_BTN_CLASS},\n    user-query-content:hover .${FORK_BTN_CLASS},\n    .user-query-bubble-with-background:focus-within .${FORK_BTN_CLASS},\n    .user-query-container:focus-within .${FORK_BTN_CLASS},\n    user-query:focus-within .${FORK_BTN_CLASS},\n    user-query-content:focus-within .${FORK_BTN_CLASS},\n    .${FORK_BTN_CLASS}:hover,\n    .${FORK_BTN_CLASS}:focus-visible {\n      opacity: 1;\n      visibility: visible;\n      pointer-events: auto;\n    }\n\n    html[dir=\"rtl\"] .${FORK_BTN_CLASS},\n    body[dir=\"rtl\"] .${FORK_BTN_CLASS},\n    body.gv-rtl .${FORK_BTN_CLASS} {\n      right: auto;\n      left: calc(100% + 8px);\n    }\n\n    /* Confirmation dialog */\n    .${FORK_CONFIRM_CLASS} {\n      z-index: 9999;\n      background: var(--gv-fork-confirm-bg, #fff);\n      color: var(--gv-fork-confirm-color, #202124);\n      border: 1px solid var(--gv-fork-confirm-border, rgba(0, 0, 0, 0.12));\n      border-radius: 8px;\n      padding: 12px;\n      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);\n      white-space: nowrap;\n      font-size: 13px;\n      font-family: 'Google Sans', Roboto, Arial, sans-serif;\n    }\n    .${FORK_CONFIRM_CLASS} p {\n      margin: 0 0 8px 0;\n    }\n    .${FORK_CONFIRM_CLASS} .gv-fork-actions {\n      display: flex;\n      gap: 8px;\n      justify-content: flex-end;\n    }\n    .${FORK_CONFIRM_CLASS} button {\n      padding: 4px 12px;\n      border-radius: 4px;\n      border: 1px solid var(--gv-fork-confirm-border, rgba(0, 0, 0, 0.12));\n      cursor: pointer;\n      font-size: 12px;\n      font-family: 'Google Sans', Roboto, Arial, sans-serif;\n      background: transparent;\n      color: inherit;\n    }\n    .${FORK_CONFIRM_CLASS} button.gv-fork-primary {\n      background: var(--gv-fork-primary-bg, #1a73e8);\n      color: #fff;\n      border-color: transparent;\n    }\n    .${FORK_CONFIRM_CLASS} button.gv-fork-primary:hover {\n      background: var(--gv-fork-primary-hover-bg, #1765cc);\n    }\n\n    /* Fork branch indicator group */\n    .${FORK_INDICATOR_GROUP_CLASS} {\n      display: inline-flex;\n      align-items: center;\n      gap: 4px;\n      margin-left: 8px;\n      vertical-align: middle;\n    }\n    .${FORK_INDICATOR_ITEM_CLASS} {\n      position: relative;\n      display: inline-flex;\n      align-items: center;\n      justify-content: center;\n    }\n\n    .${FORK_INDICATOR_CLASS} {\n      display: inline-flex;\n      align-items: center;\n      justify-content: center;\n      min-width: 22px;\n      height: 22px;\n      padding: 0 6px;\n      background: var(--gv-fork-indicator-bg, rgba(26, 115, 232, 0.06));\n      color: var(--gv-fork-indicator-color, #1a73e8);\n      border-radius: 4px;\n      cursor: pointer;\n      font-size: 12px;\n      font-weight: 600;\n      font-family: 'Google Sans', Roboto, Arial, sans-serif;\n      border: 1px solid var(--gv-fork-indicator-border, rgba(26, 115, 232, 0.28));\n      transition: background-color 0.15s, color 0.15s, border-color 0.15s;\n    }\n    .${FORK_INDICATOR_CLASS}:hover {\n      background: var(--gv-fork-indicator-hover-bg, rgba(26, 115, 232, 0.16));\n    }\n    .${FORK_INDICATOR_CLASS}.gv-current {\n      background: var(--gv-fork-indicator-current-bg, #1a73e8);\n      color: var(--gv-fork-indicator-current-color, #fff);\n      border-color: var(--gv-fork-indicator-current-bg, #1a73e8);\n      cursor: default;\n    }\n    .${FORK_INDICATOR_DELETE_CLASS} {\n      position: absolute;\n      top: -5px;\n      right: -5px;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      width: 14px;\n      height: 14px;\n      padding: 0;\n      border-radius: 50%;\n      border: 1px solid transparent;\n      background: #ea4335;\n      color: #fff;\n      cursor: pointer;\n      font-size: 10px;\n      font-weight: 700;\n      line-height: 1;\n      font-family: 'Google Sans', Roboto, Arial, sans-serif;\n      opacity: 0;\n      pointer-events: none;\n      transform: scale(0.8);\n      transition: opacity 0.15s, transform 0.15s;\n    }\n    .${FORK_INDICATOR_ITEM_CLASS}:hover .${FORK_INDICATOR_DELETE_CLASS},\n    .${FORK_INDICATOR_ITEM_CLASS}:focus-within .${FORK_INDICATOR_DELETE_CLASS} {\n      opacity: 1;\n      pointer-events: auto;\n      transform: scale(1);\n    }\n    .${FORK_INDICATOR_DELETE_CLASS}:disabled {\n      opacity: 0.6;\n      cursor: default;\n      pointer-events: none;\n    }\n\n    /* Dark mode */\n    html[dark] .${FORK_BTN_CLASS},\n    body.dark-theme .${FORK_BTN_CLASS} {\n      --gv-fork-btn-color: #9aa0a6;\n      --gv-fork-btn-hover-bg: rgba(255, 255, 255, 0.08);\n    }\n    html[dark] .${FORK_CONFIRM_CLASS},\n    body.dark-theme .${FORK_CONFIRM_CLASS} {\n      --gv-fork-confirm-bg: #292a2d;\n      --gv-fork-confirm-color: #e8eaed;\n      --gv-fork-confirm-border: rgba(255, 255, 255, 0.12);\n    }\n    html[dark] .${FORK_CONFIRM_CLASS} button.gv-fork-primary,\n    body.dark-theme .${FORK_CONFIRM_CLASS} button.gv-fork-primary {\n      --gv-fork-primary-bg: #8ab4f8;\n      color: #202124;\n    }\n    html[dark] .${FORK_CONFIRM_CLASS} button.gv-fork-primary:hover,\n    body.dark-theme .${FORK_CONFIRM_CLASS} button.gv-fork-primary:hover {\n      --gv-fork-primary-hover-bg: #aecbfa;\n    }\n    html[dark] .${FORK_INDICATOR_CLASS},\n    body.dark-theme .${FORK_INDICATOR_CLASS} {\n      --gv-fork-indicator-bg: rgba(138, 180, 248, 0.12);\n      --gv-fork-indicator-color: #8ab4f8;\n      --gv-fork-indicator-border: rgba(138, 180, 248, 0.28);\n      --gv-fork-indicator-hover-bg: rgba(138, 180, 248, 0.2);\n      --gv-fork-indicator-current-bg: #8ab4f8;\n      --gv-fork-indicator-current-color: #202124;\n    }\n    html[dark] .${FORK_INDICATOR_DELETE_CLASS},\n    body.dark-theme .${FORK_INDICATOR_DELETE_CLASS} {\n      background: #f28b82;\n      color: #202124;\n    }\n  `;\n  document.head.appendChild(style);\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction extractConversationIdFromUrl(): string | null {\n  const appMatch = window.location.pathname.match(/\\/app\\/([^/?#]+)/);\n  if (appMatch?.[1]) return appMatch[1];\n  const gemMatch = window.location.pathname.match(/\\/gem\\/[^/]+\\/([^/?#]+)/);\n  return gemMatch?.[1] || null;\n}\n\nfunction getConversationTitle(): string {\n  const conversationId = extractConversationIdFromUrl();\n  if (conversationId) {\n    const escapedId = conversationId.replace(/\"/g, '\\\\\"');\n    const link = document.querySelector<HTMLAnchorElement>(\n      `[data-test-id=\"conversation\"][jslog*=\"c_${escapedId}\"] a, a[href*=\"/app/${escapedId}\"]`,\n    );\n    if (link?.textContent?.trim()) return link.textContent.trim();\n  }\n  return document.title || 'Untitled';\n}\n\nfunction ensureTurnId(el: HTMLElement, index: number): string {\n  const stableId = makeStableTurnId(index);\n  const current = el.dataset?.turnId || '';\n  if (normalizeTurnId(current) !== stableId) {\n    el.dataset.turnId = stableId;\n  }\n  return stableId;\n}\n\nfunction resolveUserMessageHost(userEl: HTMLElement): HTMLElement {\n  const preferred =\n    userEl.querySelector<HTMLElement>('.user-query-bubble-with-background') ||\n    userEl.querySelector<HTMLElement>('user-query-content .user-query-bubble-with-background') ||\n    userEl.querySelector<HTMLElement>('.user-query-bubble-container');\n  return preferred || userEl;\n}\n\nfunction findUserCopyButtonAnchor(userEl: HTMLElement): HTMLElement | null {\n  const copyButton =\n    userEl.querySelector<HTMLElement>('button[data-test-id=\"copy-button\"]') ||\n    userEl\n      .querySelector<HTMLElement>(\n        'button mat-icon[fonticon=\"content_copy\"], button mat-icon[data-mat-icon-name=\"content_copy\"]',\n      )\n      ?.closest<HTMLElement>('button');\n\n  if (!copyButton) return null;\n  return copyButton.parentElement || copyButton;\n}\n\nfunction resolveForkButtonHost(userEl: HTMLElement): HTMLElement {\n  return findUserCopyButtonAnchor(userEl) || resolveUserMessageHost(userEl);\n}\n\nfunction extractConversationIdFromHref(href: string): string | null {\n  try {\n    const url = new URL(href, window.location.origin);\n    const appMatch = url.pathname.match(/\\/app\\/([^/?#]+)/);\n    if (appMatch?.[1]) return appMatch[1];\n    const gemMatch = url.pathname.match(/\\/gem\\/[^/]+\\/([^/?#]+)/);\n    return gemMatch?.[1] || null;\n  } catch {\n    return null;\n  }\n}\n\nfunction findSidebarConversationLinkById(conversationId: string): HTMLAnchorElement | null {\n  const links = Array.from(\n    document.querySelectorAll<HTMLAnchorElement>('a[href*=\"/app/\"], a[href*=\"/gem/\"]'),\n  );\n  for (const link of links) {\n    if (extractConversationIdFromHref(link.href) === conversationId) return link;\n  }\n  return null;\n}\n\nfunction triggerNativeClick(target: HTMLElement): void {\n  const options = { bubbles: true, cancelable: true, view: window };\n  target.dispatchEvent(new MouseEvent('pointerdown', options));\n  target.dispatchEvent(new MouseEvent('mousedown', options));\n  target.dispatchEvent(new MouseEvent('mouseup', options));\n  target.dispatchEvent(new MouseEvent('click', options));\n}\n\nfunction navigateToForkConversation(node: ForkNode): void {\n  if (!node.conversationId) return;\n  const currentConversationId = extractConversationIdFromUrl();\n  if (currentConversationId === node.conversationId) return;\n\n  const sidebarLink = findSidebarConversationLinkById(node.conversationId);\n  if (sidebarLink) {\n    triggerNativeClick(sidebarLink);\n    return;\n  }\n\n  const fallbackUrl =\n    node.conversationUrl ||\n    `${window.location.origin}/app/${encodeURIComponent(node.conversationId)}`;\n  window.location.assign(fallbackUrl);\n}\n\nfunction collectSidebarConversationIds(): Set<string> {\n  const ids = new Set<string>();\n  const links = document.querySelectorAll<HTMLAnchorElement>('a[href*=\"/app/\"], a[href*=\"/gem/\"]');\n  links.forEach((link) => {\n    const id = extractConversationIdFromHref(link.href);\n    if (id) ids.add(id);\n  });\n  return ids;\n}\n\nasync function getPreferredLanguage(): Promise<string | undefined> {\n  try {\n    const syncResult = await browser.storage.sync.get(StorageKeys.LANGUAGE);\n    const syncLanguage = syncResult?.[StorageKeys.LANGUAGE];\n    if (typeof syncLanguage === 'string' && syncLanguage.trim()) return syncLanguage;\n  } catch {\n    // Ignore sync storage failures.\n  }\n\n  try {\n    const localResult = await browser.storage.local.get(StorageKeys.LANGUAGE);\n    const localLanguage = localResult?.[StorageKeys.LANGUAGE];\n    if (typeof localLanguage === 'string' && localLanguage.trim()) return localLanguage;\n  } catch {\n    // Ignore local storage failures.\n  }\n\n  return undefined;\n}\n\nasync function checkConversationExists(\n  node: ForkNode,\n  sidebarConversationIds: Set<string>,\n): Promise<boolean> {\n  const currentConversationId = extractConversationIdFromUrl();\n  if (currentConversationId && node.conversationId === currentConversationId) return true;\n  if (sidebarConversationIds.has(node.conversationId)) {\n    conversationExistenceCache.set(node.conversationId, { exists: true, checkedAt: Date.now() });\n    return true;\n  }\n\n  const cached = conversationExistenceCache.get(node.conversationId);\n  const now = Date.now();\n  if (cached && now - cached.checkedAt <= CONVERSATION_EXISTENCE_CACHE_TTL_MS) {\n    return cached.exists;\n  }\n\n  if (!node.conversationUrl) {\n    conversationExistenceCache.set(node.conversationId, { exists: false, checkedAt: now });\n    return false;\n  }\n\n  const controller = new AbortController();\n  const timer = setTimeout(() => controller.abort(), CONVERSATION_VERIFY_TIMEOUT_MS);\n  try {\n    const response = await fetch(node.conversationUrl, {\n      method: 'GET',\n      redirect: 'follow',\n      credentials: 'include',\n      signal: controller.signal,\n    });\n    const responseConversationId = extractConversationIdFromHref(response.url);\n    const exists =\n      response.ok && !!responseConversationId && node.conversationId === responseConversationId;\n    conversationExistenceCache.set(node.conversationId, { exists, checkedAt: now });\n    return exists;\n  } catch {\n    // If verification fails for network/CSP reasons, keep node to avoid destructive false positives.\n    return true;\n  } finally {\n    clearTimeout(timer);\n  }\n}\n\nasync function pruneDeletedNodesFromGroup(\n  groupNodes: ForkNode[],\n  sidebarConversationIds: Set<string>,\n): Promise<ForkNode[]> {\n  const cleaned: ForkNode[] = [];\n\n  for (const node of groupNodes) {\n    const exists = await checkConversationExists(node, sidebarConversationIds);\n    if (exists) {\n      cleaned.push(node);\n      continue;\n    }\n\n    try {\n      await ForkNodesService.removeForkNode(node.conversationId, node.turnId, node.forkGroupId);\n    } catch (error) {\n      if (!isExtensionContextInvalidatedError(error)) {\n        console.error('[Fork] Failed to prune deleted fork node:', error);\n      }\n    }\n  }\n\n  return cleaned;\n}\n\nfunction clearInjectedForkIndicators(): void {\n  document.querySelectorAll(`.${FORK_INDICATOR_GROUP_CLASS}`).forEach((el) => el.remove());\n}\n\nfunction hasOrDedupForkIndicatorGroup(hostEl: HTMLElement): boolean {\n  const groups = Array.from(hostEl.querySelectorAll<HTMLElement>(`.${FORK_INDICATOR_GROUP_CLASS}`));\n  if (groups.length === 0) return false;\n  if (groups.length > 1) {\n    for (let i = 1; i < groups.length; i++) {\n      groups[i].remove();\n    }\n  }\n  return true;\n}\n\n/**\n * Extract conversation content up to and including the given user turn index.\n *\n * Step 1: Extract turns 0..N with both user and assistant content when available.\n * Step 2: Remove the last assistant response to let users continue from the last user turn.\n */\nfunction extractConversationUpToTurn(userTurnIndex: number, sourceTurnId: string): string {\n  const pairs = collectForkChatPairs();\n  if (pairs.length === 0) return '';\n\n  const sourceIndex = pairs.findIndex(\n    (pair) => normalizeTurnId(pair.turnId) === normalizeTurnId(sourceTurnId),\n  );\n  const targetIndex = sourceIndex >= 0 ? sourceIndex : userTurnIndex;\n  const turns: ForkExtractedTurn[] = [];\n  for (let i = 0; i <= targetIndex && i < pairs.length; i++) {\n    turns.push({\n      user: pairs[i].user || '',\n      assistant: pairs[i].assistant || '',\n    });\n  }\n\n  return buildForkMarkdown(getConversationTitle(), turns, true);\n}\n\n// ============================================================================\n// Fork Button Injection\n// ============================================================================\n\nlet observer: MutationObserver | null = null;\nlet observerDebounceTimer: ReturnType<typeof setTimeout> | null = null;\nlet storageRefreshTimer: ReturnType<typeof setTimeout> | null = null;\nlet activeConfirm: HTMLElement | null = null;\n\nfunction dismissConfirm(): void {\n  if (activeConfirm) {\n    activeConfirm.remove();\n    activeConfirm = null;\n  }\n}\n\nfunction onDocumentClick(e: MouseEvent): void {\n  if (activeConfirm && !activeConfirm.contains(e.target as Node)) {\n    dismissConfirm();\n  }\n}\n\nfunction scheduleForkIndicatorRefresh(): void {\n  if (storageRefreshTimer) clearTimeout(storageRefreshTimer);\n  storageRefreshTimer = setTimeout(() => {\n    clearInjectedForkIndicators();\n    void injectForkIndicators();\n    storageRefreshTimer = null;\n  }, OBSERVER_DEBOUNCE_MS);\n}\n\nfunction injectForkButtons(): void {\n  const pairs = collectForkChatPairs();\n\n  pairs.forEach((pair, index) => {\n    const userEl = pair.userElement;\n    ensureTurnId(userEl, index);\n    const hostEl = resolveForkButtonHost(userEl);\n\n    const existingButton = userEl.querySelector<HTMLElement>(`.${FORK_BTN_CLASS}`);\n    if (existingButton) {\n      hostEl.style.position = hostEl.style.position || 'relative';\n      if (existingButton.parentElement !== hostEl) {\n        hostEl.appendChild(existingButton);\n      }\n      return;\n    }\n\n    const btn = document.createElement('button');\n    btn.className = FORK_BTN_CLASS;\n    btn.title = getTranslationSync('forkConversation');\n    btn.innerHTML = `${FORK_ICON}<span>${getTranslationSync('forkConversation')}</span>`;\n\n    btn.addEventListener('click', (e) => {\n      e.stopPropagation();\n      e.preventDefault();\n      showForkConfirmation(btn, userEl, index);\n    });\n\n    // Add at the end of the user message container\n    hostEl.style.position = hostEl.style.position || 'relative';\n    hostEl.appendChild(btn);\n  });\n}\n\nfunction showForkConfirmation(btn: HTMLElement, userEl: HTMLElement, turnIndex: number): void {\n  dismissConfirm();\n\n  const confirm = document.createElement('div');\n  confirm.className = FORK_CONFIRM_CLASS;\n  confirm.innerHTML = `\n    <p>${getTranslationSync('forkConfirm')}</p>\n    <div class=\"gv-fork-actions\">\n      <button class=\"gv-fork-cancel\">${getTranslationSync('forkCancel')}</button>\n      <button class=\"gv-fork-primary\">${getTranslationSync('forkConfirmBtn')}</button>\n    </div>\n  `;\n\n  const cancelBtn = confirm.querySelector('.gv-fork-cancel')!;\n  const confirmBtn = confirm.querySelector('.gv-fork-primary')!;\n\n  cancelBtn.addEventListener('click', (e) => {\n    e.stopPropagation();\n    e.preventDefault();\n    dismissConfirm();\n  });\n  confirmBtn.addEventListener('click', (e) => {\n    e.stopPropagation();\n    e.preventDefault();\n    dismissConfirm();\n    void executeFork(userEl, turnIndex);\n  });\n\n  // Prevent clicks inside the dialog from bubbling to parent handlers\n  confirm.addEventListener('click', (e) => e.stopPropagation());\n\n  // Position near the fork button using fixed positioning\n  const btnRect = btn.getBoundingClientRect();\n  confirm.style.position = 'fixed';\n  confirm.style.top = `${btnRect.top - 4}px`;\n  confirm.style.left = `${btnRect.right}px`;\n  confirm.style.transform = 'translateY(-100%)';\n\n  document.body.appendChild(confirm);\n  activeConfirm = confirm;\n}\n\nasync function executeFork(userEl: HTMLElement, turnIndex: number): Promise<void> {\n  const conversationId = extractConversationIdFromUrl();\n  if (!conversationId) {\n    console.warn('[Fork] No conversation ID found');\n    return;\n  }\n\n  const turnId = ensureTurnId(userEl, turnIndex);\n  const markdown = extractConversationUpToTurn(turnIndex, turnId);\n  if (!markdown.trim()) {\n    console.warn('[Fork] No content extracted');\n    return;\n  }\n\n  // Open new window IMMEDIATELY to preserve user gesture context.\n  // Firefox and Safari block window.open() that follows async operations.\n  const newWindow = window.open('https://gemini.google.com/app', '_blank');\n  if (!newWindow) {\n    console.warn('[Fork] Failed to open new window (popup blocked?)');\n    return;\n  }\n\n  // Async work: resolve language and fork group (safe now, window already opened)\n  const preferredLanguage = await getPreferredLanguage();\n  const markdownWithContext = composeForkInputWithContext(markdown, preferredLanguage);\n\n  let forkGroupId = generateUniqueId('fork');\n  let sourceForkIndex = 0;\n  let nextForkIndex = 1;\n\n  try {\n    const conversationNodes = await ForkNodesService.getForConversation(conversationId);\n    const candidateGroupIds = Array.from(\n      new Set(\n        conversationNodes\n          .filter((node) => normalizeTurnId(node.turnId) === normalizeTurnId(turnId))\n          .map((node) => node.forkGroupId),\n      ),\n    );\n\n    const groups: Record<string, ForkNode[]> = {};\n    for (const groupId of candidateGroupIds) {\n      groups[groupId] = await ForkNodesService.getGroup(groupId);\n    }\n\n    const plan = resolveForkPlan(conversationId, turnId, conversationNodes, groups, () =>\n      generateUniqueId('fork'),\n    );\n\n    forkGroupId = plan.forkGroupId;\n    sourceForkIndex = plan.sourceForkIndex;\n    nextForkIndex = plan.nextForkIndex;\n  } catch (error) {\n    if (!isExtensionContextInvalidatedError(error)) {\n      console.error('[Fork] Failed to resolve fork group, using default:', error);\n    }\n  }\n\n  // Store pending fork data in extension storage (cross-tab accessible).\n  // sessionStorage is per-tab and its copy semantics with window.open() vary by browser.\n  const pendingFork: PendingForkData = {\n    sourceConversationId: conversationId,\n    sourceTurnId: turnId,\n    sourceUrl: window.location.href,\n    sourceTitle: getConversationTitle(),\n    forkGroupId,\n    sourceForkIndex,\n    nextForkIndex,\n    markdown: markdownWithContext,\n    createdAt: Date.now(),\n  };\n\n  try {\n    await browser.storage.local.set({ [PENDING_FORK_KEY]: pendingFork });\n  } catch (e) {\n    console.error('[Fork] Failed to save pending fork:', e);\n  }\n}\n\n// ============================================================================\n// Pending Fork Handling (New Conversation)\n// ============================================================================\n\ninterface PendingForkData {\n  sourceConversationId: string;\n  sourceTurnId: string;\n  sourceUrl: string;\n  sourceTitle: string;\n  forkGroupId: string;\n  sourceForkIndex: number;\n  nextForkIndex: number;\n  markdown: string;\n  createdAt?: number;\n}\n\nconst PENDING_FORK_STALE_MS = 60000; // Discard pending fork data older than 60s\n\nasync function readPendingFork(): Promise<PendingForkData | null> {\n  try {\n    const result = await browser.storage.local.get(PENDING_FORK_KEY);\n    const parsed = result[PENDING_FORK_KEY] as Partial<PendingForkData> | undefined;\n    if (!parsed) return null;\n\n    // Discard stale data (e.g. from a previous failed fork)\n    if (parsed.createdAt && Date.now() - parsed.createdAt > PENDING_FORK_STALE_MS) {\n      await browser.storage.local.remove(PENDING_FORK_KEY);\n      return null;\n    }\n\n    const pendingFork: PendingForkData = {\n      sourceConversationId: parsed.sourceConversationId || '',\n      sourceTurnId: parsed.sourceTurnId || '',\n      sourceUrl: parsed.sourceUrl || '',\n      sourceTitle: parsed.sourceTitle || '',\n      forkGroupId: parsed.forkGroupId || '',\n      sourceForkIndex: Number.isFinite(parsed.sourceForkIndex) ? parsed.sourceForkIndex! : 0,\n      nextForkIndex: Number.isFinite(parsed.nextForkIndex) ? parsed.nextForkIndex! : 1,\n      markdown: parsed.markdown || '',\n      createdAt: parsed.createdAt,\n    };\n\n    if (\n      !pendingFork.sourceConversationId ||\n      !pendingFork.sourceTurnId ||\n      !pendingFork.forkGroupId ||\n      !pendingFork.markdown.trim()\n    ) {\n      await browser.storage.local.remove(PENDING_FORK_KEY);\n      return null;\n    }\n\n    return pendingFork;\n  } catch {\n    return null;\n  }\n}\n\nfunction checkAndHandlePendingFork(): void {\n  // Only handle on a new conversation page (no conversation ID yet)\n  const currentConvId = extractConversationIdFromUrl();\n  if (currentConvId) return;\n\n  // Clean up legacy sessionStorage data (from versions before this fix)\n  try {\n    sessionStorage.removeItem(PENDING_FORK_KEY);\n  } catch {\n    // Ignore\n  }\n\n  void handlePendingForkFromStorage();\n}\n\nasync function handlePendingForkFromStorage(): Promise<void> {\n  // The opener tab writes to storage.local after async work, which may take a moment.\n  // Try immediately, then retry once after a short delay.\n  let pendingFork = await readPendingFork();\n  if (!pendingFork) {\n    await new Promise((r) => setTimeout(r, 2000));\n    pendingFork = await readPendingFork();\n  }\n  if (!pendingFork) return;\n\n  // Clear immediately so other tabs don't pick it up\n  try {\n    await browser.storage.local.remove(PENDING_FORK_KEY);\n  } catch {\n    // Ignore\n  }\n\n  // Re-check: still on a new conversation page?\n  if (extractConversationIdFromUrl()) return;\n\n  // Wait for the input field to be available\n  const input = await waitForElement('rich-textarea [contenteditable=\"true\"]', 10000);\n  if (!input) {\n    console.warn('[Fork] Input field not found');\n    return;\n  }\n\n  // Paste the markdown content\n  input.focus();\n  try {\n    document.execCommand('insertText', false, pendingFork.markdown);\n  } catch {\n    // Fallback: set textContent\n    input.textContent = pendingFork.markdown;\n    input.dispatchEvent(new Event('input', { bubbles: true }));\n  }\n\n  // Watch for URL change (conversation created after submission)\n  watchForNewConversation(pendingFork);\n}\n\nfunction waitForElement(selector: string, timeoutMs: number): Promise<HTMLElement | null> {\n  return new Promise((resolve) => {\n    const existing = document.querySelector<HTMLElement>(selector);\n    if (existing && existing.getBoundingClientRect().height > 0) {\n      resolve(existing);\n      return;\n    }\n\n    const deadline = Date.now() + timeoutMs;\n    const check = () => {\n      const el = document.querySelector<HTMLElement>(selector);\n      if (el && el.getBoundingClientRect().height > 0) {\n        resolve(el);\n        return;\n      }\n      if (Date.now() > deadline) {\n        resolve(null);\n        return;\n      }\n      requestAnimationFrame(check);\n    };\n    requestAnimationFrame(check);\n  });\n}\n\nfunction watchForNewConversation(pendingFork: PendingForkData): void {\n  let lastUrl = window.location.href;\n\n  const checkUrl = async () => {\n    const currentUrl = window.location.href;\n    if (currentUrl === lastUrl) return;\n    lastUrl = currentUrl;\n\n    const newConvId = extractConversationIdFromUrl();\n    if (!newConvId) return;\n\n    // New conversation created! Create fork nodes for both sides\n    urlObserver.disconnect();\n\n    try {\n      // Create fork node for the SOURCE conversation (original, index 0)\n      const sourceNode: ForkNode = {\n        turnId: pendingFork.sourceTurnId,\n        conversationId: pendingFork.sourceConversationId,\n        conversationUrl: pendingFork.sourceUrl,\n        conversationTitle: pendingFork.sourceTitle,\n        forkGroupId: pendingFork.forkGroupId,\n        forkIndex: pendingFork.sourceForkIndex,\n        createdAt: Date.now(),\n      };\n      await ForkNodesService.addForkNode(sourceNode);\n\n      // Create fork node for the NEW conversation (fork)\n      // Use the first user turn ID in the new conversation\n      const newNode: ForkNode = {\n        turnId: 'u-0', // First user turn in new conversation\n        conversationId: newConvId,\n        conversationUrl: currentUrl,\n        conversationTitle: getConversationTitle(),\n        forkGroupId: pendingFork.forkGroupId,\n        forkIndex: pendingFork.nextForkIndex,\n        createdAt: Date.now(),\n      };\n      await ForkNodesService.addForkNode(newNode);\n\n      // Inject fork indicators in the new conversation\n      setTimeout(() => injectForkIndicators(), 1000);\n    } catch (error) {\n      if (!isExtensionContextInvalidatedError(error)) {\n        console.error('[Fork] Failed to create fork nodes:', error);\n      }\n    }\n  };\n\n  // Use a MutationObserver on the URL (via popstate + polling)\n  const urlObserver = new MutationObserver(() => void checkUrl());\n  urlObserver.observe(document, { subtree: true, childList: true });\n\n  // Also listen to popstate and hashchange\n  const onUrlChange = () => void checkUrl();\n  window.addEventListener('popstate', onUrlChange);\n  window.addEventListener('hashchange', onUrlChange);\n\n  // Poll as fallback (SPA navigation may not trigger popstate)\n  const pollInterval = setInterval(() => {\n    void checkUrl();\n  }, 500);\n\n  // Cleanup after 60 seconds\n  setTimeout(() => {\n    urlObserver.disconnect();\n    window.removeEventListener('popstate', onUrlChange);\n    window.removeEventListener('hashchange', onUrlChange);\n    clearInterval(pollInterval);\n  }, 60000);\n}\n\n// ============================================================================\n// Fork Indicator UI\n// ============================================================================\n\nasync function injectForkIndicators(): Promise<void> {\n  const conversationId = extractConversationIdFromUrl();\n  if (!conversationId) return;\n\n  let forkNodes: ForkNode[];\n  try {\n    forkNodes = await ForkNodesService.getForConversation(conversationId);\n  } catch (error) {\n    if (!isExtensionContextInvalidatedError(error)) {\n      console.error('[Fork] Failed to get fork nodes:', error);\n    }\n    return;\n  }\n\n  if (forkNodes.length === 0) return;\n\n  // Build a map of normalized turnId -> forkGroupIds\n  const turnForkMap = new Map<string, Set<string>>();\n  for (const node of forkNodes) {\n    const normalizedTurnId = normalizeTurnId(node.turnId);\n    if (!turnForkMap.has(normalizedTurnId)) {\n      turnForkMap.set(normalizedTurnId, new Set<string>());\n    }\n    turnForkMap.get(normalizedTurnId)?.add(node.forkGroupId);\n  }\n\n  const pairs = collectForkChatPairs();\n  const sidebarConversationIds = collectSidebarConversationIds();\n  if (conversationId) sidebarConversationIds.add(conversationId);\n\n  for (let index = 0; index < pairs.length; index++) {\n    const userEl = pairs[index].userElement;\n    const turnId = normalizeTurnId(ensureTurnId(userEl, index));\n    const hostEl = resolveUserMessageHost(userEl);\n    const forkGroupIds = turnForkMap.get(turnId);\n    if (!forkGroupIds || forkGroupIds.size === 0) continue;\n\n    if (hasOrDedupForkIndicatorGroup(hostEl)) continue;\n\n    const groupNodesList: ForkNode[][] = [];\n    for (const forkGroupId of forkGroupIds) {\n      try {\n        const groupNodes = await ForkNodesService.getGroup(forkGroupId);\n        if (groupNodes.length === 0) continue;\n        const cleanedGroupNodes = await pruneDeletedNodesFromGroup(\n          groupNodes,\n          sidebarConversationIds,\n        );\n        if (cleanedGroupNodes.length > 0) groupNodesList.push(cleanedGroupNodes);\n      } catch {\n        // Ignore single group failure and continue rendering available groups.\n      }\n    }\n    if (groupNodesList.length === 0) continue;\n\n    const displayNodes = buildBranchDisplayNodes(groupNodesList);\n    if (displayNodes.length < 2) continue;\n\n    // Re-check after async group loading to avoid duplicate render in concurrent injections.\n    if (hasOrDedupForkIndicatorGroup(hostEl)) continue;\n\n    const group = document.createElement('div');\n    group.className = FORK_INDICATOR_GROUP_CLASS;\n\n    for (let displayIndex = 0; displayIndex < displayNodes.length; displayIndex++) {\n      const node = displayNodes[displayIndex];\n      const branchNumber = displayIndex + 1;\n      const isCurrent = node.conversationId === conversationId;\n      const item = document.createElement('div');\n      item.className = FORK_INDICATOR_ITEM_CLASS;\n\n      const indicator = document.createElement('button');\n      indicator.className = `${FORK_INDICATOR_CLASS}${isCurrent ? ' gv-current' : ''}`;\n      indicator.type = 'button';\n      indicator.textContent = String(branchNumber);\n      indicator.title = `${getTranslationSync('forkBranch')} ${branchNumber}${\n        isCurrent ? ` - ${getTranslationSync('forkCurrent')}` : ''\n      }`;\n\n      if (isCurrent) {\n        indicator.setAttribute('aria-current', 'true');\n        indicator.disabled = true;\n      } else {\n        indicator.addEventListener('click', (e) => {\n          e.stopPropagation();\n          e.preventDefault();\n          navigateToForkConversation(node);\n        });\n      }\n      item.appendChild(indicator);\n\n      const deleteBtn = document.createElement('button');\n      deleteBtn.className = FORK_INDICATOR_DELETE_CLASS;\n      deleteBtn.type = 'button';\n      deleteBtn.textContent = '×';\n      deleteBtn.title = getTranslationSync('forkDeleteData');\n      deleteBtn.setAttribute('aria-label', getTranslationSync('forkDeleteData'));\n      deleteBtn.addEventListener('click', async (e) => {\n        e.stopPropagation();\n        e.preventDefault();\n        const confirmed = window.confirm(getTranslationSync('forkDeleteDataConfirm'));\n        if (!confirmed) return;\n\n        deleteBtn.disabled = true;\n        try {\n          await ForkNodesService.removeForkNode(node.conversationId, node.turnId, node.forkGroupId);\n        } catch (error) {\n          if (!isExtensionContextInvalidatedError(error)) {\n            console.error('[Fork] Failed to delete fork branch data:', error);\n          }\n        } finally {\n          clearInjectedForkIndicators();\n          void injectForkIndicators();\n        }\n      });\n      item.appendChild(deleteBtn);\n      group.appendChild(item);\n    }\n\n    hostEl.style.position = hostEl.style.position || 'relative';\n    hostEl.appendChild(group);\n  }\n}\n\n// ============================================================================\n// Language Update\n// ============================================================================\n\nfunction updateForkButtonTexts(): void {\n  const buttons = document.querySelectorAll<HTMLElement>(`.${FORK_BTN_CLASS}`);\n  buttons.forEach((btn) => {\n    btn.title = getTranslationSync('forkConversation');\n    const span = btn.querySelector('span');\n    if (span) span.textContent = getTranslationSync('forkConversation');\n  });\n\n  // Update indicator titles (sequence numbers stay the same, language labels change)\n  const indicators = document.querySelectorAll<HTMLElement>(`.${FORK_INDICATOR_CLASS}`);\n  indicators.forEach((ind) => {\n    const branchNumber = ind.textContent?.trim();\n    if (!branchNumber) return;\n    const isCurrent = ind.classList.contains('gv-current');\n    ind.title = `${getTranslationSync('forkBranch')} ${branchNumber}${\n      isCurrent ? ` - ${getTranslationSync('forkCurrent')}` : ''\n    }`;\n  });\n\n  const deleteButtons = document.querySelectorAll<HTMLElement>(`.${FORK_INDICATOR_DELETE_CLASS}`);\n  deleteButtons.forEach((btn) => {\n    btn.title = getTranslationSync('forkDeleteData');\n    btn.setAttribute('aria-label', getTranslationSync('forkDeleteData'));\n  });\n}\n\n// ============================================================================\n// Module Entry Point\n// ============================================================================\n\nexport function startFork(): () => void {\n  injectStyles();\n\n  // Check for pending fork data (new conversation paste)\n  checkAndHandlePendingFork();\n\n  // Inject fork buttons and indicators\n  const setup = () => {\n    injectForkButtons();\n    void injectForkIndicators();\n  };\n\n  // Initial injection with delay to let DOM settle\n  setTimeout(setup, 1000);\n\n  // MutationObserver for dynamically loaded messages\n  observer = new MutationObserver(() => {\n    if (observerDebounceTimer) clearTimeout(observerDebounceTimer);\n    observerDebounceTimer = setTimeout(() => {\n      injectForkButtons();\n      void injectForkIndicators();\n    }, OBSERVER_DEBOUNCE_MS);\n  });\n\n  observer.observe(document.body, {\n    childList: true,\n    subtree: true,\n  });\n\n  // Dismiss confirm dialog on click outside\n  document.addEventListener('click', onDocumentClick);\n\n  // Language change listener\n  const onStorageChanged = (\n    changes: Record<string, browser.Storage.StorageChange>,\n    areaName: string,\n  ) => {\n    if ((areaName === 'sync' || areaName === 'local') && changes[StorageKeys.LANGUAGE]) {\n      updateForkButtonTexts();\n    }\n    if (areaName === 'local' && changes[StorageKeys.FORK_NODES]) {\n      scheduleForkIndicatorRefresh();\n    }\n  };\n  browser.storage.onChanged.addListener(onStorageChanged);\n\n  // Cleanup function\n  return () => {\n    if (observer) {\n      observer.disconnect();\n      observer = null;\n    }\n    if (observerDebounceTimer) {\n      clearTimeout(observerDebounceTimer);\n      observerDebounceTimer = null;\n    }\n    if (storageRefreshTimer) {\n      clearTimeout(storageRefreshTimer);\n      storageRefreshTimer = null;\n    }\n    dismissConfirm();\n    document.removeEventListener('click', onDocumentClick);\n    browser.storage.onChanged.removeListener(onStorageChanged);\n\n    // Remove injected elements\n    document.querySelectorAll(`.${FORK_BTN_CLASS}`).forEach((el) => el.remove());\n    document.querySelectorAll(`.${FORK_INDICATOR_CLASS}`).forEach((el) => el.remove());\n    document.querySelectorAll(`.${FORK_INDICATOR_GROUP_CLASS}`).forEach((el) => el.remove());\n    const style = document.getElementById(STYLE_ID);\n    if (style) style.remove();\n  };\n}\n"
  },
  {
    "path": "src/pages/content/fork/markdown.ts",
    "content": "export interface ForkExtractedTurn {\n  user: string;\n  assistant?: string;\n}\n\nexport function buildForkMarkdown(\n  title: string,\n  turns: ForkExtractedTurn[],\n  dropLastAssistant: boolean,\n): string {\n  if (!turns.length) return '';\n\n  const normalizedTurns = turns.map((turn) => ({ ...turn }));\n  if (dropLastAssistant && normalizedTurns.length > 0) {\n    normalizedTurns[normalizedTurns.length - 1].assistant = '';\n  }\n\n  const lines: string[] = [];\n  lines.push(`# ${title || 'Untitled'}`);\n  lines.push('');\n\n  for (const turn of normalizedTurns) {\n    lines.push('### 👤 User');\n    lines.push('');\n    lines.push(turn.user || '');\n    lines.push('');\n\n    if (turn.assistant?.trim()) {\n      lines.push('### 🤖 Assistant');\n      lines.push('');\n      lines.push(turn.assistant);\n      lines.push('');\n    }\n  }\n\n  return lines.join('\\n');\n}\n"
  },
  {
    "path": "src/pages/content/fork/turnId.ts",
    "content": "const USER_TURN_ID_RE = /^u-(\\d+)(?:-.+)?$/;\n\nexport function makeStableTurnId(index: number): string {\n  return `u-${Math.max(0, index)}`;\n}\n\nexport function normalizeTurnId(turnId: string): string {\n  const trimmed = turnId.trim();\n  const match = USER_TURN_ID_RE.exec(trimmed);\n  if (!match) return trimmed;\n  return `u-${match[1]}`;\n}\n"
  },
  {
    "path": "src/pages/content/gemsHider/index.ts",
    "content": "/**\n * Gems Hider - Elegant hide/show toggle for Gems list section in sidebar\n *\n * Design Philosophy:\n * Similar to recentsHider, we use a contextual \"hover reveal\" pattern where\n * a subtle hide button appears on hover. When hidden, a minimal \"peek bar\"\n * allows users to restore the section.\n */\nimport browser from 'webextension-polyfill';\n\nimport { StorageKeys } from '@/core/types/common';\n\nimport { getTranslationSync } from '../../../utils/i18n';\n\n// Constants\nconst STYLE_ID = 'gv-gems-hider-style';\nconst HIDDEN_CLASS = 'gv-gems-hidden';\nconst PEEK_BAR_CLASS = 'gv-gems-peek-bar';\nconst TOGGLE_BTN_CLASS = 'gv-gems-toggle-btn';\nconst STORAGE_KEY = 'gvGemsHidden';\n\n// Selectors - targeting the gems list container\nconst GEMS_CONTAINER_SELECTOR = '.gems-list-container';\nconst ARROW_ICON_SELECTOR = '[data-test-id=\"arrow-icon\"]';\n\nlet initialized = false;\nlet observer: MutationObserver | null = null;\n\n/**\n * Inject CSS styles for the hide/show functionality\n */\nfunction injectStyles(): void {\n  if (document.getElementById(STYLE_ID)) return;\n\n  const style = document.createElement('style');\n  style.id = STYLE_ID;\n  style.textContent = `\n    /* Container for proper positioning */\n    ${GEMS_CONTAINER_SELECTOR} {\n      position: relative;\n      transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);\n    }\n\n    /* Toggle button - inline next to arrow icon */\n    .${TOGGLE_BTN_CLASS} {\n      width: 24px;\n      height: 24px;\n      border-radius: 50%;\n      border: none;\n      background: transparent;\n      cursor: pointer;\n      display: inline-flex;\n      align-items: center;\n      justify-content: center;\n      opacity: 0;\n      transform: scale(0.8);\n      transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n      color: var(--gm3-sys-color-on-surface-variant, #5f6368);\n      vertical-align: middle;\n      margin-right: 4px;\n    }\n\n    .${TOGGLE_BTN_CLASS}:hover {\n      background: var(--gm3-sys-color-surface-container-highest, rgba(0, 0, 0, 0.12));\n      transform: scale(1.1);\n    }\n\n    .${TOGGLE_BTN_CLASS}:active {\n      transform: scale(0.95);\n    }\n\n    .${TOGGLE_BTN_CLASS} svg {\n      width: 16px;\n      height: 16px;\n      transition: transform 0.2s ease;\n    }\n\n    /* Show button when hovering near arrow area */\n    ${ARROW_ICON_SELECTOR}:hover .${TOGGLE_BTN_CLASS},\n    .${TOGGLE_BTN_CLASS}:hover {\n      opacity: 1;\n      transform: scale(1);\n    }\n\n    /* Hidden state - collapse with smooth animation */\n    .${HIDDEN_CLASS} {\n      max-height: 0 !important;\n      overflow: hidden !important;\n      opacity: 0 !important;\n      margin: 0 !important;\n      padding: 0 !important;\n      pointer-events: none !important;\n    }\n\n    /* Peek bar - minimal restore hint */\n    .${PEEK_BAR_CLASS} {\n      height: 6px;\n      margin: 8px 16px;\n      border-radius: 3px;\n      background: linear-gradient(\n        90deg,\n        transparent 0%,\n        var(--gm3-sys-color-outline-variant, rgba(0, 0, 0, 0.08)) 20%,\n        var(--gm3-sys-color-outline-variant, rgba(0, 0, 0, 0.08)) 80%,\n        transparent 100%\n      );\n      cursor: pointer;\n      transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);\n      position: relative;\n      display: none;\n    }\n\n    .${PEEK_BAR_CLASS}::after {\n      content: '';\n      position: absolute;\n      top: 50%;\n      left: 50%;\n      transform: translate(-50%, -50%);\n      width: 40px;\n      height: 4px;\n      border-radius: 2px;\n      background: var(--gm3-sys-color-primary, #1a73e8);\n      opacity: 0;\n      transition: all 0.2s ease;\n    }\n\n    .${PEEK_BAR_CLASS}:hover {\n      height: 12px;\n      background: linear-gradient(\n        90deg,\n        transparent 0%,\n        var(--gm3-sys-color-primary-container, rgba(26, 115, 232, 0.12)) 15%,\n        var(--gm3-sys-color-primary-container, rgba(26, 115, 232, 0.12)) 85%,\n        transparent 100%\n      );\n    }\n\n    .${PEEK_BAR_CLASS}:hover::after {\n      opacity: 1;\n      width: 60px;\n    }\n\n    /* Tooltip for peek bar */\n    .${PEEK_BAR_CLASS}[data-tooltip]::before {\n      content: attr(data-tooltip);\n      position: absolute;\n      bottom: 100%;\n      left: 50%;\n      transform: translateX(-50%) translateY(-4px);\n      padding: 6px 12px;\n      background: var(--gm3-sys-color-inverse-surface, #303030);\n      color: var(--gm3-sys-color-inverse-on-surface, #f5f5f5);\n      font-family: 'Google Sans', Roboto, sans-serif;\n      font-size: 12px;\n      font-weight: 500;\n      border-radius: 8px;\n      white-space: nowrap;\n      opacity: 0;\n      pointer-events: none;\n      transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n      z-index: 1000;\n    }\n\n    .${PEEK_BAR_CLASS}:hover[data-tooltip]::before {\n      opacity: 1;\n      transform: translateX(-50%) translateY(-8px);\n    }\n\n    /* Show peek bar when gems is hidden */\n    .${PEEK_BAR_CLASS}.gv-visible {\n      display: block;\n    }\n\n    /* Dark mode adjustments */\n    @media (prefers-color-scheme: dark) {\n      .${TOGGLE_BTN_CLASS} {\n        background: rgba(255, 255, 255, 0.08);\n        color: #e8eaed;\n      }\n      .${TOGGLE_BTN_CLASS}:hover {\n        background: rgba(255, 255, 255, 0.14);\n      }\n      .${PEEK_BAR_CLASS} {\n        background: linear-gradient(\n          90deg,\n          transparent 0%,\n          rgba(255, 255, 255, 0.06) 20%,\n          rgba(255, 255, 255, 0.06) 80%,\n          transparent 100%\n        );\n      }\n      .${PEEK_BAR_CLASS}:hover {\n        background: linear-gradient(\n          90deg,\n          transparent 0%,\n          rgba(138, 180, 248, 0.15) 15%,\n          rgba(138, 180, 248, 0.15) 85%,\n          transparent 100%\n        );\n      }\n      .${PEEK_BAR_CLASS}::after {\n        background: #8ab4f8;\n      }\n    }\n\n    /* Explicit dark theme support */\n    body[data-theme=\"dark\"] .${TOGGLE_BTN_CLASS},\n    body.dark-theme .${TOGGLE_BTN_CLASS} {\n      background: rgba(255, 255, 255, 0.08);\n      color: #e8eaed;\n    }\n    body[data-theme=\"dark\"] .${TOGGLE_BTN_CLASS}:hover,\n    body.dark-theme .${TOGGLE_BTN_CLASS}:hover {\n      background: rgba(255, 255, 255, 0.14);\n    }\n  `;\n  document.head.appendChild(style);\n}\n\n/**\n * Create the toggle button element\n */\nfunction createToggleButton(): HTMLButtonElement {\n  const btn = document.createElement('button');\n  btn.className = TOGGLE_BTN_CLASS;\n  btn.setAttribute('aria-label', getTranslationSync('gemsHide') || 'Hide Gems');\n  btn.title = getTranslationSync('gemsHide') || 'Hide Gems';\n\n  // Eye-off icon (Material Symbols)\n  btn.innerHTML = `\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"currentColor\">\n      <path d=\"m644-428-58-58q9-47-27-88t-93-32l-58-58q17-8 34.5-12t37.5-4q75 0 127.5 52.5T660-500q0 20-4 37.5T644-428Zm128 126-58-56q38-29 67.5-63.5T832-500q-50-101-143.5-160.5T480-720q-29 0-57 4t-55 12l-62-62q41-17 84-25.5t90-8.5q151 0 269 83.5T920-500q-23 59-60.5 109.5T772-302Zm20 246L624-222q-35 11-70.5 16.5T480-200q-151 0-269-83.5T40-500q21-53 53-98.5t73-81.5L56-792l56-56 736 736-56 56ZM222-624q-29 26-53 57t-41 67q50 101 143.5 160.5T480-280q20 0 39-2.5t39-5.5l-36-38q-11 3-21 4.5t-21 1.5q-75 0-127.5-52.5T300-500q0-11 1.5-21t4.5-21l-84-82Zm319 93Zm-151 75Z\"/>\n    </svg>\n  `;\n\n  return btn;\n}\n\n/**\n * Create the peek bar element for restoring hidden section\n */\nfunction createPeekBar(): HTMLDivElement {\n  const bar = document.createElement('div');\n  bar.className = PEEK_BAR_CLASS;\n  bar.setAttribute('data-tooltip', getTranslationSync('gemsShow') || 'Show Gems');\n  bar.setAttribute('role', 'button');\n  bar.setAttribute('tabindex', '0');\n  bar.setAttribute('aria-label', getTranslationSync('gemsShow') || 'Show Gems');\n\n  return bar;\n}\n\n/**\n * Get the current hidden state from storage\n */\nasync function getHiddenState(): Promise<boolean> {\n  return new Promise((resolve) => {\n    try {\n      chrome.storage?.local?.get({ [STORAGE_KEY]: false }, (result) => {\n        resolve(result?.[STORAGE_KEY] === true);\n      });\n    } catch {\n      // Fallback to localStorage\n      resolve(localStorage.getItem(STORAGE_KEY) === 'true');\n    }\n  });\n}\n\n/**\n * Save the hidden state to storage\n */\nasync function setHiddenState(hidden: boolean): Promise<void> {\n  return new Promise((resolve) => {\n    try {\n      chrome.storage?.local?.set({ [STORAGE_KEY]: hidden }, () => resolve());\n    } catch {\n      // Fallback to localStorage\n      localStorage.setItem(STORAGE_KEY, String(hidden));\n      resolve();\n    }\n  });\n}\n\n/**\n * Apply the hidden/visible state to the gems section\n */\nfunction applyState(gemsEl: HTMLElement, peekBar: HTMLDivElement, hidden: boolean): void {\n  if (hidden) {\n    gemsEl.classList.add(HIDDEN_CLASS);\n    peekBar.classList.add('gv-visible');\n  } else {\n    gemsEl.classList.remove(HIDDEN_CLASS);\n    peekBar.classList.remove('gv-visible');\n  }\n}\n\n/**\n * Setup the hide/show functionality for a gems container element\n */\nasync function setupGemsHider(containerEl: HTMLElement): Promise<void> {\n  // Check if already processed\n  if (document.querySelector(`.${TOGGLE_BTN_CLASS}`)) return;\n\n  // Find the arrow icon to insert button next to it\n  const arrowIcon = containerEl.querySelector(ARROW_ICON_SELECTOR);\n  if (!arrowIcon) return;\n\n  // Create UI elements\n  const toggleBtn = createToggleButton();\n  const peekBar = createPeekBar();\n\n  // Insert button before arrow icon (inside the same parent)\n  arrowIcon.insertBefore(toggleBtn, arrowIcon.firstChild);\n  containerEl.parentElement?.insertBefore(peekBar, containerEl.nextSibling);\n\n  // Get initial state and apply\n  const isHidden = await getHiddenState();\n  applyState(containerEl, peekBar, isHidden);\n\n  // Toggle button click handler\n  toggleBtn.addEventListener('click', async (e) => {\n    e.stopPropagation();\n    e.preventDefault();\n\n    await setHiddenState(true);\n    applyState(containerEl, peekBar, true);\n  });\n\n  // Peek bar click handler\n  peekBar.addEventListener('click', async () => {\n    await setHiddenState(false);\n    applyState(containerEl, peekBar, false);\n  });\n\n  // Keyboard support for peek bar\n  peekBar.addEventListener('keydown', async (e) => {\n    if (e.key === 'Enter' || e.key === ' ') {\n      e.preventDefault();\n      await setHiddenState(false);\n      applyState(containerEl, peekBar, false);\n    }\n  });\n}\n\n/**\n * Update UI text when language changes\n */\nfunction updateLanguageText(): void {\n  // Update toggle buttons\n  document.querySelectorAll<HTMLButtonElement>(`.${TOGGLE_BTN_CLASS}`).forEach((btn) => {\n    const text = getTranslationSync('gemsHide') || 'Hide Gems';\n    btn.setAttribute('aria-label', text);\n    btn.title = text;\n  });\n\n  // Update peek bars\n  document.querySelectorAll<HTMLDivElement>(`.${PEEK_BAR_CLASS}`).forEach((bar) => {\n    const text = getTranslationSync('gemsShow') || 'Show Gems';\n    bar.setAttribute('data-tooltip', text);\n    bar.setAttribute('aria-label', text);\n  });\n}\n\n/**\n * Initialize the gems hider\n */\nfunction initGemsHider(): void {\n  if (initialized) return;\n  initialized = true;\n\n  injectStyles();\n\n  // Setup existing gems container elements\n  const gemsEls = document.querySelectorAll<HTMLElement>(GEMS_CONTAINER_SELECTOR);\n  gemsEls.forEach((el) => setupGemsHider(el));\n\n  // Observe for dynamically added gems elements (SPA navigation)\n  observer = new MutationObserver((mutations) => {\n    for (const mutation of mutations) {\n      for (const node of Array.from(mutation.addedNodes)) {\n        if (node instanceof HTMLElement) {\n          if (node.matches(GEMS_CONTAINER_SELECTOR)) {\n            setupGemsHider(node);\n          }\n          // Also check children\n          const children = node.querySelectorAll<HTMLElement>(GEMS_CONTAINER_SELECTOR);\n          children.forEach((el) => setupGemsHider(el));\n        }\n      }\n    }\n  });\n\n  observer.observe(document.body, { childList: true, subtree: true });\n\n  // Listen for language changes and update UI text\n  browser.storage.onChanged.addListener((changes, areaName) => {\n    if ((areaName === 'sync' || areaName === 'local') && changes[StorageKeys.LANGUAGE]) {\n      updateLanguageText();\n    }\n  });\n}\n\n/**\n * Cleanup function\n */\nfunction cleanup(): void {\n  if (observer) {\n    observer.disconnect();\n    observer = null;\n  }\n\n  // Remove styles\n  document.getElementById(STYLE_ID)?.remove();\n\n  // Remove added elements\n  document.querySelectorAll(`.${TOGGLE_BTN_CLASS}`).forEach((el) => el.remove());\n  document.querySelectorAll(`.${PEEK_BAR_CLASS}`).forEach((el) => el.remove());\n  document.querySelectorAll(`.${HIDDEN_CLASS}`).forEach((el) => {\n    el.classList.remove(HIDDEN_CLASS);\n  });\n\n  initialized = false;\n}\n\n/**\n * Start the gems hider feature\n */\nexport function startGemsHider(): () => void {\n  // Only run on gemini.google.com\n  if (location.hostname !== 'gemini.google.com') {\n    return () => {};\n  }\n\n  // Wait for DOM to be ready\n  if (document.readyState === 'loading') {\n    document.addEventListener('DOMContentLoaded', initGemsHider);\n  } else {\n    // Small delay to ensure Gemini's UI is rendered\n    setTimeout(initGemsHider, 500);\n  }\n\n  return cleanup;\n}\n"
  },
  {
    "path": "src/pages/content/index.tsx",
    "content": "import { StorageKeys } from '@/core/types/common';\nimport { isSafari } from '@/core/utils/browser';\nimport {\n  hasValidExtensionContext,\n  isExtensionContextInvalidatedError,\n} from '@/core/utils/extensionContext';\nimport { isGeminiEnterpriseEnvironment } from '@/core/utils/gemini';\nimport { startFormulaCopy } from '@/features/formulaCopy';\nimport { initI18n } from '@/utils/i18n';\n\nimport { startChangelog } from './changelog/index';\nimport { startChatWidthAdjuster } from './chatWidth/index';\nimport { startContextSync } from './contextSync';\nimport { startDeepResearchExport } from './deepResearch/index';\nimport DefaultModelManager from './defaultModel/modelLocker';\nimport { startEditInputWidthAdjuster } from './editInputWidth/index';\nimport { startExportButton } from './export/index';\nimport { startAIStudioFolderManager } from './folder/aistudio';\nimport { startFolderManager } from './folder/index';\nimport { startFolderSpacingAdjuster } from './folderSpacing/index';\nimport { isForkFeatureEnabledValue } from './fork/featureFlag';\nimport { startFork } from './fork/index';\nimport { startGemsHider } from './gemsHider/index';\nimport { startInputCollapse } from './inputCollapse/index';\nimport { initKaTeXConfig } from './katexConfig';\nimport { startMarkdownPatcher } from './markdownPatcher/index';\nimport { startMermaid } from './mermaid/index';\nimport { startPreventAutoScroll } from './preventAutoScroll/index';\nimport { startPromptManager } from './prompt/index';\nimport { startQuoteReply } from './quoteReply/index';\nimport { startRecentsHider } from './recentsHider/index';\nimport { startSendBehavior } from './sendBehavior/index';\nimport { startSidebarAutoHide } from './sidebarAutoHide';\nimport { startSidebarWidthAdjuster } from './sidebarWidth';\nimport { startTimeline } from './timeline/index';\nimport { startTitleUpdater } from './titleUpdater';\nimport { startRainEffect, startSakuraEffect, startSnowEffect } from './visualEffects';\nimport { startWatermarkRemover } from './watermarkRemover/index';\n\n// Suppress Vite's CSS preload errors in the Chrome extension content script context.\n// Dynamic imports (e.g., mermaid) trigger Vite's __vitePreload helper which tries to\n// create <link> elements with paths like \"/assets/foo.css\". In a content script, these\n// resolve to the web page origin (e.g., https://gemini.google.com/assets/foo.css)\n// instead of the extension, causing false \"Unable to preload CSS\" errors.\n// The CSS is already injected via contentStyle.css, so these preloads are unnecessary.\nwindow.addEventListener('vite:preloadError', (event) => {\n  event.preventDefault();\n});\n\n/**\n * Staggered initialization to prevent \"thundering herd\" problem when multiple tabs\n * are restored simultaneously (e.g., after browser restart).\n *\n * Background tabs get a random delay (3-8s) to distribute initialization load.\n * Foreground tabs initialize immediately for good UX.\n *\n * This prevents triggering Google's rate limiting when restoring sessions with\n * many Gemini tabs containing long conversations.\n */\n\n// Initialization delay constants (in milliseconds)\nconst HEAVY_FEATURE_INIT_DELAY = 100; // For resource-intensive features (Timeline, Folder)\nconst LIGHT_FEATURE_INIT_DELAY = 50; // For lightweight features\nconst BACKGROUND_TAB_MIN_DELAY = 3000; // Minimum delay for background tabs\nconst BACKGROUND_TAB_MAX_DELAY = 8000; // Maximum delay for background tabs (3000 + 5000)\n\nlet initialized = false;\nlet initializationTimer: number | null = null;\nlet folderManagerInstance: Awaited<ReturnType<typeof startFolderManager>> | null = null;\n\nlet promptManagerInstance: Awaited<ReturnType<typeof startPromptManager>> | null = null;\nlet quoteReplyCleanup: (() => void) | null = null;\nlet sendBehaviorCleanup: (() => void) | null = null;\nlet forkCleanup: (() => void) | null = null;\n\nasync function isForkFeatureEnabled(): Promise<boolean> {\n  try {\n    const result = await chrome.storage?.sync?.get({ [StorageKeys.FORK_ENABLED]: false });\n    return isForkFeatureEnabledValue(result?.[StorageKeys.FORK_ENABLED]);\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Check if current hostname matches any custom websites\n */\nasync function isCustomWebsite(): Promise<boolean> {\n  try {\n    const result = await chrome.storage?.sync?.get({ gvPromptCustomWebsites: [] });\n    const customWebsites = Array.isArray(result?.gvPromptCustomWebsites)\n      ? result.gvPromptCustomWebsites\n      : [];\n\n    // Normalize current hostname\n    const currentHost = location.hostname.toLowerCase().replace(/^www\\./, '');\n\n    console.log('[Gemini Voyager] Checking custom websites:', {\n      currentHost,\n      customWebsites,\n      hostname: location.hostname,\n    });\n\n    const isCustom = customWebsites.some((website: string) => {\n      const normalizedWebsite = website.toLowerCase().replace(/^www\\./, '');\n      const matches =\n        currentHost === normalizedWebsite || currentHost.endsWith('.' + normalizedWebsite);\n      console.log('[Gemini Voyager] Comparing:', { currentHost, normalizedWebsite, matches });\n      return matches;\n    });\n\n    console.log('[Gemini Voyager] Is custom website:', isCustom);\n    return isCustom;\n  } catch (e) {\n    if (isExtensionContextInvalidatedError(e)) {\n      return false;\n    }\n    console.error('[Gemini Voyager] Error checking custom websites:', e);\n    return false;\n  }\n}\n\n/**\n * Initialize all features sequentially to reduce simultaneous load\n */\nasync function initializeFeatures(): Promise<void> {\n  if (initialized) return;\n  initialized = true;\n\n  try {\n    if (!hasValidExtensionContext()) {\n      return;\n    }\n    // Sequential initialization with small delays between features\n    // to further reduce simultaneous resource usage\n    const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\n    // Check if this is a custom website (only prompt manager should be enabled)\n    const isCustomSite = await isCustomWebsite();\n\n    if (isCustomSite) {\n      // Only start prompt manager for custom websites\n      console.log('[Gemini Voyager] Custom website detected, starting Prompt Manager only');\n\n      promptManagerInstance = await startPromptManager();\n      return;\n    }\n\n    console.log('[Gemini Voyager] Not a custom website, checking for Gemini/AI Studio');\n\n    const isEnterprise = isGeminiEnterpriseEnvironment(\n      {\n        hostname: location.hostname,\n        pathname: location.pathname,\n        search: location.search,\n        hash: location.hash,\n      },\n      document,\n    );\n\n    if (isEnterprise) {\n      console.log('[Gemini Voyager] Gemini Enterprise detected, starting Prompt Manager only');\n      promptManagerInstance = await startPromptManager();\n      return;\n    }\n\n    if (location.hostname === 'gemini.google.com') {\n      // Timeline is most resource-intensive, start it first\n      startTimeline();\n      await delay(HEAVY_FEATURE_INIT_DELAY);\n\n      folderManagerInstance = await startFolderManager();\n      await delay(HEAVY_FEATURE_INIT_DELAY);\n\n      startFolderSpacingAdjuster('gemini');\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      startChatWidthAdjuster();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      startEditInputWidthAdjuster();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      startSidebarWidthAdjuster();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      startSidebarAutoHide();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      startSnowEffect();\n      startSakuraEffect();\n      startRainEffect();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      startInputCollapse();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      startPreventAutoScroll();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      startFormulaCopy();\n\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      // Quote Reply - conditionally start based on storage setting\n      const quoteReplyResult = await new Promise<{ gvQuoteReplyEnabled?: boolean }>((resolve) => {\n        try {\n          chrome.storage?.sync?.get({ gvQuoteReplyEnabled: true }, resolve);\n        } catch {\n          resolve({ gvQuoteReplyEnabled: true });\n        }\n      });\n      if (quoteReplyResult.gvQuoteReplyEnabled !== false) {\n        quoteReplyCleanup = startQuoteReply();\n      }\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      // Watermark remover - based on gemini-watermark-remover by journey-ad\n      // https://github.com/journey-ad/gemini-watermark-remover\n      // Skip on Safari due to fetch interceptor limitations in extension sandbox\n      if (!isSafari()) {\n        startWatermarkRemover();\n      }\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      startTitleUpdater();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      startDeepResearchExport();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      startContextSync();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      // Send behavior (Ctrl+Enter to send)\n      sendBehaviorCleanup = await startSendBehavior();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      // Recents hider - hide/show toggle for recent items section\n      startRecentsHider();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      // Gems hider - hide/show toggle for Gems list section\n      startGemsHider();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      // Markdown Patcher - fixes broken bold tags due to HTML injection\n      startMarkdownPatcher();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      // Default Model Manager\n      DefaultModelManager.getInstance().init();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      startExportButton();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      if (await isForkFeatureEnabled()) {\n        forkCleanup = startFork();\n        await delay(LIGHT_FEATURE_INIT_DELAY);\n      }\n\n      startChangelog();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n    }\n\n    if (\n      location.hostname === 'gemini.google.com' ||\n      location.hostname === 'aistudio.google.com' ||\n      location.hostname === 'aistudio.google.cn'\n    ) {\n      promptManagerInstance = await startPromptManager();\n      await delay(HEAVY_FEATURE_INIT_DELAY);\n    }\n\n    if (location.hostname === 'gemini.google.com') {\n      // Initialize Mermaid rendering (lightweight)\n      startMermaid();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n    }\n\n    if (location.hostname === 'aistudio.google.com' || location.hostname === 'aistudio.google.cn') {\n      // Check if user has disabled Voyager on AI Studio\n      const aiStudioEnabled = await new Promise<boolean>((resolve) => {\n        try {\n          chrome.storage?.sync?.get({ [StorageKeys.GV_AISTUDIO_ENABLED]: true }, (res) =>\n            resolve(res?.[StorageKeys.GV_AISTUDIO_ENABLED] !== false),\n          );\n        } catch {\n          resolve(true);\n        }\n      });\n\n      if (!aiStudioEnabled) {\n        console.log('[Gemini Voyager] AI Studio features disabled by user');\n        return;\n      }\n\n      startAIStudioFolderManager();\n      await delay(HEAVY_FEATURE_INIT_DELAY);\n\n      startFolderSpacingAdjuster('aistudio');\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n\n      // Formula copy support for AI Studio\n      startFormulaCopy();\n      await delay(LIGHT_FEATURE_INIT_DELAY);\n    }\n  } catch (e) {\n    if (isExtensionContextInvalidatedError(e)) {\n      return;\n    }\n    console.error('[Gemini Voyager] Initialization error:', e);\n  }\n}\n\n/**\n * Determine initialization delay based on tab visibility\n */\nfunction getInitializationDelay(): number {\n  // Check if tab is currently visible\n  const isVisible = document.visibilityState === 'visible';\n\n  if (isVisible) {\n    // Foreground tab: initialize immediately for good UX\n    console.log('[Gemini Voyager] Foreground tab detected, initializing immediately');\n    return 0;\n  } else {\n    // Background tab: add random delay to distribute load across multiple tabs\n    const randomRange = BACKGROUND_TAB_MAX_DELAY - BACKGROUND_TAB_MIN_DELAY;\n    const randomDelay = BACKGROUND_TAB_MIN_DELAY + Math.random() * randomRange;\n    console.log(\n      `[Gemini Voyager] Background tab detected, delaying initialization by ${Math.round(randomDelay)}ms`,\n    );\n    return randomDelay;\n  }\n}\n\n/**\n * Handle tab visibility changes\n */\nfunction handleVisibilityChange(): void {\n  if (document.visibilityState === 'visible' && !initialized) {\n    // Tab became visible before initialization completed\n    // Cancel any pending delayed initialization and start immediately\n    if (initializationTimer !== null) {\n      clearTimeout(initializationTimer);\n      initializationTimer = null;\n      console.log('[Gemini Voyager] Tab became visible, initializing immediately');\n    }\n    initializeFeatures();\n  }\n}\n\n// Main initialization logic\n(function () {\n  try {\n    if (!hasValidExtensionContext()) return;\n\n    const onUnhandledRejection = (event: PromiseRejectionEvent) => {\n      if (isExtensionContextInvalidatedError(event.reason)) {\n        event.preventDefault();\n      }\n    };\n    const onWindowError = (event: ErrorEvent) => {\n      if (isExtensionContextInvalidatedError(event.error ?? event.message)) {\n        event.preventDefault();\n      }\n    };\n    window.addEventListener('unhandledrejection', onUnhandledRejection);\n    window.addEventListener('error', onWindowError);\n    const onStorageChanged = (\n      changes: Record<string, chrome.storage.StorageChange>,\n      areaName: string,\n    ) => {\n      if (\n        (areaName !== 'sync' && areaName !== 'local') ||\n        location.hostname !== 'gemini.google.com'\n      ) {\n        return;\n      }\n\n      const forkSetting = changes[StorageKeys.FORK_ENABLED];\n      if (!forkSetting) return;\n\n      const enabled = isForkFeatureEnabledValue(forkSetting.newValue);\n      if (enabled) {\n        if (!forkCleanup) {\n          forkCleanup = startFork();\n        }\n      } else if (forkCleanup) {\n        forkCleanup();\n        forkCleanup = null;\n      }\n    };\n\n    // Quick check: only run on supported websites\n    const hostname = location.hostname.toLowerCase();\n    const isSupportedSite =\n      hostname.includes('gemini.google.com') ||\n      hostname.includes('business.gemini.google') ||\n      hostname.includes('aistudio.google.com') ||\n      hostname.includes('aistudio.google.cn');\n\n    // Initialize KaTeX configuration early to suppress Unicode warnings\n    // This must run before any formulas are rendered on the page\n    if (isSupportedSite) {\n      initKaTeXConfig();\n      // Initialize i18n early to ensure translations are available\n      initI18n().catch((e) => console.error('[Gemini Voyager] i18n init error:', e));\n    }\n\n    // If not a known site, check if it's a custom website (async)\n    if (!isSupportedSite) {\n      // For unknown sites, check storage asynchronously\n      chrome.storage?.sync?.get({ gvPromptCustomWebsites: [] }, (result) => {\n        const customWebsites = Array.isArray(result?.gvPromptCustomWebsites)\n          ? result.gvPromptCustomWebsites\n          : [];\n        const currentHost = hostname.replace(/^www\\./, '');\n\n        const isCustomSite = customWebsites.some((website: string) => {\n          const normalizedWebsite = website.toLowerCase().replace(/^www\\./, '');\n          return currentHost === normalizedWebsite || currentHost.endsWith('.' + normalizedWebsite);\n        });\n\n        if (isCustomSite) {\n          console.log('[Gemini Voyager] Custom website detected:', hostname);\n          initializeFeatures();\n        } else {\n          // Not a supported site, exit early\n          console.log('[Gemini Voyager] Not a supported website, skipping initialization');\n        }\n      });\n      return;\n    }\n    chrome.storage?.onChanged?.addListener(onStorageChanged);\n\n    const delay = getInitializationDelay();\n\n    if (delay === 0) {\n      // Immediate initialization for foreground tabs\n      initializeFeatures();\n    } else {\n      // Delayed initialization for background tabs\n      initializationTimer = window.setTimeout(() => {\n        initializationTimer = null;\n        initializeFeatures();\n      }, delay);\n    }\n\n    // Listen for visibility changes to handle tab switching\n    document.addEventListener('visibilitychange', handleVisibilityChange);\n\n    // Setup cleanup on page unload to prevent memory leaks\n    window.addEventListener('beforeunload', () => {\n      try {\n        window.removeEventListener('unhandledrejection', onUnhandledRejection);\n        window.removeEventListener('error', onWindowError);\n        if (folderManagerInstance) {\n          folderManagerInstance.destroy();\n          folderManagerInstance = null;\n        }\n        if (promptManagerInstance) {\n          promptManagerInstance.destroy();\n          promptManagerInstance = null;\n        }\n        if (quoteReplyCleanup) {\n          quoteReplyCleanup();\n          quoteReplyCleanup = null;\n        }\n        if (sendBehaviorCleanup) {\n          sendBehaviorCleanup();\n          sendBehaviorCleanup = null;\n        }\n        if (forkCleanup) {\n          forkCleanup();\n          forkCleanup = null;\n        }\n        chrome.storage?.onChanged?.removeListener(onStorageChanged);\n      } catch (e) {\n        if (isExtensionContextInvalidatedError(e)) {\n          return;\n        }\n        console.error('[Gemini Voyager] Cleanup error:', e);\n      }\n    });\n  } catch (e) {\n    if (isExtensionContextInvalidatedError(e)) {\n      return;\n    }\n    console.error('[Gemini Voyager] Fatal initialization error:', e);\n  }\n})();\n"
  },
  {
    "path": "src/pages/content/inputCollapse/__tests__/inputCollapse.test.ts",
    "content": "/**\n * Tests for inputCollapse feature\n */\nimport { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { StorageKeys } from '@/core/types/common';\n\n// Mock webextension-polyfill BEFORE importing the module\nvi.mock('webextension-polyfill', () => ({\n  default: {\n    storage: {\n      onChanged: { addListener: vi.fn(), removeListener: vi.fn() },\n    },\n  },\n}));\n\n// Mock chrome APIs\nglobal.chrome = {\n  storage: {\n    sync: {\n      get: vi.fn(),\n      set: vi.fn(),\n    },\n    onChanged: {\n      addListener: vi.fn(),\n    },\n  },\n} as unknown as typeof chrome;\n\n// Mock i18n\nvi.mock('@/utils/i18n', () => ({\n  getTranslationSync: (key: string) => {\n    const translations: Record<string, string> = {\n      inputCollapsePlaceholder: 'Message Gemini',\n    };\n    return translations[key] || key;\n  },\n}));\n\ndescribe('inputCollapse', () => {\n  beforeEach(() => {\n    vi.resetModules();\n    vi.useFakeTimers();\n    vi.clearAllMocks();\n    document.body.innerHTML = '';\n\n    // Default mock for storage.get\n    (chrome.storage.sync.get as unknown as Mock).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({\n          [StorageKeys.INPUT_COLLAPSE_ENABLED]: false,\n          [StorageKeys.INPUT_COLLAPSE_WHEN_NOT_EMPTY]: false,\n        });\n      },\n    );\n  });\n\n  afterEach(async () => {\n    // Use dynamic import to match how we import in beforeEach\n    const { cleanup } = await import('../index');\n    cleanup?.();\n    vi.useRealTimers();\n  });\n\n  async function createMockContainer(content: string = ''): Promise<HTMLElement> {\n    const container = document.createElement('div');\n    // Don't pre-mark as processed - let MutationObserver handle it\n    container.className = 'element-to-collapse';\n    // Set a background color so getInputContainer will find it\n    container.style.backgroundColor = '#f0f0f0';\n    container.style.display = 'flex';\n\n    const textarea = document.createElement('rich-textarea');\n    textarea.textContent = content;\n    container.appendChild(textarea);\n\n    const placeholder = document.createElement('div');\n    placeholder.className = 'gv-collapse-placeholder';\n    container.appendChild(placeholder);\n\n    document.body.appendChild(container);\n\n    // Wait for MutationObserver to process the new container\n    // In fake timers mode, we need to advance time to trigger microtasks\n    await vi.runAllTimersAsync();\n\n    return container;\n  }\n\n  describe('Feature disabled', () => {\n    it('does not initialize when feature is disabled', async () => {\n      (chrome.storage.sync.get as unknown as Mock).mockImplementation(\n        (\n          _defaults: Record<string, unknown>,\n          callback: (result: Record<string, unknown>) => void,\n        ) => {\n          callback({ [StorageKeys.INPUT_COLLAPSE_ENABLED]: false });\n        },\n      );\n\n      const { startInputCollapse } = await import('../index');\n      startInputCollapse();\n\n      // Check that styles are not injected\n      expect(document.getElementById('gemini-voyager-input-collapse')).toBeNull();\n    });\n  });\n\n  describe('Default behavior (collapse only when empty)', () => {\n    beforeEach(async () => {\n      (chrome.storage.sync.get as unknown as Mock).mockImplementation(\n        (\n          _defaults: Record<string, unknown>,\n          callback: (result: Record<string, unknown>) => void,\n        ) => {\n          callback({\n            [StorageKeys.INPUT_COLLAPSE_ENABLED]: true,\n            [StorageKeys.INPUT_COLLAPSE_WHEN_NOT_EMPTY]: false,\n          });\n        },\n      );\n\n      const { startInputCollapse } = await import('../index');\n      startInputCollapse();\n    });\n\n    it('collapses when input is empty and loses focus', async () => {\n      const container = await createMockContainer('');\n\n      // Simulate focusout event\n      const focusOutEvent = new Event('focusout', { bubbles: true });\n      Object.defineProperty(focusOutEvent, 'relatedTarget', { value: null, writable: false });\n      container.dispatchEvent(focusOutEvent);\n\n      vi.advanceTimersByTime(200);\n\n      expect(container.classList.contains('gv-input-collapsed')).toBe(true);\n    });\n\n    it('does not collapse when input has content and loses focus', async () => {\n      const container = await createMockContainer('test content');\n\n      const focusOutEvent = new Event('focusout', { bubbles: true });\n      Object.defineProperty(focusOutEvent, 'relatedTarget', { value: null, writable: false });\n      container.dispatchEvent(focusOutEvent);\n\n      vi.advanceTimersByTime(200);\n\n      expect(container.classList.contains('gv-input-collapsed')).toBe(false);\n    });\n\n    it('expands on click when collapsed', async () => {\n      const container = await createMockContainer('');\n\n      // First collapse it\n      const focusOutEvent = new Event('focusout', { bubbles: true });\n      Object.defineProperty(focusOutEvent, 'relatedTarget', { value: null, writable: false });\n      container.dispatchEvent(focusOutEvent);\n      vi.advanceTimersByTime(200);\n      expect(container.classList.contains('gv-input-collapsed')).toBe(true);\n\n      // Then click to expand\n      container.dispatchEvent(new Event('click', { bubbles: true }));\n      expect(container.classList.contains('gv-input-collapsed')).toBe(false);\n    });\n  });\n\n  describe('Allow collapse when not empty (new feature)', () => {\n    beforeEach(async () => {\n      (chrome.storage.sync.get as unknown as Mock).mockImplementation(\n        (\n          _defaults: Record<string, unknown>,\n          callback: (result: Record<string, unknown>) => void,\n        ) => {\n          callback({\n            [StorageKeys.INPUT_COLLAPSE_ENABLED]: true,\n            [StorageKeys.INPUT_COLLAPSE_WHEN_NOT_EMPTY]: true,\n          });\n        },\n      );\n\n      const { startInputCollapse } = await import('../index');\n      startInputCollapse();\n    });\n\n    it('collapses when input is empty and loses focus', async () => {\n      const container = await createMockContainer('');\n\n      const focusOutEvent = new Event('focusout', { bubbles: true });\n      Object.defineProperty(focusOutEvent, 'relatedTarget', { value: null, writable: false });\n      container.dispatchEvent(focusOutEvent);\n\n      vi.advanceTimersByTime(200);\n\n      expect(container.classList.contains('gv-input-collapsed')).toBe(true);\n    });\n\n    it('collapses even when input has content and loses focus', async () => {\n      const container = await createMockContainer('test content');\n\n      const focusOutEvent = new Event('focusout', { bubbles: true });\n      Object.defineProperty(focusOutEvent, 'relatedTarget', { value: null, writable: false });\n      container.dispatchEvent(focusOutEvent);\n\n      vi.advanceTimersByTime(200);\n\n      expect(container.classList.contains('gv-input-collapsed')).toBe(true);\n    });\n\n    it('expands on click when collapsed with content', async () => {\n      const container = await createMockContainer('test content');\n\n      // Collapse it first\n      const focusOutEvent = new Event('focusout', { bubbles: true });\n      Object.defineProperty(focusOutEvent, 'relatedTarget', { value: null, writable: false });\n      container.dispatchEvent(focusOutEvent);\n      vi.advanceTimersByTime(200);\n      expect(container.classList.contains('gv-input-collapsed')).toBe(true);\n\n      // Verify content is preserved\n      const textarea = container.querySelector('rich-textarea');\n      expect(textarea?.textContent).toBe('test content');\n\n      // Click to expand\n      container.dispatchEvent(new Event('click', { bubbles: true }));\n      expect(container.classList.contains('gv-input-collapsed')).toBe(false);\n    });\n  });\n\n  describe('Setting changes', () => {\n    it('responds to enable/disable changes dynamically', async () => {\n      // Start with feature enabled\n      (chrome.storage.sync.get as unknown as Mock).mockImplementation(\n        (\n          _defaults: Record<string, unknown>,\n          callback: (result: Record<string, unknown>) => void,\n        ) => {\n          callback({\n            [StorageKeys.INPUT_COLLAPSE_ENABLED]: true,\n            [StorageKeys.INPUT_COLLAPSE_WHEN_NOT_EMPTY]: false,\n          });\n        },\n      );\n\n      const { startInputCollapse } = await import('../index');\n      startInputCollapse();\n\n      const container = await createMockContainer('');\n      const focusOutEvent = new Event('focusout', { bubbles: true });\n      Object.defineProperty(focusOutEvent, 'relatedTarget', { value: null, writable: false });\n      container.dispatchEvent(focusOutEvent);\n      vi.advanceTimersByTime(200);\n      expect(container.classList.contains('gv-input-collapsed')).toBe(true);\n\n      // Simulate setting change to disabled\n      const mockCallbacks = (chrome.storage.onChanged.addListener as unknown as Mock).mock.calls;\n      if (mockCallbacks && mockCallbacks.length > 0) {\n        const onChangeCallback = mockCallbacks[0][0];\n        onChangeCallback({ [StorageKeys.INPUT_COLLAPSE_ENABLED]: { newValue: false } }, 'sync');\n\n        container.classList.remove('gv-input-collapsed');\n        container.dispatchEvent(focusOutEvent);\n        vi.advanceTimersByTime(200);\n        // Should not collapse when disabled\n        expect(container.classList.contains('gv-input-collapsed')).toBe(false);\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/content/inputCollapse/index.ts",
    "content": "import browser from 'webextension-polyfill';\n\nimport { StorageKeys } from '@/core/types/common';\n\nimport { getTranslationSync } from '../../../utils/i18n';\n\nconst STYLE_ID = 'gemini-voyager-input-collapse';\nconst COLLAPSED_CLASS = 'gv-input-collapsed';\nconst PLACEHOLDER_CLASS = 'gv-collapse-placeholder';\n\n/**\n * Checks if the current page is the homepage or a new conversation page.\n * These pages have the URL pattern /app or /u/<num>/app without a conversation ID.\n * Examples of homepage/new conversation:\n *   - /app\n *   - /u/0/app\n *   - /u/1/app\n * Examples of existing conversations (should NOT match):\n *   - /app/abc123def456\n *   - /u/0/app/abc123def456\n *   - /gem/xxx/abc123\n */\nfunction isHomepageOrNewConversation(): boolean {\n  const pathname = window.location.pathname;\n  // Match /app or /u/<num>/app exactly (no conversation ID after /app)\n  // Must NOT have anything after /app except optional trailing slash\n  return /^\\/(?:u\\/\\d+\\/)?app\\/?$/.test(pathname);\n}\n\n/**\n * Checks if the current page is a gems editor page (create or edit).\n * These pages should not have auto-collapse behavior.\n */\nfunction isGemsEditorPage(): boolean {\n  const pathname = window.location.pathname;\n  // Match /gems/create, /gems/edit/*, or /u/<num>/gems/create, /u/<num>/gems/edit/*\n  return /^\\/(?:u\\/\\d+\\/)?gems\\/(?:create|edit)\\/?/.test(pathname);\n}\n\n/**\n * Checks if auto-collapse should be disabled on the current page.\n */\nfunction shouldDisableAutoCollapse(): boolean {\n  return isHomepageOrNewConversation() || isGemsEditorPage();\n}\n\n/**\n * Injects the CSS styles for the collapsed input state.\n */\nfunction injectStyles() {\n  if (document.getElementById(STYLE_ID)) return;\n\n  const style = document.createElement('style');\n  style.id = STYLE_ID;\n  style.textContent = `\n    /* Transitions for the input container */\n    .element-to-collapse {\n      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n    }\n\n    /* \n     * Collapsed State Styles\n     */\n    .${COLLAPSED_CLASS} {\n      /* Compact dimensions */\n      height: 48px !important;\n      min-height: 48px !important;\n      max-height: 48px !important;\n      \n      /* Pill shape */\n      border-radius: 24px !important;\n      width: auto !important;\n      min-width: 200px !important;\n      max-width: 600px !important;\n      margin-left: auto !important;\n      margin-right: auto !important;\n      padding: 0 24px !important;\n      \n      /* Hide overflow */\n      overflow: hidden !important;\n      \n      /* Visual styling - Clean, no borders if possible to avoid \"shadow edge\" issues */\n      background-color: var(--gm3-sys-color-surface-container, #f0f4f9) !important;\n      /* Subtle shadow */\n      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important;\n      border: none !important;\n      \n      /* Center content */\n      display: flex !important;\n      align-items: center !important;\n      justify-content: center !important;\n      \n      /* Ensure it's clickable */\n      cursor: pointer !important;\n      position: relative !important;\n      z-index: 999 !important;\n      \n      /* Reset layout */\n      gap: 0 !important;\n      transform: none !important;\n    }\n\n    /* Hiding Strategy:\n       Target ALL descendants that are NOT our placeholder.\n       Use opacity 0 to hide.\n    */\n    .${COLLAPSED_CLASS} > *:not(.${PLACEHOLDER_CLASS}) {\n      visibility: hidden !important;\n      opacity: 0 !important;\n      width: 0 !important;\n      height: 0 !important;\n      margin: 0 !important;\n      padding: 0 !important;\n      position: absolute !important;\n      pointer-events: none !important;\n    }\n\n    /* Placeholder Styling - HIDDEN by default */\n    .${PLACEHOLDER_CLASS} {\n      /* Hidden by default when not collapsed */\n      display: none !important;\n      visibility: hidden !important;\n      opacity: 0 !important;\n    }\n    \n    /* Show placeholder ONLY when collapsed */\n    .${COLLAPSED_CLASS} > .${PLACEHOLDER_CLASS} {\n      /* Force visibility */\n      visibility: visible !important;\n      opacity: 1 !important;\n      display: flex !important;\n      position: relative !important;\n      \n      /* Typography - Brighter color */\n      color: var(--gm3-sys-color-on-surface, #1f1f1f);\n      font-family: Google Sans, Roboto, sans-serif;\n      font-size: 15px; \n      font-weight: 500;\n      white-space: nowrap;\n      \n      align-items: center;\n      gap: 10px;\n      pointer-events: none;\n    }\n\n    /* Dark mode adjustments */\n    @media (prefers-color-scheme: dark) {\n      .${COLLAPSED_CLASS} {\n        background-color: var(--gm3-sys-color-surface-container-high, #2b2b2b) !important;\n        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important; \n      }\n      .${COLLAPSED_CLASS} > .${PLACEHOLDER_CLASS} {\n        color: var(--gm3-sys-color-on-surface, #e8eaed);\n      }\n    }\n    \n    body[data-theme=\"dark\"] .${COLLAPSED_CLASS},\n    body.dark-theme .${COLLAPSED_CLASS} {\n        background-color: #2b2b2b !important;\n    }\n    body[data-theme=\"dark\"] .${COLLAPSED_CLASS} > .${PLACEHOLDER_CLASS},\n    body.dark-theme .${COLLAPSED_CLASS} > .${PLACEHOLDER_CLASS} {\n        color: #e8eaed;\n    }\n  `;\n  document.head.appendChild(style);\n}\n\n/**\n * Finds the logical root of the input bar.\n * We need the container that holds the background color and the full width.\n */\nfunction getInputContainer(): HTMLElement | null {\n  // Safety check for test environments and edge cases\n  if (typeof document === 'undefined') return null;\n\n  const textarea = document.querySelector('rich-textarea');\n  if (!textarea) return null;\n\n  let current = textarea.parentElement;\n  let bestCandidate: HTMLElement | null = null;\n\n  // Traverse up to 8 levels\n  for (let i = 0; i < 8; i++) {\n    if (!current) break;\n\n    // Check computed style for background color to find the visual \"island\"\n    const style = window.getComputedStyle(current);\n    const hasBackground =\n      style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent';\n    const isFlex = style.display.includes('flex');\n\n    // Check for specific Gemini/Material classes or roles\n    // We prioritize the container that has a background color\n    if (hasBackground) {\n      bestCandidate = current as HTMLElement;\n      // If we found a substantial container (flex + background), it's a strong candidate.\n      if (isFlex) {\n        // Continue one more level just in case there's a wrapper, but update bestCandidate\n      }\n    }\n\n    // Stop if we hit the limit or dangerous nodes\n    if (\n      current.tagName === 'MAIN' ||\n      current.tagName === 'BODY' ||\n      current.classList.contains('content-wrapper')\n    ) {\n      break;\n    }\n\n    current = current.parentElement;\n  }\n\n  // If we found a candidate with a background, use it.\n  // Otherwise fallback to heuristic parents.\n  return bestCandidate || textarea.parentElement?.parentElement || textarea.parentElement;\n}\n\nexport function expandInputCollapseIfNeeded(): void {\n  const container = getInputContainer();\n  if (!container) return;\n  expand(container);\n}\n\n/**\n * Expands the input area and moves cursor to the end (for keyboard shortcut)\n */\nexport function expandInputWithCursorAtEnd(): void {\n  const container = getInputContainer();\n  if (!container) return;\n  expand(container, true); // true = move cursor to end\n}\n\n/**\n * Collapses the input area immediately (for keyboard shortcut)\n * This bypasses the delay and state checks in tryCollapse\n */\nexport function collapseInput(): void {\n  const container = getInputContainer();\n  if (!container) return;\n\n  // Respect the \"collapse when not empty\" setting\n  if (!allowCollapseWhenNotEmpty && !isInputEmpty(container)) return;\n\n  // Immediately collapse\n  container.classList.add(COLLAPSED_CLASS);\n\n  // Remove focus from the input\n  const active = document.activeElement;\n  if (active && container.contains(active)) {\n    (active as HTMLElement).blur();\n  }\n}\n\n/**\n * Checks if the input is effectively empty.\n */\nfunction isInputEmpty(container: HTMLElement): boolean {\n  // Check the text content of the rich-textarea\n  const textarea =\n    container.querySelector('rich-textarea') ||\n    container.querySelector('textarea') ||\n    container.querySelector('[contenteditable=\"true\"]');\n  if (!textarea) return true;\n\n  // Check for attachments. If attachments exist, the input is not considered empty.\n  const attachmentsArea =\n    container.querySelector('uploader-file-preview') ||\n    container.querySelector('.file-preview-wrapper');\n  if (attachmentsArea) return false;\n\n  const text = textarea.textContent?.trim() || '';\n  return text.length === 0;\n}\n\n/**\n * Adds the placeholder element to the container if it doesn't exist.\n */\nfunction ensurePlaceholder(container: HTMLElement) {\n  if (container.querySelector(`.${PLACEHOLDER_CLASS}`)) return;\n\n  const placeholder = document.createElement('div');\n  placeholder.className = PLACEHOLDER_CLASS;\n\n  // Use i18n for the placeholder text\n  let text = getTranslationSync('inputCollapsePlaceholder') || 'Message Gemini';\n\n  placeholder.innerHTML = `\n      <svg xmlns=\"http://www.w3.org/2000/svg\" height=\"20\" viewBox=\"0 -960 960 960\" width=\"20\" fill=\"currentColor\">\n        <path d=\"M240-400h320v-80H240v80Zm0-120h480v-80H240v80Zm0-120h480v-80H240v80ZM80-80v-720q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H240L80-80Zm126-240h594v-480H160v525l46-45Zm-46 0v-480 480Z\"/>\n      </svg>\n      <span>${text}</span>\n    `;\n\n  container.appendChild(placeholder);\n}\n\nexport function startInputCollapse() {\n  // Check if feature is enabled (default: false)\n  chrome.storage?.sync?.get(\n    {\n      [StorageKeys.INPUT_COLLAPSE_ENABLED]: false,\n      [StorageKeys.INPUT_COLLAPSE_WHEN_NOT_EMPTY]: false,\n    },\n    (res) => {\n      if (res?.[StorageKeys.INPUT_COLLAPSE_ENABLED] === false) {\n        // Feature is disabled, don't initialize\n        return;\n      }\n\n      // Feature is enabled, proceed with initialization\n      initInputCollapse(res?.[StorageKeys.INPUT_COLLAPSE_WHEN_NOT_EMPTY] === true);\n    },\n  );\n\n  // Listen for setting changes\n  chrome.storage?.onChanged?.addListener((changes, area) => {\n    if (area !== 'sync') return;\n    if (\n      !changes[StorageKeys.INPUT_COLLAPSE_ENABLED] &&\n      !changes[StorageKeys.INPUT_COLLAPSE_WHEN_NOT_EMPTY]\n    )\n      return;\n\n    if (changes[StorageKeys.INPUT_COLLAPSE_ENABLED]?.newValue === false) {\n      // Disable: remove styles and classes\n      cleanup();\n      return;\n    }\n\n    if (changes[StorageKeys.INPUT_COLLAPSE_WHEN_NOT_EMPTY]) {\n      // Update the setting in-place (no need to re-initialize)\n      allowCollapseWhenNotEmpty =\n        changes[StorageKeys.INPUT_COLLAPSE_WHEN_NOT_EMPTY].newValue === true;\n    }\n\n    if (changes[StorageKeys.INPUT_COLLAPSE_ENABLED]?.newValue === true) {\n      // Enable: initialize with current sub-setting\n      chrome.storage?.sync?.get({ [StorageKeys.INPUT_COLLAPSE_WHEN_NOT_EMPTY]: false }, (res) => {\n        initInputCollapse(res?.[StorageKeys.INPUT_COLLAPSE_WHEN_NOT_EMPTY] === true);\n      });\n    }\n  });\n}\n\nlet observer: MutationObserver | null = null;\nlet initialized = false;\nlet eventController: AbortController | null = null;\nlet allowCollapseWhenNotEmpty = false; // Track the \"collapse when not empty\" setting\nlet collapseTimer: number | null = null; // Timer for delayed collapse\n\n/**\n * Cleans up the input collapse feature.\n * Removes all event listeners, styles, and resets state.\n * Exported for testing purposes.\n */\nexport function cleanup() {\n  // Clear any pending collapse timer\n  if (collapseTimer !== null) {\n    clearTimeout(collapseTimer);\n    collapseTimer = null;\n  }\n\n  // Abort all event listeners managed by the controller\n  if (eventController) {\n    eventController.abort();\n    eventController = null;\n  }\n\n  // Remove styles\n  const style = document.getElementById(STYLE_ID);\n  if (style) style.remove();\n\n  // Remove classes from containers\n  document.querySelectorAll(`.${COLLAPSED_CLASS}`).forEach((el) => {\n    el.classList.remove(COLLAPSED_CLASS);\n  });\n  document.querySelectorAll('.element-to-collapse').forEach((el) => {\n    el.classList.remove('element-to-collapse');\n  });\n  document.querySelectorAll('.gv-processed').forEach((el) => {\n    el.classList.remove('gv-processed');\n  });\n  document.querySelectorAll(`.${PLACEHOLDER_CLASS}`).forEach((el) => {\n    el.remove();\n  });\n\n  // Disconnect observer\n  if (observer) {\n    observer.disconnect();\n    observer = null;\n  }\n\n  initialized = false;\n}\n\nfunction initInputCollapse(allowCollapseNotEmpty: boolean = false) {\n  if (initialized) return;\n  initialized = true;\n  allowCollapseWhenNotEmpty = allowCollapseNotEmpty; // Store the setting\n\n  injectStyles();\n\n  let lastPathname = window.location.pathname;\n\n  // Create AbortController for managing all event listeners\n  eventController = new AbortController();\n  const { signal } = eventController;\n\n  // Auto-expand the input area when a file is dragged into the window.\n  document.addEventListener(\n    'dragenter',\n    (e) => {\n      if (e.dataTransfer?.types.includes('Files')) {\n        const container = getInputContainer();\n        if (container && container.classList.contains(COLLAPSED_CLASS)) {\n          expand(container);\n        }\n      }\n    },\n    { signal, capture: true },\n  );\n\n  // Handle URL changes for SPA navigation\n  const urlChangeHandler = () => {\n    // Safety check for test environments and edge cases\n    if (typeof window === 'undefined' || !window.location) return;\n\n    const currentPathname = window.location.pathname;\n    if (currentPathname === lastPathname) return;\n\n    lastPathname = currentPathname;\n\n    const container = getInputContainer();\n    if (!container) return;\n\n    if (shouldDisableAutoCollapse()) {\n      // On homepage/new conversation/gems create: expand the input\n      container.classList.remove(COLLAPSED_CLASS);\n    } else {\n      // On conversation page: try to collapse if appropriate\n      tryCollapse(container);\n    }\n  };\n\n  // Listen for URL changes (browser back/forward)\n  window.addEventListener('popstate', urlChangeHandler, { signal });\n\n  // MutationObserver to re-apply when Gemini re-renders and detect SPA navigation\n  // Use MutationObserver so we re-apply if Gemini re-renders (common in SPAs)\n  observer = new MutationObserver(() => {\n    // Check for URL changes on DOM mutations (catches SPA navigation)\n    urlChangeHandler?.();\n\n    const container = getInputContainer();\n    if (container && !container.classList.contains('gv-processed')) {\n      container.classList.add('gv-processed');\n      container.classList.add('element-to-collapse'); // Add transition class\n\n      ensurePlaceholder(container);\n\n      // Events - use signal for automatic cleanup\n      container.addEventListener(\n        'click',\n        () => {\n          expand(container);\n        },\n        { signal },\n      );\n\n      // Capture focus events deeply\n      // focusin cancels delayed collapse when focus returns to input area\n      container.addEventListener(\n        'focusin',\n        () => {\n          expand(container);\n          // If we have a pending collapse, cancel it since focus is coming back\n          if (collapseTimer !== null) {\n            clearTimeout(collapseTimer);\n            collapseTimer = null;\n          }\n        },\n        { signal },\n      );\n\n      // Store container reference for use in closures\n      const currentContainer = container;\n\n      container.addEventListener(\n        'focusout',\n        (e) => {\n          // Clear any existing timer\n          if (collapseTimer !== null) {\n            clearTimeout(collapseTimer);\n            collapseTimer = null;\n          }\n\n          const newFocus = e.relatedTarget as HTMLElement;\n\n          // Check if focus is still inside the container\n          if (newFocus && currentContainer.contains(newFocus)) {\n            return; // Focus is still inside\n          }\n\n          // Use a small delay before collapsing\n          // This allows focusin events to cancel the collapse if focus returns\n          collapseTimer = window.setTimeout(() => {\n            // Double-check: focus should truly be away from input-related elements\n            const active = document.activeElement;\n            if (active && currentContainer.contains(active)) {\n              return; // Focus came back, don't collapse\n            }\n\n            // Also check if the new focus is in an input-related overlay/menu\n            if (newFocus && isInputRelatedElement(newFocus, currentContainer)) {\n              return; // Focus moved to input-related UI, don't collapse\n            }\n\n            // Now safe to collapse\n            tryCollapse(currentContainer);\n            collapseTimer = null;\n          }, 50); // 50ms delay - enough for focusin to cancel if needed\n        },\n        { signal },\n      );\n\n      // Initial check - only collapse if not on excluded pages\n      if (!shouldDisableAutoCollapse()) {\n        tryCollapse(container);\n      }\n    }\n  });\n\n  observer.observe(document.body, { childList: true, subtree: true });\n\n  // Add keyboard shortcuts for collapse/expand\n  document.addEventListener(\n    'keydown',\n    (e) => {\n      const container = getInputContainer();\n      if (!container) return;\n\n      // ESC key - collapse input\n      if (e.key === 'Escape') {\n        // Only respond when focus is within the input container\n        const active = document.activeElement;\n        if (active && container.contains(active)) {\n          e.preventDefault();\n          e.stopPropagation();\n          collapseInput();\n        }\n        return;\n      }\n\n      // Ctrl+I - expand input and focus with cursor at end\n      if (e.key === 'i' || e.key === 'I') {\n        if (e.ctrlKey || e.metaKey) {\n          // Only respond when input is collapsed\n          if (container.classList.contains(COLLAPSED_CLASS)) {\n            e.preventDefault();\n            e.stopPropagation();\n            expandInputWithCursorAtEnd();\n          }\n        }\n        return;\n      }\n    },\n    { signal, capture: true }, // capture phase to ensure we intercept before other handlers\n  );\n\n  // Listen for language changes and update placeholder text\n  browser.storage.onChanged.addListener((changes, areaName) => {\n    if ((areaName === 'sync' || areaName === 'local') && changes[StorageKeys.LANGUAGE]) {\n      // Update all placeholder text\n      document.querySelectorAll<HTMLDivElement>(`.${PLACEHOLDER_CLASS}`).forEach((placeholder) => {\n        const span = placeholder.querySelector('span');\n        if (span) {\n          span.textContent = getTranslationSync('inputCollapsePlaceholder') || 'Message Gemini';\n        }\n      });\n    }\n  });\n\n  // Try once immediately\n  const container = getInputContainer();\n  if (container) {\n    // trigger logic manually just in case\n    container.classList.remove('gv-processed');\n  }\n}\n\n/**\n * Check if an element is part of input-related UI (menus, overlays, etc.)\n * This prevents collapse when clicking model selector, attachment button, etc.\n */\nfunction isInputRelatedElement(element: HTMLElement, container: HTMLElement): boolean {\n  if (!element) return false;\n\n  // Check if the element is or is inside known input-related containers\n  const INPUT_RELATED_SELECTORS = [\n    // Material/CDK overlays (menus, dialogs, autocomplete dropdowns)\n    '.cdk-overlay-container',\n    '.mat-mdc-menu-panel',\n    '.mat-mdc-dialog-container',\n    '.ng-trigger',\n    // Model selector and related UI\n    '[role=\"listbox\"]',\n    '[role=\"option\"]',\n    '[role=\"combobox\"]',\n    // Attachment and file-related UI\n    '[data-test-id*=\"attachment\"]',\n    '[data-test-id*=\"upload\"]',\n    '[data-test-id*=\"file\"]',\n  ];\n\n  // Check if element matches any of the selectors\n  for (const selector of INPUT_RELATED_SELECTORS) {\n    if (element.matches(selector) || element.closest(selector)) {\n      return true;\n    }\n  }\n\n  // Additional heuristic: check if element is within a reasonable proximity\n  // to the input container (within 5 levels up, but not the body/main)\n  let parent = element.parentElement;\n  let levels = 0;\n  while (parent && levels < 5) {\n    // If we reach body or main, we've gone too far\n    if (parent.tagName === 'BODY' || parent.tagName === 'MAIN') {\n      break;\n    }\n    // If we find the container, the element is input-related\n    if (parent === container) {\n      return true;\n    }\n    parent = parent.parentElement;\n    levels++;\n  }\n\n  return false;\n}\n\nfunction expand(container: HTMLElement, moveCursorToEnd: boolean = false) {\n  if (container.classList.contains(COLLAPSED_CLASS)) {\n    container.classList.remove(COLLAPSED_CLASS);\n\n    // Auto-focus the Quill editor\n    const editor =\n      container.querySelector('.ql-editor') ||\n      container.querySelector('[contenteditable]') ||\n      container.querySelector('rich-textarea');\n\n    if (editor && editor instanceof HTMLElement) {\n      setTimeout(() => {\n        editor.focus();\n        if (moveCursorToEnd && !isInputEmpty(container)) {\n          moveCursorToEndOfElement(editor);\n        }\n      }, 0);\n    }\n  }\n}\n\n/**\n * Moves the cursor to the end of the content in a contenteditable element\n */\nfunction moveCursorToEndOfElement(element: HTMLElement): void {\n  const selection = window.getSelection();\n  if (!selection) return;\n\n  const range = document.createRange();\n\n  const targetNode = element.lastChild || element;\n\n  range.selectNodeContents(targetNode);\n  range.collapse(false); // false = collapse to end\n\n  selection.removeAllRanges();\n  selection.addRange(range);\n}\n\nfunction tryCollapse(container: HTMLElement) {\n  // We need a small delay to handle transient states\n  setTimeout(() => {\n    // Don't collapse on excluded pages (homepage, new conversation, gems create)\n    if (shouldDisableAutoCollapse()) {\n      container.classList.remove(COLLAPSED_CLASS);\n      return;\n    }\n\n    const active = document.activeElement;\n    const isStillFocused = container.contains(active);\n\n    if (!isStillFocused) {\n      // Check if we should collapse based on setting and input state\n      // If allowCollapseWhenNotEmpty is true, we can collapse even with content\n      // Otherwise, only collapse when empty (original behavior)\n      const canCollapse = allowCollapseWhenNotEmpty || isInputEmpty(container);\n      if (canCollapse) {\n        container.classList.add(COLLAPSED_CLASS);\n      }\n    }\n  }, 150);\n}\n"
  },
  {
    "path": "src/pages/content/katexConfig/index.ts",
    "content": "/**\n * KaTeX Configuration Override\n * Suppresses KaTeX strict mode warnings for Unicode text in math mode\n * This allows formulas to contain CJK characters without console warnings\n */\nimport browser from 'webextension-polyfill';\n\nimport { logger } from '@/core';\n\nconst katexLogger = logger.createChild('KaTeXConfig');\n\n/**\n * Override KaTeX strict mode to suppress Unicode warnings\n * Must be called early in page load, before KaTeX renders formulas\n */\nexport function configureKaTeX(): void {\n  try {\n    // Load external script to avoid CSP issues with inline scripts\n    const script = document.createElement('script');\n    script.src = browser.runtime.getURL('katex-config.js');\n    script.type = 'text/javascript';\n\n    // Inject into page context (not content script context)\n    (document.head || document.documentElement).appendChild(script);\n\n    katexLogger.info('KaTeX configuration script injected successfully');\n  } catch (error) {\n    katexLogger.error('Failed to configure KaTeX', { error });\n  }\n}\n\n/**\n * Initialize KaTeX configuration override\n */\nexport function initKaTeXConfig(): void {\n  configureKaTeX();\n}\n"
  },
  {
    "path": "src/pages/content/markdownPatcher/__tests__/fixBrokenBoldTags.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it } from 'vitest';\n\nimport { fixBrokenBoldTags } from '../index';\n\ndescribe('fixBrokenBoldTags', () => {\n  let container: HTMLDivElement;\n\n  beforeEach(() => {\n    container = document.createElement('div');\n    document.body.appendChild(container);\n  });\n\n  afterEach(() => {\n    document.body.removeChild(container);\n  });\n\n  it('fix intra-node bolding', () => {\n    container.innerHTML = 'Normal text **bold text** normal text.';\n    fixBrokenBoldTags(container);\n    expect(container.innerHTML).toBe('Normal text <strong>bold text</strong> normal text.');\n  });\n\n  it('fix multiple intra-node bolds', () => {\n    container.innerHTML = '**One** and **Two**';\n    fixBrokenBoldTags(container);\n    expect(container.innerHTML).toBe('<strong>One</strong> and <strong>Two</strong>');\n  });\n\n  it('handles split-node bolding (interrupted by element)', () => {\n    // Setup: Text node \"Prefix **\" -> Element -> Text node \"** Suffix\"\n    const text1 = document.createTextNode('Prefix **');\n    const elem = document.createElement('span');\n    elem.setAttribute('data-path-to-node', '1,2,3');\n    elem.textContent = 'INTERRUPT';\n    const text2 = document.createTextNode('** Suffix');\n\n    container.appendChild(text1);\n    container.appendChild(elem);\n    container.appendChild(text2);\n\n    fixBrokenBoldTags(container);\n\n    const strong = container.querySelector('strong');\n    expect(strong).not.toBeNull();\n    // The strong tag should wrap the content\n    expect(strong?.textContent).toBe('INTERRUPT');\n    // Original text nodes should be cleaned up\n    expect(text1.textContent).toBe('Prefix ');\n    expect(text2.textContent).toBe(' Suffix');\n  });\n\n  it('handles mixed intra-node and split-node', () => {\n    // Setup: \"**Intra** and **Split\" -> Elem -> \"** End\"\n    const text1 = document.createTextNode('**Intra** and **Split');\n    const elem = document.createElement('span');\n    elem.setAttribute('data-path-to-node', 'x');\n    elem.textContent = 'ELEM';\n    const text2 = document.createTextNode('** End');\n\n    container.appendChild(text1);\n    container.appendChild(elem);\n    container.appendChild(text2);\n\n    fixBrokenBoldTags(container);\n\n    // Initial check: Intra matches\n    // Expect: one strong for Intra, one strong for Split\n    const strongs = container.querySelectorAll('strong');\n    expect(strongs.length).toBe(2);\n    expect(strongs[0].textContent).toBe('Intra');\n    expect(strongs[1].textContent).toBe('SplitELEM');\n\n    // Check surrounding text\n    // The first text node originally \" **Intra** and **Split\"\n    // Becomes: \" \" (before Intra, empty?) -> strong(Intra) -> \" and \" -> strong(Split...)\n    // Actually the function replaces the text node.\n    // The DOM structure should be:\n    // strong(Intra) \" and \" strong(SplitELEM) \" End\"\n    // Note: My split logic keeps \" End\" in the trailing text node.\n\n    // Wait, let's verify exact text structure\n    expect(container.textContent).toBe('Intra and SplitELEM End');\n  });\n\n  // ===== Issue #507: Consecutive bold groups across split nodes =====\n\n  describe('consecutive split-node bolds (#507)', () => {\n    it('two split-node bolds with short connector: **A** elem \"**和**\" elem **B**', () => {\n      // DOM: TextNode(\"**\") Elem(A) TextNode(\"**和**\") Elem(B) TextNode(\"**\")\n      // Expected: <strong>A</strong>和<strong>B</strong>\n      const t1 = document.createTextNode('研究**');\n      const e1 = document.createElement('b');\n      e1.setAttribute('data-path-to-node', '0,1');\n      e1.textContent = 'Centralized ETC';\n      const t2 = document.createTextNode('**和**');\n      const e2 = document.createElement('b');\n      e2.setAttribute('data-path-to-node', '0,2');\n      e2.textContent = 'Centralized UCB';\n      const t3 = document.createTextNode('**时');\n\n      container.append(t1, e1, t2, e2, t3);\n      fixBrokenBoldTags(container);\n\n      const strongs = container.querySelectorAll('strong');\n      expect(strongs.length).toBe(2);\n      expect(strongs[0].textContent).toBe('Centralized ETC');\n      expect(strongs[1].textContent).toBe('Centralized UCB');\n      expect(container.textContent).toBe('研究Centralized ETC和Centralized UCB时');\n    });\n\n    it('two split-node bolds with multi-char connector', () => {\n      // DOM: TextNode(\"前缀**\") Elem(A) TextNode(\"**，同时**\") Elem(B) TextNode(\"**后缀\")\n      const t1 = document.createTextNode('前缀**');\n      const e1 = document.createElement('b');\n      e1.setAttribute('data-path-to-node', '0,1');\n      e1.textContent = 'AlphaContent';\n      const t2 = document.createTextNode('**，同时**');\n      const e2 = document.createElement('b');\n      e2.setAttribute('data-path-to-node', '0,2');\n      e2.textContent = 'BetaContent';\n      const t3 = document.createTextNode('**后缀');\n\n      container.append(t1, e1, t2, e2, t3);\n      fixBrokenBoldTags(container);\n\n      const strongs = container.querySelectorAll('strong');\n      expect(strongs.length).toBe(2);\n      expect(strongs[0].textContent).toBe('AlphaContent');\n      expect(strongs[1].textContent).toBe('BetaContent');\n      expect(container.textContent).toBe('前缀AlphaContent，同时BetaContent后缀');\n    });\n\n    it('three consecutive split-node bolds', () => {\n      // **A**、**B**和**C**\n      const t1 = document.createTextNode('**');\n      const e1 = document.createElement('b');\n      e1.setAttribute('data-path-to-node', '0,1');\n      e1.textContent = 'AAA';\n      const t2 = document.createTextNode('**、**');\n      const e2 = document.createElement('b');\n      e2.setAttribute('data-path-to-node', '0,2');\n      e2.textContent = 'BBB';\n      const t3 = document.createTextNode('**和**');\n      const e3 = document.createElement('b');\n      e3.setAttribute('data-path-to-node', '0,3');\n      e3.textContent = 'CCC';\n      const t4 = document.createTextNode('**');\n\n      container.append(t1, e1, t2, e2, t3, e3, t4);\n      fixBrokenBoldTags(container);\n\n      const strongs = container.querySelectorAll('strong');\n      expect(strongs.length).toBe(3);\n      expect(strongs[0].textContent).toBe('AAA');\n      expect(strongs[1].textContent).toBe('BBB');\n      expect(strongs[2].textContent).toBe('CCC');\n      expect(container.textContent).toBe('AAA、BBB和CCC');\n    });\n\n    it('split-node bold with multiple intermediate elements', () => {\n      // TextNode(\"**\") Elem1 Elem2 TextNode(\"**\")\n      // Two data-path-to-node elements between the ** markers\n      const t1 = document.createTextNode('start **');\n      const e1 = document.createElement('b');\n      e1.setAttribute('data-path-to-node', '0,1');\n      e1.textContent = 'part1';\n      const e2 = document.createElement('b');\n      e2.setAttribute('data-path-to-node', '0,2');\n      e2.textContent = 'part2';\n      const t2 = document.createTextNode('** end');\n\n      container.append(t1, e1, e2, t2);\n      fixBrokenBoldTags(container);\n\n      const strong = container.querySelector('strong');\n      expect(strong).not.toBeNull();\n      expect(strong?.textContent).toBe('part1part2');\n      expect(container.textContent).toBe('start part1part2 end');\n    });\n\n    it('split-node bold with text node between elements', () => {\n      // TextNode(\"**\") Elem TextNode(\"middle\") Elem TextNode(\"**\")\n      const t1 = document.createTextNode('**');\n      const e1 = document.createElement('b');\n      e1.setAttribute('data-path-to-node', '0,1');\n      e1.textContent = 'E1';\n      const tMid = document.createTextNode(' middle ');\n      const e2 = document.createElement('b');\n      e2.setAttribute('data-path-to-node', '0,2');\n      e2.textContent = 'E2';\n      const t2 = document.createTextNode('**');\n\n      container.append(t1, e1, tMid, e2, t2);\n      fixBrokenBoldTags(container);\n\n      const strong = container.querySelector('strong');\n      expect(strong).not.toBeNull();\n      expect(strong?.textContent).toBe('E1 middle E2');\n      expect(container.textContent).toBe('E1 middle E2');\n    });\n\n    it('exact issue #507 scenario: long content with injected nodes', () => {\n      // Simulates: 研究**中心化探索与利用算法（Centralized ETC）**和**中心化置信上限算法（Centralized UCB）**时\n      // DOM split by Gemini into:\n      //   TextNode(\"研究**\")\n      //   <b data-path-to-node>中心化探索与利用算法（Centralized ETC）</b>\n      //   TextNode(\"**和**\")\n      //   <b data-path-to-node>中心化置信上限算法（Centralized UCB）</b>\n      //   TextNode(\"**时\")\n      const t1 = document.createTextNode('研究**');\n      const e1 = document.createElement('b');\n      e1.setAttribute('data-path-to-node', '0,0,1');\n      e1.textContent = '中心化探索与利用算法（Centralized ETC）';\n      const t2 = document.createTextNode('**和**');\n      const e2 = document.createElement('b');\n      e2.setAttribute('data-path-to-node', '0,0,2');\n      e2.textContent = '中心化置信上限算法（Centralized UCB）';\n      const t3 = document.createTextNode('**时');\n\n      container.append(t1, e1, t2, e2, t3);\n      fixBrokenBoldTags(container);\n\n      const strongs = container.querySelectorAll('strong');\n      expect(strongs.length).toBe(2);\n      expect(strongs[0].textContent).toContain('Centralized ETC');\n      expect(strongs[1].textContent).toContain('Centralized UCB');\n      // No ** markers should remain visible\n      expect(container.textContent).not.toContain('**');\n      expect(container.textContent).toBe(\n        '研究中心化探索与利用算法（Centralized ETC）和中心化置信上限算法（Centralized UCB）时',\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/content/markdownPatcher/index.ts",
    "content": "import { LoggerService } from '@/core/services/LoggerService';\n\nconst logger = LoggerService.getInstance().createChild('MarkdownPatcher');\n\n/**\n * Scans a container for broken bold markdown syntax caused by injected HTML tags\n * and fixes them by wrapping the content in <strong> tags.\n *\n * Specific target pattern:\n * TextNode containing \"**\" -> ElementNode(b[data-path-to-node]) -> TextNode containing \"**\"\n */\n// Export for testing\nexport function fixBrokenBoldTags(root: HTMLElement) {\n  // Use a TreeWalker to safely iterate text nodes\n  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);\n  const textNodes: Text[] = [];\n  let node: Node | null;\n\n  while ((node = walker.nextNode())) {\n    const parent = node.parentElement;\n    // Skip if inside code block, pre tags, or math/formula containers\n    if (\n      parent &&\n      (parent.tagName === 'CODE' ||\n        parent.tagName === 'PRE' ||\n        parent.tagName === 'MATH-BLOCK' || // Gemini custom element\n        parent.tagName === 'MATH-INLINE' || // Gemini custom element\n        parent.classList.contains('math-block') ||\n        parent.classList.contains('math-inline') ||\n        parent.closest('code') ||\n        parent.closest('pre') ||\n        parent.closest('code-block') ||\n        parent.closest('.math-block') ||\n        parent.closest('.math-inline') ||\n        // Skip editable areas to avoid modifying user input\n        parent.closest('rich-textarea') ||\n        parent.closest('[contenteditable=\"true\"]') ||\n        parent.closest('[role=\"textbox\"]'))\n    ) {\n      continue;\n    }\n\n    if (node.textContent?.includes('**')) {\n      textNodes.push(node as Text);\n    }\n  }\n\n  for (const startNode of textNodes) {\n    if (!startNode.isConnected) continue;\n\n    let currentNode = startNode;\n    const originalText = currentNode.textContent || '';\n\n    // Phase 1: Fix intra-node bolds (e.g., \"start **bold** end\")\n    // We match all complete pairs of **...** within the node\n    // Improved Regex: require non-whitespace immediately inside the asterisks\n    // and prevent matching across long distances if not intended.\n    // However, maintaining the basic structure, let's enforce:\n    // **(non-space...non-space)** to be safer.\n    // Updated regex: \\*\\*([^\\s].*?[^\\s]|[^\\s])\\*\\* OR \\*\\*([^\\s])\\*\\*\n    // This prevents matching \"** \" or \" **\"\n    const matches = Array.from(originalText.matchAll(/\\*\\*([^\\s].*?[^\\s]|[^\\s])\\*\\*/g));\n\n    if (matches.length > 0) {\n      const fragment = document.createDocumentFragment();\n      let lastCursor = 0;\n      let lastTextNode: Text | null = null;\n\n      matches.forEach((m) => {\n        const matchStart = m.index!;\n        const matchEnd = matchStart + m[0].length;\n        const content = m[1];\n\n        // Text before\n        if (matchStart > lastCursor) {\n          fragment.appendChild(document.createTextNode(originalText.slice(lastCursor, matchStart)));\n        }\n\n        // Bold content\n        const strong = document.createElement('strong');\n        strong.textContent = content;\n        fragment.appendChild(strong);\n\n        lastCursor = matchEnd;\n      });\n\n      // Text after (this might contain a trailing unmatched '**' for Phase 2)\n      if (lastCursor < originalText.length) {\n        lastTextNode = document.createTextNode(originalText.slice(lastCursor));\n        fragment.appendChild(lastTextNode);\n      }\n\n      // Replace the original node with our processed fragment\n      if (currentNode.parentNode) {\n        currentNode.parentNode.replaceChild(fragment, currentNode);\n      }\n\n      // Prepare for Phase 2:\n      // If we created a trailing text node, that is now the candidate for the \"split\" check.\n      // If we didn't (node ended with bold), there's no dangling start marker, so we're done with this node.\n      if (lastTextNode) {\n        currentNode = lastTextNode;\n      } else {\n        continue;\n      }\n    }\n\n    // Phase 2: Fix split-node bolds (e.g., \"text**\" -> element(s) -> \"text**\")\n    // Walk forward through siblings to find the closing ** marker,\n    // collecting all intermediate nodes (elements and text nodes without **).\n    const startText = currentNode.textContent || '';\n    const startIdx = startText.lastIndexOf('**');\n\n    if (startIdx === -1) continue;\n\n    // Collect intermediate siblings until we find a text node containing **\n    const middleNodes: Node[] = [];\n    let walker2: Node | null = currentNode.nextSibling;\n    let endNode: Text | null = null;\n    const MAX_WALK = 10; // safety limit to avoid walking too far\n\n    for (let steps = 0; walker2 && steps < MAX_WALK; steps++) {\n      if (walker2.nodeType === Node.TEXT_NODE) {\n        const text = walker2.textContent || '';\n        if (text.includes('**')) {\n          endNode = walker2 as Text;\n          break;\n        }\n        // Text node without ** — part of the bold content\n        middleNodes.push(walker2);\n      } else if (\n        walker2.nodeType === Node.ELEMENT_NODE &&\n        (walker2 as HTMLElement).hasAttribute('data-path-to-node')\n      ) {\n        middleNodes.push(walker2);\n      } else {\n        // Unknown node type — stop to avoid unexpected behavior\n        break;\n      }\n      walker2 = walker2.nextSibling;\n    }\n\n    if (!endNode || middleNodes.length === 0) continue;\n\n    const endText = endNode.textContent || '';\n    const endIdx = endText.indexOf('**');\n\n    if (endIdx === -1) continue;\n\n    try {\n      logger.info('Found broken markdown pattern due to injected node, applying fix...');\n\n      // 1. Create wrapper\n      const strong = document.createElement('strong');\n\n      // 2. Insert the strong tag before the first middle node\n      if (currentNode.parentNode) {\n        currentNode.parentNode.insertBefore(strong, middleNodes[0]);\n      }\n\n      // 3. Extract and move content INTO the strong tag\n      // Content from start node (after the **)\n      const afterStart = startText.substring(startIdx + 2);\n      if (afterStart) {\n        strong.appendChild(document.createTextNode(afterStart));\n      }\n\n      // All intermediate nodes\n      for (const mid of middleNodes) {\n        strong.appendChild(mid);\n      }\n\n      // Content from end node (before the **)\n      const beforeEnd = endText.substring(0, endIdx);\n      if (beforeEnd) {\n        strong.appendChild(document.createTextNode(beforeEnd));\n      }\n\n      // 4. Cleanup original text nodes\n      currentNode.textContent = startText.substring(0, startIdx);\n      endNode.textContent = endText.substring(endIdx + 2);\n    } catch (e) {\n      logger.error('Failed to apply markdown fix', { error: e });\n    }\n  }\n}\n\n/**\n * Starts the observer to patch broken markdown rendering in Gemini\n */\nexport function startMarkdownPatcher() {\n  logger.info('Starting Markdown Patcher');\n\n  // Initial fix\n  fixBrokenBoldTags(document.body);\n\n  const observer = new MutationObserver((mutations) => {\n    // Collect all added nodes to scan them\n    const nodesToScan: HTMLElement[] = [];\n\n    for (const m of mutations) {\n      m.addedNodes.forEach((node) => {\n        if (node.nodeType === Node.ELEMENT_NODE) {\n          nodesToScan.push(node as HTMLElement);\n        }\n      });\n    }\n\n    if (nodesToScan.length > 0) {\n      nodesToScan.forEach((node) => {\n        // Skip editable areas to avoid modifying user input\n        if (\n          node.closest('rich-textarea') ||\n          node.closest('[contenteditable=\"true\"]') ||\n          node.closest('[role=\"textbox\"]')\n        ) {\n          return;\n        }\n        fixBrokenBoldTags(node);\n      });\n    }\n  });\n\n  observer.observe(document.body, {\n    childList: true,\n    subtree: true,\n  });\n\n  return () => observer.disconnect();\n}\n"
  },
  {
    "path": "src/pages/content/mermaid/__tests__/mermaid.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport {\n  _resetMermaidLoader,\n  isGenericLanguageLabel,\n  isMermaidCode,\n  loadMermaid,\n  normalizeWhitespace,\n} from '../index';\n\n// Mock the dynamic import of 'mermaid'\nvi.mock('mermaid', () => ({\n  default: {\n    initialize: vi.fn(),\n    render: vi.fn(),\n  },\n}));\n\ndescribe('Mermaid dynamic loading', () => {\n  beforeEach(() => {\n    _resetMermaidLoader();\n    vi.clearAllMocks();\n  });\n\n  describe('loadMermaid', () => {\n    it('should load mermaid module successfully', async () => {\n      const mermaid = await loadMermaid();\n      expect(mermaid).not.toBeNull();\n      expect(mermaid).toHaveProperty('initialize');\n      expect(mermaid).toHaveProperty('render');\n    });\n\n    it('should cache the loaded instance on subsequent calls', async () => {\n      const first = await loadMermaid();\n      const second = await loadMermaid();\n      expect(first).toBe(second);\n    });\n\n    it('should return cached instance without re-importing', async () => {\n      // First call loads and caches\n      const first = await loadMermaid();\n      expect(first).not.toBeNull();\n\n      // Second call returns cached instance immediately (no new import)\n      const second = await loadMermaid();\n      expect(second).toBe(first);\n    });\n  });\n\n  describe('isMermaidCode', () => {\n    it('should detect flowchart syntax', () => {\n      const code = `flowchart TD\n        A[Start] --> B{Is it working?}\n        B -- Yes --> C[Great!]\n        B -- No --> D[Fix it]\n        D --> B`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect graph syntax', () => {\n      const code = `graph LR\n        A[Start] --> B[Process]\n        B --> C[End]\n        C --> D[Done]`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect sequenceDiagram syntax', () => {\n      const code = `sequenceDiagram\n        participant Alice\n        participant Bob\n        Alice->>Bob: Hello Bob\n        Bob-->>Alice: Hi Alice`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect classDiagram syntax', () => {\n      const code = `classDiagram\n        class Animal {\n          +String name\n          +makeSound()\n        }\n        Animal <|-- Duck\n        Animal <|-- Fish`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect erDiagram syntax', () => {\n      const code = `erDiagram\n        CUSTOMER ||--o{ ORDER : places\n        ORDER ||--|{ LINE-ITEM : contains\n        CUSTOMER }|..|{ DELIVERY-ADDRESS : uses`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect gantt syntax', () => {\n      const code = `gantt\n        title A Gantt Diagram\n        dateFormat  YYYY-MM-DD\n        section Section\n        A task           :a1, 2024-01-01, 30d`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect pie chart syntax', () => {\n      const code = `pie title Pets adopted by volunteers\n        \"Dogs\" : 386\n        \"Cats\" : 85\n        \"Rats\" : 15\n        \"Others\" : 35`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect gitGraph syntax', () => {\n      const code = `gitGraph\n        commit\n        branch develop\n        checkout develop\n        commit\n        checkout main\n        merge develop`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect mermaid comment prefix (%%)', () => {\n      const code = `%% This is a mermaid diagram\n        graph TD\n        A --> B\n        B --> C`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    // C4 diagrams\n    it('should detect C4Context syntax', () => {\n      const code = `C4Context\n        title System Context diagram\n        Person(customerA, \"Customer A\")\n        System(systemA, \"System A\")\n        Rel(customerA, systemA, \"Uses\")`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    // New v11 diagram types (both -beta and non-beta forms)\n    it('should detect xychart-beta syntax', () => {\n      const code = `xychart-beta\n        title \"Sales Revenue\"\n        x-axis [jan, feb, mar, apr]\n        y-axis \"Revenue (in $)\" 4000 --> 11000\n        bar [5000, 6000, 7500, 8200]`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect xychart syntax (without -beta)', () => {\n      const code = `xychart\n        title \"Sales Revenue\"\n        x-axis [jan, feb, mar, apr]\n        y-axis \"Revenue (in $)\" 4000 --> 11000\n        bar [5000, 6000, 7500, 8200]`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect block-beta syntax', () => {\n      const code = `block-beta\n        columns 3\n        a[\"Block A\"] b[\"Block B\"] c[\"Block C\"]\n        d[\"Block D\"]:3\n        e[\"Block E\"] f[\"Block F\"]`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect block syntax (without -beta)', () => {\n      const code = `block\n        columns 3\n        a[\"Block A\"] b[\"Block B\"] c[\"Block C\"]\n        d[\"Block D\"]:3\n        e[\"Block E\"] f[\"Block F\"]`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect packet-beta syntax', () => {\n      const code = `packet-beta\n        title TCP Header\n        0-15: \"Source Port\"\n        16-31: \"Destination Port\"\n        32-63: \"Sequence Number\"`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect packet syntax (without -beta)', () => {\n      const code = `packet\n        title TCP Header\n        0-15: \"Source Port\"\n        16-31: \"Destination Port\"\n        32-63: \"Sequence Number\"`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect architecture syntax', () => {\n      const code = `architecture\n        group api(cloud)[API]\n        service db(database)[Database]\n        service web(server)[Web Server]\n        db:L -- R:web`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect kanban syntax', () => {\n      const code = `kanban\n        Todo\n          id1[Task 1]\n          id2[Task 2]\n        \"In Progress\"\n          id3[Task 3]`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect radar-beta syntax', () => {\n      const code = `radar-beta\n        title Skills Assessment\n        axis1 \"JavaScript\"\n        axis2 \"TypeScript\"\n        axis3 \"React\"\n        curve a: 5, 4, 3`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect treemap syntax', () => {\n      const code = `treemap\n        root(\"Project\")\n          src(\"Source\")\n            core(\"Core\")\n            features(\"Features\")\n          tests(\"Tests\")`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect sankey syntax (without -beta)', () => {\n      const code = `sankey\n        Agricultural \"ichael\",Fossil fuels,17.5\n        Biofuel imports,Liquid,35.8\n        Biomass imports,Solid,15.5\n        Coal imports,Coal,12.3`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should detect requirement syntax (without Diagram suffix)', () => {\n      const code = `requirement\n        functionalRequirement test_req {\n          id: 1\n          text: \"The system shall do something\"\n          risk: high\n        }`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n\n    it('should reject code shorter than 50 chars', () => {\n      expect(isMermaidCode('graph TD\\n  A --> B')).toBe(false);\n    });\n\n    it('should reject code with fewer than 3 non-empty lines', () => {\n      const code = `flowchart TD\n        A[Start] --> B[End]`;\n      expect(isMermaidCode(code)).toBe(false);\n    });\n\n    it('should reject code with incomplete endings', () => {\n      const code = `flowchart TD\n        A[Start] --> B{Decision}\n        B -- Yes --> C[Process]\n        C -->`;\n      expect(isMermaidCode(code)).toBe(false);\n    });\n\n    it('should reject non-mermaid code', () => {\n      const code = `function hello() {\n        console.log(\"Hello World\");\n        return true;\n        // some more code here to pass length check\n      }`;\n      expect(isMermaidCode(code)).toBe(false);\n    });\n\n    it('should be case-insensitive for keywords', () => {\n      const code = `FLOWCHART TD\n        A[Start] --> B[Process]\n        B --> C[End]\n        C --> D[Done]`;\n      expect(isMermaidCode(code)).toBe(true);\n    });\n  });\n\n  describe('normalizeWhitespace', () => {\n    it('should replace non-breaking spaces with standard spaces', () => {\n      const input = 'graph\\u00A0TD\\u00A0A-->B';\n      expect(normalizeWhitespace(input)).toBe('graph TD A-->B');\n    });\n\n    it('should replace em spaces', () => {\n      const input = 'graph\\u2003TD';\n      expect(normalizeWhitespace(input)).toBe('graph TD');\n    });\n\n    it('should replace en spaces', () => {\n      const input = 'graph\\u2002TD';\n      expect(normalizeWhitespace(input)).toBe('graph TD');\n    });\n\n    it('should replace thin spaces', () => {\n      const input = 'graph\\u2009TD';\n      expect(normalizeWhitespace(input)).toBe('graph TD');\n    });\n\n    it('should replace ideographic (CJK full-width) spaces', () => {\n      const input = 'graph\\u3000TD';\n      expect(normalizeWhitespace(input)).toBe('graph TD');\n    });\n\n    it('should remove zero-width spaces', () => {\n      const input = 'graph\\u200BTD';\n      expect(normalizeWhitespace(input)).toBe('graphTD');\n    });\n\n    it('should remove zero-width non-joiner', () => {\n      const input = 'graph\\u200CTD';\n      expect(normalizeWhitespace(input)).toBe('graphTD');\n    });\n\n    it('should remove zero-width joiner', () => {\n      const input = 'graph\\u200DTD';\n      expect(normalizeWhitespace(input)).toBe('graphTD');\n    });\n\n    it('should remove BOM character', () => {\n      const input = '\\uFEFFgraph TD';\n      expect(normalizeWhitespace(input)).toBe('graph TD');\n    });\n\n    it('should handle mixed special whitespace', () => {\n      const input = 'graph\\u00A0TD\\u200B\\u2003A\\u2009-->\\u3000B';\n      expect(normalizeWhitespace(input)).toBe('graph TD A --> B');\n    });\n\n    it('should leave standard whitespace unchanged', () => {\n      const input = 'graph TD\\n  A --> B\\n  B --> C';\n      expect(normalizeWhitespace(input)).toBe('graph TD\\n  A --> B\\n  B --> C');\n    });\n  });\n\n  describe('isGenericLanguageLabel', () => {\n    it('should return true for null (no label)', () => {\n      expect(isGenericLanguageLabel(null)).toBe(true);\n    });\n\n    it('should return true for generic English labels', () => {\n      expect(isGenericLanguageLabel('code')).toBe(true);\n      expect(isGenericLanguageLabel('text')).toBe(true);\n      expect(isGenericLanguageLabel('plaintext')).toBe(true);\n      expect(isGenericLanguageLabel('snippet')).toBe(true);\n      expect(isGenericLanguageLabel('example')).toBe(true);\n    });\n\n    it('should return true for generic Chinese labels', () => {\n      expect(isGenericLanguageLabel('代码段')).toBe(true);\n      expect(isGenericLanguageLabel('代码')).toBe(true);\n      expect(isGenericLanguageLabel('示例')).toBe(true);\n    });\n\n    it('should return false for specific programming languages', () => {\n      expect(isGenericLanguageLabel('python')).toBe(false);\n      expect(isGenericLanguageLabel('javascript')).toBe(false);\n      expect(isGenericLanguageLabel('typescript')).toBe(false);\n      expect(isGenericLanguageLabel('rust')).toBe(false);\n      expect(isGenericLanguageLabel('matlab')).toBe(false);\n    });\n\n    it('should return true for \"mermaid\" as it is in the generic set... wait no', () => {\n      // \"mermaid\" is NOT in the generic set — it's handled separately in processCodeBlocks\n      expect(isGenericLanguageLabel('mermaid')).toBe(false);\n    });\n\n    it('should be case-insensitive', () => {\n      expect(isGenericLanguageLabel('Code')).toBe(true);\n      expect(isGenericLanguageLabel('TEXT')).toBe(true);\n      expect(isGenericLanguageLabel('Plaintext')).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/content/mermaid/index.ts",
    "content": "/**\n * Lazily loaded Mermaid instance.\n * Mermaid is dynamically imported to reduce initial content script bundle size.\n * The library (~1 MB) is only loaded when a Mermaid code block is actually detected.\n */\nlet mermaidInstance: Awaited<typeof import('mermaid')>['default'] | null = null;\nlet mermaidLoadFailed = false;\n\n/**\n * Reset internal loader state. Only for testing.\n * @internal\n */\nexport const _resetMermaidLoader = () => {\n  mermaidInstance = null;\n  mermaidLoadFailed = false;\n};\n\n/**\n * Dynamically load the Mermaid library.\n * Returns the mermaid default export, or null if loading fails.\n * Once loaded (or failed), the result is cached.\n */\n/**\n * @internal Exported for testing\n */\nexport const loadMermaid = async (): Promise<typeof mermaidInstance> => {\n  if (mermaidInstance) return mermaidInstance;\n  if (mermaidLoadFailed) return null;\n\n  try {\n    const mod = await import('mermaid');\n    mermaidInstance = mod.default;\n    return mermaidInstance;\n  } catch (error) {\n    mermaidLoadFailed = true;\n    console.error('[Gemini Voyager] Failed to load Mermaid library:', error);\n    return null;\n  }\n};\n\n/**\n * Initialize Mermaid configuration\n */\nconst initMermaid = async (): Promise<boolean> => {\n  const mermaid = await loadMermaid();\n  if (!mermaid) return false;\n\n  const isDarkMode =\n    document.body.classList.contains('dark-theme') ||\n    document.body.getAttribute('data-theme') === 'dark' ||\n    document.documentElement.classList.contains('dark') ||\n    window.matchMedia('(prefers-color-scheme: dark)').matches;\n\n  mermaid.initialize({\n    startOnLoad: false,\n    theme: isDarkMode ? 'dark' : 'default',\n    securityLevel: 'loose',\n    fontFamily: 'Google Sans, Roboto, sans-serif',\n    logLevel: 5, // 5 = fatal, only log fatal errors (v9.x uses numbers)\n  });\n\n  return true;\n};\n\n/**\n * Check if a code block contains Mermaid syntax and appears complete enough to render\n * @internal Exported for testing\n */\nexport const isMermaidCode = (code: string): boolean => {\n  const codeTrimmed = code.trim();\n\n  // Minimum length to avoid parsing incomplete/streaming content\n  if (codeTrimmed.length < 50) return false;\n\n  // Keywords aligned with mermaid's own detector regexes.\n  // Order matters: longer/more-specific prefixes should come before shorter ones\n  // so that e.g. \"flowchart-elk\" isn't matched by \"flowchart\" and missed.\n  const keywords = [\n    // Core diagram types (v9+)\n    'graph',\n    'flowchart',\n    'sequenceDiagram',\n    'classDiagram',\n    'stateDiagram',\n    'erDiagram',\n    'gantt',\n    'pie',\n    'gitGraph',\n    'journey',\n    'mindmap',\n    'timeline',\n    'zenuml',\n    'quadrantChart',\n    'requirementDiagram',\n    'requirement', // v11: requirement(Diagram)? — shorter form\n    'sankey-beta',\n    'sankey', // v11: sankey(-beta)?\n    // C4 diagrams (v9+, often overlooked)\n    'C4Context',\n    'C4Container',\n    'C4Component',\n    'C4Dynamic',\n    'C4Deployment',\n    // New diagram types (v10+/v11+, Chrome/Safari)\n    'xychart-beta',\n    'xychart', // v11: xychart(-beta)?\n    'block-beta',\n    'block', // v11: block(-beta)?\n    'packet-beta',\n    'packet', // v11: packet(-beta)?\n    'architecture-beta',\n    'architecture', // v11: architecture(-beta)?\n    'kanban',\n    'radar-beta', // v11\n    'treemap', // v11\n  ];\n\n  const startsWithKeyword =\n    codeTrimmed.startsWith('%%') ||\n    keywords.some((keyword) => codeTrimmed.toLowerCase().startsWith(keyword.toLowerCase()));\n\n  if (!startsWithKeyword) return false;\n\n  // Check if code looks complete (has multiple lines and doesn't end mid-statement)\n  const lines = codeTrimmed.split('\\n').filter((l) => l.trim().length > 0);\n  if (lines.length < 3) return false;\n\n  // Check last line doesn't look incomplete (ending with operators or open brackets)\n  const lastLine = lines[lines.length - 1].trim();\n  const incompleteEndings = ['-->', '---', '-.', '==>', ':::', '[', '(', '{', '|', '&', ','];\n  if (incompleteEndings.some((ending) => lastLine.endsWith(ending))) return false;\n\n  return true;\n};\n\n/**\n * Create styles for mermaid components and fullscreen viewer\n */\nconst createStyles = () => {\n  if (document.getElementById('gv-mermaid-styles')) return;\n\n  const style = document.createElement('style');\n  style.id = 'gv-mermaid-styles';\n  style.textContent = `\n    .gv-mermaid-wrapper {\n      position: relative;\n    }\n    \n    .gv-mermaid-toggle {\n      position: absolute;\n      top: 8px;\n      right: 8px;\n      z-index: 10;\n      display: flex;\n      align-items: center; /* Center items vertically */\n      gap: 4px;\n      background: var(--gemini-surface-container, rgba(0,0,0,0.05));\n      border-radius: 8px;\n      padding: 2px;\n      border: 1px solid var(--gemini-outline-variant, rgba(0,0,0,0.1));\n    }\n    \n    .gv-mermaid-toggle button {\n      padding: 4px 10px;\n      border: none;\n      border-radius: 6px;\n      cursor: pointer;\n      font-size: 12px;\n      font-family: 'Google Sans', sans-serif;\n      transition: all 0.2s ease;\n      background: transparent;\n      color: var(--gemini-on-surface-variant, #666);\n    }\n    \n    .gv-mermaid-toggle button:hover {\n      background: var(--gemini-surface-container-high, rgba(0,0,0,0.08));\n    }\n    \n    .gv-mermaid-toggle button.active {\n      background: var(--gemini-primary, #1a73e8);\n      color: white;\n    }\n    \n    .gv-mermaid-diagram {\n      padding: 16px;\n      text-align: center;\n      overflow-x: auto;\n      min-height: 100px;\n      cursor: zoom-in;\n    }\n    \n    .gv-mermaid-diagram svg {\n      max-width: 100%;\n      height: auto;\n    }\n    \n    /* Fullscreen Modal */\n    .gv-mermaid-modal {\n      position: fixed;\n      top: 0;\n      left: 0;\n      width: 100vw;\n      height: 100vh;\n      background: rgba(0, 0, 0, 0.9);\n      z-index: 999999;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      opacity: 0;\n      transition: opacity 0.3s ease;\n    }\n    \n    .gv-mermaid-modal.visible {\n      opacity: 1;\n    }\n    \n    .gv-mermaid-modal-toolbar {\n      position: fixed;\n      top: 16px;\n      right: 16px;\n      display: flex;\n      gap: 8px;\n      z-index: 1000000;\n    }\n    \n    .gv-mermaid-modal-toolbar button {\n      width: 40px;\n      height: 40px;\n      border-radius: 50%;\n      border: none;\n      background: rgba(255, 255, 255, 0.2);\n      color: white;\n      font-size: 18px;\n      cursor: pointer;\n      transition: all 0.2s ease;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n    }\n    \n    .gv-mermaid-modal-toolbar button:hover {\n      background: rgba(255, 255, 255, 0.3);\n      transform: scale(1.1);\n    }\n    \n    .gv-mermaid-modal-content {\n      position: relative;\n      cursor: grab;\n      user-select: none;\n    }\n    \n    .gv-mermaid-modal-content.dragging {\n      cursor: grabbing;\n    }\n    \n    .gv-mermaid-modal-content svg {\n      max-width: none;\n      max-height: none;\n    }\n    \n    .gv-mermaid-modal-hint {\n      position: fixed;\n      bottom: 20px;\n      left: 50%;\n      transform: translateX(-50%);\n      color: rgba(255, 255, 255, 0.6);\n      font-size: 14px;\n      font-family: 'Google Sans', sans-serif;\n      pointer-events: none;\n    }\n  `;\n  document.head.appendChild(style);\n};\n\n/**\n * Fullscreen viewer state\n */\nlet currentModal: HTMLElement | null = null;\nlet scale = 1;\nlet translateX = 0;\nlet translateY = 0;\nlet isDragging = false;\nlet startX = 0;\nlet startY = 0;\n\n/**\n * Open fullscreen viewer for SVG\n */\nconst openFullscreen = (svgHtml: string) => {\n  if (currentModal) return;\n\n  // Reset state\n  scale = 1;\n  translateX = 0;\n  translateY = 0;\n\n  // Create modal\n  const modal = document.createElement('div');\n  modal.className = 'gv-mermaid-modal';\n\n  // Toolbar\n  const toolbar = document.createElement('div');\n  toolbar.className = 'gv-mermaid-modal-toolbar';\n\n  const zoomInBtn = document.createElement('button');\n  zoomInBtn.innerHTML = '+';\n  zoomInBtn.title = 'Zoom In';\n\n  const zoomOutBtn = document.createElement('button');\n  zoomOutBtn.innerHTML = '−';\n  zoomOutBtn.title = 'Zoom Out';\n\n  const resetBtn = document.createElement('button');\n  resetBtn.innerHTML = '⊙';\n  resetBtn.title = 'Reset';\n\n  const closeBtn = document.createElement('button');\n  closeBtn.innerHTML = '✕';\n  closeBtn.title = 'Close (ESC)';\n\n  toolbar.appendChild(zoomInBtn);\n  toolbar.appendChild(zoomOutBtn);\n  toolbar.appendChild(resetBtn);\n  toolbar.appendChild(closeBtn);\n\n  // Content container\n  const content = document.createElement('div');\n  content.className = 'gv-mermaid-modal-content';\n  content.innerHTML = svgHtml;\n\n  // Hint\n  const hint = document.createElement('div');\n  hint.className = 'gv-mermaid-modal-hint';\n  hint.textContent = 'Scroll to zoom • Drag to pan • ESC to close';\n\n  modal.appendChild(toolbar);\n  modal.appendChild(content);\n  modal.appendChild(hint);\n  document.body.appendChild(modal);\n  currentModal = modal;\n\n  // Initial fit scale (updated after auto-fit calculation)\n  let initialScale = 1;\n\n  // Apply transform\n  const applyTransform = () => {\n    content.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;\n  };\n\n  // Zoom functions\n  const zoomIn = () => {\n    scale = Math.min(scale * 1.2, 10);\n    applyTransform();\n  };\n\n  const zoomOut = () => {\n    scale = Math.max(scale / 1.2, 0.1);\n    applyTransform();\n  };\n\n  const resetView = () => {\n    scale = initialScale;\n    translateX = 0;\n    translateY = 0;\n    applyTransform();\n  };\n\n  const closeModal = () => {\n    modal.classList.remove('visible');\n    setTimeout(() => {\n      modal.remove();\n      currentModal = null;\n    }, 300);\n  };\n\n  // Event listeners\n  zoomInBtn.addEventListener('click', zoomIn);\n  zoomOutBtn.addEventListener('click', zoomOut);\n  resetBtn.addEventListener('click', resetView);\n  closeBtn.addEventListener('click', closeModal);\n\n  // Click backdrop to close\n  modal.addEventListener('click', (e) => {\n    if (e.target === modal) closeModal();\n  });\n\n  // ESC to close\n  const handleKeyDown = (e: KeyboardEvent) => {\n    if (e.key === 'Escape') {\n      closeModal();\n      document.removeEventListener('keydown', handleKeyDown);\n    }\n  };\n  document.addEventListener('keydown', handleKeyDown);\n\n  // Mouse wheel zoom\n  modal.addEventListener(\n    'wheel',\n    (e) => {\n      e.preventDefault();\n      if (e.deltaY < 0) {\n        scale = Math.min(scale * 1.1, 10);\n      } else {\n        scale = Math.max(scale / 1.1, 0.1);\n      }\n      applyTransform();\n    },\n    { passive: false },\n  );\n\n  // Drag to pan\n  content.addEventListener('mousedown', (e) => {\n    isDragging = true;\n    startX = e.clientX - translateX;\n    startY = e.clientY - translateY;\n    content.classList.add('dragging');\n  });\n\n  document.addEventListener('mousemove', (e) => {\n    if (!isDragging) return;\n    translateX = e.clientX - startX;\n    translateY = e.clientY - startY;\n    applyTransform();\n  });\n\n  document.addEventListener('mouseup', () => {\n    isDragging = false;\n    content.classList.remove('dragging');\n  });\n\n  // Auto-fit SVG to viewport\n  const svgElement = content.querySelector('svg');\n  if (svgElement) {\n    const padding = 80; // px padding from viewport edges\n    const viewportWidth = window.innerWidth - padding * 2;\n    const viewportHeight = window.innerHeight - padding * 2;\n    const svgWidth = svgElement.scrollWidth || svgElement.clientWidth;\n    const svgHeight = svgElement.scrollHeight || svgElement.clientHeight;\n\n    if (svgWidth > 0 && svgHeight > 0) {\n      const fitScale = Math.min(viewportWidth / svgWidth, viewportHeight / svgHeight);\n      // Scale to fit viewport: scale down if too large, scale up if too small\n      scale = Math.min(Math.max(fitScale, 0.1), 10);\n      initialScale = scale;\n      applyTransform();\n    }\n  }\n\n  // Show modal with animation\n  requestAnimationFrame(() => {\n    modal.classList.add('visible');\n  });\n};\n\n/**\n * Normalize whitespace characters in Mermaid code\n * Replaces non-breaking spaces (NBSP \\u00A0) and other special whitespace\n * with standard spaces to prevent Mermaid parsing errors.\n *\n * Common problematic characters:\n * - \\u00A0 (NBSP): From web pages, Word, Notion, WeChat, etc.\n * - \\u2003 (Em Space)\n * - \\u2002 (En Space)\n * - \\u2009 (Thin Space)\n * - \\u200B (Zero-width Space)\n * - \\u3000 (Ideographic Space - CJK full-width space)\n */\n/**\n * @internal Exported for testing\n */\nexport const normalizeWhitespace = (code: string): string => {\n  return (\n    code\n      // Replace various special space characters with standard space\n      .replace(/[\\u00A0\\u2002\\u2003\\u2009\\u3000]/g, ' ')\n      // Remove zero-width characters that can cause issues\n      .replace(/[\\u200B\\u200C\\u200D\\uFEFF]/g, '')\n  );\n};\n\n/**\n * Render Mermaid diagram for a code block\n */\nconst renderMermaid = async (codeBlock: HTMLElement, code: string) => {\n  // Normalize whitespace before processing\n  const normalizedCode = normalizeWhitespace(code);\n  if (codeBlock.dataset.mermaidCode === normalizedCode) return;\n  if (codeBlock.dataset.mermaidProcessing === 'true') return;\n\n  codeBlock.dataset.mermaidProcessing = 'true';\n\n  try {\n    const codeBlockHost = codeBlock.closest('code-block') as HTMLElement;\n    if (!codeBlockHost) {\n      codeBlock.dataset.mermaidProcessing = 'false';\n      return;\n    }\n\n    // Ensure Mermaid is loaded before rendering\n    const mermaid = await loadMermaid();\n    if (!mermaid) {\n      // Mermaid failed to load — gracefully degrade by showing raw code\n      codeBlock.dataset.mermaidProcessing = 'false';\n      return;\n    }\n\n    // First, try to render to validate the code\n    const uniqueId = `mermaid-${Math.random().toString(36).substr(2, 9)}`;\n    let svg: string;\n\n    try {\n      // v9.x render returns string directly, v10.x returns {svg: string}\n      const result = await mermaid.render(uniqueId, normalizedCode);\n      svg = typeof result === 'string' ? result : (result as { svg: string }).svg;\n    } catch (renderError) {\n      // Mermaid failed - likely incomplete or invalid syntax\n\n      // Clean up any error SVGs mermaid may have created\n      const errorSvg = document.getElementById(uniqueId);\n      if (errorSvg) errorSvg.remove();\n\n      // Also clean up any floating error containers mermaid creates\n      document.querySelectorAll('[id^=\"d\"]').forEach((el) => {\n        if (el.textContent?.includes('Syntax error') || el.querySelector('.error-icon')) {\n          el.remove();\n        }\n      });\n\n      // Create a friendly error message\n      const errorMsg = renderError instanceof Error ? renderError.message : 'Unknown error';\n      const shortError = errorMsg.length > 100 ? errorMsg.substring(0, 100) + '...' : errorMsg;\n      svg = `\n                <div style=\"padding: 24px; text-align: center; color: var(--gemini-on-surface-variant, #666);\">\n                    <div style=\"font-size: 32px; margin-bottom: 12px;\">⚠️</div>\n                    <div style=\"font-weight: 500; margin-bottom: 8px;\">Mermaid Syntax Error</div>\n                    <div style=\"font-size: 12px; opacity: 0.7; font-family: monospace; max-width: 400px; margin: 0 auto; word-break: break-word;\">${shortError}</div>\n                    <div style=\"margin-top: 12px; font-size: 13px;\">Click <b>\"&lt;/&gt; Code\"</b> to view source</div>\n                </div>\n            `;\n    }\n\n    // Rendering succeeded! Now create or update the UI\n    let wrapper = codeBlockHost.parentElement;\n    if (!wrapper?.classList.contains('gv-mermaid-wrapper')) {\n      wrapper = document.createElement('div');\n      wrapper.className = 'gv-mermaid-wrapper';\n      codeBlockHost.parentElement?.insertBefore(wrapper, codeBlockHost);\n      wrapper.appendChild(codeBlockHost);\n\n      // Toggle buttons\n      const toggleContainer = document.createElement('div');\n      toggleContainer.className = 'gv-mermaid-toggle';\n\n      // Try to find and move the native copy button to our toolbar\n      // This prevents overlap/covering issues and keeps the UI clean\n      // We look for .buttons container (newer Gemini) or .copy-button class\n      const parentElement = wrapper?.parentElement || codeBlockHost.parentElement;\n      const nativeCopyBtn =\n        parentElement?.querySelector('.buttons') || parentElement?.querySelector('.copy-button');\n\n      // Only move if it looks like the right button (close to the code block)\n      if (nativeCopyBtn) {\n        // Reset positioning that might conflict\n        (nativeCopyBtn as HTMLElement).style.position = 'static';\n        (nativeCopyBtn as HTMLElement).style.top = 'auto';\n        (nativeCopyBtn as HTMLElement).style.right = 'auto';\n        (nativeCopyBtn as HTMLElement).style.marginTop = '0';\n        toggleContainer.appendChild(nativeCopyBtn);\n      }\n\n      const diagramBtn = document.createElement('button');\n      diagramBtn.textContent = '📊 Diagram';\n      diagramBtn.className = 'active';\n      diagramBtn.dataset.view = 'diagram';\n\n      const codeBtn = document.createElement('button');\n      codeBtn.textContent = '</> Code';\n      codeBtn.dataset.view = 'code';\n\n      toggleContainer.appendChild(diagramBtn);\n      toggleContainer.appendChild(codeBtn);\n      wrapper.appendChild(toggleContainer);\n\n      // Diagram container\n      const diagramContainer = document.createElement('div');\n      diagramContainer.className = 'gv-mermaid-diagram';\n      wrapper.appendChild(diagramContainer);\n\n      codeBlockHost.style.display = 'none';\n\n      const updateView = (view: 'diagram' | 'code') => {\n        if (view === 'diagram') {\n          codeBlockHost.style.display = 'none';\n          diagramContainer.style.display = 'block';\n          diagramBtn.classList.add('active');\n          codeBtn.classList.remove('active');\n        } else {\n          codeBlockHost.style.display = '';\n          diagramContainer.style.display = 'none';\n          diagramBtn.classList.remove('active');\n          codeBtn.classList.add('active');\n        }\n      };\n\n      diagramBtn.addEventListener('click', () => updateView('diagram'));\n      codeBtn.addEventListener('click', () => updateView('code'));\n\n      // Click diagram to fullscreen (only if it's a valid SVG, not error)\n      diagramContainer.addEventListener('click', () => {\n        const svgElement = diagramContainer.querySelector('svg');\n        if (svgElement) {\n          openFullscreen(diagramContainer.innerHTML);\n        }\n      });\n    }\n\n    const diagramContainer = wrapper.querySelector('.gv-mermaid-diagram') as HTMLElement;\n    if (!diagramContainer) {\n      codeBlock.dataset.mermaidProcessing = 'false';\n      return;\n    }\n\n    // Insert the successfully rendered SVG\n    diagramContainer.innerHTML = svg;\n\n    codeBlock.dataset.mermaidCode = normalizedCode;\n    codeBlock.dataset.mermaidProcessing = 'false';\n    console.log('[Gemini Voyager] Mermaid diagram rendered:', uniqueId);\n  } catch {\n    codeBlock.dataset.mermaidProcessing = 'false';\n\n    const codeBlockHost = codeBlock.closest('code-block') as HTMLElement;\n    if (codeBlockHost) {\n      codeBlockHost.style.display = '';\n    }\n  }\n};\n\n/**\n * Get the language label from a code block's header decoration\n * Returns the language name (lowercase) or null if not found\n */\nconst getCodeBlockLanguage = (codeEl: Element): string | null => {\n  // Navigate up to find the code-block container\n  const codeBlock = codeEl.closest('.code-block, code-block');\n  if (!codeBlock) return null;\n\n  // Look for the language label in the header decoration\n  // Gemini uses: <div class=\"code-block-decoration\"><span>Language</span>...</div>\n  const decoration = codeBlock.querySelector('.code-block-decoration');\n  if (!decoration) return null;\n\n  // The first span child typically contains the language name\n  const langSpan = decoration.querySelector(':scope > span');\n  if (!langSpan) return null;\n\n  const language = langSpan.textContent?.trim().toLowerCase();\n  return language || null;\n};\n\n/**\n * Generic/non-specific language labels that should still allow Mermaid detection\n * These are labels that don't represent a specific programming language\n */\nconst GENERIC_LANGUAGE_LABELS = new Set([\n  // Chinese\n  '代码段',\n  '代码',\n  '代码块',\n  '示例',\n  '示例代码',\n  // English\n  'code',\n  'code snippet',\n  'snippet',\n  'example',\n  'code example',\n  'sample',\n  // Common generic terms\n  'text',\n  'plain',\n  'plaintext',\n  'raw',\n  'output',\n  'result',\n]);\n\n/**\n * Check if a language label is generic (not a specific programming language)\n */\n/**\n * @internal Exported for testing\n */\nexport const isGenericLanguageLabel = (language: string | null): boolean => {\n  if (!language) return true; // No label = generic\n  return GENERIC_LANGUAGE_LABELS.has(language.toLowerCase());\n};\n\n/**\n * Find and process code blocks\n */\nconst processCodeBlocks = () => {\n  const codeElements = document.querySelectorAll('code[data-test-id=\"code-content\"]');\n\n  codeElements.forEach((codeEl) => {\n    const codeText = codeEl.textContent || '';\n\n    // Check the language label from Gemini's code block header\n    const language = getCodeBlockLanguage(codeEl);\n\n    // Case 1: Language is explicitly \"mermaid\" - always render\n    if (language === 'mermaid') {\n      renderMermaid(codeEl as HTMLElement, codeText);\n      return;\n    }\n\n    // Case 2: Language is a specific programming language (not generic) - skip rendering\n    // This prevents false positives for MATLAB (%% comments), Python, etc.\n    if (language && !isGenericLanguageLabel(language)) {\n      return;\n    }\n\n    // Case 3: No language label or generic label - use content detection\n    if (isMermaidCode(codeText)) {\n      renderMermaid(codeEl as HTMLElement, codeText);\n    }\n  });\n};\n\n/**\n * Track whether Mermaid is enabled\n */\nlet mermaidEnabled = true;\nlet observer: MutationObserver | null = null;\n\n/**\n * Start Mermaid feature\n */\nexport const startMermaid = () => {\n  // Check if Mermaid rendering is enabled in settings\n  chrome.storage?.sync?.get({ gvMermaidEnabled: true }, (result) => {\n    mermaidEnabled = result?.gvMermaidEnabled !== false;\n\n    if (mermaidEnabled) {\n      initializeMermaid();\n    } else {\n      console.log('[Gemini Voyager] Mermaid rendering is disabled');\n    }\n  });\n\n  // Listen for setting changes\n  chrome.storage?.onChanged?.addListener((changes, areaName) => {\n    if (areaName === 'sync' && changes.gvMermaidEnabled) {\n      mermaidEnabled = changes.gvMermaidEnabled.newValue !== false;\n      if (mermaidEnabled) {\n        initializeMermaid();\n        console.log('[Gemini Voyager] Mermaid rendering enabled');\n      } else {\n        // Stop observing when disabled\n        if (observer) {\n          observer.disconnect();\n          observer = null;\n        }\n        console.log('[Gemini Voyager] Mermaid rendering disabled');\n      }\n    }\n  });\n};\n\n/**\n * Initialize Mermaid rendering\n */\nconst initializeMermaid = async () => {\n  createStyles();\n\n  const loaded = await initMermaid();\n  if (!loaded) {\n    console.warn('[Gemini Voyager] Mermaid library failed to load, diagrams will show as code');\n    return;\n  }\n\n  processCodeBlocks();\n\n  // Only create observer if not already exists\n  if (!observer) {\n    let timeout: ReturnType<typeof setTimeout>;\n    const debouncedProcess = () => {\n      if (!mermaidEnabled) return;\n      clearTimeout(timeout);\n      timeout = setTimeout(processCodeBlocks, 1000);\n    };\n\n    observer = new MutationObserver(() => {\n      debouncedProcess();\n    });\n\n    observer.observe(document.body, {\n      childList: true,\n      subtree: true,\n      characterData: true,\n    });\n  }\n\n  console.log('[Gemini Voyager] Mermaid integration started');\n};\n"
  },
  {
    "path": "src/pages/content/preventAutoScroll/index.ts",
    "content": "import { isExtensionContextInvalidatedError } from '@/core/utils/extensionContext';\n\nconst GV_BRIDGE_ID = 'gv-prevent-auto-scroll-bridge';\n\nfunction getBridgeElement(): HTMLElement {\n  let bridge = document.getElementById(GV_BRIDGE_ID);\n  if (!bridge) {\n    bridge = document.createElement('div');\n    bridge.id = GV_BRIDGE_ID;\n    bridge.style.display = 'none';\n    document.documentElement.appendChild(bridge);\n  }\n  return bridge;\n}\n\nfunction notifyScript(enabled: boolean): void {\n  const bridge = getBridgeElement();\n  bridge.dataset.enabled = String(enabled);\n}\n\nfunction injectScript(): void {\n  const scriptId = 'gv-prevent-auto-scroll-script';\n  if (document.getElementById(scriptId)) return;\n\n  const script = document.createElement('script');\n  script.id = scriptId;\n  script.src = chrome.runtime.getURL('prevent-auto-scroll.js');\n  script.onload = () => {\n    script.remove(); // Clean up after injection\n  };\n  (document.head || document.documentElement).appendChild(script);\n}\n\nexport async function startPreventAutoScroll(): Promise<void> {\n  try {\n    // Initialize bridge element first\n    getBridgeElement();\n\n    // Check if feature is enabled, default to true or false?\n    // Probably default to true since it's a helpful feature, but typically\n    // we let user turn it on/off in popup.\n    const result = await chrome.storage?.sync?.get({ gvPreventAutoScrollEnabled: false });\n    const isEnabled = result?.gvPreventAutoScrollEnabled !== false; // wait, if default is false, then !== false is true...\n    // Let's set default to false for new feature to not surprise users unless they turn it on.\n\n    // Ah, wait. Usually settings default to false or true.\n    // Let's make it default to false so they have to opt-in, or true if requested.\n    // The user requested: \"I hope we can have a feature to prevent jump\". Let's make it default false to be safe.\n\n    // Wait, let's fix the logic:\n    // const isEnabled = result?.gvPreventAutoScrollEnabled === true;\n\n    notifyScript(result?.gvPreventAutoScrollEnabled === true);\n    injectScript();\n\n    // Listen for storage changes to update the bridge dynamically\n    chrome.storage.onChanged.addListener((changes, area) => {\n      if (area === 'sync' && changes.gvPreventAutoScrollEnabled) {\n        notifyScript(changes.gvPreventAutoScrollEnabled.newValue === true);\n      }\n    });\n\n    console.log('[Gemini Voyager] Prevent auto scroll initialized');\n  } catch (error) {\n    if (isExtensionContextInvalidatedError(error)) {\n      return;\n    }\n    console.error('[Gemini Voyager] Prevent auto scroll initialization failed:', error);\n  }\n}\n"
  },
  {
    "path": "src/pages/content/prompt/__tests__/scrollHint.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { getScrollHintState } from '../scrollHint';\n\ndescribe('getScrollHintState', () => {\n  it('returns no hint when content does not overflow', () => {\n    expect(getScrollHintState(0, 180, 180)).toEqual({\n      isOverflowing: false,\n      showHint: false,\n    });\n  });\n\n  it('shows hint when content overflows and user is near top', () => {\n    expect(getScrollHintState(0, 140, 320)).toEqual({\n      isOverflowing: true,\n      showHint: true,\n    });\n  });\n\n  it('hides hint when user is near the bottom', () => {\n    expect(getScrollHintState(176, 140, 320)).toEqual({\n      isOverflowing: true,\n      showHint: false,\n    });\n  });\n\n  it('handles invalid numeric inputs safely', () => {\n    expect(getScrollHintState(Number.NaN, Number.NaN, Number.NaN)).toEqual({\n      isOverflowing: false,\n      showHint: false,\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/content/prompt/index.ts",
    "content": "/* Prompt Manager content module\n * - Injects a floating trigger button using the extension icon\n * - Opens a small anchored panel above the trigger (default)\n * - Panel supports: i18n language switch, add prompt, tag chips, search, copy, import/export\n * - Optional lock to pin panel position; when locked, panel is draggable and persisted\n */\nimport DOMPurify from 'dompurify';\nimport JSZip from 'jszip';\nimport 'katex/dist/katex.min.css';\nimport type { marked as MarkedFn } from 'marked';\nimport browser from 'webextension-polyfill';\n\nimport { logger } from '@/core/services/LoggerService';\nimport { promptStorageService } from '@/core/services/StorageService';\nimport { type StorageKey, StorageKeys } from '@/core/types/common';\nimport { isSafari, shouldShowSafariUpdateReminder } from '@/core/utils/browser';\nimport { isExtensionContextInvalidatedError } from '@/core/utils/extensionContext';\nimport { migrateFromLocalStorage } from '@/core/utils/storageMigration';\nimport { shouldShowUpdateReminderForCurrentVersion } from '@/core/utils/updateReminder';\nimport { compareVersions } from '@/core/utils/version';\nimport { getCurrentLanguage, getTranslationSync, initI18n, setCachedLanguage } from '@/utils/i18n';\nimport {\n  APP_LANGUAGES,\n  APP_LANGUAGE_LABELS,\n  type AppLanguage,\n  isAppLanguage,\n  normalizeLanguage,\n} from '@/utils/language';\nimport type { TranslationKey } from '@/utils/translations';\n\nimport { hasUnreadChangelog, openChangelog, showChangelogModalDirect } from '../changelog/index';\nimport { createFolderStorageAdapter } from '../folder/storage/FolderStorageAdapter';\nimport { getScrollHintState } from './scrollHint';\n\ntype PromptItem = {\n  id: string;\n  text: string;\n  tags: string[];\n  createdAt: number;\n  updatedAt?: number;\n};\n\ntype PanelPosition = { top: number; left: number };\ntype TriggerPosition = { bottom: number; right: number };\n\nconst STORAGE_KEYS = {\n  items: StorageKeys.PROMPT_ITEMS,\n  locked: StorageKeys.PROMPT_PANEL_LOCKED,\n  position: StorageKeys.PROMPT_PANEL_POSITION,\n  triggerPos: StorageKeys.PROMPT_TRIGGER_POSITION,\n  language: StorageKeys.LANGUAGE, // reuse global language key\n  theme: StorageKeys.PROMPT_THEME,\n} as const;\n\nconst ID = {\n  trigger: 'gv-pm-trigger',\n  panel: 'gv-pm-panel',\n} as const;\n\nconst LATEST_VERSION_CACHE_KEY = 'gvLatestVersionCache';\nconst LATEST_VERSION_MAX_AGE = 1000 * 60 * 60 * 6; // 6 hours\n\ntype PMTheme = 'light' | 'dark';\n\nfunction detectPageTheme(): PMTheme {\n  if (\n    document.querySelector('.theme-host.dark-theme') ||\n    document.body.classList.contains('dark-theme') ||\n    document.documentElement.classList.contains('dark') ||\n    document.body.getAttribute('data-theme') === 'dark'\n  ) {\n    return 'dark';\n  }\n  if (\n    document.querySelector('.theme-host.light-theme') ||\n    document.body.classList.contains('light-theme') ||\n    document.documentElement.classList.contains('light') ||\n    document.body.getAttribute('data-theme') === 'light'\n  ) {\n    return 'light';\n  }\n  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';\n}\n\nfunction getRuntimeUrl(path: string): string {\n  // Try the standard Web Extensions API first (mainly for Firefox)\n  try {\n    return browser.runtime.getURL(path);\n  } catch {\n    const win = window as Window & { chrome?: { runtime?: { getURL?: (path: string) => string } } };\n    return win.chrome?.runtime?.getURL?.(path) || path;\n  }\n}\n\nfunction safeParseJSON<T>(raw: string, fallback: T): T {\n  try {\n    const v = JSON.parse(raw);\n    return v as T;\n  } catch {\n    return fallback;\n  }\n}\n\n// Use centralized i18n system\nfunction createI18n() {\n  return {\n    t: (key: TranslationKey): string => getTranslationSync(key),\n    set: async (lang: AppLanguage) => {\n      try {\n        // Check if extension context is still valid\n        if (!browser.runtime?.id) {\n          // Extension context invalidated, skip\n          return;\n        }\n        setCachedLanguage(lang);\n        await browser.storage.sync.set({ language: lang });\n        return;\n      } catch (e) {\n        try {\n          await browser.storage.local.set({ language: lang });\n          return;\n        } catch (localError) {\n          // Silently ignore extension context errors\n          if (\n            isExtensionContextInvalidatedError(e) ||\n            isExtensionContextInvalidatedError(localError)\n          ) {\n            return;\n          }\n          console.warn('[PromptManager] Failed to set language:', e, localError);\n        }\n      }\n    },\n    get: async (): Promise<AppLanguage> => await getCurrentLanguage(),\n  };\n}\n\nfunction uid(): string {\n  // FNV-1a-ish hash over timestamp + rand\n  const seed = `${Date.now()}-${Math.random().toString(36).slice(2)}`;\n  let h = 2166136261 >>> 0;\n  for (let i = 0; i < seed.length; i++) {\n    h ^= seed.charCodeAt(i);\n    h = Math.imul(h, 16777619);\n  }\n  return (h >>> 0).toString(36);\n}\n\n/**\n * Storage adapter - uses chrome.storage.local for cross-domain data sharing\n * Falls back to localStorage if chrome.storage is unavailable\n */\nconst pmLogger = logger.createChild('PromptManager');\n\nconst normalizeVersionString = (version?: string | null): string | null => {\n  if (!version) return null;\n  const trimmed = version.trim();\n  return trimmed ? trimmed.replace(/^v/i, '') : null;\n};\n\nasync function readStorage<T>(key: StorageKey, fallback: T): Promise<T> {\n  const result = await promptStorageService.get<T>(key);\n  if (result.success) {\n    return result.data;\n  }\n  pmLogger.debug(`Key not found: ${key}, using fallback`);\n  return fallback;\n}\n\nasync function writeStorage<T>(key: StorageKey, value: T): Promise<void> {\n  const result = await promptStorageService.set(key, value);\n  if (!result.success) {\n    pmLogger.error(`Failed to write key: ${key}`, {\n      error: result.error?.message || 'Unknown error',\n      errorDetails: result.error,\n    });\n  }\n}\n\nasync function getLatestVersionCached(): Promise<string | null> {\n  try {\n    if (!browser.runtime?.id) return null;\n\n    const now = Date.now();\n    const cache = await browser.storage.local.get(LATEST_VERSION_CACHE_KEY);\n    const cached = cache?.[LATEST_VERSION_CACHE_KEY] as\n      | { version?: string; fetchedAt?: number }\n      | undefined;\n    if (\n      cached &&\n      cached.version &&\n      cached.fetchedAt &&\n      now - cached.fetchedAt < LATEST_VERSION_MAX_AGE\n    ) {\n      return cached.version;\n    }\n\n    const resp = await fetch(\n      'https://api.github.com/repos/Nagi-ovo/gemini-voyager/releases/latest',\n      {\n        headers: { Accept: 'application/vnd.github+json' },\n      },\n    );\n    if (!resp.ok) {\n      throw new Error(`HTTP ${resp.status}`);\n    }\n\n    const data = await resp.json();\n    const candidate =\n      typeof data.tag_name === 'string'\n        ? data.tag_name\n        : typeof data.name === 'string'\n          ? data.name\n          : null;\n\n    if (candidate) {\n      await browser.storage.local.set({\n        [LATEST_VERSION_CACHE_KEY]: { version: candidate, fetchedAt: now },\n      });\n      return candidate;\n    }\n  } catch (error) {\n    pmLogger.debug('Latest version check failed', { error });\n  }\n  return null;\n}\n\nfunction createEl<K extends keyof HTMLElementTagNameMap>(\n  tag: K,\n  className?: string,\n): HTMLElementTagNameMap[K] {\n  const el = document.createElement(tag);\n  if (className) el.className = className;\n  return el;\n}\n\nfunction elFromHTML(html: string): HTMLElement {\n  const tpl = document.createElement('template');\n  tpl.innerHTML = html.trim();\n  return tpl.content.firstElementChild as HTMLElement;\n}\n\nfunction escapeHtml(s: string): string {\n  return s\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n    .replace(/'/g, '&#39;');\n}\n\nfunction dedupeTags(tags: string[]): string[] {\n  const seen = new Set<string>();\n  const out: string[] = [];\n  for (const raw of tags) {\n    const t = raw.trim().toLowerCase();\n    if (!t) continue;\n    if (!seen.has(t)) {\n      seen.add(t);\n      out.push(t);\n    }\n  }\n  return out;\n}\n\nfunction collectAllTags(items: PromptItem[]): string[] {\n  const set = new Set<string>();\n  for (const it of items) for (const t of it.tags || []) set.add(String(t).toLowerCase());\n  return Array.from(set).sort();\n}\n\nfunction copyText(text: string): Promise<void> {\n  try {\n    return navigator.clipboard.writeText(text);\n  } catch {\n    return new Promise<void>((resolve) => {\n      const ta = document.createElement('textarea');\n      ta.value = text;\n      ta.style.position = 'fixed';\n      ta.style.left = '-9999px';\n      document.body.appendChild(ta);\n      ta.select();\n      try {\n        document.execCommand('copy');\n      } catch {}\n      ta.remove();\n      resolve();\n    });\n  }\n}\n\nfunction computeAnchoredPosition(\n  trigger: HTMLElement,\n  panel: HTMLElement,\n): { top: number; left: number } {\n  const rect = trigger.getBoundingClientRect();\n  const vw = window.innerWidth;\n  const pad = 8;\n  const panelW = Math.min(380, Math.max(300, panel.getBoundingClientRect().width || 320));\n  const tentativeLeft = Math.min(vw - panelW - pad, Math.max(pad, rect.left + rect.width - panelW));\n  const top = Math.max(pad, rect.top - (panel.getBoundingClientRect().height || 360) - 10);\n  return { top, left: Math.round(tentativeLeft) };\n}\n\nexport async function startPromptManager(): Promise<{ destroy: () => void }> {\n  let marked!: typeof MarkedFn;\n  try {\n    // Check if the prompt manager should be hidden & changelog badge state\n    let pmHiddenByUser = false;\n    let changelogBadgeActive = false;\n\n    try {\n      const result = await browser.storage.sync.get({ gvHidePromptManager: false });\n      pmHiddenByUser = result?.gvHidePromptManager === true;\n    } catch (error) {\n      pmLogger.warn(\n        'Failed to check hide prompt manager setting, continuing with default behavior',\n        { error },\n      );\n    }\n\n    // Check changelog badge mode\n    try {\n      const [modeRes, unread] = await Promise.all([\n        browser.storage.local.get(StorageKeys.CHANGELOG_NOTIFY_MODE),\n        hasUnreadChangelog(),\n      ]);\n      const notifyMode = modeRes?.[StorageKeys.CHANGELOG_NOTIFY_MODE];\n      changelogBadgeActive = notifyMode === 'badge' && unread;\n    } catch {\n      // Ignore errors\n    }\n\n    if (pmHiddenByUser && !changelogBadgeActive) {\n      pmLogger.info('Prompt Manager is hidden by user settings');\n      return { destroy: () => {} };\n    }\n\n    // Monkey patch console.warn to suppress KaTeX quirks mode warning in content script\n    const originalWarn = console.warn;\n    console.warn = function (...args) {\n      const message = args[0];\n      if (\n        typeof message === 'string' &&\n        (message.includes(\"KaTeX doesn't work in quirks mode\") ||\n          message.includes('unicodeTextInMathMode') ||\n          message.includes('LaTeX-incompatible input and strict mode'))\n      ) {\n        return;\n      }\n      return originalWarn.apply(console, args);\n    };\n\n    // Migrate data from localStorage to chrome.storage.local (one-time migration)\n    try {\n      const keysToMigrate = [\n        STORAGE_KEYS.items,\n        STORAGE_KEYS.locked,\n        STORAGE_KEYS.position,\n        STORAGE_KEYS.triggerPos,\n      ];\n\n      const migrationResult = await migrateFromLocalStorage(keysToMigrate, promptStorageService, {\n        deleteAfterMigration: false, // Keep localStorage as backup\n        skipExisting: true, // Skip if already migrated\n      });\n\n      if (migrationResult.migratedKeys.length > 0) {\n        pmLogger.info('Migrated prompt data from localStorage to chrome.storage.local', {\n          migratedKeys: migrationResult.migratedKeys,\n        });\n      }\n\n      if (migrationResult.errors.length > 0) {\n        pmLogger.warn('Some keys failed to migrate', { errors: migrationResult.errors });\n      }\n    } catch (migrationError) {\n      pmLogger.error('Migration failed, continuing with current storage', { migrationError });\n      // Continue even if migration fails - data will still work from current storage\n    }\n\n    // Dynamic imports to prevent side effects on unsupported pages\n    // Dynamic imports to prevent side effects on unsupported pages\n    marked = (await import('marked')).marked;\n    const { default: markedKatex } = await import('marked-katex-extension');\n\n    // markdown config: respect single newlines as <br> and KaTeX inline/display math\n    try {\n      marked.use(\n        markedKatex({\n          throwOnError: false,\n          output: 'html',\n          trust: true, // Trust the rendering environment (content script context)\n          strict: false, // Disable strict mode checks including quirks mode detection\n        }),\n      );\n      marked.setOptions({ breaks: true });\n    } catch {}\n    // Initialize centralized i18n system\n    await initI18n();\n    const i18n = createI18n();\n\n    // Prevent duplicate injection\n    if (document.getElementById(ID.trigger)) return { destroy: () => {} };\n\n    // Trigger button\n    const trigger = createEl('button', 'gv-pm-trigger');\n    trigger.id = ID.trigger;\n    trigger.setAttribute('aria-label', 'Prompt Manager');\n    const img = document.createElement('img');\n    img.width = 24;\n    img.height = 24;\n    img.alt = 'pm';\n    img.src = getRuntimeUrl('icon-32.png');\n    img.addEventListener(\n      'error',\n      () => {\n        // dev fallback\n        const devUrl = getRuntimeUrl('icon-32.png');\n        if (img.src !== devUrl) img.src = devUrl;\n      },\n      { once: true },\n    );\n    trigger.appendChild(img);\n    if (changelogBadgeActive) {\n      trigger.classList.add('gv-pm-trigger-new');\n    }\n    document.body.appendChild(trigger);\n    // Helper: place trigger near a target element (e.g. Gemini FAB touch target)\n    function placeTriggerNextToHost(): void {\n      try {\n        const candidates = Array.from(\n          document.querySelectorAll('span.mat-mdc-button-touch-target'),\n        ) as HTMLElement[];\n        if (!candidates.length) return;\n        const vw = window.innerWidth;\n        const vh = window.innerHeight;\n        const pick = candidates\n          .map((el) => ({ el, r: el.getBoundingClientRect() }))\n          .filter((x) => x.r.width > 0 && x.r.height > 0)\n          // choose the element closest to bottom-right corner\n          .sort((a, b) => a.r.bottom + a.r.right - (b.r.bottom + b.r.right))\n          .reduce((_, x) => x, undefined as { el: HTMLElement; r: DOMRect } | undefined);\n        if (!pick) return;\n        const r = pick.r;\n        const th = trigger.getBoundingClientRect().height || 36;\n        const gap = 10;\n        const right = Math.max(6, Math.round(vw - r.left + gap));\n        const bottom = Math.max(6, Math.round(vh - (r.top + r.height / 2 + th / 2)));\n        trigger.style.right = `${right}px`;\n        trigger.style.bottom = `${bottom}px`;\n      } catch {}\n    }\n\n    // Helper: constrain trigger position to viewport bounds\n    function constrainTriggerPosition(): void {\n      try {\n        const vw = window.innerWidth;\n        const vh = window.innerHeight;\n        const rect = trigger.getBoundingClientRect();\n        const tw = rect.width || 44;\n        const th = rect.height || 44;\n        const minPad = 6;\n\n        // Parse current position\n        const currentRight = parseFloat(trigger.style.right || '18') || 18;\n        const currentBottom = parseFloat(trigger.style.bottom || '18') || 18;\n\n        // Calculate constraints (ensure at least minPad from edges)\n        const maxRight = vw - tw - minPad;\n        const maxBottom = vh - th - minPad;\n\n        // Constrain position\n        const right = Math.max(minPad, Math.min(currentRight, maxRight));\n        const bottom = Math.max(minPad, Math.min(currentBottom, maxBottom));\n\n        trigger.style.right = `${Math.round(right)}px`;\n        trigger.style.bottom = `${Math.round(bottom)}px`;\n      } catch {}\n    }\n\n    // Restore trigger position if saved; otherwise place next to host button\n    try {\n      const pos = await readStorage<TriggerPosition | null>(STORAGE_KEYS.triggerPos, null);\n      if (pos && Number.isFinite(pos.bottom) && Number.isFinite(pos.right)) {\n        trigger.style.bottom = `${Math.max(6, Math.round(pos.bottom))}px`;\n        trigger.style.right = `${Math.max(6, Math.round(pos.right))}px`;\n        // Constrain position after restore to handle window resize/split screen\n        requestAnimationFrame(constrainTriggerPosition);\n      } else {\n        // defer a bit to wait for host DOM\n        placeTriggerNextToHost();\n        requestAnimationFrame(placeTriggerNextToHost);\n        window.setTimeout(placeTriggerNextToHost, 350);\n      }\n    } catch {\n      placeTriggerNextToHost();\n    }\n\n    // Panel root\n    const panel = createEl('div', 'gv-pm-panel gv-hidden');\n    panel.id = ID.panel;\n    panel.setAttribute('role', 'dialog');\n    panel.setAttribute('aria-modal', 'false');\n    document.body.appendChild(panel);\n\n    // Build panel DOM\n    const header = createEl('div', 'gv-pm-header');\n    const dragHandle = createEl('div', 'gv-pm-drag');\n\n    const titleRow = createEl('div', 'gv-pm-title-row');\n    const title = createEl('div', 'gv-pm-title');\n    const titleText = document.createElement('span');\n    titleText.textContent = 'Voyager';\n    title.appendChild(titleText);\n\n    const manifestVersion = chrome?.runtime?.getManifest?.()?.version;\n    const currentVersionNormalized = normalizeVersionString(manifestVersion);\n    const versionBadge = document.createElement('span');\n    versionBadge.className = 'gv-pm-version';\n    versionBadge.style.cursor = 'pointer';\n    versionBadge.title = manifestVersion\n      ? `${i18n.t('extensionVersion')} ${manifestVersion}`\n      : i18n.t('extensionVersion');\n    versionBadge.textContent = manifestVersion ?? '...';\n\n    // Version badge always opens changelog modal\n    versionBadge.addEventListener('click', async (e) => {\n      e.stopPropagation();\n      closePanel();\n      // If badge was active, clear it\n      if (changelogBadgeActive) {\n        changelogBadgeActive = false;\n        trigger.classList.remove('gv-pm-trigger-new');\n        versionBadge.classList.remove('gv-pm-version-outdated');\n        try {\n          await showChangelogModalDirect();\n        } catch {\n          // Ignore\n        }\n        if (pmHiddenByUser) {\n          trigger.style.display = 'none';\n        }\n      } else {\n        try {\n          await openChangelog();\n        } catch {\n          // Ignore\n        }\n      }\n    });\n\n    // Show NEW mark on version badge when changelog badge is active\n    if (changelogBadgeActive) {\n      versionBadge.classList.add('gv-pm-version-outdated');\n    }\n\n    // Theme toggle\n    const themeToggle = createEl('button', 'gv-pm-theme-toggle');\n    themeToggle.setAttribute('type', 'button');\n    let currentPMTheme: PMTheme = detectPageTheme();\n\n    function applyPMTheme(theme: PMTheme) {\n      currentPMTheme = theme;\n      panel.setAttribute('data-gv-theme', theme);\n      themeToggle.classList.toggle('gv-pm-theme-dark', theme === 'dark');\n      themeToggle.title = theme === 'dark' ? i18n.t('pm_theme_light') : i18n.t('pm_theme_dark');\n      themeToggle.setAttribute('aria-label', themeToggle.title);\n    }\n\n    applyPMTheme(currentPMTheme);\n\n    // Load saved theme preference\n    (async () => {\n      try {\n        const result = await browser.storage.sync.get(STORAGE_KEYS.theme);\n        const saved = result[STORAGE_KEYS.theme];\n        if (saved === 'light' || saved === 'dark') {\n          applyPMTheme(saved);\n        }\n      } catch {\n        // Ignore — keep detected theme\n      }\n    })();\n\n    themeToggle.addEventListener('click', async () => {\n      const newTheme: PMTheme = currentPMTheme === 'dark' ? 'light' : 'dark';\n      // Enable smooth color transition on all panel children\n      panel.classList.add('gv-pm-transitioning');\n      applyPMTheme(newTheme);\n      setTimeout(() => panel.classList.remove('gv-pm-transitioning'), 450);\n      try {\n        await browser.storage.sync.set({ [STORAGE_KEYS.theme]: newTheme });\n      } catch {\n        try {\n          await browser.storage.local.set({ [STORAGE_KEYS.theme]: newTheme });\n        } catch {\n          // Ignore\n        }\n      }\n    });\n\n    titleRow.appendChild(title);\n    titleRow.appendChild(themeToggle);\n    titleRow.appendChild(versionBadge);\n\n    // Check for newer version on GitHub (visual indicator only, no link)\n    (async () => {\n      const isSafariBrowser = isSafari();\n      const safariUpdateReminderEnabled = isSafariBrowser && shouldShowSafariUpdateReminder();\n\n      if (isSafariBrowser && !safariUpdateReminderEnabled) return;\n\n      const shouldShowUpdateNotification = shouldShowUpdateReminderForCurrentVersion({\n        currentVersion: currentVersionNormalized,\n        isSafariBrowser,\n        safariReminderEnabled: safariUpdateReminderEnabled,\n      });\n      if (!shouldShowUpdateNotification) return;\n\n      const latest = await getLatestVersionCached();\n      const latestNormalized = normalizeVersionString(latest);\n      const hasUpdate =\n        currentVersionNormalized && latestNormalized\n          ? compareVersions(latestNormalized, currentVersionNormalized) > 0\n          : false;\n\n      if (!hasUpdate || !latestNormalized) return;\n\n      versionBadge.classList.add('gv-pm-version-outdated');\n      versionBadge.title = `${i18n.t('latestVersionLabel')}: v${latestNormalized}`;\n    })();\n\n    const controls = createEl('div', 'gv-pm-controls');\n\n    const langSel = createEl('select', 'gv-pm-lang');\n    for (const lang of APP_LANGUAGES) {\n      const opt = createEl('option');\n      opt.value = lang;\n      opt.textContent = APP_LANGUAGE_LABELS[lang];\n      langSel.appendChild(opt);\n    }\n    // Set initial language value asynchronously\n    i18n\n      .get()\n      .then((lang) => {\n        langSel.value = lang;\n      })\n      .catch(() => {\n        langSel.value = 'en';\n      });\n\n    const lockBtn = createEl('button', 'gv-pm-lock');\n    lockBtn.setAttribute('aria-pressed', 'false');\n    lockBtn.setAttribute('data-icon', '🔓');\n    lockBtn.title = i18n.t('pm_lock');\n\n    const addBtn = createEl('button', 'gv-pm-add');\n    addBtn.textContent = i18n.t('pm_add');\n\n    controls.appendChild(langSel);\n    controls.appendChild(addBtn);\n    controls.appendChild(lockBtn);\n    header.appendChild(dragHandle);\n    header.appendChild(titleRow);\n    header.appendChild(controls);\n\n    const searchWrap = createEl('div', 'gv-pm-search');\n    const searchInput = createEl('input') as HTMLInputElement;\n    searchInput.type = 'search';\n    searchInput.placeholder = i18n.t('pm_search_placeholder');\n    searchWrap.appendChild(searchInput);\n\n    const tagsWrapOuter = createEl('div', 'gv-pm-tags-wrap');\n    const tagsWrap = createEl('div', 'gv-pm-tags');\n    const tagsScrollHint = createEl('div', 'gv-pm-tags-scroll-hint');\n    tagsScrollHint.setAttribute('aria-hidden', 'true');\n    tagsScrollHint.textContent = '▼';\n    tagsWrapOuter.appendChild(tagsWrap);\n    tagsWrapOuter.appendChild(tagsScrollHint);\n\n    const list = createEl('div', 'gv-pm-list');\n\n    const footer = createEl('div', 'gv-pm-footer');\n    const importInput = createEl('input') as HTMLInputElement;\n    importInput.type = 'file';\n    importInput.accept = '.json,application/json';\n    importInput.className = 'gv-pm-import-input';\n\n    // Backup button - primary action\n    const backupBtn = createEl('button', 'gv-pm-backup-btn');\n    backupBtn.textContent = '💾 ' + i18n.t('pm_backup');\n    backupBtn.title = i18n.t('pm_backup_tooltip');\n\n    // Official Website button - primary action (right side)\n    const websiteBtn = createEl('a', 'gv-pm-website-btn');\n    websiteBtn.target = '_blank';\n    websiteBtn.rel = 'noreferrer';\n    // Initial text/href will be set in refreshUITexts\n\n    // Primary actions container\n    const primaryActions = createEl('div', 'gv-pm-footer-actions');\n    primaryActions.appendChild(backupBtn);\n    primaryActions.appendChild(websiteBtn);\n\n    // Secondary actions container\n    const secondaryActions = createEl('div', 'gv-pm-footer-secondary');\n\n    const importBtn = createEl('button', 'gv-pm-import-btn');\n    importBtn.textContent = i18n.t('pm_import');\n\n    const exportBtn = createEl('button', 'gv-pm-export-btn');\n    exportBtn.textContent = i18n.t('pm_export');\n\n    const settingsBtn = createEl('button', 'gv-pm-settings');\n    settingsBtn.textContent = i18n.t('pm_settings');\n    settingsBtn.title = i18n.t('pm_settings_tooltip');\n\n    const gh = document.createElement('a');\n    gh.className = 'gv-pm-gh';\n    gh.href = 'https://github.com/Nagi-ovo/gemini-voyager';\n    gh.target = '_blank';\n    gh.rel = 'noreferrer';\n    gh.title = i18n.t('starProject');\n\n    // Add icon and text\n    const ghIcon = document.createElement('span');\n    ghIcon.className = 'gv-pm-gh-icon';\n    const ghText = document.createElement('span');\n    ghText.className = 'gv-pm-gh-text';\n    ghText.textContent = i18n.t('starProject');\n    gh.appendChild(ghIcon);\n    gh.appendChild(ghText);\n\n    secondaryActions.appendChild(importBtn);\n    secondaryActions.appendChild(exportBtn);\n    secondaryActions.appendChild(settingsBtn);\n    secondaryActions.appendChild(gh);\n\n    footer.appendChild(primaryActions);\n    footer.appendChild(secondaryActions);\n    footer.appendChild(importInput);\n\n    const addForm = elFromHTML(\n      `<form class=\"gv-pm-add-form gv-hidden\">\n        <textarea class=\"gv-pm-input-text\" placeholder=\"${escapeHtml(\n          i18n.t('pm_prompt_placeholder') || 'Prompt text',\n        )}\" rows=\"3\"></textarea>\n        <input class=\"gv-pm-input-tags\" type=\"text\" placeholder=\"${escapeHtml(\n          i18n.t('pm_tags_placeholder') || 'Tags (comma separated)',\n        )}\" />\n        <div class=\"gv-pm-add-actions\">\n          <span class=\"gv-pm-inline-hint\" aria-live=\"polite\"></span>\n          <button type=\"submit\" class=\"gv-pm-save\">${escapeHtml(i18n.t('pm_save') || 'Save')}</button>\n          <button type=\"button\" class=\"gv-pm-cancel\">${escapeHtml(\n            i18n.t('pm_cancel') || 'Cancel',\n          )}</button>\n        </div>\n      </form>`,\n    );\n\n    // Notice as floating toast (not in footer layout)\n    const notice = createEl('div', 'gv-pm-notice');\n\n    panel.appendChild(header);\n    panel.appendChild(searchWrap);\n    panel.appendChild(tagsWrapOuter);\n    panel.appendChild(addForm);\n    panel.appendChild(list);\n    panel.appendChild(footer);\n    panel.appendChild(notice);\n\n    // State\n    let items: PromptItem[] = await readStorage<PromptItem[]>(STORAGE_KEYS.items, []);\n    let open = false;\n    let selectedTags: Set<string> = new Set<string>();\n    let locked = !!(await readStorage<boolean>(STORAGE_KEYS.locked, false));\n    let savedPos = await readStorage<PanelPosition | null>(STORAGE_KEYS.position, null);\n    let dragging = false;\n    let dragOffset = { x: 0, y: 0 };\n    let draggingTrigger = false;\n    let editingId: string | null = null;\n    let expandedItems: Set<string> = new Set<string>(); // Track expanded prompt items\n\n    function setNotice(text: string, kind: 'ok' | 'err' = 'ok') {\n      notice.textContent = text || '';\n      notice.classList.toggle('ok', kind === 'ok');\n      notice.classList.toggle('err', kind === 'err');\n      if (text) {\n        window.setTimeout(() => {\n          if (notice.textContent === text) notice.textContent = '';\n        }, 1800);\n      }\n    }\n\n    function setInlineHint(text: string, kind: 'ok' | 'err' = 'err'): void {\n      const hint = addForm.querySelector('.gv-pm-inline-hint') as HTMLSpanElement | null;\n      if (!hint) return;\n      hint.textContent = text || '';\n      hint.classList.toggle('ok', kind === 'ok');\n      hint.classList.toggle('err', kind === 'err');\n    }\n\n    function syncTagScrollHint(): void {\n      const { isOverflowing, showHint } = getScrollHintState(\n        tagsWrap.scrollTop,\n        tagsWrap.clientHeight,\n        tagsWrap.scrollHeight,\n      );\n      tagsWrapOuter.classList.toggle('gv-pm-tags-scrollable', isOverflowing);\n      tagsWrapOuter.classList.toggle('gv-pm-tags-scroll-end', !showHint);\n    }\n\n    function renderTags(): void {\n      const all = collectAllTags(items);\n      tagsWrap.innerHTML = '';\n      const allBtn = createEl('button', 'gv-pm-tag');\n      allBtn.textContent = i18n.t('pm_all_tags') || 'All';\n      allBtn.classList.toggle('active', selectedTags.size === 0);\n      allBtn.addEventListener('click', () => {\n        selectedTags = new Set();\n        renderTags();\n        renderList();\n      });\n      tagsWrap.appendChild(allBtn);\n      for (const tag of all) {\n        const btn = createEl('button', 'gv-pm-tag');\n        btn.textContent = tag;\n        btn.classList.toggle('active', selectedTags.has(tag));\n        btn.addEventListener('click', () => {\n          if (selectedTags.has(tag)) selectedTags.delete(tag);\n          else selectedTags.add(tag);\n          renderTags();\n          renderList();\n        });\n        tagsWrap.appendChild(btn);\n      }\n      requestAnimationFrame(syncTagScrollHint);\n    }\n\n    function renderList(): void {\n      const q = (searchInput.value || '').trim().toLowerCase();\n      const selectedTagList = Array.from(selectedTags);\n      const filtered = items.filter((it) => {\n        const okTag =\n          selectedTagList.length === 0 || selectedTagList.every((t) => it.tags.includes(t));\n        if (!okTag) return false;\n        if (!q) return true;\n        return it.text.toLowerCase().includes(q) || it.tags.some((t) => t.includes(q));\n      });\n      list.innerHTML = '';\n      if (filtered.length === 0) {\n        const empty = createEl('div', 'gv-pm-empty');\n        empty.textContent = i18n.t('pm_empty') || 'No prompts yet';\n        list.appendChild(empty);\n        return;\n      }\n      const frag = document.createDocumentFragment();\n      for (const it of filtered) {\n        const row = createEl('div', 'gv-pm-item');\n\n        // Create text container with expand/collapse functionality\n        const textContainer = createEl('div', 'gv-pm-item-text-container');\n        const textBtn = createEl('button', 'gv-pm-item-text');\n\n        // Render Markdown + KaTeX preview (sanitized)\n        const md = document.createElement('div');\n        md.className = 'gv-md';\n\n        // Apply collapsed class if not expanded\n        const isExpanded = expandedItems.has(it.id);\n        if (!isExpanded) {\n          md.classList.add('gv-md-collapsed');\n        }\n\n        // Insert element into DOM first, then render to ensure KaTeX can detect document mode correctly\n        textBtn.appendChild(md);\n\n        // Defer rendering to next frame to ensure element is fully attached\n        requestAnimationFrame(() => {\n          try {\n            const out = marked.parse(it.text as string);\n            if (typeof out === 'string') {\n              md.innerHTML = DOMPurify.sanitize(out);\n            } else {\n              out\n                .then((html: string) => {\n                  md.innerHTML = DOMPurify.sanitize(html);\n                })\n                .catch(() => {\n                  md.textContent = it.text;\n                });\n            }\n          } catch {\n            md.textContent = it.text;\n          }\n        });\n\n        textBtn.title = i18n.t('pm_copy') || 'Copy';\n        textBtn.addEventListener('click', async (e) => {\n          // Don't copy when clicking expand button\n          if ((e.target as HTMLElement).closest('.gv-pm-expand-btn')) return;\n          await copyText(it.text);\n          setNotice(i18n.t('pm_copied') || 'Copied', 'ok');\n        });\n\n        // Add expand/collapse button\n        const expandBtn = createEl('button', 'gv-pm-expand-btn');\n        expandBtn.innerHTML = isExpanded ? '▲' : '▼';\n        expandBtn.title = isExpanded\n          ? i18n.t('pm_collapse') || 'Collapse'\n          : i18n.t('pm_expand') || 'Expand';\n        expandBtn.addEventListener('click', (e) => {\n          e.stopPropagation();\n          if (expandedItems.has(it.id)) {\n            expandedItems.delete(it.id);\n          } else {\n            expandedItems.add(it.id);\n          }\n          renderList();\n        });\n\n        textContainer.appendChild(textBtn);\n        textContainer.appendChild(expandBtn);\n\n        // Edit button\n        const editBtn = createEl('button', 'gv-pm-edit');\n        editBtn.setAttribute('aria-label', i18n.t('pm_edit') || 'Edit');\n        //editBtn.textContent = '✏️';\n        editBtn.addEventListener('click', async (e) => {\n          e.stopPropagation();\n          // Start inline edit using the add form fields\n          (addForm.querySelector('.gv-pm-input-text') as HTMLTextAreaElement).value = it.text;\n          (addForm.querySelector('.gv-pm-input-tags') as HTMLInputElement).value = (\n            it.tags || []\n          ).join(', ');\n          addForm.classList.remove('gv-hidden');\n          (addForm.querySelector('.gv-pm-input-text') as HTMLTextAreaElement).focus();\n          editingId = it.id;\n        });\n        const bottom = createEl('div', 'gv-pm-bottom');\n        const meta = createEl('div', 'gv-pm-item-meta');\n        for (const t of it.tags) {\n          const chip = createEl('span', 'gv-pm-chip');\n          chip.textContent = t;\n          chip.addEventListener('click', () => {\n            if (selectedTags.has(t)) selectedTags.delete(t);\n            else selectedTags.add(t);\n            renderTags();\n            renderList();\n          });\n          meta.appendChild(chip);\n        }\n        // Actions container at row bottom-right\n        const actions = createEl('div', 'gv-pm-actions');\n        const del = createEl('button', 'gv-pm-del');\n        del.title = i18n.t('pm_delete') || 'Delete';\n        del.addEventListener('click', async (e) => {\n          e.stopPropagation();\n          // inline confirm popover (floating)\n          if (document.body.querySelector('.gv-pm-confirm')) return; // one at a time\n          const pop = document.createElement('div');\n          pop.className = 'gv-pm-confirm';\n          const msg = document.createElement('span');\n          msg.textContent = i18n.t('pm_delete_confirm') || 'Delete this prompt?';\n          const yes = document.createElement('button');\n          yes.className = 'gv-pm-confirm-yes';\n          yes.textContent = i18n.t('pm_delete') || 'Delete';\n          const no = document.createElement('button');\n          no.textContent = i18n.t('pm_cancel') || 'Cancel';\n          pop.appendChild(msg);\n          pop.appendChild(yes);\n          pop.appendChild(no);\n          document.body.appendChild(pop);\n          // position near button\n          const r = del.getBoundingClientRect();\n          const vw = window.innerWidth;\n          const side: 'left' | 'right' = r.right + 220 > vw ? 'left' : 'right';\n          const top = Math.max(8, r.top + window.scrollY - 6);\n          const left =\n            side === 'right'\n              ? r.right + window.scrollX + 10\n              : r.left + window.scrollX - pop.offsetWidth - 10;\n          pop.style.top = `${Math.round(top)}px`;\n          pop.style.left = `${Math.round(Math.max(8, left))}px`;\n          pop.setAttribute('data-side', side);\n          const cleanup = () => {\n            try {\n              pop.remove();\n            } catch {}\n            window.removeEventListener('keydown', onKey);\n            window.removeEventListener('click', onOutside, true);\n          };\n          const onOutside = (ev: MouseEvent) => {\n            const t = ev.target as HTMLElement;\n            if (!t.closest('.gv-pm-confirm')) cleanup();\n          };\n          const onKey = (ev: KeyboardEvent) => {\n            if (ev.key === 'Escape') cleanup();\n          };\n          window.addEventListener('click', onOutside, true);\n          window.addEventListener('keydown', onKey, { passive: true });\n          no.addEventListener('click', (ev) => {\n            ev.stopPropagation();\n            cleanup();\n          });\n          yes.addEventListener('click', async (ev) => {\n            ev.stopPropagation();\n            items = items.filter((x) => x.id !== it.id);\n            await writeStorage(STORAGE_KEYS.items, items);\n            cleanup();\n            renderTags();\n            renderList();\n            setNotice(i18n.t('pm_deleted') || 'Deleted', 'ok');\n          });\n        });\n\n        // Append text container instead of textBtn\n        row.appendChild(textContainer);\n\n        actions.appendChild(editBtn);\n        actions.appendChild(del);\n        bottom.appendChild(meta);\n        bottom.appendChild(actions);\n        row.appendChild(bottom);\n        frag.appendChild(row);\n      }\n      list.appendChild(frag);\n      // KaTeX rendered during Markdown step, no post-typeset needed\n    }\n\n    function openPanel(): void {\n      open = true;\n      panel.classList.remove('gv-hidden');\n      if (locked && savedPos) {\n        panel.style.left = `${Math.round(savedPos.left)}px`;\n        panel.style.top = `${Math.round(savedPos.top)}px`;\n      } else {\n        // measure after making visible\n        const pos = computeAnchoredPosition(trigger, panel);\n        panel.style.left = `${pos.left}px`;\n        panel.style.top = `${pos.top}px`;\n      }\n      requestAnimationFrame(syncTagScrollHint);\n    }\n\n    function closePanel(): void {\n      open = false;\n      panel.classList.add('gv-hidden');\n    }\n\n    function applyLockUI(): void {\n      lockBtn.classList.toggle('active', locked);\n      lockBtn.setAttribute('aria-pressed', locked ? 'true' : 'false');\n      // When locked, show 🔒; when unlocked, show 🔓.\n      lockBtn.setAttribute('data-icon', locked ? '🔒' : '🔓');\n      lockBtn.title = locked ? i18n.t('pm_unlock') || 'Unlock' : i18n.t('pm_lock') || 'Lock';\n      panel.classList.toggle('gv-locked', locked);\n    }\n\n    function refreshUITexts(): void {\n      // Keep custom icon + label\n      titleText.textContent = 'Voyager';\n      addBtn.textContent = i18n.t('pm_add');\n      searchInput.placeholder = i18n.t('pm_search_placeholder');\n      importBtn.textContent = i18n.t('pm_import');\n      exportBtn.textContent = i18n.t('pm_export');\n      backupBtn.textContent = '💾 ' + i18n.t('pm_backup');\n      backupBtn.title = i18n.t('pm_backup_tooltip');\n\n      // Update website button\n      websiteBtn.textContent = '🌐 ' + i18n.t('officialDocs');\n      i18n.get().then((lang) => {\n        websiteBtn.href =\n          lang === 'zh'\n            ? 'https://voyager.nagi.fun/guide/sponsor.html'\n            : `https://voyager.nagi.fun/${lang}/guide/sponsor.html`;\n      });\n\n      settingsBtn.textContent = i18n.t('pm_settings');\n      settingsBtn.title = i18n.t('pm_settings_tooltip');\n      gh.title = i18n.t('starProject');\n      const ghTextEl = gh.querySelector('.gv-pm-gh-text');\n      if (ghTextEl) ghTextEl.textContent = i18n.t('starProject');\n      (addForm.querySelector('.gv-pm-input-text') as HTMLTextAreaElement).placeholder =\n        i18n.t('pm_prompt_placeholder');\n      (addForm.querySelector('.gv-pm-input-tags') as HTMLInputElement).placeholder =\n        i18n.t('pm_tags_placeholder');\n      (addForm.querySelector('.gv-pm-save') as HTMLButtonElement).textContent = i18n.t('pm_save');\n      (addForm.querySelector('.gv-pm-cancel') as HTMLButtonElement).textContent =\n        i18n.t('pm_cancel');\n      applyLockUI();\n      renderTags();\n      renderList();\n    }\n\n    function onReposition(): void {\n      if (!open) return;\n      if (locked) return;\n      const pos = computeAnchoredPosition(trigger, panel);\n      panel.style.left = `${pos.left}px`;\n      panel.style.top = `${pos.top}px`;\n    }\n\n    function beginDrag(ev: PointerEvent): void {\n      if (locked) return;\n      dragging = true;\n      const rect = panel.getBoundingClientRect();\n      dragOffset = { x: ev.clientX - rect.left, y: ev.clientY - rect.top };\n      try {\n        panel.setPointerCapture?.(ev.pointerId);\n      } catch {}\n    }\n\n    async function endDrag(_ev: PointerEvent): Promise<void> {\n      if (!dragging) return;\n      dragging = false;\n      const rect = panel.getBoundingClientRect();\n      savedPos = { left: rect.left, top: rect.top };\n      await writeStorage(STORAGE_KEYS.position, savedPos);\n    }\n\n    function onDragMove(ev: PointerEvent): void {\n      if (dragging) {\n        const x = ev.clientX - dragOffset.x;\n        const y = ev.clientY - dragOffset.y;\n        panel.style.left = `${Math.round(x)}px`;\n        panel.style.top = `${Math.round(y)}px`;\n      } else if (draggingTrigger) {\n        const vw = window.innerWidth;\n        const vh = window.innerHeight;\n        const rect = trigger.getBoundingClientRect();\n        const w = rect.width || 36;\n        const h = rect.height || 36;\n        const right = Math.max(6, Math.min(vw - 6 - w, vw - ev.clientX - w / 2));\n        const bottom = Math.max(6, Math.min(vh - 6 - h, vh - ev.clientY - h / 2));\n        trigger.style.right = `${Math.round(right)}px`;\n        trigger.style.bottom = `${Math.round(bottom)}px`;\n      }\n    }\n\n    // Events\n    trigger.addEventListener('click', async () => {\n      // Suppress click after a drag gesture\n      if (triggerWasDragged) {\n        triggerWasDragged = false;\n        return;\n      }\n\n      // When changelog badge is active, open changelog modal instead of prompt manager\n      if (changelogBadgeActive) {\n        changelogBadgeActive = false;\n        trigger.classList.remove('gv-pm-trigger-new');\n        try {\n          await showChangelogModalDirect();\n        } catch {\n          // Ignore errors\n        }\n        // If PM was hidden by user, hide trigger again after changelog closes\n        if (pmHiddenByUser) {\n          trigger.style.display = 'none';\n        }\n        return;\n      }\n\n      if (open) closePanel();\n      else {\n        openPanel();\n        renderTags();\n        renderList();\n      }\n    });\n\n    // Handle window resize - constrain trigger and reposition panel\n    // Handle window resize - constrain trigger and reposition panel\n    const onWindowResize = () => {\n      constrainTriggerPosition();\n      onReposition();\n      syncTagScrollHint();\n    };\n    window.addEventListener('resize', onWindowResize, { passive: true });\n\n    window.addEventListener('scroll', onReposition, { passive: true });\n    tagsWrap.addEventListener('scroll', syncTagScrollHint, { passive: true });\n\n    // Close when clicking outside of the manager (panel/trigger/confirm are exceptions)\n    const onWindowPointerDown = (ev: PointerEvent) => {\n      if (!open) return;\n      const target = ev.target as HTMLElement | null;\n      if (!target) return;\n      if (target.closest(`#${ID.panel}`)) return;\n      if (target.closest(`#${ID.trigger}`)) return;\n      if (target.closest('.gv-pm-confirm')) return;\n      closePanel();\n    };\n    window.addEventListener('pointerdown', onWindowPointerDown, { capture: true });\n\n    // Close on Escape\n    const onWindowKeyDown = (ev: KeyboardEvent) => {\n      if (!open) return;\n      if (ev.key === 'Escape') closePanel();\n    };\n    window.addEventListener('keydown', onWindowKeyDown, { passive: true });\n\n    lockBtn.addEventListener('click', async (ev) => {\n      ev.preventDefault();\n      ev.stopPropagation();\n      locked = !locked;\n      await writeStorage(STORAGE_KEYS.locked, locked);\n      applyLockUI();\n      try {\n        (ev.currentTarget as HTMLButtonElement)?.blur?.();\n      } catch {}\n      if (locked) {\n        const rect = panel.getBoundingClientRect();\n        savedPos = { left: rect.left, top: rect.top };\n        await writeStorage(STORAGE_KEYS.position, savedPos);\n      } else {\n        onReposition();\n      }\n    });\n    panel.addEventListener('pointerdown', (ev: PointerEvent) => {\n      const target = ev.target as HTMLElement;\n      if (target.closest('.gv-pm-drag')) beginDrag(ev);\n    });\n    window.addEventListener('pointermove', onDragMove, { passive: true });\n    window.addEventListener('pointerup', endDrag, { passive: true });\n\n    // Trigger drag (always draggable)\n    let triggerDragStartPos: { x: number; y: number } | null = null;\n    let triggerWasDragged = false;\n    trigger.addEventListener('pointerdown', (ev: PointerEvent) => {\n      if (typeof ev.button === 'number' && ev.button !== 0) return;\n      draggingTrigger = true;\n      triggerWasDragged = false;\n      triggerDragStartPos = { x: ev.clientX, y: ev.clientY };\n      try {\n        trigger.setPointerCapture?.(ev.pointerId);\n      } catch {}\n    });\n\n    const onTriggerDragEnd = async (ev: PointerEvent) => {\n      if (draggingTrigger) {\n        draggingTrigger = false;\n        // Only save if the trigger actually moved (threshold: 5px)\n        if (triggerDragStartPos) {\n          const dx = Math.abs(ev.clientX - triggerDragStartPos.x);\n          const dy = Math.abs(ev.clientY - triggerDragStartPos.y);\n          if (dx > 5 || dy > 5) {\n            triggerWasDragged = true;\n            const r = parseFloat((trigger.style.right || '').replace('px', '')) || 18;\n            const b = parseFloat((trigger.style.bottom || '').replace('px', '')) || 18;\n            await writeStorage(STORAGE_KEYS.triggerPos, { right: r, bottom: b });\n          }\n        }\n        triggerDragStartPos = null;\n      }\n    };\n    window.addEventListener('pointerup', onTriggerDragEnd, { passive: true });\n\n    langSel.addEventListener('change', async () => {\n      const next = langSel.value;\n      if (!isAppLanguage(next)) return;\n      await i18n.set(next);\n      refreshUITexts();\n    });\n\n    // Listen to external language changes (popup/options)\n    // Note: The centralized i18n system already handles storage changes,\n    // we just need to update the UI when language changes\n    const storageChangeHandler = (\n      changes: Record<string, browser.Storage.StorageChange>,\n      area: string,\n    ) => {\n      // Handle language changes from sync storage\n      const nextRaw = changes[StorageKeys.LANGUAGE]?.newValue;\n      if (area === 'sync' && typeof nextRaw === 'string') {\n        try {\n          langSel.value = normalizeLanguage(nextRaw);\n        } catch {}\n        refreshUITexts();\n      }\n      // Handle hide prompt manager setting changes\n      if (area === 'sync' && changes?.gvHidePromptManager) {\n        const shouldHide = changes.gvHidePromptManager.newValue === true;\n        pmHiddenByUser = shouldHide;\n        pmLogger.info('Hide prompt manager setting changed', { shouldHide });\n        if (shouldHide && !changelogBadgeActive) {\n          // Hide trigger and panel\n          trigger.style.display = 'none';\n          panel.classList.add('gv-hidden');\n          open = false;\n        } else {\n          // Show trigger\n          trigger.style.display = '';\n        }\n      }\n      // Handle changelog notify mode changes (dynamic badge update)\n      if (\n        area === 'local' &&\n        (changes[StorageKeys.CHANGELOG_NOTIFY_MODE] ||\n          changes[StorageKeys.CHANGELOG_DISMISSED_VERSION])\n      ) {\n        void (async () => {\n          try {\n            const [modeRes, unread] = await Promise.all([\n              browser.storage.local.get(StorageKeys.CHANGELOG_NOTIFY_MODE),\n              hasUnreadChangelog(),\n            ]);\n            const mode = modeRes?.[StorageKeys.CHANGELOG_NOTIFY_MODE];\n            const shouldBadge = mode === 'badge' && unread;\n\n            if (shouldBadge && !changelogBadgeActive) {\n              changelogBadgeActive = true;\n              trigger.classList.add('gv-pm-trigger-new');\n              trigger.style.display = '';\n              versionBadge.classList.toggle('gv-pm-version-outdated', changelogBadgeActive);\n            } else if (!shouldBadge && changelogBadgeActive) {\n              changelogBadgeActive = false;\n              trigger.classList.remove('gv-pm-trigger-new');\n              versionBadge.classList.toggle('gv-pm-version-outdated', changelogBadgeActive);\n              if (pmHiddenByUser) {\n                trigger.style.display = 'none';\n              }\n            }\n          } catch {\n            // Ignore errors\n          }\n        })();\n      }\n      // Handle prompt data changes from cloud sync (local storage)\n      if (area === 'local' && changes?.gvPromptItems) {\n        pmLogger.info('Prompt data changed in chrome.storage.local, reloading...');\n        const newItems = changes.gvPromptItems.newValue;\n        if (Array.isArray(newItems)) {\n          items = newItems;\n          renderTags();\n          renderList();\n          setNotice(i18n.t('syncSuccess') || 'Synced', 'ok');\n        }\n      }\n    };\n\n    try {\n      browser.storage.onChanged.addListener(storageChangeHandler);\n    } catch {}\n\n    addBtn.addEventListener('click', (ev) => {\n      ev.preventDefault();\n      ev.stopPropagation();\n      editingId = null;\n      addForm.classList.remove('gv-hidden');\n      (addForm.querySelector('.gv-pm-input-text') as HTMLTextAreaElement)?.focus();\n    });\n\n    settingsBtn.addEventListener('click', async (ev) => {\n      ev.preventDefault();\n      ev.stopPropagation();\n      try {\n        // Check if extension context is still valid\n        if (!browser.runtime?.id) {\n          // Extension context invalidated, show fallback message\n          setNotice(\n            i18n.t('pm_settings_fallback') || '请点击浏览器工具栏中的扩展图标打开设置',\n            'err',\n          );\n          return;\n        }\n\n        // Send message to background to open popup\n        const response = (await browser.runtime.sendMessage({ type: 'gv.openPopup' })) as {\n          ok?: boolean;\n        };\n        if (!response?.ok) {\n          // If programmatic opening failed, show a helpful message\n          setNotice(\n            i18n.t('pm_settings_fallback') || '请点击浏览器工具栏中的扩展图标打开设置',\n            'err',\n          );\n        }\n      } catch (err) {\n        // Silently handle extension context errors\n        if (isExtensionContextInvalidatedError(err)) {\n          setNotice(\n            i18n.t('pm_settings_fallback') || '请点击浏览器工具栏中的扩展图标打开设置',\n            'err',\n          );\n          return;\n        }\n        console.warn('[PromptManager] Failed to open settings:', err);\n        setNotice(\n          i18n.t('pm_settings_fallback') || '请点击浏览器工具栏中的扩展图标打开设置',\n          'err',\n        );\n      }\n    });\n\n    (addForm.querySelector('.gv-pm-cancel') as HTMLButtonElement).addEventListener(\n      'click',\n      (ev) => {\n        ev.preventDefault();\n        ev.stopPropagation();\n        editingId = null;\n        addForm.classList.add('gv-hidden');\n      },\n    );\n    addForm.addEventListener('submit', async (e) => {\n      e.preventDefault();\n      const text = (addForm.querySelector('.gv-pm-input-text') as HTMLTextAreaElement).value;\n      const tagsRaw = (addForm.querySelector('.gv-pm-input-tags') as HTMLInputElement).value;\n      const tags = dedupeTags((tagsRaw || '').split(',').map((s) => s.trim()));\n      if (!text.trim()) return;\n      if (editingId) {\n        const dup = items.some(\n          (x) => x.id !== editingId && x.text.trim().toLowerCase() === text.trim().toLowerCase(),\n        );\n        if (dup) {\n          setInlineHint(i18n.t('pm_duplicate') || 'Duplicate prompt', 'err');\n          return;\n        }\n        const target = items.find((x) => x.id === editingId);\n        if (target) {\n          target.text = text;\n          target.tags = tags;\n          target.updatedAt = Date.now();\n          await writeStorage(STORAGE_KEYS.items, items);\n          setNotice(i18n.t('pm_saved') || 'Saved', 'ok');\n        }\n        editingId = null;\n      } else {\n        // prevent duplicates (case-insensitive, same text)\n        const exists = items.some((x) => x.text.trim().toLowerCase() === text.trim().toLowerCase());\n        if (exists) {\n          setInlineHint(i18n.t('pm_duplicate') || 'Duplicate prompt', 'err');\n          return;\n        }\n        const it: PromptItem = { id: uid(), text, tags, createdAt: Date.now() };\n        items = [it, ...items];\n        await writeStorage(STORAGE_KEYS.items, items);\n      }\n      (addForm.querySelector('.gv-pm-input-text') as HTMLTextAreaElement).value = '';\n      (addForm.querySelector('.gv-pm-input-tags') as HTMLInputElement).value = '';\n      setInlineHint('');\n      addForm.classList.add('gv-hidden');\n      renderTags();\n      renderList();\n    });\n\n    searchInput.addEventListener('input', () => renderList());\n\n    exportBtn.addEventListener('click', async () => {\n      try {\n        const data = await readStorage<PromptItem[]>(STORAGE_KEYS.items, []);\n        const payload = {\n          format: 'gemini-voyager.prompts.v1',\n          exportedAt: new Date().toISOString(),\n          items: data,\n        };\n        const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });\n        const url = URL.createObjectURL(blob);\n        const a = document.createElement('a');\n        a.href = url;\n        a.download = `prompts-${Date.now()}.json`;\n        document.body.appendChild(a);\n        a.click();\n        a.remove();\n        URL.revokeObjectURL(url);\n      } catch {\n        setNotice('Export failed', 'err');\n      }\n    });\n\n    // Backup button handler - creates timestamp folder with all data\n    backupBtn.addEventListener('click', async () => {\n      try {\n        // Read prompts\n        const prompts = await readStorage<PromptItem[]>(STORAGE_KEYS.items, []);\n        const promptPayload = {\n          format: 'gemini-voyager.prompts.v1',\n          exportedAt: new Date().toISOString(),\n          items: prompts,\n        };\n\n        // Read folders (Safari-compatible: uses storage adapter)\n        const folderStorage = createFolderStorageAdapter();\n        const folderData = (await folderStorage.loadData('gvFolderData')) || {\n          folders: [],\n          folderContents: {},\n        };\n\n        // Create folder export payload with correct format\n        const folderPayload = {\n          format: 'gemini-voyager.folders.v1',\n          exportedAt: new Date().toISOString(),\n          version: '0.9.3',\n          data: {\n            folders: folderData.folders || [],\n            folderContents: folderData.folderContents || {},\n          },\n        };\n\n        // Count conversations\n        const conversationCount = Object.values(folderData.folderContents || {}).reduce(\n          (sum: number, convs: unknown) => sum + (Array.isArray(convs) ? convs.length : 0),\n          0,\n        );\n\n        // Create metadata\n        const metadata = {\n          version: '0.9.3',\n          timestamp: new Date().toISOString(),\n          includesPrompts: true,\n          includesFolders: true,\n          promptCount: prompts.length,\n          folderCount: folderData.folders?.length || 0,\n          conversationCount,\n        };\n\n        // Generate timestamp for folder/file name\n        const pad = (n: number) => String(n).padStart(2, '0');\n        const d = new Date();\n        const timestamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;\n        const folderName = `backup-${timestamp}`;\n\n        // Check File System Access API support\n        if ('showDirectoryPicker' in window) {\n          // Modern browsers (Chrome, Edge) - use File System Access API\n          const dirHandle = await (\n            window as Window & {\n              showDirectoryPicker: (opts?: { mode?: string }) => Promise<FileSystemDirectoryHandle>;\n            }\n          ).showDirectoryPicker({ mode: 'readwrite' });\n          if (!dirHandle) {\n            setNotice(i18n.t('pm_backup_cancelled') || 'Backup cancelled', 'err');\n            return;\n          }\n\n          const backupDir = await dirHandle.getDirectoryHandle(folderName, { create: true });\n\n          // Write files\n          const promptsFile = await backupDir.getFileHandle('prompts.json', { create: true });\n          const promptsWritable = await promptsFile.createWritable();\n          await promptsWritable.write(JSON.stringify(promptPayload, null, 2));\n          await promptsWritable.close();\n\n          const foldersFile = await backupDir.getFileHandle('folders.json', { create: true });\n          const foldersWritable = await foldersFile.createWritable();\n          await foldersWritable.write(JSON.stringify(folderPayload, null, 2));\n          await foldersWritable.close();\n\n          const metaFile = await backupDir.getFileHandle('metadata.json', { create: true });\n          const metaWritable = await metaFile.createWritable();\n          await metaWritable.write(JSON.stringify(metadata, null, 2));\n          await metaWritable.close();\n\n          setNotice(\n            `✓ Backed up ${prompts.length} prompts, ${folderData.folders?.length || 0} folders`,\n            'ok',\n          );\n        } else {\n          // Fallback for Firefox, Safari - download as ZIP file\n          const zip = new JSZip();\n\n          // Add files to ZIP\n          zip.file('prompts.json', JSON.stringify(promptPayload, null, 2));\n          zip.file('folders.json', JSON.stringify(folderPayload, null, 2));\n          zip.file('metadata.json', JSON.stringify(metadata, null, 2));\n\n          // Generate ZIP file\n          const zipBlob = await zip.generateAsync({ type: 'blob' });\n\n          // Download ZIP file\n          const url = URL.createObjectURL(zipBlob);\n          const a = document.createElement('a');\n          a.href = url;\n          a.download = `${folderName}.zip`;\n          document.body.appendChild(a);\n          a.click();\n          a.remove();\n          URL.revokeObjectURL(url);\n\n          setNotice(\n            `✓ Downloaded ${folderName}.zip (${prompts.length} prompts, ${folderData.folders?.length || 0} folders)`,\n            'ok',\n          );\n        }\n      } catch (err) {\n        if (err instanceof Error && err.name === 'AbortError') {\n          setNotice(i18n.t('pm_backup_cancelled') || 'Backup cancelled', 'err');\n        } else {\n          console.error('[PromptManager] Backup failed:', err);\n          setNotice(i18n.t('pm_backup_error') || '✗ Backup failed', 'err');\n        }\n      }\n    });\n\n    importBtn.addEventListener('click', () => importInput.click());\n    importInput.addEventListener('change', async () => {\n      const file = importInput.files && importInput.files[0];\n      if (!file) return;\n      try {\n        const text = await file.text();\n        const json = safeParseJSON<Record<string, unknown> | null>(text, null);\n        if (!json || (json.format !== 'gemini-voyager.prompts.v1' && !Array.isArray(json.items))) {\n          setNotice(i18n.t('pm_import_invalid') || 'Invalid file format', 'err');\n          return;\n        }\n        const arr: PromptItem[] = Array.isArray(json)\n          ? json\n          : Array.isArray(json.items)\n            ? json.items\n            : [];\n        const valid: PromptItem[] = [];\n        const seen = new Set<string>();\n        for (const it of arr) {\n          const itObj = it as Record<string, unknown>;\n          const text = String((itObj && itObj.text) || '').trim();\n          if (!text) continue;\n          const tags = Array.isArray(itObj.tags) ? itObj.tags.map((t: unknown) => String(t)) : [];\n          const key = `${text.toLowerCase()}|${tags.sort().join(',')}`;\n          if (seen.has(key)) continue;\n          seen.add(key);\n          valid.push({ id: uid(), text, tags: dedupeTags(tags), createdAt: Date.now() });\n        }\n        if (valid.length) {\n          // Merge by text equality (case-insensitive)\n          const map = new Map<string, PromptItem>();\n          for (const it of items) map.set(it.text.toLowerCase(), it);\n          for (const it of valid) {\n            const k = it.text.toLowerCase();\n            if (map.has(k)) {\n              const prev = map.get(k)!;\n              const mergedTags = dedupeTags([...(prev.tags || []), ...(it.tags || [])]);\n              prev.tags = mergedTags;\n              prev.updatedAt = Date.now();\n              map.set(k, prev);\n            } else {\n              map.set(k, it);\n            }\n          }\n          items = Array.from(map.values()).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));\n          await writeStorage(STORAGE_KEYS.items, items);\n          setNotice(\n            (i18n.t('pm_import_success') || 'Imported').replace('{count}', String(valid.length)),\n            'ok',\n          );\n          renderTags();\n          renderList();\n        } else {\n          setNotice(i18n.t('pm_import_invalid') || 'Invalid file format', 'err');\n        }\n      } catch {\n        setNotice(i18n.t('pm_import_invalid') || 'Invalid file format', 'err');\n      } finally {\n        importInput.value = '';\n      }\n    });\n\n    // Initialize\n    refreshUITexts();\n\n    // Return destroy function\n    return {\n      destroy: () => {\n        try {\n          window.removeEventListener('resize', onWindowResize);\n          window.removeEventListener('scroll', onReposition);\n          window.removeEventListener('pointerdown', onWindowPointerDown, { capture: true });\n          window.removeEventListener('keydown', onWindowKeyDown);\n          window.removeEventListener('pointermove', onDragMove);\n          window.removeEventListener('pointerup', endDrag);\n          window.removeEventListener('pointerup', onTriggerDragEnd);\n          tagsWrap.removeEventListener('scroll', syncTagScrollHint);\n\n          chrome.storage?.onChanged?.removeListener(storageChangeHandler);\n\n          trigger.remove();\n          panel.remove();\n          document.querySelectorAll('.gv-pm-confirm').forEach((el) => el.remove());\n        } catch (e) {\n          console.error('[PromptManager] Destroy error:', e);\n        }\n      },\n    };\n  } catch (err) {\n    try {\n      if (isExtensionContextInvalidatedError(err)) {\n        return { destroy: () => {} };\n      }\n      console.error('Prompt Manager init failed', err);\n    } catch {}\n    return { destroy: () => {} };\n  }\n}\n\nexport default { startPromptManager };\n"
  },
  {
    "path": "src/pages/content/prompt/scrollHint.ts",
    "content": "export type ScrollHintState = {\n  isOverflowing: boolean;\n  showHint: boolean;\n};\n\nconst DEFAULT_TOLERANCE_PX = 4;\n\nexport function getScrollHintState(\n  scrollTop: number,\n  clientHeight: number,\n  scrollHeight: number,\n  tolerancePx = DEFAULT_TOLERANCE_PX,\n): ScrollHintState {\n  const safeTolerance = Number.isFinite(tolerancePx)\n    ? Math.max(0, tolerancePx)\n    : DEFAULT_TOLERANCE_PX;\n  const safeClientHeight = Number.isFinite(clientHeight) ? Math.max(0, clientHeight) : 0;\n  const safeScrollHeight = Number.isFinite(scrollHeight) ? Math.max(0, scrollHeight) : 0;\n  const safeScrollTop = Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0;\n\n  const maxScrollTop = Math.max(0, safeScrollHeight - safeClientHeight);\n  const isOverflowing = maxScrollTop > safeTolerance;\n  const showHint = isOverflowing && safeScrollTop < maxScrollTop - safeTolerance;\n\n  return { isOverflowing, showHint };\n}\n"
  },
  {
    "path": "src/pages/content/quoteReply/__tests__/quoteReply.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { getBrowserName } from '@/core/utils/browser';\n\nimport { expandInputCollapseIfNeeded } from '../../inputCollapse/index';\nimport { startQuoteReply } from '../index';\n\nvi.mock('../../inputCollapse/index', () => ({\n  expandInputCollapseIfNeeded: vi.fn(),\n}));\n\nvi.mock('@/core/utils/browser', () => ({\n  getBrowserName: vi.fn(() => 'Chrome/Chromium'),\n}));\n\nfunction selectSourceText(start = 0, end = 5) {\n  const selection = window.getSelection();\n  const textNode = document.getElementById('source')?.firstChild;\n  if (!(textNode instanceof Text)) {\n    throw new Error('Expected a Text node for quote selection.');\n  }\n\n  const range = document.createRange();\n  range.setStart(textNode, start);\n  range.setEnd(textNode, end);\n  selection?.removeAllRanges();\n  selection?.addRange(range);\n}\n\nfunction triggerQuoteReply() {\n  selectSourceText();\n  document.dispatchEvent(new MouseEvent('mouseup'));\n  vi.runAllTimers();\n\n  const quoteButton = document.querySelector<HTMLElement>('.gv-quote-btn');\n  if (!(quoteButton instanceof HTMLElement)) {\n    throw new Error('Expected quote button to be present.');\n  }\n\n  quoteButton.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));\n  vi.runAllTimers();\n}\n\ndescribe('quote reply', () => {\n  beforeEach(() => {\n    vi.useFakeTimers();\n    vi.mocked(getBrowserName).mockReturnValue('Chrome/Chromium');\n\n    document.body.innerHTML = `\n      <main>\n        <p id=\"source\">Hello world</p>\n      </main>\n      <div id=\"input-container\">\n        <rich-textarea>\n          <div id=\"input\" contenteditable=\"true\"></div>\n        </rich-textarea>\n      </div>\n    `;\n\n    const input = document.getElementById('input') as HTMLElement;\n    input.getBoundingClientRect = () =>\n      ({\n        height: 20,\n        width: 100,\n        top: 0,\n        left: 0,\n        bottom: 20,\n        right: 100,\n        x: 0,\n        y: 0,\n        toJSON: () => {},\n      }) as DOMRect;\n    input.focus = vi.fn();\n    input.scrollIntoView = vi.fn();\n\n    Object.defineProperty(Range.prototype, 'getBoundingClientRect', {\n      value: vi.fn(\n        () =>\n          ({\n            height: 10,\n            width: 10,\n            top: 0,\n            left: 0,\n            bottom: 10,\n            right: 10,\n            x: 0,\n            y: 0,\n            toJSON: () => {},\n          }) as DOMRect,\n      ),\n      configurable: true,\n    });\n\n    Object.defineProperty(document, 'execCommand', {\n      value: vi.fn((command: string, _showUI?: boolean, value?: string) => {\n        if (command !== 'insertText' || typeof value !== 'string') {\n          return false;\n        }\n        const input = document.getElementById('input');\n        if (!(input instanceof HTMLElement)) {\n          return false;\n        }\n        input.textContent = (input.textContent ?? '') + value;\n        return true;\n      }),\n      configurable: true,\n    });\n  });\n\n  afterEach(() => {\n    vi.runOnlyPendingTimers();\n    vi.useRealTimers();\n    vi.restoreAllMocks();\n    document.body.innerHTML = '';\n  });\n\n  it('expands input collapse when using quote reply', () => {\n    const cleanup = startQuoteReply();\n    triggerQuoteReply();\n\n    expect(expandInputCollapseIfNeeded).toHaveBeenCalledTimes(1);\n\n    cleanup();\n  });\n\n  it('treats ql-blank editor as empty even if placeholder text exists', () => {\n    const cleanup = startQuoteReply();\n    const input = document.getElementById('input');\n    if (!(input instanceof HTMLElement)) {\n      throw new Error('Expected quote input element.');\n    }\n\n    input.classList.add('ql-blank');\n    input.setAttribute('data-placeholder', 'Message Gemini');\n    input.textContent = 'Message Gemini';\n\n    triggerQuoteReply();\n\n    expect(input.textContent).toBe('Message Gemini> Hello\\n');\n\n    cleanup();\n  });\n\n  it('treats stale ql-blank with real user text as non-empty', () => {\n    const cleanup = startQuoteReply();\n    const input = document.getElementById('input');\n    if (!(input instanceof HTMLElement)) {\n      throw new Error('Expected quote input element.');\n    }\n\n    input.classList.add('ql-blank');\n    input.setAttribute('data-placeholder', 'Message Gemini');\n    input.textContent = '已有内容';\n\n    triggerQuoteReply();\n\n    expect(input.textContent).toBe('已有内容\\n\\n> Hello\\n');\n\n    cleanup();\n  });\n\n  it('adds a blank line when input has visible text', () => {\n    const cleanup = startQuoteReply();\n    const input = document.getElementById('input');\n    if (!(input instanceof HTMLElement)) {\n      throw new Error('Expected quote input element.');\n    }\n\n    input.textContent = 'Existing';\n\n    triggerQuoteReply();\n\n    expect(input.textContent).toBe('Existing\\n\\n> Hello\\n');\n\n    cleanup();\n  });\n\n  it('uses single-line separator for Firefox contenteditable', () => {\n    const cleanup = startQuoteReply();\n    const input = document.getElementById('input');\n    if (!(input instanceof HTMLElement)) {\n      throw new Error('Expected quote input element.');\n    }\n\n    vi.mocked(getBrowserName).mockReturnValue('Firefox');\n\n    const execCommandMock = vi.spyOn(document, 'execCommand');\n    input.textContent = 'Existing';\n\n    triggerQuoteReply();\n\n    expect(execCommandMock).toHaveBeenCalledWith('insertText', false, '\\n');\n    expect(execCommandMock).not.toHaveBeenCalledWith('insertText', false, '\\n\\n');\n    expect(input.textContent).toBe('Existing\\n> Hello\\n');\n\n    cleanup();\n  });\n\n  it('prepends two newlines for non-empty textarea input', () => {\n    const cleanup = startQuoteReply();\n    const inputContainer = document.getElementById('input-container');\n    if (!(inputContainer instanceof HTMLElement)) {\n      throw new Error('Expected input container element.');\n    }\n\n    inputContainer.innerHTML = '<textarea id=\"input\" placeholder=\"Ask Gemini\"></textarea>';\n    const textarea = document.getElementById('input');\n    if (!(textarea instanceof HTMLTextAreaElement)) {\n      throw new Error('Expected textarea input element.');\n    }\n\n    textarea.getBoundingClientRect = () =>\n      ({\n        height: 20,\n        width: 100,\n        top: 0,\n        left: 0,\n        bottom: 20,\n        right: 100,\n        x: 0,\n        y: 0,\n        toJSON: () => {},\n      }) as DOMRect;\n    textarea.focus = vi.fn();\n    textarea.scrollIntoView = vi.fn();\n    textarea.value = 'Existing';\n\n    triggerQuoteReply();\n\n    expect(textarea.value).toBe('Existing\\n\\n> Hello\\n');\n\n    cleanup();\n  });\n\n  it('falls back to Range insertion when execCommand is unavailable', () => {\n    const cleanup = startQuoteReply();\n    const input = document.getElementById('input');\n    if (!(input instanceof HTMLElement)) {\n      throw new Error('Expected quote input element.');\n    }\n\n    const execCommandMock = vi.spyOn(document, 'execCommand').mockReturnValue(false);\n    input.textContent = 'Existing';\n\n    triggerQuoteReply();\n\n    expect(execCommandMock).toHaveBeenCalledWith('insertText', false, '\\n\\n> Hello\\n');\n    expect(input.textContent).toBe('Existing\\n\\n> Hello\\n');\n\n    cleanup();\n  });\n\n  it('avoids full fallback separator when separator insertion partially mutates content', () => {\n    const cleanup = startQuoteReply();\n    const input = document.getElementById('input');\n    if (!(input instanceof HTMLElement)) {\n      throw new Error('Expected quote input element.');\n    }\n\n    input.textContent = 'Existing';\n    const execCommandMock = vi\n      .spyOn(document, 'execCommand')\n      .mockImplementation((command: string, _showUI?: boolean, value?: string) => {\n        if (command === 'insertText' && typeof value === 'string') {\n          const stripped = value.startsWith('\\n') ? value.slice(1) : value;\n          input.textContent = `${input.textContent ?? ''}${stripped}`;\n          return true;\n        }\n        return false;\n      });\n\n    triggerQuoteReply();\n\n    expect(execCommandMock.mock.calls).toEqual(\n      expect.arrayContaining([['insertText', false, '\\n\\n']]),\n    );\n    expect(execCommandMock).not.toHaveBeenCalledWith('insertText', false, '\\n\\n> Hello\\n');\n    expect(input.textContent).toBe('Existing\\n\\n> Hello\\n');\n\n    cleanup();\n  });\n\n  it('keeps leading newline fallback when separator command does not change content', () => {\n    const cleanup = startQuoteReply();\n    const input = document.getElementById('input');\n    if (!(input instanceof HTMLElement)) {\n      throw new Error('Expected quote input element.');\n    }\n\n    input.textContent = 'Existing';\n    const execCommandMock = vi\n      .spyOn(document, 'execCommand')\n      .mockImplementation((command: string, _showUI?: boolean, value?: string) => {\n        if (command === 'insertText' && typeof value === 'string') {\n          if (value === '\\n\\n') {\n            return true; // Pretend success but do not mutate content\n          }\n          input.textContent = `${input.textContent ?? ''}${value}`;\n          return true;\n        }\n        return false;\n      });\n\n    triggerQuoteReply();\n\n    expect(execCommandMock.mock.calls).toEqual(\n      expect.arrayContaining([\n        ['insertText', false, '\\n\\n'],\n        ['insertText', false, '\\n\\n> Hello\\n'],\n      ]),\n    );\n    expect(input.textContent).toBe('Existing\\n\\n> Hello\\n');\n\n    cleanup();\n  });\n\n  it('treats separator as inserted when only innerText reflects line breaks', () => {\n    const cleanup = startQuoteReply();\n    const input = document.getElementById('input');\n    if (!(input instanceof HTMLElement)) {\n      throw new Error('Expected quote input element.');\n    }\n\n    const state = {\n      visible: 'Existing',\n      raw: 'Existing',\n    };\n\n    Object.defineProperty(input, 'innerText', {\n      configurable: true,\n      get: () => state.visible,\n      set: (value: string) => {\n        state.visible = value;\n      },\n    });\n\n    Object.defineProperty(input, 'textContent', {\n      configurable: true,\n      get: () => state.raw,\n      set: (value: string | null) => {\n        state.raw = value ?? '';\n      },\n    });\n\n    const execCommandMock = vi\n      .spyOn(document, 'execCommand')\n      .mockImplementation((command: string, _showUI?: boolean, value?: string) => {\n        if (command !== 'insertText' || typeof value !== 'string') {\n          return false;\n        }\n\n        if (value === '\\n\\n') {\n          // Simulate Quill: visual line breaks changed, raw textContent unchanged.\n          state.visible = `${state.visible}\\n\\n`;\n          return true;\n        }\n\n        state.visible = `${state.visible}${value}`;\n        state.raw = `${state.raw}${value.replace(/\\n/g, '')}`;\n        return true;\n      });\n\n    triggerQuoteReply();\n\n    expect(execCommandMock.mock.calls).toEqual(\n      expect.arrayContaining([\n        ['insertText', false, '\\n\\n'],\n        ['insertText', false, '> Hello\\n'],\n      ]),\n    );\n    expect(execCommandMock).not.toHaveBeenCalledWith('insertText', false, '\\n\\n> Hello\\n');\n    expect(state.visible).toBe('Existing\\n\\n> Hello\\n');\n\n    cleanup();\n  });\n\n  it('preserves inline math LaTeX syntax in quoted text', () => {\n    const cleanup = startQuoteReply();\n    const source = document.getElementById('source');\n    if (!source) throw new Error('Expected source element.');\n\n    source.innerHTML =\n      'Variable <span class=\"math-inline\"><span data-math=\"U \\\\in [0, 1)\">U∈[0,1)</span></span> is uniform';\n\n    const selection = window.getSelection();\n    const range = document.createRange();\n    range.selectNodeContents(source);\n    selection?.removeAllRanges();\n    selection?.addRange(range);\n\n    document.dispatchEvent(new MouseEvent('mouseup'));\n    vi.runAllTimers();\n\n    const quoteButton = document.querySelector<HTMLElement>('.gv-quote-btn');\n    if (!quoteButton) throw new Error('Expected quote button.');\n    quoteButton.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));\n    vi.runAllTimers();\n\n    const input = document.getElementById('input');\n    if (!input) throw new Error('Expected input element.');\n\n    expect(input.textContent).toContain('$U \\\\in [0, 1)$');\n\n    cleanup();\n  });\n\n  it('preserves block math LaTeX syntax in quoted text', () => {\n    const cleanup = startQuoteReply();\n    const source = document.getElementById('source');\n    if (!source) throw new Error('Expected source element.');\n\n    source.innerHTML =\n      'Equation: <span class=\"math-block\"><span data-math=\"E = mc^2\">E=mc²</span></span>';\n\n    const selection = window.getSelection();\n    const range = document.createRange();\n    range.selectNodeContents(source);\n    selection?.removeAllRanges();\n    selection?.addRange(range);\n\n    document.dispatchEvent(new MouseEvent('mouseup'));\n    vi.runAllTimers();\n\n    const quoteButton = document.querySelector<HTMLElement>('.gv-quote-btn');\n    if (!quoteButton) throw new Error('Expected quote button.');\n    quoteButton.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));\n    vi.runAllTimers();\n\n    const input = document.getElementById('input');\n    if (!input) throw new Error('Expected input element.');\n\n    expect(input.textContent).toContain('$$E = mc^2$$');\n\n    cleanup();\n  });\n\n  it('preserves standalone data-math elements without container', () => {\n    const cleanup = startQuoteReply();\n    const source = document.getElementById('source');\n    if (!source) throw new Error('Expected source element.');\n\n    source.innerHTML = 'Value <span data-math=\"x^2\">x²</span> here';\n\n    const selection = window.getSelection();\n    const range = document.createRange();\n    range.selectNodeContents(source);\n    selection?.removeAllRanges();\n    selection?.addRange(range);\n\n    document.dispatchEvent(new MouseEvent('mouseup'));\n    vi.runAllTimers();\n\n    const quoteButton = document.querySelector<HTMLElement>('.gv-quote-btn');\n    if (!quoteButton) throw new Error('Expected quote button.');\n    quoteButton.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));\n    vi.runAllTimers();\n\n    const input = document.getElementById('input');\n    if (!input) throw new Error('Expected input element.');\n\n    expect(input.textContent).toContain('$x^2$');\n\n    cleanup();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/quoteReply/index.ts",
    "content": "import browser from 'webextension-polyfill';\n\nimport { StorageKeys } from '@/core/types/common';\nimport { getBrowserName } from '@/core/utils/browser';\n\nimport { getTranslationSync } from '../../../utils/i18n';\nimport { expandInputCollapseIfNeeded } from '../inputCollapse/index';\n\n// ============================================================================\n// Constants\n// ============================================================================\n\n/** CSS class names for quote reply button */\nconst CSS_CLASSES = {\n  BUTTON: 'gv-quote-btn',\n  HIDDEN: 'gv-hidden',\n} as const;\n\n/** Timing constants (in milliseconds) */\nconst TIMING = {\n  /** Delay before performing insertion to wait for UI expansion transitions */\n  INSERTION_DELAY_MS: 200,\n  /** Delay before retrying focus for editors that need extra time */\n  FOCUS_RETRY_DELAY_MS: 50,\n  /** Debounce delay for selection change detection */\n  SELECTION_DEBOUNCE_MS: 250,\n} as const;\n\n/** UI positioning constants (in pixels) */\nconst POSITIONING = {\n  /** Minimum distance from viewport edge */\n  MIN_EDGE_OFFSET_PX: 10,\n  /** Gap between button and selection */\n  BUTTON_SELECTION_GAP_PX: 16,\n} as const;\n\n/** SVG icon for the quote button */\nconst QUOTE_ICON = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z\"></path><path d=\"M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z\"></path></svg>`;\n\nconst STYLE_ID = 'gemini-voyager-quote-reply-style';\n\nfunction injectStyles() {\n  if (document.getElementById(STYLE_ID)) return;\n  const style = document.createElement('style');\n  style.id = STYLE_ID;\n  style.textContent = `\n    .gv-quote-btn {\n      position: fixed;\n      z-index: 9999;\n      display: flex;\n      align-items: center;\n      gap: 6px;\n      padding: 6px 10px;\n      background-color: #1e1e1e;\n      color: #fff;\n      border-radius: 6px;\n      box-shadow: 0 4px 12px rgba(0,0,0,0.15);\n      cursor: pointer;\n      font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n      font-size: 13px;\n      font-weight: 500;\n      transition: all 0.2s ease;\n      border: 1px solid rgba(255,255,255,0.1);\n      transform: translateY(0);\n      opacity: 1;\n      pointer-events: auto;\n    }\n    .gv-quote-btn:hover {\n      background-color: #2d2d2d;\n      transform: translateY(-1px);\n      box-shadow: 0 6px 16px rgba(0,0,0,0.2);\n    }\n    .gv-quote-btn svg {\n      width: 14px;\n      height: 14px;\n      opacity: 0.9;\n    }\n    .gv-quote-btn.gv-hidden {\n      opacity: 0;\n      transform: translateY(4px);\n      pointer-events: none;\n      visibility: hidden;\n    }\n    /* Light mode support */\n    @media (prefers-color-scheme: light) {\n      .gv-quote-btn {\n        background-color: #fff;\n        color: #1f1f1f;\n        border: 1px solid rgba(0,0,0,0.08);\n      }\n      .gv-quote-btn:hover {\n        background-color: #f5f5f5;\n      }\n    }\n    /* Check for specific theme attributes if Gemini uses them */\n    body[data-theme=\"light\"] .gv-quote-btn {\n      background-color: #fff;\n      color: #1f1f1f;\n      border: 1px solid rgba(0,0,0,0.08);\n    }\n    body[data-theme=\"light\"] .gv-quote-btn:hover {\n       background-color: #f5f5f5;\n    }\n  `;\n  document.head.appendChild(style);\n}\n\n// Function to find the chat input\nfunction getChatInput(): HTMLElement | null {\n  // Gemini usually has a rich-textarea\n  // Try multiple selectors from most specific to generic\n  const selectors = [\n    'rich-textarea [contenteditable=\"true\"]',\n    'div[contenteditable=\"true\"][role=\"textbox\"]',\n    '.input-area textarea',\n    'textarea[placeholder*=\"Ask\"]',\n    'textarea', // Fallback, might be dangerous\n  ];\n\n  for (const selector of selectors) {\n    // We probably want the one in the main footer/input area, not others (like edit mode)\n    // Usually the main input is visible and larger.\n    const els = document.querySelectorAll(selector);\n    for (const el of Array.from(els)) {\n      // Check if it's visible\n      if (el.getBoundingClientRect().height > 0) {\n        return el as HTMLElement;\n      }\n    }\n  }\n  return null;\n}\n\nfunction countLineBreaks(raw: string): number {\n  return (raw.match(/\\n/g) || []).length;\n}\n\ninterface SeparatorInsertResult {\n  inserted: boolean;\n  insertedBreaks: number;\n}\n\nfunction getContenteditableQuoteSeparator(): string {\n  // Firefox + Quill contenteditable tends to render an extra visual break\n  // for double-newline insertion, so we use a single newline separator there.\n  return getBrowserName() === 'Firefox' ? '\\n' : '\\n\\n';\n}\n\nfunction getPlaceholderCandidates(input: HTMLElement): string[] {\n  const richTextarea = input.closest('rich-textarea');\n  const candidates = [\n    input.getAttribute('data-placeholder'),\n    input.getAttribute('aria-placeholder'),\n    input.getAttribute('placeholder'),\n    richTextarea?.getAttribute('data-placeholder'),\n    richTextarea?.getAttribute('aria-placeholder'),\n    richTextarea?.getAttribute('placeholder'),\n  ];\n\n  return candidates.filter((value): value is string => Boolean(value)).map((value) => value.trim());\n}\n\nfunction isChatInputEmpty(input: HTMLElement | HTMLTextAreaElement): boolean {\n  if (input instanceof HTMLTextAreaElement) {\n    return input.value.trim().length === 0;\n  }\n\n  const rawContent = input.innerText ?? input.textContent ?? '';\n  const trimmedContent = rawContent.trim();\n\n  // If visible text exists and it's not placeholder text, treat as non-empty even if\n  // Quill's `ql-blank` class lags behind DOM updates.\n  if (trimmedContent.length > 0) {\n    const placeholders = getPlaceholderCandidates(input);\n    const isPlaceholderText = placeholders.some(\n      (placeholder) => placeholder.length > 0 && placeholder === trimmedContent,\n    );\n    if (!isPlaceholderText) {\n      return false;\n    }\n  }\n\n  // Gemini currently uses Quill internals. `ql-blank` is its canonical empty marker.\n  if (input.classList.contains('ql-blank')) {\n    return true;\n  }\n\n  return trimmedContent.length === 0;\n}\n\n/**\n * Attempts to insert separator text via execCommand and reports whether\n * content changed plus how many line breaks were observed as inserted.\n */\nfunction tryInsertQuoteSeparator(input: HTMLElement, separator: string): SeparatorInsertResult {\n  const beforeVisible = input.innerText ?? '';\n  const beforeRaw = input.textContent ?? '';\n  const beforeVisibleLineBreakCount = countLineBreaks(beforeVisible);\n  const beforeRawLineBreakCount = countLineBreaks(beforeRaw);\n  let ok = false;\n  try {\n    ok = document.execCommand('insertText', false, separator);\n  } catch {\n    ok = false;\n  }\n  if (!ok) return { inserted: false, insertedBreaks: 0 };\n\n  const afterVisible = input.innerText ?? '';\n  const afterRaw = input.textContent ?? '';\n  if (afterVisible === beforeVisible && afterRaw === beforeRaw) {\n    return { inserted: false, insertedBreaks: 0 };\n  }\n\n  const visibleLineBreakDelta = countLineBreaks(afterVisible) - beforeVisibleLineBreakCount;\n  const rawLineBreakDelta = countLineBreaks(afterRaw) - beforeRawLineBreakCount;\n  const insertedBreaks = Math.max(0, visibleLineBreakDelta, rawLineBreakDelta);\n  return { inserted: true, insertedBreaks };\n}\n\n/**\n * Replace math elements in a cloned DOM tree with LaTeX text nodes.\n * Gemini uses `.math-inline` / `.math-block` containers with `[data-math]` children.\n */\nfunction replaceMathWithLatex(root: DocumentFragment): void {\n  // 1. Replace .math-inline / .math-block containers\n  for (const container of Array.from(root.querySelectorAll('.math-inline, .math-block'))) {\n    const dataMathEl = container.querySelector('[data-math]');\n    const latex = dataMathEl?.getAttribute('data-math');\n    if (latex) {\n      const isBlock = container.classList.contains('math-block');\n      container.replaceWith(document.createTextNode(isBlock ? `$$${latex}$$` : `$${latex}$`));\n    }\n  }\n\n  // 2. Handle any remaining [data-math] elements not inside a container\n  for (const el of Array.from(root.querySelectorAll('[data-math]'))) {\n    const latex = el.getAttribute('data-math');\n    if (latex) {\n      el.replaceWith(document.createTextNode(`$${latex}$`));\n    }\n  }\n}\n\n/**\n * Extract text from a Range, preserving LaTeX math syntax.\n *\n * `Range.toString()` returns visually rendered text, which loses LaTeX\n * delimiters (e.g. `U∈[0,1)` instead of `$U \\in [0, 1)$`). This function\n * clones the range contents, replaces math elements with their `$...$` /\n * `$$...$$` LaTeX source, then returns the resulting text.\n */\nfunction extractTextWithLatex(range: Range): string {\n  const fragment = range.cloneContents();\n\n  // Short-circuit: no math elements → use native Range.toString()\n  if (!fragment.querySelector('.math-inline, .math-block, [data-math]')) {\n    return range.toString();\n  }\n\n  replaceMathWithLatex(fragment);\n\n  // Use a temporary element to get innerText (preserves newlines from block elements / <br>)\n  const temp = document.createElement('div');\n  temp.style.position = 'fixed';\n  temp.style.left = '-9999px';\n  temp.style.opacity = '0';\n  temp.style.pointerEvents = 'none';\n  temp.appendChild(fragment);\n  document.body.appendChild(temp);\n  // innerText preserves newlines from block elements / <br>; textContent is the fallback\n  const text = temp.innerText ?? temp.textContent ?? '';\n  temp.remove();\n\n  return text;\n}\n\nexport function startQuoteReply() {\n  injectStyles();\n\n  let quoteBtn: HTMLElement | null = null;\n  let currentSelectionRange: Range | null = null;\n  let isInternalClick = false;\n  let scrollRafId: number | null = null;\n  let selectionDebounceTimer: ReturnType<typeof setTimeout> | null = null;\n\n  /** Update button position based on current selection range's viewport coordinates. */\n  function updatePosition() {\n    if (!quoteBtn || !currentSelectionRange) return;\n\n    const rangeRect = currentSelectionRange.getBoundingClientRect();\n\n    // Hide when selection is scrolled out of viewport\n    const isOffScreen = rangeRect.bottom < 0 || rangeRect.top > window.innerHeight;\n\n    if (isOffScreen) {\n      if (!quoteBtn.classList.contains(CSS_CLASSES.HIDDEN)) {\n        quoteBtn.classList.add(CSS_CLASSES.HIDDEN);\n      }\n      return;\n    }\n\n    if (quoteBtn.classList.contains(CSS_CLASSES.HIDDEN)) {\n      quoteBtn.classList.remove(CSS_CLASSES.HIDDEN);\n    }\n\n    // Ensure the button is visible before measuring to get actual dimensions\n    const btnRect = quoteBtn.getBoundingClientRect();\n\n    // Use getClientRects to get the precise position of the first line.\n    // This prevents the button from being pushed down by empty space in multi-line selections.\n    const firstLineRect =\n      typeof currentSelectionRange.getClientRects === 'function'\n        ? currentSelectionRange.getClientRects()[0] || rangeRect\n        : rangeRect;\n\n    // position: fixed uses viewport coordinates, no scrollY/X needed\n    const top = firstLineRect.top - btnRect.height - POSITIONING.BUTTON_SELECTION_GAP_PX;\n    const left = rangeRect.left + rangeRect.width / 2 - btnRect.width / 2;\n\n    // Edge protection: prevent the button from being clipped or overflowing the viewport\n    const maxLeft = window.innerWidth - btnRect.width - POSITIONING.MIN_EDGE_OFFSET_PX;\n\n    quoteBtn.style.top = `${Math.max(POSITIONING.MIN_EDGE_OFFSET_PX, top)}px`;\n    quoteBtn.style.left = `${Math.min(maxLeft, Math.max(POSITIONING.MIN_EDGE_OFFSET_PX, left))}px`;\n  }\n\n  function onScrollOrResize() {\n    if (scrollRafId) return;\n    scrollRafId = requestAnimationFrame(() => {\n      updatePosition();\n      scrollRafId = null;\n    });\n  }\n\n  // Create button\n  function createButton() {\n    if (quoteBtn) return;\n    quoteBtn = document.createElement('div');\n    quoteBtn.className = `${CSS_CLASSES.BUTTON} ${CSS_CLASSES.HIDDEN}`;\n    const text = getTranslationSync('quoteReply');\n\n    quoteBtn.innerHTML = `${QUOTE_ICON}<span>${text}</span>`;\n\n    quoteBtn.addEventListener('mousedown', (e) => {\n      e.preventDefault();\n      e.stopPropagation();\n      isInternalClick = true;\n      handleQuoteClick();\n    });\n\n    document.body.appendChild(quoteBtn);\n  }\n\n  function handleQuoteClick() {\n    if (!currentSelectionRange) return;\n    const selectedText = extractTextWithLatex(currentSelectionRange).trim();\n    if (!selectedText) return;\n\n    const input = getChatInput();\n    if (input) {\n      expandInputCollapseIfNeeded();\n\n      // Format: > selection\n      // Prepare quote body (without leading/trailing newlines - those are added at insertion time)\n      const quoteBody = selectedText\n        .split('\\n')\n        .map((line) => `> ${line}`)\n        .join('\\n');\n\n      // Ensure the input is visible\n      input.scrollIntoView({ behavior: 'smooth', block: 'center' });\n\n      // Robust insertion and focus logic\n      const performInsertion = () => {\n        // First focus attempt\n        input.focus();\n\n        // Check input state at insertion time to avoid race conditions\n        // (user might type or another quote might be inserted during the delay)\n        const isInputEmpty = isChatInputEmpty(input);\n\n        // 1. Add a newline at the end (any quote)\n        // 2. Add a newline at the start if not the first quote\n        // Example:\n        // ------------\n        // |> Quote 1 |\n        // |New text 1|\n        // |> Quote 2 |\n        // |New text 2|\n        // ------------\n        const quoteWithTrailingNewline = `${quoteBody}\\n`;\n\n        if (input instanceof HTMLTextAreaElement) {\n          // Standard Textarea logic - simplified append\n          const prefix = isInputEmpty ? '' : '\\n\\n';\n          input.value += `${prefix}${quoteWithTrailingNewline}`;\n          input.selectionStart = input.selectionEnd = input.value.length;\n          input.dispatchEvent(new Event('input', { bubbles: true }));\n        } else {\n          // Contenteditable (Gemini/Quill) logic\n          const sel = window.getSelection();\n\n          // For empty editors, insert from start.\n          if (sel) {\n            const range = document.createRange();\n            range.selectNodeContents(input);\n            if (isInputEmpty) {\n              range.collapse(true);\n            } else {\n              range.collapse(false); // Move cursor to very end\n            }\n            sel.removeAllRanges();\n            sel.addRange(range);\n          }\n\n          // Try to insert a separator via execCommand in one shot.\n          // If the command succeeds and mutates content, only the quote body\n          // (or missing part of it) remains to be inserted.\n          // If insertion does not mutate content, fall back to prepending separator.\n          const quoteSeparator = getContenteditableQuoteSeparator();\n          const requiredSeparatorBreaks = countLineBreaks(quoteSeparator);\n          let contentToInsert: string;\n          let forceRangeInsertion = false;\n          if (!isInputEmpty) {\n            const separatorResult = tryInsertQuoteSeparator(input, quoteSeparator);\n            if (separatorResult.inserted) {\n              const missingBreaks = Math.max(\n                0,\n                requiredSeparatorBreaks - separatorResult.insertedBreaks,\n              );\n              contentToInsert =\n                missingBreaks > 0\n                  ? `${'\\n'.repeat(missingBreaks)}${quoteWithTrailingNewline}`\n                  : quoteWithTrailingNewline;\n              // Avoid re-running execCommand after partial mutation to prevent duplicate separators.\n              forceRangeInsertion = missingBreaks > 0;\n            } else {\n              contentToInsert = `${quoteSeparator}${quoteWithTrailingNewline}`;\n            }\n          } else {\n            contentToInsert = quoteWithTrailingNewline;\n          }\n\n          // Quill handles text insertion better with native insertText command.\n          // Fallback to manual Range insertion when command is unavailable.\n          let inserted = false;\n          if (!forceRangeInsertion) {\n            try {\n              inserted = document.execCommand('insertText', false, contentToInsert);\n            } catch {\n              inserted = false;\n            }\n          }\n\n          if (!inserted) {\n            const textNode = document.createTextNode(contentToInsert);\n            if (sel) {\n              if (forceRangeInsertion) {\n                const endRange = document.createRange();\n                endRange.selectNodeContents(input);\n                endRange.collapse(false);\n                sel.removeAllRanges();\n                sel.addRange(endRange);\n              }\n            }\n\n            if (sel && sel.rangeCount > 0) {\n              const insertRange = sel.getRangeAt(0);\n              insertRange.insertNode(textNode);\n\n              // Move cursor to after the inserted text\n              insertRange.setStartAfter(textNode);\n              insertRange.setEndAfter(textNode);\n              sel.removeAllRanges();\n              sel.addRange(insertRange);\n            } else {\n              // Fallback: just append to the input\n              input.appendChild(textNode);\n            }\n          }\n\n          // Re-force cursor to the end after insertion\n          const finalRange = document.createRange();\n          finalRange.selectNodeContents(input);\n          finalRange.collapse(false);\n          sel?.removeAllRanges();\n          sel?.addRange(finalRange);\n\n          input.dispatchEvent(new Event('input', { bubbles: true }));\n        }\n\n        // Final focus — only if the editor doesn't already have focus,\n        // to avoid interrupting an in-progress IME composition (#497).\n        if (document.activeElement !== input) {\n          input.focus();\n        }\n        // Retry for editors that need extra time to show the cursor,\n        // but skip if the element is already focused (IME-safe).\n        setTimeout(() => {\n          if (document.activeElement !== input) {\n            input.focus();\n          }\n        }, TIMING.FOCUS_RETRY_DELAY_MS);\n      };\n\n      // Use a slightly longer delay to wait for any expansion transitions\n      setTimeout(performInsertion, TIMING.INSERTION_DELAY_MS);\n\n      // Hide button and clear selection state\n      hideButton();\n      currentSelectionRange = null;\n      window.getSelection()?.removeAllRanges();\n    } else {\n      console.warn('[Gemini Voyager] Could not find chat input.');\n    }\n  }\n\n  function showButton() {\n    if (!quoteBtn) createButton();\n    if (!quoteBtn) return;\n\n    // updatePosition() manages visibility (HIDDEN class) based on viewport check\n    updatePosition();\n\n    // Add listeners for scroll/resize\n    window.addEventListener('scroll', onScrollOrResize, { capture: true, passive: true });\n    window.addEventListener('resize', onScrollOrResize, { passive: true });\n  }\n\n  function hideButton() {\n    if (quoteBtn) {\n      quoteBtn.classList.add(CSS_CLASSES.HIDDEN);\n    }\n    // Remove listeners\n    window.removeEventListener('scroll', onScrollOrResize, { capture: true });\n    window.removeEventListener('resize', onScrollOrResize);\n    if (scrollRafId) {\n      cancelAnimationFrame(scrollRafId);\n      scrollRafId = null;\n    }\n  }\n\n  function handleSelectionChange() {\n    // Debounce to let selection settle and avoid redundant updates on rapid key events\n    if (selectionDebounceTimer) clearTimeout(selectionDebounceTimer);\n    selectionDebounceTimer = setTimeout(() => {\n      const selection = window.getSelection();\n      if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {\n        hideButton();\n        currentSelectionRange = null;\n        return;\n      }\n\n      const text = selection.toString().trim();\n      if (!text) {\n        hideButton();\n        currentSelectionRange = null;\n        return;\n      }\n\n      // Check if selection is within a message user/model bubble\n      // We don't want to quote random UI elements\n      const anchor = selection.anchorNode;\n      if (!anchor) return;\n\n      const element =\n        anchor.nodeType === Node.TEXT_NODE ? anchor.parentElement : (anchor as HTMLElement);\n\n      // Check if selection is inside main content area\n      // Gemini uses <main> or sometimes specific classes. We want to avoid nav, sidebar, etc.\n      const mainContent = document.querySelector('main');\n      if (mainContent && !mainContent.contains(element)) {\n        hideButton();\n        return;\n      }\n\n      // Also explicitly check for sidebar classes just in case\n      if (\n        element?.closest('nav') ||\n        element?.closest('[role=\"navigation\"]') ||\n        element?.closest('.sidebar') ||\n        element?.closest('.mat-drawer')\n      ) {\n        hideButton();\n        return;\n      }\n\n      // Selectors for valid areas: user-query-container, model-response, conversation-container\n      // Or just check if it's not the input box itself\n      if (element?.closest('[contenteditable=\"true\"]')) {\n        hideButton();\n        return;\n      }\n\n      // Also check if we are selecting code block content? Might be fine.\n\n      const range = selection.getRangeAt(0);\n      currentSelectionRange = range;\n      const rect = range.getBoundingClientRect();\n\n      // If rect is zero (e.g. invisible), don't show\n      if (rect.width === 0 && rect.height === 0) return;\n\n      showButton();\n    }, TIMING.SELECTION_DEBOUNCE_MS);\n  }\n\n  function onMouseUp(_: MouseEvent) {\n    if (isInternalClick) {\n      isInternalClick = false;\n      return;\n    }\n    handleSelectionChange();\n  }\n\n  // Function to update button text when language changes\n  function updateButtonText() {\n    if (quoteBtn) {\n      const span = quoteBtn.querySelector('span');\n      if (span) {\n        span.textContent = getTranslationSync('quoteReply');\n      }\n    }\n  }\n\n  // Listen to selection changes via mouseup (often better for \"finished\" selection)\n  // selectionchange event fires too often while dragging.\n  document.addEventListener('mouseup', onMouseUp);\n\n  function onKeys(e: KeyboardEvent) {\n    if (e.key === 'Shift' || e.key.startsWith('Arrow')) {\n      handleSelectionChange();\n    }\n  }\n\n  // Also listen to keyup for keyboard selection\n  document.addEventListener('keyup', onKeys);\n\n  // Listen for language changes and update button text\n  function onStorageChanged(\n    changes: Record<string, browser.Storage.StorageChange>,\n    areaName: string,\n  ) {\n    if ((areaName === 'sync' || areaName === 'local') && changes[StorageKeys.LANGUAGE]) {\n      updateButtonText();\n    }\n  }\n  browser.storage.onChanged.addListener(onStorageChanged);\n\n  // Cleanup\n  return () => {\n    hideButton();\n    if (selectionDebounceTimer) clearTimeout(selectionDebounceTimer);\n    document.removeEventListener('mouseup', onMouseUp);\n    document.removeEventListener('keyup', onKeys);\n    browser.storage.onChanged.removeListener(onStorageChanged);\n    if (quoteBtn) quoteBtn.remove();\n    const style = document.getElementById(STYLE_ID);\n    if (style) style.remove();\n  };\n}\n"
  },
  {
    "path": "src/pages/content/recentsHider/index.ts",
    "content": "/**\n * Recents Hider - Elegant hide/show toggle for \"my-stuff-recents-preview\" section\n *\n * Design Philosophy:\n * Instead of a popup toggle, we use a contextual \"hover reveal\" pattern where\n * a subtle hide button appears on hover. When hidden, a minimal \"peek bar\"\n * allows users to restore the section.\n */\nimport browser from 'webextension-polyfill';\n\nimport { StorageKeys } from '@/core/types/common';\n\nimport { getTranslationSync } from '../../../utils/i18n';\n\n// Constants\nconst STYLE_ID = 'gv-recents-hider-style';\nconst HIDDEN_CLASS = 'gv-recents-hidden';\nconst PEEK_BAR_CLASS = 'gv-recents-peek-bar';\nconst TOGGLE_BTN_CLASS = 'gv-recents-toggle-btn';\nconst STORAGE_KEY = 'gvRecentsHidden';\n\n// Selectors - targeting the recents preview section\nconst RECENTS_SELECTOR = '.my-stuff-recents-preview';\n\nlet initialized = false;\nlet observer: MutationObserver | null = null;\n\n/**\n * Inject CSS styles for the hide/show functionality\n */\nfunction injectStyles(): void {\n  if (document.getElementById(STYLE_ID)) return;\n\n  const style = document.createElement('style');\n  style.id = STYLE_ID;\n  style.textContent = `\n    /* Container for proper positioning */\n    .my-stuff-recents-preview {\n      position: relative;\n      transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);\n    }\n\n    /* Toggle button - appears on hover */\n    .${TOGGLE_BTN_CLASS} {\n      position: absolute;\n      top: 8px;\n      right: 8px;\n      width: 28px;\n      height: 28px;\n      border-radius: 50%;\n      border: none;\n      background: var(--gm3-sys-color-surface-container-high, rgba(0, 0, 0, 0.06));\n      backdrop-filter: blur(8px);\n      cursor: pointer;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      opacity: 0;\n      transform: scale(0.8);\n      transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n      z-index: 100;\n      color: var(--gm3-sys-color-on-surface-variant, #5f6368);\n    }\n\n    .${TOGGLE_BTN_CLASS}:hover {\n      background: var(--gm3-sys-color-surface-container-highest, rgba(0, 0, 0, 0.12));\n      transform: scale(1.1);\n    }\n\n    .${TOGGLE_BTN_CLASS}:active {\n      transform: scale(0.95);\n    }\n\n    .${TOGGLE_BTN_CLASS} svg {\n      width: 16px;\n      height: 16px;\n      transition: transform 0.2s ease;\n    }\n\n    /* Show button on container hover */\n    .my-stuff-recents-preview:hover .${TOGGLE_BTN_CLASS} {\n      opacity: 1;\n      transform: scale(1);\n    }\n\n    /* Hidden state - collapse with smooth animation */\n    .${HIDDEN_CLASS} {\n      max-height: 0 !important;\n      overflow: hidden !important;\n      opacity: 0 !important;\n      margin: 0 !important;\n      padding: 0 !important;\n      pointer-events: none !important;\n    }\n\n    /* Peek bar - minimal restore hint */\n    .${PEEK_BAR_CLASS} {\n      height: 6px;\n      margin: 8px 16px;\n      border-radius: 3px;\n      background: linear-gradient(\n        90deg,\n        transparent 0%,\n        var(--gm3-sys-color-outline-variant, rgba(0, 0, 0, 0.08)) 20%,\n        var(--gm3-sys-color-outline-variant, rgba(0, 0, 0, 0.08)) 80%,\n        transparent 100%\n      );\n      cursor: pointer;\n      transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);\n      position: relative;\n      display: none;\n    }\n\n    .${PEEK_BAR_CLASS}::after {\n      content: '';\n      position: absolute;\n      top: 50%;\n      left: 50%;\n      transform: translate(-50%, -50%);\n      width: 40px;\n      height: 4px;\n      border-radius: 2px;\n      background: var(--gm3-sys-color-primary, #1a73e8);\n      opacity: 0;\n      transition: all 0.2s ease;\n    }\n\n    .${PEEK_BAR_CLASS}:hover {\n      height: 12px;\n      background: linear-gradient(\n        90deg,\n        transparent 0%,\n        var(--gm3-sys-color-primary-container, rgba(26, 115, 232, 0.12)) 15%,\n        var(--gm3-sys-color-primary-container, rgba(26, 115, 232, 0.12)) 85%,\n        transparent 100%\n      );\n    }\n\n    .${PEEK_BAR_CLASS}:hover::after {\n      opacity: 1;\n      width: 60px;\n    }\n\n    /* Tooltip for peek bar */\n    .${PEEK_BAR_CLASS}[data-tooltip]::before {\n      content: attr(data-tooltip);\n      position: absolute;\n      bottom: 100%;\n      left: 50%;\n      transform: translateX(-50%) translateY(-4px);\n      padding: 6px 12px;\n      background: var(--gm3-sys-color-inverse-surface, #303030);\n      color: var(--gm3-sys-color-inverse-on-surface, #f5f5f5);\n      font-family: 'Google Sans', Roboto, sans-serif;\n      font-size: 12px;\n      font-weight: 500;\n      border-radius: 8px;\n      white-space: nowrap;\n      opacity: 0;\n      pointer-events: none;\n      transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n      z-index: 1000;\n    }\n\n    .${PEEK_BAR_CLASS}:hover[data-tooltip]::before {\n      opacity: 1;\n      transform: translateX(-50%) translateY(-8px);\n    }\n\n    /* Show peek bar when recents is hidden */\n    .${PEEK_BAR_CLASS}.gv-visible {\n      display: block;\n    }\n\n    /* Dark mode adjustments */\n    @media (prefers-color-scheme: dark) {\n      .${TOGGLE_BTN_CLASS} {\n        background: rgba(255, 255, 255, 0.08);\n        color: #e8eaed;\n      }\n      .${TOGGLE_BTN_CLASS}:hover {\n        background: rgba(255, 255, 255, 0.14);\n      }\n      .${PEEK_BAR_CLASS} {\n        background: linear-gradient(\n          90deg,\n          transparent 0%,\n          rgba(255, 255, 255, 0.06) 20%,\n          rgba(255, 255, 255, 0.06) 80%,\n          transparent 100%\n        );\n      }\n      .${PEEK_BAR_CLASS}:hover {\n        background: linear-gradient(\n          90deg,\n          transparent 0%,\n          rgba(138, 180, 248, 0.15) 15%,\n          rgba(138, 180, 248, 0.15) 85%,\n          transparent 100%\n        );\n      }\n      .${PEEK_BAR_CLASS}::after {\n        background: #8ab4f8;\n      }\n    }\n\n    /* Explicit dark theme support */\n    body[data-theme=\"dark\"] .${TOGGLE_BTN_CLASS},\n    body.dark-theme .${TOGGLE_BTN_CLASS} {\n      background: rgba(255, 255, 255, 0.08);\n      color: #e8eaed;\n    }\n    body[data-theme=\"dark\"] .${TOGGLE_BTN_CLASS}:hover,\n    body.dark-theme .${TOGGLE_BTN_CLASS}:hover {\n      background: rgba(255, 255, 255, 0.14);\n    }\n  `;\n  document.head.appendChild(style);\n}\n\n/**\n * Create the toggle button element\n */\nfunction createToggleButton(): HTMLButtonElement {\n  const btn = document.createElement('button');\n  btn.className = TOGGLE_BTN_CLASS;\n  btn.setAttribute('aria-label', getTranslationSync('recentsHide') || 'Hide recent items');\n  btn.title = getTranslationSync('recentsHide') || 'Hide recent items';\n\n  // Eye-off icon (Material Symbols)\n  btn.innerHTML = `\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"currentColor\">\n      <path d=\"m644-428-58-58q9-47-27-88t-93-32l-58-58q17-8 34.5-12t37.5-4q75 0 127.5 52.5T660-500q0 20-4 37.5T644-428Zm128 126-58-56q38-29 67.5-63.5T832-500q-50-101-143.5-160.5T480-720q-29 0-57 4t-55 12l-62-62q41-17 84-25.5t90-8.5q151 0 269 83.5T920-500q-23 59-60.5 109.5T772-302Zm20 246L624-222q-35 11-70.5 16.5T480-200q-151 0-269-83.5T40-500q21-53 53-98.5t73-81.5L56-792l56-56 736 736-56 56ZM222-624q-29 26-53 57t-41 67q50 101 143.5 160.5T480-280q20 0 39-2.5t39-5.5l-36-38q-11 3-21 4.5t-21 1.5q-75 0-127.5-52.5T300-500q0-11 1.5-21t4.5-21l-84-82Zm319 93Zm-151 75Z\"/>\n    </svg>\n  `;\n\n  return btn;\n}\n\n/**\n * Create the peek bar element for restoring hidden section\n */\nfunction createPeekBar(): HTMLDivElement {\n  const bar = document.createElement('div');\n  bar.className = PEEK_BAR_CLASS;\n  bar.setAttribute('data-tooltip', getTranslationSync('recentsShow') || 'Show recent items');\n  bar.setAttribute('role', 'button');\n  bar.setAttribute('tabindex', '0');\n  bar.setAttribute('aria-label', getTranslationSync('recentsShow') || 'Show recent items');\n\n  return bar;\n}\n\n/**\n * Get the current hidden state from storage\n */\nasync function getHiddenState(): Promise<boolean> {\n  return new Promise((resolve) => {\n    try {\n      chrome.storage?.local?.get({ [STORAGE_KEY]: false }, (result) => {\n        resolve(result?.[STORAGE_KEY] === true);\n      });\n    } catch {\n      // Fallback to localStorage\n      resolve(localStorage.getItem(STORAGE_KEY) === 'true');\n    }\n  });\n}\n\n/**\n * Save the hidden state to storage\n */\nasync function setHiddenState(hidden: boolean): Promise<void> {\n  return new Promise((resolve) => {\n    try {\n      chrome.storage?.local?.set({ [STORAGE_KEY]: hidden }, () => resolve());\n    } catch {\n      // Fallback to localStorage\n      localStorage.setItem(STORAGE_KEY, String(hidden));\n      resolve();\n    }\n  });\n}\n\n/**\n * Apply the hidden/visible state to the recents section\n */\nfunction applyState(recentsEl: HTMLElement, peekBar: HTMLDivElement, hidden: boolean): void {\n  if (hidden) {\n    recentsEl.classList.add(HIDDEN_CLASS);\n    peekBar.classList.add('gv-visible');\n  } else {\n    recentsEl.classList.remove(HIDDEN_CLASS);\n    peekBar.classList.remove('gv-visible');\n  }\n}\n\n/**\n * Setup the hide/show functionality for a recents element\n */\nasync function setupRecentsHider(recentsEl: HTMLElement): Promise<void> {\n  // Check if already processed\n  if (recentsEl.querySelector(`.${TOGGLE_BTN_CLASS}`)) return;\n\n  // Create UI elements\n  const toggleBtn = createToggleButton();\n  const peekBar = createPeekBar();\n\n  // Insert elements\n  recentsEl.appendChild(toggleBtn);\n  recentsEl.parentElement?.insertBefore(peekBar, recentsEl.nextSibling);\n\n  // Get initial state and apply\n  const isHidden = await getHiddenState();\n  applyState(recentsEl, peekBar, isHidden);\n\n  // Toggle button click handler\n  toggleBtn.addEventListener('click', async (e) => {\n    e.stopPropagation();\n    e.preventDefault();\n\n    await setHiddenState(true);\n    applyState(recentsEl, peekBar, true);\n  });\n\n  // Peek bar click handler\n  peekBar.addEventListener('click', async () => {\n    await setHiddenState(false);\n    applyState(recentsEl, peekBar, false);\n  });\n\n  // Keyboard support for peek bar\n  peekBar.addEventListener('keydown', async (e) => {\n    if (e.key === 'Enter' || e.key === ' ') {\n      e.preventDefault();\n      await setHiddenState(false);\n      applyState(recentsEl, peekBar, false);\n    }\n  });\n}\n\n/**\n * Update UI text when language changes\n */\nfunction updateLanguageText(): void {\n  // Update toggle buttons\n  document.querySelectorAll<HTMLButtonElement>(`.${TOGGLE_BTN_CLASS}`).forEach((btn) => {\n    const text = getTranslationSync('recentsHide') || 'Hide recent items';\n    btn.setAttribute('aria-label', text);\n    btn.title = text;\n  });\n\n  // Update peek bars\n  document.querySelectorAll<HTMLDivElement>(`.${PEEK_BAR_CLASS}`).forEach((bar) => {\n    const text = getTranslationSync('recentsShow') || 'Show recent items';\n    bar.setAttribute('data-tooltip', text);\n    bar.setAttribute('aria-label', text);\n  });\n}\n\n/**\n * Initialize the recents hider\n */\nfunction initRecentsHider(): void {\n  if (initialized) return;\n  initialized = true;\n\n  injectStyles();\n\n  // Setup existing recents elements\n  const recentsEls = document.querySelectorAll<HTMLElement>(RECENTS_SELECTOR);\n  recentsEls.forEach((el) => setupRecentsHider(el));\n\n  // Observe for dynamically added recents elements (SPA navigation)\n  observer = new MutationObserver((mutations) => {\n    for (const mutation of mutations) {\n      for (const node of Array.from(mutation.addedNodes)) {\n        if (node instanceof HTMLElement) {\n          if (node.matches(RECENTS_SELECTOR)) {\n            setupRecentsHider(node);\n          }\n          // Also check children\n          const children = node.querySelectorAll<HTMLElement>(RECENTS_SELECTOR);\n          children.forEach((el) => setupRecentsHider(el));\n        }\n      }\n    }\n  });\n\n  observer.observe(document.body, { childList: true, subtree: true });\n\n  // Listen for language changes and update UI text\n  browser.storage.onChanged.addListener((changes, areaName) => {\n    if ((areaName === 'sync' || areaName === 'local') && changes[StorageKeys.LANGUAGE]) {\n      updateLanguageText();\n    }\n  });\n}\n\n/**\n * Cleanup function\n */\nfunction cleanup(): void {\n  if (observer) {\n    observer.disconnect();\n    observer = null;\n  }\n\n  // Remove styles\n  document.getElementById(STYLE_ID)?.remove();\n\n  // Remove added elements\n  document.querySelectorAll(`.${TOGGLE_BTN_CLASS}`).forEach((el) => el.remove());\n  document.querySelectorAll(`.${PEEK_BAR_CLASS}`).forEach((el) => el.remove());\n  document.querySelectorAll(`.${HIDDEN_CLASS}`).forEach((el) => {\n    el.classList.remove(HIDDEN_CLASS);\n  });\n\n  initialized = false;\n}\n\n/**\n * Start the recents hider feature\n */\nexport function startRecentsHider(): () => void {\n  // Only run on gemini.google.com\n  if (location.hostname !== 'gemini.google.com') {\n    return () => {};\n  }\n\n  // Wait for DOM to be ready\n  if (document.readyState === 'loading') {\n    document.addEventListener('DOMContentLoaded', initRecentsHider);\n  } else {\n    // Small delay to ensure Gemini's UI is rendered\n    setTimeout(initRecentsHider, 500);\n  }\n\n  return cleanup;\n}\n"
  },
  {
    "path": "src/pages/content/sendBehavior/__tests__/sendBehavior.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { StorageKeys } from '@/core/types/common';\n\nfunction markElementVisible(element: HTMLElement): void {\n  Object.defineProperty(element, 'offsetParent', {\n    configurable: true,\n    value: document.body,\n  });\n}\n\nfunction fireCtrlEnter(target: HTMLElement): KeyboardEvent {\n  const event = new KeyboardEvent('keydown', {\n    key: 'Enter',\n    code: 'Enter',\n    ctrlKey: true,\n    bubbles: true,\n    cancelable: true,\n  });\n  target.dispatchEvent(event);\n  return event;\n}\n\ndescribe('sendBehavior', () => {\n  beforeEach(() => {\n    vi.resetModules();\n    vi.clearAllMocks();\n    document.body.innerHTML = '';\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ [StorageKeys.CTRL_ENTER_SEND]: true });\n      },\n    );\n  });\n\n  afterEach(() => {\n    document.body.innerHTML = '';\n  });\n\n  it('clicks the send button within the main chat container, ignoring stale update buttons elsewhere', async () => {\n    // Stale update button from a previous edit — outside the main input container\n    const staleUpdateButton = document.createElement('button');\n    staleUpdateButton.className = 'update-button';\n    markElementVisible(staleUpdateButton);\n\n    // Main chat input container (.text-input-field)\n    const inputContainer = document.createElement('div');\n    inputContainer.className = 'text-input-field';\n\n    const input = document.createElement('div');\n    input.setAttribute('contenteditable', 'true');\n\n    const sendButton = document.createElement('button');\n    sendButton.setAttribute('aria-label', 'Send message');\n    markElementVisible(sendButton);\n\n    inputContainer.append(input, sendButton);\n    document.body.append(staleUpdateButton, inputContainer);\n\n    const staleClickSpy = vi.spyOn(staleUpdateButton, 'click');\n    const sendClickSpy = vi.spyOn(sendButton, 'click');\n\n    const { startSendBehavior } = await import('../index');\n    const cleanup = await startSendBehavior();\n\n    const event = fireCtrlEnter(input);\n\n    expect(sendClickSpy).toHaveBeenCalledTimes(1);\n    expect(staleClickSpy).not.toHaveBeenCalled();\n    expect(event.defaultPrevented).toBe(true);\n\n    cleanup();\n  });\n\n  it('clicks the update button within an edit container (chat-message)', async () => {\n    // Edit mode: input and update button are inside a chat-message element\n    const chatMessage = document.createElement('chat-message');\n\n    const input = document.createElement('div');\n    input.setAttribute('contenteditable', 'true');\n\n    const updateButton = document.createElement('button');\n    updateButton.className = 'update-button';\n    markElementVisible(updateButton);\n\n    chatMessage.append(input, updateButton);\n    document.body.append(chatMessage);\n\n    const updateClickSpy = vi.spyOn(updateButton, 'click');\n\n    const { startSendBehavior } = await import('../index');\n    const cleanup = await startSendBehavior();\n\n    const event = fireCtrlEnter(input);\n\n    expect(updateClickSpy).toHaveBeenCalledTimes(1);\n    expect(event.defaultPrevented).toBe(true);\n\n    cleanup();\n  });\n\n  it('does not click any button when no known container is found', async () => {\n    // Input is in an unknown container — no .text-input-field, chat-message, etc.\n    const unknownDiv = document.createElement('div');\n\n    const input = document.createElement('div');\n    input.setAttribute('contenteditable', 'true');\n\n    const randomButton = document.createElement('button');\n    randomButton.setAttribute('aria-label', 'Send');\n    markElementVisible(randomButton);\n\n    unknownDiv.append(input);\n    document.body.append(unknownDiv, randomButton);\n\n    const buttonClickSpy = vi.spyOn(randomButton, 'click');\n\n    const { startSendBehavior } = await import('../index');\n    const cleanup = await startSendBehavior();\n\n    const event = fireCtrlEnter(input);\n\n    expect(buttonClickSpy).not.toHaveBeenCalled();\n    expect(event.defaultPrevented).toBe(false);\n\n    cleanup();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/sendBehavior/index.ts",
    "content": "/**\n * Send Behavior Module\n *\n * Modifies Gemini's input behavior:\n * - Enter key inserts a newline instead of sending\n * - Ctrl+Enter sends the message\n *\n * This feature is controlled by the `gvCtrlEnterSend` storage setting.\n *\n * ARCHITECTURE:\n * - Observer and listeners are ONLY active when the feature is enabled\n * - When disabled, no DOM observation or event handling occurs (zero performance overhead)\n * - Storage listener remains active to respond to setting changes\n */\nimport { StorageKeys } from '@/core/types/common';\nimport { isExtensionContextInvalidatedError } from '@/core/utils/extensionContext';\n\nimport { getTextOffset, setCaretPosition } from './utils';\n\n// ============================================================================\n// Constants\n// ============================================================================\n\n/** Selectors for finding the send button */\nconst SEND_BUTTON_SELECTORS = [\n  '.update-button', // Explicit class for Edit mode (User provided)\n  'button[aria-label*=\"Send\"]',\n  'button[aria-label*=\"send\"]',\n  'button[data-tooltip*=\"Send\"]',\n  'button[data-tooltip*=\"send\"]',\n  'button mat-icon[fonticon=\"send\"]',\n  '[data-send-button]',\n  '.send-button',\n  // Fallback selectors\n  'button[aria-label*=\"Update\"]',\n  'button[aria-label*=\"Save\"]',\n  'button[aria-label*=\"更新\"]',\n] as const;\n\n/** Selector for editable elements */\nconst EDITABLE_SELECTORS = '[contenteditable=\"true\"], [role=\"textbox\"], textarea';\n\n/** Log prefix for consistent logging */\nconst LOG_PREFIX = '[SendBehavior]';\n\n// ============================================================================\n// State\n// ============================================================================\n\nlet isEnabled = false;\nlet observer: MutationObserver | null = null;\nlet cleanupFns: (() => void)[] = [];\nlet storageListener:\n  | ((changes: Record<string, chrome.storage.StorageChange>, areaName: string) => void)\n  | null = null;\n\n/** Track elements that already have listeners attached to prevent duplicates */\nconst attachedElements = new WeakSet<HTMLElement>();\n\n// ============================================================================\n// DOM Helpers\n// ============================================================================\n\n/**\n * Find the send button associated with the current input element.\n *\n * Strategy:\n * 1. Container Search: Use `closest()` to find a known container (e.g. `.text-input-field`, `chat-message`).\n * 2. Scoped Button Search: Only search for buttons within the found container to avoid stale matches.\n */\nfunction findSendButton(inputElement: HTMLElement): HTMLElement | null {\n  // 1. First, find a cohesive container wrapper that holds BOTH the input and its corresponding button\n  //    Gemini provides distinct containers for the main input and edit inputs\n  const containerSelectors = [\n    // Global/main chat input wrapper\n    '.text-input-field',\n    // Active conversation edit container\n    'chat-message',\n    'form',\n    // Modals/Dialogs\n    '[role=\"dialog\"]',\n    '.mat-mdc-dialog-container',\n  ];\n\n  let container: HTMLElement | null = null;\n  for (const selector of containerSelectors) {\n    const closest = inputElement.closest(selector);\n    if (closest instanceof HTMLElement) {\n      container = closest;\n      break;\n    }\n  }\n\n  // 2. Search for the button strictly WITHIN the container or via bounded upward traversal\n  // If we found a known cohesive container, just search in it\n  if (container) {\n    for (const selector of SEND_BUTTON_SELECTORS) {\n      try {\n        const element = container.querySelector(selector);\n        if (element instanceof HTMLElement) {\n          const closestButton = element.closest('button');\n          // Ensure the found button is still within our safe container boundary\n          const button =\n            closestButton && container.contains(closestButton) ? closestButton : element;\n\n          if (button instanceof HTMLElement && button.offsetParent !== null) {\n            return button;\n          }\n        }\n      } catch {\n        // Invalid selector, continue\n      }\n    }\n\n    // Fallback search within container by icon text\n    const allButtons = container.querySelectorAll('button');\n    for (const button of allButtons) {\n      const iconElement = button.querySelector('.material-symbols-outlined, mat-icon');\n      if (\n        iconElement?.textContent?.trim().toLowerCase() === 'send' &&\n        button.offsetParent !== null\n      ) {\n        return button;\n      }\n    }\n  }\n\n  return null;\n}\n\n/**\n * Insert a newline in a contenteditable element\n *\n * Gemini uses Quill editor (identified by class \"ql-editor\").\n * Direct DOM manipulation with <br> elements doesn't work well with Quill\n * because it manages its own DOM state.\n *\n * Strategy:\n * 1. First try document.execCommand('insertLineBreak') - works in most browsers\n * 2. If that fails, simulate a Shift+Enter keypress which Quill handles natively\n */\nfunction insertNewlineInContentEditable(target: HTMLElement): void {\n  // Method 1: Try execCommand (deprecated but still works in most browsers)\n  // This is the most reliable method for contenteditable elements\n  // 1. SNAPSHOT: Remember where the cursor is (text offset)\n  const currentOffset = getTextOffset(target);\n\n  // 2. EXECUTE: Native command\n  // This might trigger a React re-render, creating a new DOM structure\n  const success = document.execCommand('insertLineBreak', false);\n\n  if (success) {\n    // 3. RESTORE: Put the cursor back where it belongs\n    // Use requestAnimationFrame to wait for any immediate React re-renders to settle\n    if (currentOffset !== null) {\n      // +1 to account for the newly inserted newline character\n      const newOffset = currentOffset + 1;\n\n      // Try immediate restore (for synchronous DOM updates)\n      setCaretPosition(target, newOffset);\n\n      // Also try next frame (for asynchronous React updates)\n      requestAnimationFrame(() => {\n        setCaretPosition(target, newOffset);\n      });\n    }\n\n    // Trigger input event to notify listeners (ensure data sync)\n    // NOTE: Maintainer's code had this. We kept it but manage the cursor consequence.\n    target.dispatchEvent(new Event('input', { bubbles: true }));\n    return;\n  }\n  // Method 2: Try insertHTML with a <br> tag\n  const htmlSuccess = document.execCommand('insertHTML', false, '<br><br>');\n\n  if (htmlSuccess) {\n    target.dispatchEvent(new Event('input', { bubbles: true }));\n    return;\n  }\n\n  // Method 3: Simulate Shift+Enter keypress as fallback\n  // This tells Quill to handle the newline in its own way\n  const shiftEnterEvent = new KeyboardEvent('keydown', {\n    key: 'Enter',\n    code: 'Enter',\n    keyCode: 13,\n    which: 13,\n    shiftKey: true,\n    bubbles: true,\n    cancelable: true,\n  });\n\n  target.dispatchEvent(shiftEnterEvent);\n}\n\n/**\n * Insert a newline in a textarea\n */\nfunction insertNewlineInTextarea(textarea: HTMLTextAreaElement): void {\n  const start = textarea.selectionStart;\n  const end = textarea.selectionEnd;\n  const value = textarea.value;\n\n  textarea.value = value.substring(0, start) + '\\n' + value.substring(end);\n  textarea.selectionStart = textarea.selectionEnd = start + 1;\n\n  // Trigger input event to notify any listeners\n  textarea.dispatchEvent(new Event('input', { bubbles: true }));\n}\n\n// ============================================================================\n// Event Handlers\n// ============================================================================\n\n/**\n * Handle keydown events on the input area\n */\nfunction handleKeyDown(event: KeyboardEvent): void {\n  // Early exit if feature is disabled (should not happen, but defensive check)\n  if (!isEnabled) return;\n\n  // Fix for Issue 260: Ignore events during IME composition\n  if (event.isComposing) return;\n\n  // Only handle Enter key\n  if (event.key !== 'Enter') return;\n\n  const target = event.target as HTMLElement;\n\n  // Check if we're in an editable area (Gemini uses contenteditable divs)\n  const isContentEditable =\n    target.isContentEditable || target.getAttribute('contenteditable') === 'true';\n  const isTextarea = target.tagName === 'TEXTAREA';\n\n  // Ignore INPUT elements - they are usually single-line (search, rename)\n  // and pressing Enter there should trigger the default submit action\n  if (!isContentEditable && !isTextarea) return;\n\n  // Ctrl+Enter or Cmd+Enter: Send the message\n  if (event.ctrlKey || event.metaKey) {\n    // Pass the current input target context so we only find its corresponding send button\n    const sendButton = findSendButton(target);\n    if (sendButton) {\n      event.preventDefault();\n      event.stopPropagation();\n      sendButton.click();\n    }\n    return;\n  }\n\n  // Shift+Enter: Default behavior (already inserts newline in most cases)\n  if (event.shiftKey) return;\n\n  // Plain Enter: Insert a newline instead of sending\n  event.preventDefault();\n  event.stopPropagation();\n\n  if (isContentEditable) {\n    insertNewlineInContentEditable(target);\n  } else if (isTextarea) {\n    insertNewlineInTextarea(target as HTMLTextAreaElement);\n  }\n}\n\n// ============================================================================\n// Attachment Logic\n// ============================================================================\n\n/**\n * Attach event listener to an input element\n */\nfunction attachToInput(element: HTMLElement): void {\n  // Prevent duplicate listeners\n  if (attachedElements.has(element)) return;\n\n  // Use capture phase to intercept before other handlers\n  element.addEventListener('keydown', handleKeyDown, { capture: true });\n\n  attachedElements.add(element);\n\n  cleanupFns.push(() => {\n    element.removeEventListener('keydown', handleKeyDown, { capture: true });\n    attachedElements.delete(element);\n  });\n}\n\n/**\n * Find and attach to all input areas on the page\n */\nfunction attachToAllInputs(): void {\n  const editables = document.querySelectorAll<HTMLElement>(EDITABLE_SELECTORS);\n  editables.forEach(attachToInput);\n}\n\n// ============================================================================\n// Observer Management\n// ============================================================================\n\n/**\n * Setup observer to watch for dynamically added input elements\n * NOTE: Only call this when the feature is enabled!\n */\nfunction setupObserver(): void {\n  if (observer) return;\n\n  observer = new MutationObserver((mutations) => {\n    for (const mutation of mutations) {\n      for (const node of mutation.addedNodes) {\n        if (!(node instanceof HTMLElement)) continue;\n\n        // Check if the node itself is an input\n        if (\n          node.isContentEditable ||\n          node.getAttribute('role') === 'textbox' ||\n          node.tagName === 'TEXTAREA'\n        ) {\n          attachToInput(node);\n        }\n\n        // Check descendants\n        const editables = node.querySelectorAll<HTMLElement>(EDITABLE_SELECTORS);\n        editables.forEach(attachToInput);\n      }\n    }\n  });\n\n  observer.observe(document.body, {\n    childList: true,\n    subtree: true,\n  });\n}\n\n/**\n * Disconnect the observer\n */\nfunction disconnectObserver(): void {\n  if (observer) {\n    observer.disconnect();\n    observer = null;\n  }\n}\n\n// ============================================================================\n// Feature Enable/Disable\n// ============================================================================\n\n/**\n * Enable the feature: attach listeners and start observing\n */\nfunction enableFeature(): void {\n  if (isEnabled) return;\n\n  isEnabled = true;\n  attachToAllInputs();\n  setupObserver();\n\n  console.log(LOG_PREFIX, 'Feature enabled');\n}\n\n/**\n * Disable the feature: remove all listeners and stop observing\n */\nfunction disableFeature(): void {\n  if (!isEnabled) return;\n\n  isEnabled = false;\n\n  // Remove all event listeners\n  cleanupFns.forEach((fn) => fn());\n  cleanupFns = [];\n\n  // Stop observing DOM changes\n  disconnectObserver();\n\n  console.log(LOG_PREFIX, 'Feature disabled');\n}\n\n// ============================================================================\n// Storage & Initialization\n// ============================================================================\n\n/**\n * Load the enabled state from storage\n */\nasync function loadSettings(): Promise<boolean> {\n  return new Promise((resolve) => {\n    try {\n      if (!chrome.storage?.sync?.get) {\n        resolve(false);\n        return;\n      }\n      chrome.storage.sync.get({ [StorageKeys.CTRL_ENTER_SEND]: false }, (result) => {\n        const enabled = result?.[StorageKeys.CTRL_ENTER_SEND] === true;\n        resolve(enabled);\n      });\n    } catch (error) {\n      if (isExtensionContextInvalidatedError(error)) {\n        resolve(false);\n        return;\n      }\n      console.warn(LOG_PREFIX, 'Failed to load settings:', error);\n      resolve(false);\n    }\n  });\n}\n\n/**\n * Setup storage change listener\n * NOTE: This listener remains active even when feature is disabled,\n * so we can respond to setting changes.\n */\nfunction setupStorageListener(): void {\n  if (storageListener) return;\n\n  storageListener = (changes, areaName) => {\n    if (areaName !== 'sync') return;\n    if (!(StorageKeys.CTRL_ENTER_SEND in changes)) return;\n\n    const newValue = changes[StorageKeys.CTRL_ENTER_SEND].newValue === true;\n\n    if (newValue && !isEnabled) {\n      enableFeature();\n    } else if (!newValue && isEnabled) {\n      disableFeature();\n    }\n  };\n\n  try {\n    chrome.storage?.onChanged?.addListener(storageListener);\n  } catch (error) {\n    if (isExtensionContextInvalidatedError(error)) {\n      return;\n    }\n    console.warn(LOG_PREFIX, 'Failed to setup storage listener:', error);\n  }\n}\n\n/**\n * Cleanup all resources\n */\nfunction cleanup(): void {\n  disableFeature();\n\n  if (storageListener) {\n    try {\n      chrome.storage?.onChanged?.removeListener(storageListener);\n    } catch {\n      // Ignore cleanup errors\n    }\n    storageListener = null;\n  }\n\n  console.log(LOG_PREFIX, 'Cleanup complete');\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Initialize the send behavior module\n * @returns A cleanup function to be called on unmount\n */\nexport async function startSendBehavior(): Promise<() => void> {\n  // Always setup storage listener first (to respond to setting changes)\n  setupStorageListener();\n\n  // Load initial setting and enable if needed\n  const initialEnabled = await loadSettings();\n  if (initialEnabled) {\n    enableFeature();\n  } else {\n    console.log(LOG_PREFIX, 'Feature disabled, skipping initialization');\n  }\n\n  return cleanup;\n}\n"
  },
  {
    "path": "src/pages/content/sendBehavior/utils.ts",
    "content": "/**\n * Selection/Cursor Helpers\n *\n * Provides utilities to get/set cursor position based on pure text offset via TreeWalker.\n * This is robust against DOM re-renders as long as the text content remains consistent.\n */\n\n/**\n * Get the current cursor position as a global text offset relative to the root element.\n */\nexport function getTextOffset(root: HTMLElement): number | null {\n  const selection = window.getSelection();\n  if (!selection || selection.rangeCount === 0) return null;\n\n  const range = selection.getRangeAt(0);\n\n  // Create a range that spans from the start of the root to the cursor\n  const preCaretRange = range.cloneRange();\n  preCaretRange.selectNodeContents(root);\n  preCaretRange.setEnd(range.endContainer, range.endOffset);\n\n  // The length of the string in that range is our offset\n  return preCaretRange.toString().length;\n}\n\n/**\n * Restore the cursor to a specific global text offset.\n */\nexport function setCaretPosition(root: HTMLElement, targetOffset: number): void {\n  const selection = window.getSelection();\n  if (!selection) return;\n\n  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);\n  let currentOffset = 0;\n  let found = false;\n\n  while (walker.nextNode()) {\n    const node = walker.currentNode;\n    const length = node.textContent?.length || 0;\n\n    if (currentOffset + length >= targetOffset) {\n      // The target is inside this node\n      const range = document.createRange();\n      const relativeOffset = targetOffset - currentOffset;\n\n      range.setStart(node, relativeOffset);\n      range.collapse(true);\n\n      selection.removeAllRanges();\n      selection.addRange(range);\n      found = true;\n      break;\n    }\n\n    currentOffset += length;\n  }\n\n  // Fallback: If targetOffset is beyond content length, set to end\n  if (!found) {\n    const range = document.createRange();\n    range.selectNodeContents(root);\n    range.collapse(false);\n    selection.removeAllRanges();\n    selection.addRange(range);\n  }\n}\n"
  },
  {
    "path": "src/pages/content/shared/__tests__/nativeMenuItemTemplate.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport {\n  createMenuItemFromNativeTemplate,\n  updateMenuItemTemplateLabel,\n} from '../nativeMenuItemTemplate';\n\nfunction createTemplateButton(): HTMLButtonElement {\n  const button = document.createElement('button');\n  button.className = 'mat-mdc-menu-item mat-focus-indicator';\n  button.setAttribute('data-test-id', 'share-button');\n\n  const icon = document.createElement('mat-icon');\n  icon.className =\n    'mat-icon notranslate menu-icon google-symbols mat-ligature-font mat-icon-no-color';\n  icon.setAttribute('fonticon', 'share');\n  icon.setAttribute('data-mat-icon-type', 'font');\n  icon.setAttribute('data-mat-icon-name', 'share');\n  icon.setAttribute('aria-hidden', 'true');\n\n  const text = document.createElement('span');\n  text.className = 'mat-mdc-menu-item-text';\n\n  const textInner = document.createElement('span');\n  textInner.className = 'menu-text';\n  textInner.textContent = 'Share conversation';\n  text.appendChild(textInner);\n\n  button.append(icon, text);\n  return button;\n}\n\nfunction createMenuContent(): HTMLElement {\n  const content = document.createElement('div');\n  content.className = 'mat-mdc-menu-content';\n  content.appendChild(createTemplateButton());\n  document.body.appendChild(content);\n  return content;\n}\n\nfunction createWrappedMenuContent(): HTMLElement {\n  const content = document.createElement('div');\n  content.className = 'mat-mdc-menu-content';\n\n  const wrapper = document.createElement('div');\n  wrapper.setAttribute('data-test-id', 'share-button-tooltip-container');\n  wrapper.appendChild(createTemplateButton());\n  content.appendChild(wrapper);\n\n  document.body.appendChild(content);\n  return content;\n}\n\ndescribe('nativeMenuItemTemplate', () => {\n  it('updates data-mat-icon-name to match injected icon', () => {\n    const menuContent = createMenuContent();\n    const button = createMenuItemFromNativeTemplate({\n      menuContent,\n      injectedClassName: 'gv-export-conversation-menu-btn',\n      iconName: 'download',\n      label: '导出对话记录',\n      tooltip: '导出对话记录',\n    });\n\n    expect(button).toBeTruthy();\n    const icon = button?.querySelector('mat-icon');\n    expect(icon?.getAttribute('fonticon')).toBe('download');\n    expect(icon?.getAttribute('data-mat-icon-name')).toBe('download');\n  });\n\n  it('keeps native menu-text wrapper when replacing label text', () => {\n    const menuContent = createMenuContent();\n    const button = createMenuItemFromNativeTemplate({\n      menuContent,\n      injectedClassName: 'gv-export-conversation-menu-btn',\n      iconName: 'download',\n      label: '导出对话记录',\n      tooltip: '导出对话记录',\n    });\n\n    expect(button).toBeTruthy();\n    const textInner = button?.querySelector('.mat-mdc-menu-item-text > .menu-text');\n    expect(textInner).toBeTruthy();\n    expect(textInner?.textContent).toBe('导出对话记录');\n  });\n\n  it('keeps native menu-text wrapper when updating existing button label', () => {\n    const menuContent = createMenuContent();\n    const button = createMenuItemFromNativeTemplate({\n      menuContent,\n      injectedClassName: 'gv-export-conversation-menu-btn',\n      iconName: 'download',\n      label: 'Export conversation history',\n      tooltip: 'Export conversation history',\n    });\n\n    expect(button).toBeTruthy();\n    if (!button) return;\n\n    updateMenuItemTemplateLabel(button, '导出对话记录', '导出对话记录');\n    const textInner = button.querySelector('.mat-mdc-menu-item-text > .menu-text');\n    expect(textInner).toBeTruthy();\n    expect(textInner?.textContent).toBe('导出对话记录');\n  });\n\n  it('still finds nested native template when direct buttons are excluded', () => {\n    const menuContent = createWrappedMenuContent();\n    const injected = document.createElement('button');\n    injected.className = 'mat-mdc-menu-item gv-export-conversation-menu-btn';\n    menuContent.appendChild(injected);\n\n    const button = createMenuItemFromNativeTemplate({\n      menuContent,\n      injectedClassName: 'gv-export-conversation-menu-btn',\n      iconName: 'download',\n      label: 'Export conversation history',\n      tooltip: 'Export conversation history',\n      excludedClassNames: ['gv-export-conversation-menu-btn'],\n    });\n\n    expect(button).toBeTruthy();\n    expect(button?.classList.contains('gv-export-conversation-menu-btn')).toBe(true);\n    expect(button?.querySelector('mat-icon')?.className).toContain('menu-icon');\n  });\n});\n"
  },
  {
    "path": "src/pages/content/shared/nativeMenuItemTemplate.ts",
    "content": "type NativeMenuItemTemplateOptions = {\n  menuContent: HTMLElement;\n  injectedClassName: string;\n  iconName: string;\n  label: string;\n  tooltip?: string;\n  excludedClassNames?: string[];\n};\n\nfunction findTemplateMenuItem(\n  menuContent: HTMLElement,\n  excludedClassNames: string[],\n): HTMLButtonElement | null {\n  const directButtons = Array.from(menuContent.children).filter(\n    (node): node is HTMLButtonElement =>\n      node instanceof HTMLButtonElement && node.classList.contains('mat-mdc-menu-item'),\n  );\n\n  const nestedButtons = Array.from(\n    menuContent.querySelectorAll<HTMLButtonElement>('button.mat-mdc-menu-item'),\n  );\n  const candidates: HTMLButtonElement[] = [...directButtons];\n  for (const button of nestedButtons) {\n    if (!candidates.includes(button)) {\n      candidates.push(button);\n    }\n  }\n\n  return (\n    candidates.find(\n      (button) => !excludedClassNames.some((className) => button.classList.contains(className)),\n    ) ?? null\n  );\n}\n\nfunction updateMenuItemLabel(button: HTMLButtonElement, label: string): void {\n  const textContainer = button.querySelector('.mat-mdc-menu-item-text') as HTMLElement | null;\n  if (!textContainer) return;\n\n  const styledLabel = textContainer.querySelector(\n    '.menu-text, .gds-body-m, .gds-label-m, .subtitle',\n  );\n  if (styledLabel) {\n    styledLabel.textContent = label;\n    return;\n  }\n\n  textContainer.textContent = label;\n}\n\nfunction updateMenuItemIcon(button: HTMLButtonElement, iconName: string): void {\n  const icon = button.querySelector('mat-icon') as HTMLElement | null;\n  if (!icon) return;\n\n  const usesFontIconAttribute = icon.hasAttribute('fonticon');\n  if (usesFontIconAttribute) {\n    icon.setAttribute('fonticon', iconName);\n  } else {\n    icon.removeAttribute('fonticon');\n  }\n  if (icon.hasAttribute('data-mat-icon-name')) {\n    icon.setAttribute('data-mat-icon-name', iconName);\n  }\n  icon.setAttribute('aria-hidden', 'true');\n  icon.textContent = usesFontIconAttribute ? '' : iconName;\n}\n\nfunction clearTemplateSpecificAttributes(button: HTMLButtonElement): void {\n  const attributesToRemove = [\n    'data-test-id',\n    'id',\n    'jslog',\n    'jscontroller',\n    'jsaction',\n    'jsname',\n    'aria-describedby',\n    'aria-labelledby',\n  ];\n\n  for (const attribute of attributesToRemove) {\n    button.removeAttribute(attribute);\n  }\n\n  const classesToRemove = [\n    'cdk-focused',\n    'cdk-keyboard-focused',\n    'cdk-program-focused',\n    'cdk-mouse-focused',\n    'mat-mdc-menu-item-highlighted',\n  ];\n  for (const className of classesToRemove) {\n    button.classList.remove(className);\n  }\n}\n\nexport function createMenuItemFromNativeTemplate({\n  menuContent,\n  injectedClassName,\n  iconName,\n  label,\n  tooltip,\n  excludedClassNames = [],\n}: NativeMenuItemTemplateOptions): HTMLButtonElement | null {\n  const template = findTemplateMenuItem(menuContent, [injectedClassName, ...excludedClassNames]);\n  if (!template) return null;\n\n  const button = template.cloneNode(true) as HTMLButtonElement;\n  clearTemplateSpecificAttributes(button);\n  button.classList.add(injectedClassName);\n  button.setAttribute('role', 'menuitem');\n  button.setAttribute('tabindex', '0');\n  button.setAttribute('aria-disabled', 'false');\n  button.disabled = false;\n\n  const description = tooltip || label;\n  button.title = description;\n  button.setAttribute('aria-label', description);\n\n  updateMenuItemIcon(button, iconName);\n  updateMenuItemLabel(button, label);\n\n  return button;\n}\n\nexport function updateMenuItemTemplateLabel(\n  button: HTMLButtonElement,\n  label: string,\n  tooltip?: string,\n): void {\n  const description = tooltip || label;\n  button.title = description;\n  button.setAttribute('aria-label', description);\n  updateMenuItemLabel(button, label);\n}\n"
  },
  {
    "path": "src/pages/content/sidebarAutoHide/__tests__/SidebarAutoHide.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nfunction mockVisibleRect(element: HTMLElement, width: number = 300, height: number = 600): void {\n  vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({\n    x: 0,\n    y: 0,\n    top: 0,\n    left: 0,\n    right: width,\n    bottom: height,\n    width,\n    height,\n    toJSON: () => ({}),\n  } as DOMRect);\n}\n\ndescribe('sidebarAutoHide', () => {\n  beforeEach(() => {\n    vi.resetModules();\n    vi.useFakeTimers();\n    vi.clearAllMocks();\n    document.body.innerHTML = '';\n    document.body.className = '';\n  });\n\n  afterEach(() => {\n    window.dispatchEvent(new Event('beforeunload'));\n    vi.useRealTimers();\n  });\n\n  it('does not collapse when folder color picker is open', async () => {\n    document.body.classList.add('mat-sidenav-opened');\n\n    const sidenav = document.createElement('bard-sidenav');\n    mockVisibleRect(sidenav, 320, 800);\n    document.body.appendChild(sidenav);\n\n    const toggleButton = document.createElement('button');\n    toggleButton.setAttribute('data-test-id', 'side-nav-menu-button');\n    const toggleSpy = vi.fn();\n    toggleButton.addEventListener('click', toggleSpy);\n    document.body.appendChild(toggleButton);\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvSidebarAutoHide: true });\n      },\n    );\n\n    const { startSidebarAutoHide } = await import('../index');\n    startSidebarAutoHide();\n\n    const colorPicker = document.createElement('div');\n    colorPicker.className = 'gv-color-picker-dialog';\n    mockVisibleRect(colorPicker, 180, 120);\n    document.body.appendChild(colorPicker);\n\n    sidenav.dispatchEvent(new Event('mouseleave'));\n    vi.advanceTimersByTime(600);\n    expect(toggleSpy).not.toHaveBeenCalled();\n\n    colorPicker.remove();\n    sidenav.dispatchEvent(new Event('mouseleave'));\n    vi.advanceTimersByTime(600);\n    expect(toggleSpy).toHaveBeenCalledTimes(1);\n  });\n\n  it('does not expand on quick sidebar hover pass-through', async () => {\n    const sidenav = document.createElement('bard-sidenav');\n    mockVisibleRect(sidenav, 320, 800);\n\n    const sideNavigationContent = document.createElement('side-navigation-content');\n    const collapsedContainer = document.createElement('div');\n    collapsedContainer.className = 'collapsed';\n    sideNavigationContent.appendChild(collapsedContainer);\n    sidenav.appendChild(sideNavigationContent);\n    document.body.appendChild(sidenav);\n\n    const toggleButton = document.createElement('button');\n    toggleButton.setAttribute('data-test-id', 'side-nav-menu-button');\n    const toggleSpy = vi.fn();\n    toggleButton.addEventListener('click', toggleSpy);\n    document.body.appendChild(toggleButton);\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvSidebarAutoHide: true });\n      },\n    );\n\n    const { startSidebarAutoHide } = await import('../index');\n    startSidebarAutoHide();\n\n    sidenav.dispatchEvent(new Event('mouseenter'));\n    vi.advanceTimersByTime(150);\n    sidenav.dispatchEvent(new Event('mouseleave'));\n    vi.advanceTimersByTime(400);\n\n    expect(toggleSpy).not.toHaveBeenCalled();\n  });\n\n  it('reveals the sidebar after clicking the toggle button in full-hide mode', async () => {\n    const sidenav = document.createElement('bard-sidenav');\n    mockVisibleRect(sidenav, 320, 800);\n\n    const sideNavigationContent = document.createElement('side-navigation-content');\n    const collapsedContainer = document.createElement('div');\n    collapsedContainer.className = 'collapsed';\n    sideNavigationContent.appendChild(collapsedContainer);\n    sidenav.appendChild(sideNavigationContent);\n    document.body.appendChild(sidenav);\n\n    const toggleButton = document.createElement('button');\n    toggleButton.setAttribute('data-test-id', 'side-nav-menu-button');\n    toggleButton.addEventListener('click', () => {\n      collapsedContainer.classList.remove('collapsed');\n    });\n    document.body.appendChild(toggleButton);\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvSidebarFullHide: true });\n      },\n    );\n\n    const { startSidebarAutoHide } = await import('../index');\n    startSidebarAutoHide();\n\n    vi.advanceTimersByTime(300);\n    expect(document.documentElement.classList.contains('gv-sidebar-full-hide-collapsed')).toBe(\n      true,\n    );\n\n    toggleButton.click();\n    vi.advanceTimersByTime(400);\n\n    expect(document.documentElement.classList.contains('gv-sidebar-full-hide-collapsed')).toBe(\n      false,\n    );\n\n    const edgeTrigger = document.getElementById('gv-sidebar-edge-trigger');\n    expect(edgeTrigger).not.toBeNull();\n    expect(edgeTrigger?.style.display).toBe('none');\n  });\n\n  it('auto-collapses after expanding from the full-hide edge trigger', async () => {\n    const sidenav = document.createElement('bard-sidenav');\n    mockVisibleRect(sidenav, 320, 800);\n\n    const sideNavigationContent = document.createElement('side-navigation-content');\n    const collapsedContainer = document.createElement('div');\n    collapsedContainer.className = 'collapsed';\n    sideNavigationContent.appendChild(collapsedContainer);\n    sidenav.appendChild(sideNavigationContent);\n    document.body.appendChild(sidenav);\n\n    const toggleButton = document.createElement('button');\n    toggleButton.setAttribute('data-test-id', 'side-nav-menu-button');\n    const toggleSpy = vi.fn(() => {\n      collapsedContainer.classList.toggle('collapsed');\n    });\n    toggleButton.addEventListener('click', toggleSpy);\n    document.body.appendChild(toggleButton);\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvSidebarAutoHide: true, gvSidebarFullHide: true });\n      },\n    );\n\n    const { startSidebarAutoHide } = await import('../index');\n    startSidebarAutoHide();\n\n    vi.advanceTimersByTime(300);\n    const edgeTrigger = document.getElementById('gv-sidebar-edge-trigger');\n    expect(edgeTrigger).not.toBeNull();\n\n    edgeTrigger?.dispatchEvent(new Event('mouseenter'));\n    vi.advanceTimersByTime(300);\n\n    expect(toggleSpy).toHaveBeenCalledTimes(1);\n    expect(collapsedContainer.classList.contains('collapsed')).toBe(false);\n\n    sidenav.dispatchEvent(new Event('mouseleave'));\n    vi.advanceTimersByTime(600);\n\n    expect(toggleSpy).toHaveBeenCalledTimes(2);\n    expect(collapsedContainer.classList.contains('collapsed')).toBe(true);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/sidebarAutoHide/index.ts",
    "content": "/**\n * Sidebar Auto-Hide & Full-Hide Feature for Gemini\n *\n * Auto-hide: sidebar automatically collapses when the mouse leaves,\n * and expands when the mouse enters.\n *\n * Full-hide: when collapsed (by auto-hide or manually), the sidebar\n * is fully hidden (zero width). A left-edge hover trigger allows\n * revealing it.\n *\n * Uses the `side-nav-menu-button` to toggle sidebar state.\n */\n\nconst STYLE_ID = 'gv-sidebar-auto-hide-style';\nconst FULL_HIDE_STYLE_ID = 'gv-sidebar-full-hide-style';\nconst STORAGE_KEY = 'gvSidebarAutoHide';\nconst FULL_HIDE_STORAGE_KEY = 'gvSidebarFullHide';\nconst EDGE_TRIGGER_ID = 'gv-sidebar-edge-trigger';\nconst FULL_HIDE_COLLAPSED_CLASS = 'gv-sidebar-full-hide-collapsed';\nconst SIDEBAR_TOGGLE_BUTTON_SELECTOR =\n  'button[data-test-id=\"side-nav-menu-button\"], side-nav-menu-button button';\nconst SIDEBAR_TOGGLE_BUTTON_MATCH_SELECTOR = `${SIDEBAR_TOGGLE_BUTTON_SELECTOR}, side-nav-menu-button`;\nconst SIDEBAR_STATE_SYNC_DELAYS_MS = [0, 120, 360] as const;\n\n// Debounce delay to avoid rapid toggling\nconst LEAVE_DELAY_MS = 500;\nconst ENTER_DELAY_MS = 300;\n// Interval to check for sidenav element reappearing\nconst SIDENAV_CHECK_INTERVAL_MS = 1000;\n// Debounce delay for resize events\nconst RESIZE_DEBOUNCE_MS = 200;\n// Pause duration after menu item click (wait for dialog to appear)\nconst MENU_CLICK_PAUSE_MS = 1500;\n// Width of the invisible edge trigger zone (px)\nconst EDGE_TRIGGER_WIDTH = 6;\nconst CUSTOM_POPUP_SELECTORS = [\n  '.gv-folder-dialog',\n  '.gv-folder-dialog-overlay',\n  '.gv-folder-confirm-dialog',\n  '.gv-folder-import-dialog',\n  '.gv-folder-menu',\n  '.gv-color-picker-dialog',\n];\n\n// Auto-hide state\nlet enabled = false;\nlet leaveTimeoutId: number | null = null;\nlet enterTimeoutId: number | null = null;\nlet sidenavElement: HTMLElement | null = null;\nlet autoCollapsed = false;\nlet pausedUntil = 0;\n\n// Full-hide state\nlet fullHideEnabled = false;\nlet edgeTriggerElement: HTMLElement | null = null;\n\n// Shared infrastructure\nlet observer: MutationObserver | null = null;\nlet resizeHandler: (() => void) | null = null;\nlet resizeDebounceTimer: number | null = null;\nlet sidenavCheckTimer: number | null = null;\nlet menuClickHandler: ((e: Event) => void) | null = null;\nlet sidebarStateSyncTimeoutIds: number[] = [];\nlet internalToggleClickDepth = 0;\n\nfunction isElementVisible(element: HTMLElement): boolean {\n  const style = window.getComputedStyle(element);\n  if (style.display === 'none' || style.visibility === 'hidden') {\n    return false;\n  }\n  const rect = element.getBoundingClientRect();\n  return rect.width > 0 || rect.height > 0;\n}\n\n// ─── Transition CSS (shared) ───────────────────────────────────────────\n\nfunction getTransitionStyle(): string {\n  return `\n    /* Smooth transition for sidebar auto-hide / full-hide */\n    bard-sidenav,\n    bard-sidenav side-navigation-content,\n    bard-sidenav side-navigation-content > div {\n      transition: width 0.25s ease, transform 0.25s ease !important;\n    }\n  `;\n}\n\nfunction insertTransitionStyle(): void {\n  if (document.getElementById(STYLE_ID)) return;\n  const style = document.createElement('style');\n  style.id = STYLE_ID;\n  style.textContent = getTransitionStyle();\n  document.documentElement.appendChild(style);\n}\n\nfunction removeTransitionStyle(): void {\n  const style = document.getElementById(STYLE_ID);\n  if (style) style.remove();\n}\n\n// ─── Full-Hide CSS ─────────────────────────────────────────────────────\n\nfunction getFullHideStyle(): string {\n  return `\n    /* Fully hide collapsed sidebar */\n    html.${FULL_HIDE_COLLAPSED_CLASS} bard-sidenav,\n    html.${FULL_HIDE_COLLAPSED_CLASS} bard-sidenav side-navigation-content,\n    html.${FULL_HIDE_COLLAPSED_CLASS} bard-sidenav side-navigation-content > div {\n      width: 0 !important;\n      min-width: 0 !important;\n      overflow: hidden !important;\n      padding: 0 !important;\n    }\n  `;\n}\n\nfunction insertFullHideStyle(): void {\n  if (document.getElementById(FULL_HIDE_STYLE_ID)) return;\n  const style = document.createElement('style');\n  style.id = FULL_HIDE_STYLE_ID;\n  style.textContent = getFullHideStyle();\n  document.documentElement.appendChild(style);\n}\n\nfunction removeFullHideStyle(): void {\n  const style = document.getElementById(FULL_HIDE_STYLE_ID);\n  if (style) style.remove();\n}\n\n// ─── Edge Trigger (full-hide) ──────────────────────────────────────────\n\nfunction handleEdgeTriggerLeave(e: MouseEvent): void {\n  if (!fullHideEnabled) return;\n\n  const related = e.relatedTarget as HTMLElement | null;\n  if (related) {\n    const sidenav = getSidenavElement();\n    if (sidenav && (sidenav === related || sidenav.contains(related))) {\n      return;\n    }\n  }\n\n  if (enterTimeoutId !== null) {\n    window.clearTimeout(enterTimeoutId);\n    enterTimeoutId = null;\n  }\n}\n\nfunction createEdgeTrigger(): void {\n  if (edgeTriggerElement) return;\n  const el = document.createElement('div');\n  el.id = EDGE_TRIGGER_ID;\n  el.style.cssText = `\n    position: fixed;\n    left: 0;\n    top: 0;\n    width: ${EDGE_TRIGGER_WIDTH}px;\n    height: 100vh;\n    z-index: 99999;\n    background: transparent;\n    display: none;\n  `;\n  el.addEventListener('mouseenter', handleMouseEnter);\n  el.addEventListener('mouseleave', handleEdgeTriggerLeave);\n  document.documentElement.appendChild(el);\n  edgeTriggerElement = el;\n}\n\nfunction removeEdgeTrigger(): void {\n  if (edgeTriggerElement) {\n    edgeTriggerElement.removeEventListener('mouseenter', handleMouseEnter);\n    edgeTriggerElement.removeEventListener('mouseleave', handleEdgeTriggerLeave);\n    edgeTriggerElement.remove();\n    edgeTriggerElement = null;\n  }\n}\n\nfunction showEdgeTrigger(): void {\n  if (edgeTriggerElement) {\n    edgeTriggerElement.style.display = 'block';\n  }\n}\n\nfunction hideEdgeTrigger(): void {\n  if (edgeTriggerElement) {\n    edgeTriggerElement.style.display = 'none';\n  }\n}\n\n// ─── Sidebar State Detection ───────────────────────────────────────────\n\nfunction findToggleButton(): HTMLButtonElement | null {\n  const btn = document.querySelector<HTMLButtonElement>(SIDEBAR_TOGGLE_BUTTON_SELECTOR);\n  if (btn) return btn;\n\n  const sideNavMenuButton = document.querySelector('side-nav-menu-button');\n  if (sideNavMenuButton) {\n    return sideNavMenuButton.querySelector<HTMLButtonElement>('button');\n  }\n\n  return null;\n}\n\nfunction getSidebarContentContainer(): HTMLElement | null {\n  return document.querySelector<HTMLElement>('bard-sidenav side-navigation-content > div');\n}\n\nfunction getStructuredSidebarCollapsedState(): boolean | null {\n  if (document.body.classList.contains('mat-sidenav-opened')) {\n    return false;\n  }\n\n  const sideContent = getSidebarContentContainer();\n  if (sideContent) {\n    return sideContent.classList.contains('collapsed');\n  }\n\n  return null;\n}\n\nfunction isSidebarCollapsed(): boolean {\n  const structuredState = getStructuredSidebarCollapsedState();\n  if (structuredState !== null) {\n    return structuredState;\n  }\n\n  const sidenav = document.querySelector<HTMLElement>('bard-sidenav');\n  if (sidenav) {\n    const width = sidenav.getBoundingClientRect().width;\n    if (width < 80) return true;\n  }\n\n  return false;\n}\n\nfunction isSidebarVisible(): boolean {\n  const sidenav = document.querySelector<HTMLElement>('bard-sidenav');\n  if (!sidenav) return false;\n  const rect = sidenav.getBoundingClientRect();\n  return rect.width > 0 && rect.height > 0;\n}\n\nfunction isPaused(): boolean {\n  return Date.now() < pausedUntil;\n}\n\nfunction clearScheduledSidebarStateSync(): void {\n  for (const timeoutId of sidebarStateSyncTimeoutIds) {\n    window.clearTimeout(timeoutId);\n  }\n  sidebarStateSyncTimeoutIds = [];\n}\n\nfunction scheduleSidebarStateSync(): void {\n  if (!fullHideEnabled) return;\n\n  clearScheduledSidebarStateSync();\n  for (const delay of SIDEBAR_STATE_SYNC_DELAYS_MS) {\n    const timeoutId = window.setTimeout(() => {\n      checkAndReattach();\n      sidebarStateSyncTimeoutIds = sidebarStateSyncTimeoutIds.filter((id) => id !== timeoutId);\n    }, delay);\n    sidebarStateSyncTimeoutIds.push(timeoutId);\n  }\n}\n\nfunction pauseAutoCollapse(durationMs: number): void {\n  pausedUntil = Date.now() + durationMs;\n}\n\nfunction isPopupOrDialogOpen(): boolean {\n  const matDialogs = document.querySelectorAll<HTMLElement>('.mat-mdc-dialog-container');\n  for (const dialog of matDialogs) {\n    if (isElementVisible(dialog)) return true;\n  }\n\n  const matMenus = document.querySelectorAll<HTMLElement>('.mat-mdc-menu-panel');\n  for (const menu of matMenus) {\n    if (isElementVisible(menu)) return true;\n  }\n\n  for (const selector of CUSTOM_POPUP_SELECTORS) {\n    const customPopups = document.querySelectorAll<HTMLElement>(selector);\n    for (const popup of customPopups) {\n      if (isElementVisible(popup)) return true;\n    }\n  }\n\n  return false;\n}\n\nfunction isMouseOverSidebarArea(): boolean {\n  if (edgeTriggerElement?.matches(':hover')) return true;\n  if (sidenavElement?.matches(':hover')) return true;\n\n  const matDialogs = document.querySelectorAll<HTMLElement>('.mat-mdc-dialog-container');\n  for (const dialog of matDialogs) {\n    if (dialog.matches(':hover')) return true;\n  }\n\n  const matMenus = document.querySelectorAll<HTMLElement>('.mat-mdc-menu-panel');\n  for (const menu of matMenus) {\n    if (menu.matches(':hover')) return true;\n  }\n\n  for (const selector of CUSTOM_POPUP_SELECTORS) {\n    const customPopups = document.querySelectorAll<HTMLElement>(selector);\n    for (const popup of customPopups) {\n      if (popup.matches(':hover')) return true;\n    }\n  }\n\n  return false;\n}\n\n// ─── Menu Click Handling ───────────────────────────────────────────────\n\nfunction handleMenuClick(e: Event): void {\n  const target = e.target;\n  if (!(target instanceof HTMLElement)) return;\n\n  if (target.closest(SIDEBAR_TOGGLE_BUTTON_MATCH_SELECTOR)) {\n    if (internalToggleClickDepth > 0) {\n      return;\n    }\n\n    if (fullHideEnabled) {\n      scheduleSidebarStateSync();\n    }\n\n    if (enabled) {\n      pauseAutoCollapse(MENU_CLICK_PAUSE_MS);\n    }\n    return;\n  }\n\n  if (!enabled) return;\n\n  const menuItem = target.closest('[role=\"menuitem\"], [role=\"menuitemradio\"], .mat-mdc-menu-item');\n  if (menuItem) {\n    pauseAutoCollapse(MENU_CLICK_PAUSE_MS);\n    return;\n  }\n\n  const sidebarButton = target.closest('bard-sidenav button, bard-sidenav [role=\"button\"]');\n  if (sidebarButton) {\n    pauseAutoCollapse(MENU_CLICK_PAUSE_MS);\n    return;\n  }\n\n  const optionsButton = target.closest(\n    '[data-test-id*=\"options\"], [aria-label*=\"选项\"], [aria-label*=\"Options\"], [aria-label*=\"More\"]',\n  );\n  if (optionsButton) {\n    pauseAutoCollapse(MENU_CLICK_PAUSE_MS);\n    return;\n  }\n}\n\n// ─── Sidebar Toggle ────────────────────────────────────────────────────\n\nfunction clickToggleButton(): boolean {\n  const btn = findToggleButton();\n  if (!btn) return false;\n\n  internalToggleClickDepth += 1;\n  try {\n    btn.click();\n  } finally {\n    internalToggleClickDepth -= 1;\n  }\n\n  if (fullHideEnabled) {\n    scheduleSidebarStateSync();\n  }\n\n  return true;\n}\n\nfunction collapseSidebar(): void {\n  if (isPaused()) return;\n  if (isPopupOrDialogOpen()) return;\n  if (isMouseOverSidebarArea()) return;\n\n  if (!isSidebarCollapsed()) {\n    if (clickToggleButton()) {\n      autoCollapsed = true;\n    }\n  }\n}\n\nfunction expandSidebar(): void {\n  if (isSidebarCollapsed()) {\n    clickToggleButton();\n    autoCollapsed = false;\n    // Schedule a reattach so auto-hide listeners are re-added after expansion\n    setTimeout(() => checkAndReattach(), 350);\n  }\n}\n\n// ─── Mouse Event Handlers ──────────────────────────────────────────────\n\nfunction handleMouseEnter(): void {\n  if (!enabled && !fullHideEnabled) return;\n\n  if (leaveTimeoutId !== null) {\n    window.clearTimeout(leaveTimeoutId);\n    leaveTimeoutId = null;\n  }\n\n  if (enterTimeoutId !== null) {\n    window.clearTimeout(enterTimeoutId);\n  }\n\n  enterTimeoutId = window.setTimeout(() => {\n    enterTimeoutId = null;\n    if (!enabled && !fullHideEnabled) return;\n    expandSidebar();\n  }, ENTER_DELAY_MS);\n}\n\nfunction handleMouseLeave(): void {\n  if (!enabled) return;\n\n  if (enterTimeoutId !== null) {\n    window.clearTimeout(enterTimeoutId);\n    enterTimeoutId = null;\n  }\n\n  if (leaveTimeoutId !== null) {\n    window.clearTimeout(leaveTimeoutId);\n  }\n\n  leaveTimeoutId = window.setTimeout(() => {\n    leaveTimeoutId = null;\n    if (!enabled) return;\n    collapseSidebar();\n  }, LEAVE_DELAY_MS);\n}\n\n// ─── DOM Management ────────────────────────────────────────────────────\n\nfunction getSidenavElement(): HTMLElement | null {\n  return document.querySelector<HTMLElement>('bard-sidenav');\n}\n\nfunction attachEventListeners(): boolean {\n  const sidenav = getSidenavElement();\n  if (!sidenav) return false;\n  if (!isSidebarVisible()) return false;\n  if (sidenav === sidenavElement) return true;\n\n  if (sidenavElement) {\n    sidenavElement.removeEventListener('mouseenter', handleMouseEnter);\n    sidenavElement.removeEventListener('mouseleave', handleMouseLeave);\n  }\n\n  sidenavElement = sidenav;\n  sidenav.addEventListener('mouseenter', handleMouseEnter);\n  sidenav.addEventListener('mouseleave', handleMouseLeave);\n  return true;\n}\n\nfunction detachEventListeners(): void {\n  if (sidenavElement) {\n    sidenavElement.removeEventListener('mouseenter', handleMouseEnter);\n    sidenavElement.removeEventListener('mouseleave', handleMouseLeave);\n    sidenavElement = null;\n  }\n}\n\nfunction checkAndReattach(): void {\n  if (!enabled && !fullHideEnabled) return;\n\n  const currentSidenav = getSidenavElement();\n\n  // Auto-hide: manage event listeners on sidenav\n  if (enabled) {\n    if (sidenavElement && !sidenavElement.isConnected) {\n      detachEventListeners();\n      autoCollapsed = false;\n    }\n\n    if (sidenavElement && !isSidebarVisible()) {\n      detachEventListeners();\n    } else if (currentSidenav && isSidebarVisible() && currentSidenav !== sidenavElement) {\n      attachEventListeners();\n    }\n  }\n\n  // Full-hide: sync edge trigger visibility with sidebar state\n  // Check height > 0 to exclude responsive layouts where Gemini hides the sidebar entirely\n  // (our full-hide CSS only zeroes width, not height)\n  if (fullHideEnabled) {\n    syncFullHideState();\n  }\n}\n\nfunction handleResize(): void {\n  if (!enabled && !fullHideEnabled) return;\n\n  if (resizeDebounceTimer !== null) {\n    window.clearTimeout(resizeDebounceTimer);\n  }\n\n  resizeDebounceTimer = window.setTimeout(() => {\n    resizeDebounceTimer = null;\n    checkAndReattach();\n\n    setTimeout(() => {\n      if (enabled || fullHideEnabled) checkAndReattach();\n    }, 600);\n  }, RESIZE_DEBOUNCE_MS);\n}\n\nfunction startSidenavCheck(): void {\n  if (sidenavCheckTimer !== null) return;\n  sidenavCheckTimer = window.setInterval(() => {\n    checkAndReattach();\n  }, SIDENAV_CHECK_INTERVAL_MS);\n}\n\nfunction stopSidenavCheck(): void {\n  if (sidenavCheckTimer !== null) {\n    window.clearInterval(sidenavCheckTimer);\n    sidenavCheckTimer = null;\n  }\n}\n\n// ─── Shared Infrastructure ─────────────────────────────────────────────\n\nfunction setupInfrastructure(): void {\n  if (!observer) {\n    observer = new MutationObserver(() => {\n      if (enabled || fullHideEnabled) checkAndReattach();\n    });\n    observer.observe(document.body, { childList: true, subtree: true });\n  }\n\n  if (!resizeHandler) {\n    resizeHandler = handleResize;\n    window.addEventListener('resize', resizeHandler);\n  }\n\n  startSidenavCheck();\n}\n\nfunction teardownInfrastructure(): void {\n  if (enabled || fullHideEnabled) return;\n\n  stopSidenavCheck();\n  clearScheduledSidebarStateSync();\n\n  if (resizeDebounceTimer !== null) {\n    window.clearTimeout(resizeDebounceTimer);\n    resizeDebounceTimer = null;\n  }\n\n  if (observer) {\n    observer.disconnect();\n    observer = null;\n  }\n\n  if (resizeHandler) {\n    window.removeEventListener('resize', resizeHandler);\n    resizeHandler = null;\n  }\n}\n\nfunction ensureMenuClickHandler(): void {\n  if (menuClickHandler) return;\n  menuClickHandler = handleMenuClick;\n  document.addEventListener('click', menuClickHandler, true);\n}\n\nfunction maybeRemoveMenuClickHandler(): void {\n  if (enabled || fullHideEnabled || !menuClickHandler) return;\n  document.removeEventListener('click', menuClickHandler, true);\n  menuClickHandler = null;\n}\n\n// ─── Auto-Hide Feature ────────────────────────────────────────────────\n\nfunction enable(): void {\n  if (enabled) return;\n  enabled = true;\n  autoCollapsed = false;\n  pausedUntil = 0;\n\n  insertTransitionStyle();\n  attachEventListeners();\n  ensureMenuClickHandler();\n\n  setupInfrastructure();\n\n  // Initial collapse if mouse is not on sidebar and no popup is open\n  setTimeout(() => {\n    if (!enabled) return;\n    if (sidenavElement && !sidenavElement.matches(':hover') && !isPopupOrDialogOpen()) {\n      collapseSidebar();\n    }\n  }, 500);\n}\n\nfunction disable(): void {\n  if (!enabled) return;\n  enabled = false;\n\n  if (enterTimeoutId !== null) {\n    window.clearTimeout(enterTimeoutId);\n    enterTimeoutId = null;\n  }\n\n  if (leaveTimeoutId !== null) {\n    window.clearTimeout(leaveTimeoutId);\n    leaveTimeoutId = null;\n  }\n\n  if (autoCollapsed && isSidebarCollapsed()) {\n    clickToggleButton();\n  }\n  autoCollapsed = false;\n  pausedUntil = 0;\n\n  detachEventListeners();\n\n  if (!fullHideEnabled) {\n    removeTransitionStyle();\n  }\n\n  maybeRemoveMenuClickHandler();\n\n  teardownInfrastructure();\n}\n\n// ─── Full-Hide Feature ─────────────────────────────────────────────────\n\nfunction setFullHideCollapsedState(collapsed: boolean): void {\n  document.documentElement.classList.toggle(FULL_HIDE_COLLAPSED_CLASS, collapsed);\n}\n\nfunction syncFullHideState(): void {\n  const sidenav = getSidenavElement();\n  const sidenavExists = Boolean(sidenav && sidenav.getBoundingClientRect().height > 0);\n  const collapsed = sidenavExists && isSidebarCollapsed();\n\n  setFullHideCollapsedState(collapsed);\n\n  if (collapsed) {\n    showEdgeTrigger();\n  } else {\n    hideEdgeTrigger();\n  }\n}\n\nfunction enableFullHide(): void {\n  if (fullHideEnabled) return;\n  fullHideEnabled = true;\n\n  insertTransitionStyle();\n  insertFullHideStyle();\n  createEdgeTrigger();\n  ensureMenuClickHandler();\n\n  setupInfrastructure();\n  syncFullHideState();\n\n  // Show edge trigger if sidebar is already collapsed\n  setTimeout(() => {\n    if (!fullHideEnabled) return;\n    syncFullHideState();\n  }, 300);\n}\n\nfunction disableFullHide(): void {\n  if (!fullHideEnabled) return;\n  fullHideEnabled = false;\n\n  clearScheduledSidebarStateSync();\n  setFullHideCollapsedState(false);\n  removeEdgeTrigger();\n  removeFullHideStyle();\n\n  if (!enabled) {\n    removeTransitionStyle();\n  }\n\n  maybeRemoveMenuClickHandler();\n\n  teardownInfrastructure();\n}\n\n// ─── Entry Point ───────────────────────────────────────────────────────\n\nexport function startSidebarAutoHide(): void {\n  // 1) Read initial settings\n  try {\n    chrome.storage?.sync?.get({ [STORAGE_KEY]: false, [FULL_HIDE_STORAGE_KEY]: false }, (res) => {\n      if (res?.[STORAGE_KEY] === true) enable();\n      if (res?.[FULL_HIDE_STORAGE_KEY] === true) enableFullHide();\n    });\n  } catch (e) {\n    console.error('[Gemini Voyager] Failed to get sidebar settings:', e);\n  }\n\n  // 2) Respond to storage changes\n  try {\n    chrome.storage?.onChanged?.addListener((changes, area) => {\n      if (area !== 'sync') return;\n\n      if (changes[STORAGE_KEY]) {\n        if (changes[STORAGE_KEY].newValue === true) {\n          enable();\n        } else {\n          disable();\n        }\n      }\n\n      if (changes[FULL_HIDE_STORAGE_KEY]) {\n        if (changes[FULL_HIDE_STORAGE_KEY].newValue === true) {\n          enableFullHide();\n        } else {\n          disableFullHide();\n        }\n      }\n    });\n  } catch (e) {\n    console.error('[Gemini Voyager] Failed to add storage listeners for sidebar features:', e);\n  }\n\n  // 3) Cleanup on page unload\n  window.addEventListener('beforeunload', () => {\n    disable();\n    disableFullHide();\n  });\n}\n"
  },
  {
    "path": "src/pages/content/sidebarWidth/__tests__/sidebarWidthCentering.test.ts",
    "content": "import { readFileSync } from 'node:fs';\nimport { resolve } from 'node:path';\nimport { describe, expect, it } from 'vitest';\n\ndescribe('sidebar width title centering', () => {\n  it('does not override native center-section positioning', () => {\n    const code = readFileSync(\n      resolve(process.cwd(), 'src/pages/content/sidebarWidth/index.ts'),\n      'utf8',\n    );\n\n    expect(code).not.toContain('.center-section');\n    expect(code).not.toContain('translate(-50%, -50%)');\n    expect(code).not.toContain('left: 50% !important;');\n    expect(code).not.toContain('left: clamp(');\n  });\n\n  it('does not force top bar host transform/width overrides', () => {\n    const code = readFileSync(\n      resolve(process.cwd(), 'src/pages/content/sidebarWidth/index.ts'),\n      'utf8',\n    );\n\n    expect(code).not.toContain('#app-root > main > top-bar-actions {');\n    expect(code).not.toContain('#app-root > main > .top-bar-actions {');\n    expect(code).not.toContain('width: calc(100% - var(--gv-sidenav-shift)) !important;');\n  });\n\n  it('does not override top-bar actions right-section to fixed', () => {\n    const code = readFileSync(\n      resolve(process.cwd(), 'src/pages/content/sidebarWidth/index.ts'),\n      'utf8',\n    );\n\n    expect(code).not.toContain('div.right-section > div:nth-child(2)');\n    expect(code).not.toContain('position: fixed !important;');\n  });\n\n  it('does not enable pointer events on all mode-switcher descendants', () => {\n    const code = readFileSync(\n      resolve(process.cwd(), 'src/pages/content/sidebarWidth/index.ts'),\n      'utf8',\n    );\n\n    expect(code).not.toContain('#app-root > main > div > bard-mode-switcher * {');\n    expect(code).toContain('#app-root > main > div > bard-mode-switcher :is(');\n    expect(code).toContain(\"[role='button']\");\n  });\n\n  it('adds search-button hit-test diagnostics for blocked clicks', () => {\n    const code = readFileSync(\n      resolve(process.cwd(), 'src/pages/content/sidebarWidth/index.ts'),\n      'utf8',\n    );\n\n    expect(code).toContain(\"window.addEventListener('pointerdown'\");\n    expect(code).toContain('document.elementsFromPoint');\n    expect(code).toContain('[Gemini Voyager][sidebarWidth debug] Search button hit blocked');\n    expect(code).toContain(\"document.querySelector<HTMLElement>('search-nav-button button')\");\n  });\n\n  it('keeps top-bar-actions container transparent while preserving search button clicks', () => {\n    const code = readFileSync(\n      resolve(process.cwd(), 'src/pages/content/sidebarWidth/index.ts'),\n      'utf8',\n    );\n\n    expect(code).toContain('.top-bar-actions {');\n    expect(code).toContain('top-bar-actions .top-bar-actions');\n    expect(code).toContain('top-bar-actions {');\n    expect(code).toContain('pointer-events: none !important;');\n    expect(code).toContain('search-nav-button');\n    expect(code).toContain('top-bar-actions :is(');\n    expect(code).toContain('top-bar-actions search-nav-button button');\n    expect(code).toContain('search-nav-button button');\n    expect(code).toContain('pointer-events: auto !important;');\n  });\n});\n"
  },
  {
    "path": "src/pages/content/sidebarWidth/index.ts",
    "content": "/* Adjust Gemini sidebar (<bard-sidenav>) width: through CSS variable --bard-sidenav-open-width */\nconst STYLE_ID = 'gv-sidebar-width-style';\nconst DEFAULT_PERCENT = 26;\nconst MIN_PERCENT = 15;\nconst MAX_PERCENT = 45;\nconst LEGACY_BASELINE_PX = 1200;\n\nconst DEFAULT_PX = Math.round((DEFAULT_PERCENT / 100) * LEGACY_BASELINE_PX); // 312px\nconst MIN_PX = Math.round((MIN_PERCENT / 100) * LEGACY_BASELINE_PX); // 180px\nconst MAX_PX = Math.round((MAX_PERCENT / 100) * LEGACY_BASELINE_PX); // 540px\nconst SEARCH_HIT_DEBUG_THROTTLE_MS = 1200;\n\nlet searchHitDebugBound = false;\nlet lastSearchHitDebugAt = 0;\n\nconst clampNumber = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, Math.round(value)));\n\nconst normalizePercent = (value: number, fallback: number) => {\n  if (!Number.isFinite(value)) return fallback;\n  if (value > MAX_PERCENT) {\n    const approx = (value / LEGACY_BASELINE_PX) * 100;\n    return clampNumber(approx, MIN_PERCENT, MAX_PERCENT);\n  }\n  return clampNumber(value, MIN_PERCENT, MAX_PERCENT);\n};\n\nconst normalizePx = (value: number, fallback: number) => {\n  if (!Number.isFinite(value)) return fallback;\n  return clampNumber(value, MIN_PX, MAX_PX);\n};\n\nfunction normalizeWidth(value: number): { normalized: number; unit: 'px' | 'percent' } {\n  if (!Number.isFinite(value)) return { normalized: DEFAULT_PX, unit: 'px' };\n  if (value > MAX_PERCENT) {\n    return { normalized: normalizePx(value, DEFAULT_PX), unit: 'px' };\n  }\n  return { normalized: normalizePercent(value, DEFAULT_PERCENT), unit: 'percent' };\n}\n\nfunction buildStyle(widthValue: number): string {\n  const { normalized, unit } = normalizeWidth(widthValue);\n\n  const clampedWidth = unit === 'px' ? `${normalized}px` : `clamp(200px, ${normalized}vw, 800px)`; // preserve vw behavior for legacy %\n\n  const closedWidth = 'var(--bard-sidenav-closed-width, 72px)'; // fallback matches collapsed rail width\n  const openClosedDiff = `max(0px, calc(${clampedWidth} - ${closedWidth}))`;\n\n  return `\n    :root {\n      --bard-sidenav-open-width: ${clampedWidth} !important;\n      --bard-sidenav-open-closed-width-diff: ${openClosedDiff} !important;\n      --gv-sidenav-shift: ${openClosedDiff} !important;\n    }\n\n    /* When sidenav is collapsed, zero out the shift */\n    #app-root:has(side-navigation-content > div.collapsed) {\n      --gv-sidenav-shift: 0px !important;\n    }\n\n    bard-sidenav {\n      --bard-sidenav-open-width: ${clampedWidth} !important;\n      --bard-sidenav-open-closed-width-diff: ${openClosedDiff} !important;\n    }\n\n    /* Keep top-level mode switcher (header) aligned when sidebar grows/shrinks */\n    #app-root > main > div > bard-mode-switcher {\n      transform: translateX(var(--gv-sidenav-shift)) !important;\n      pointer-events: none !important;\n    }\n\n    /* Re-enable clicks only for actual interactive controls */\n    #app-root > main > div > bard-mode-switcher :is(\n      button,\n      a,\n      input,\n      select,\n      textarea,\n      [role='button'],\n      [tabindex]:not([tabindex='-1'])\n    ) {\n      pointer-events: auto;\n    }\n\n    /* Gemini can place a broad top-bar-actions hit layer above controls after sidebar shifts.\n       Let the container pass through, while keeping actual controls clickable. */\n    #app-root > main > div > bard-mode-switcher .top-bar-actions {\n      pointer-events: none !important;\n    }\n\n    top-bar-actions .top-bar-actions {\n      pointer-events: none !important;\n    }\n\n    top-bar-actions {\n      pointer-events: none !important;\n    }\n\n    #app-root > main > div > bard-mode-switcher .top-bar-actions :is(\n      button,\n      a,\n      input,\n      select,\n      textarea,\n      [role='button'],\n      [tabindex]:not([tabindex='-1']),\n      search-nav-button\n    ) {\n      pointer-events: auto !important;\n    }\n\n    top-bar-actions .top-bar-actions :is(\n      button,\n      a,\n      input,\n      select,\n      textarea,\n      [role='button'],\n      [tabindex]:not([tabindex='-1']),\n      search-nav-button\n    ) {\n      pointer-events: auto !important;\n    }\n\n    top-bar-actions :is(\n      button,\n      a,\n      input,\n      select,\n      textarea,\n      [role='button'],\n      [tabindex]:not([tabindex='-1']),\n      search-nav-button\n    ) {\n      pointer-events: auto !important;\n    }\n\n    #app-root > main > div > bard-mode-switcher search-nav-button,\n    #app-root > main > div > bard-mode-switcher search-nav-button button {\n      position: relative;\n      z-index: 1;\n      pointer-events: auto !important;\n    }\n\n    top-bar-actions search-nav-button,\n    top-bar-actions search-nav-button button {\n      position: relative;\n      z-index: 1;\n      pointer-events: auto !important;\n    }\n\n  `;\n}\n\nfunction ensureStyleEl(): HTMLStyleElement {\n  let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;\n  if (!style) {\n    style = document.createElement('style');\n    style.id = STYLE_ID;\n    document.documentElement.appendChild(style);\n  }\n  return style;\n}\n\nfunction applyWidth(widthValue: number): void {\n  const style = ensureStyleEl();\n  style.textContent = buildStyle(widthValue);\n}\n\nfunction removeStyles(): void {\n  const style = document.getElementById(STYLE_ID);\n  if (style) style.remove();\n}\n\nfunction formatElementForDebug(element: Element | null): string {\n  if (!element) return '(none)';\n  const tag = element.tagName.toLowerCase();\n  const id = element.id ? `#${element.id}` : '';\n  const classNames = element.classList.length ? `.${Array.from(element.classList).join('.')}` : '';\n  return `${tag}${id}${classNames}`;\n}\n\nfunction setupSearchButtonHitTestDebug(): void {\n  if (searchHitDebugBound) return;\n  searchHitDebugBound = true;\n\n  const onPointerDownCapture = (event: PointerEvent) => {\n    const searchButton = document.querySelector<HTMLElement>('search-nav-button button');\n    if (!searchButton) return;\n\n    const rect = searchButton.getBoundingClientRect();\n    if (rect.width <= 0 || rect.height <= 0) return;\n\n    const { clientX: x, clientY: y } = event;\n    const isInSearchRect = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;\n    if (!isInSearchRect) return;\n\n    const target = event.target instanceof Element ? event.target : null;\n    if (target && searchButton.contains(target)) return;\n\n    const now = Date.now();\n    if (now - lastSearchHitDebugAt < SEARCH_HIT_DEBUG_THROTTLE_MS) return;\n    lastSearchHitDebugAt = now;\n\n    const stack = document.elementsFromPoint(x, y).slice(0, 6);\n    const top = stack[0] ?? null;\n    const topStyle = top ? window.getComputedStyle(top) : null;\n\n    console.warn('[Gemini Voyager][sidebarWidth debug] Search button hit blocked', {\n      point: { x, y },\n      target: formatElementForDebug(target),\n      searchButton: formatElementForDebug(searchButton),\n      topElement: formatElementForDebug(top),\n      topElementPointerEvents: topStyle?.pointerEvents ?? null,\n      topElementZIndex: topStyle?.zIndex ?? null,\n      stack: stack.map((element) => formatElementForDebug(element)),\n    });\n  };\n\n  window.addEventListener('pointerdown', onPointerDownCapture, true);\n  window.addEventListener(\n    'beforeunload',\n    () => {\n      window.removeEventListener('pointerdown', onPointerDownCapture, true);\n      searchHitDebugBound = false;\n    },\n    { once: true },\n  );\n}\n\nconst ENABLED_KEY = 'gvSidebarWidthEnabled';\n\n/** Initialize and start the sidebar width adjuster */\nexport function startSidebarWidthAdjuster(): void {\n  let currentWidthValue = DEFAULT_PX;\n  let enabled = false;\n  setupSearchButtonHitTestDebug();\n\n  // 1) Read initial state — request keys without defaults so we can distinguish\n  // \"key never existed\" (upgrade) from \"explicitly set to false\"\n  try {\n    chrome.storage?.sync?.get(['geminiSidebarWidth', ENABLED_KEY], (res) => {\n      const rawW = res?.geminiSidebarWidth;\n      const w = Number.isFinite(Number(rawW)) ? Number(rawW) : DEFAULT_PX;\n      const { normalized } = normalizeWidth(w);\n      currentWidthValue = normalized;\n\n      const enabledRaw = res?.[ENABLED_KEY];\n      if (enabledRaw === undefined) {\n        // Upgrade path: auto-enable if user had previously customized\n        const isCustomized =\n          rawW !== undefined && normalizeWidth(Number(rawW)).normalized !== DEFAULT_PX;\n        enabled = isCustomized;\n        if (enabled) {\n          try {\n            chrome.storage?.sync?.set({ [ENABLED_KEY]: true });\n          } catch {}\n        }\n      } else {\n        enabled = enabledRaw === true;\n      }\n\n      if (enabled) {\n        applyWidth(currentWidthValue);\n      }\n\n      if (Number.isFinite(w) && w !== normalized) {\n        try {\n          chrome.storage?.sync?.set({ geminiSidebarWidth: normalized });\n        } catch (err) {\n          console.warn('[Gemini Voyager] Failed to migrate sidebar width to %:', err);\n        }\n      }\n    });\n  } catch (e) {\n    console.error('[Gemini Voyager] Failed to get sidebar width from storage:', e);\n  }\n\n  // 2) Respond to storage changes (from Popup slider adjustment)\n  try {\n    chrome.storage?.onChanged?.addListener((changes, area) => {\n      if (area !== 'sync') return;\n\n      if (changes[ENABLED_KEY]) {\n        enabled = changes[ENABLED_KEY].newValue === true;\n        if (enabled) {\n          applyWidth(currentWidthValue);\n        } else {\n          removeStyles();\n        }\n      }\n\n      if (changes.geminiSidebarWidth) {\n        const w = Number(changes.geminiSidebarWidth.newValue);\n        if (Number.isFinite(w)) {\n          const { normalized } = normalizeWidth(w);\n          currentWidthValue = normalized;\n          if (enabled) {\n            applyWidth(currentWidthValue);\n          }\n\n          if (normalized !== w) {\n            try {\n              chrome.storage?.sync?.set({ geminiSidebarWidth: normalized });\n            } catch (err) {\n              console.warn('[Gemini Voyager] Failed to migrate sidebar width to % on change:', err);\n            }\n          }\n        }\n      }\n    });\n  } catch (e) {\n    console.error('[Gemini Voyager] Failed to add storage listener for sidebar width:', e);\n  }\n\n  // // 3) Listen for DOM changes (<bard-sidenav> may be lazily mounted)\n  // let debounceTimer: number | null = null;\n  // const observer = new MutationObserver(() => {\n  //   if (debounceTimer !== null) window.clearTimeout(debounceTimer);\n  //   debounceTimer = window.setTimeout(() => {\n  //     applyWidth(currentWidthValue);\n  //     debounceTimer = null;\n  //   }, 150);\n  // });\n\n  // const root = document.documentElement || document.body;\n  // if (root) {\n  //   observer.observe(root, { childList: true, subtree: true });\n  // }\n\n  // 4) Cleanup\n  window.addEventListener('beforeunload', () => {\n    // observer.disconnect();\n    removeStyles();\n  });\n}\n"
  },
  {
    "path": "src/pages/content/timeline/EventBus.ts",
    "content": "/**\n * Simple Event Bus implementation using Observer pattern\n * Provides type-safe event communication between components\n */\n\nexport type EventCallback<T = unknown> = (data: T) => void;\n\ninterface EventMap {\n  'starred:added': { conversationId: string; turnId: string };\n  'starred:removed': { conversationId: string; turnId: string };\n  'starred:updated': { conversationId: string };\n  'fork:added': { conversationId: string; turnId: string; forkGroupId: string };\n  'fork:removed': { conversationId: string; turnId: string; forkGroupId: string };\n}\n\nexport class EventBus {\n  private static instance: EventBus;\n  private listeners: Map<string, Set<EventCallback<unknown>>> = new Map();\n\n  private constructor() {}\n\n  /**\n   * Singleton pattern: Get EventBus instance\n   */\n  static getInstance(): EventBus {\n    if (!EventBus.instance) {\n      EventBus.instance = new EventBus();\n    }\n    return EventBus.instance;\n  }\n\n  /**\n   * Subscribe to an event\n   */\n  on<K extends keyof EventMap>(event: K, callback: EventCallback<EventMap[K]>): () => void {\n    if (!this.listeners.has(event)) {\n      this.listeners.set(event, new Set());\n    }\n\n    this.listeners.get(event)!.add(callback as EventCallback<unknown>);\n\n    // Return unsubscribe function\n    return () => this.off(event, callback);\n  }\n\n  /**\n   * Unsubscribe from an event\n   */\n  off<K extends keyof EventMap>(event: K, callback: EventCallback<EventMap[K]>): void {\n    const callbacks = this.listeners.get(event);\n    if (callbacks) {\n      callbacks.delete(callback as EventCallback<unknown>);\n      if (callbacks.size === 0) {\n        this.listeners.delete(event);\n      }\n    }\n  }\n\n  /**\n   * Emit an event\n   */\n  emit<K extends keyof EventMap>(event: K, data: EventMap[K]): void {\n    const callbacks = this.listeners.get(event);\n    if (callbacks) {\n      callbacks.forEach((callback) => {\n        try {\n          callback(data);\n        } catch (error) {\n          console.error(`[EventBus] Error in ${event} listener:`, error);\n        }\n      });\n    }\n  }\n\n  /**\n   * Clear all listeners (useful for cleanup)\n   */\n  clear(): void {\n    this.listeners.clear();\n  }\n\n  /**\n   * Get listener count for debugging\n   */\n  getListenerCount(event?: keyof EventMap): number {\n    if (event) {\n      return this.listeners.get(event)?.size || 0;\n    }\n    let total = 0;\n    this.listeners.forEach((callbacks) => {\n      total += callbacks.size;\n    });\n    return total;\n  }\n}\n\n// Export singleton instance\nexport const eventBus = EventBus.getInstance();\n"
  },
  {
    "path": "src/pages/content/timeline/StarredMessagesService.ts",
    "content": "/**\n * Service for managing starred messages across all conversations\n * Uses message passing to background script to prevent race conditions\n */\nimport { eventBus } from './EventBus';\nimport type { StarredMessage, StarredMessagesData } from './starredTypes';\n\nexport class StarredMessagesService {\n  /**\n   * Send message to background script and wait for response\n   */\n  private static async sendMessage<T>(type: string, payload?: unknown): Promise<T> {\n    return new Promise((resolve, reject) => {\n      chrome.runtime.sendMessage({ type, payload }, (response) => {\n        if (chrome.runtime.lastError) {\n          reject(new Error(chrome.runtime.lastError.message));\n          return;\n        }\n        if (!response || !response.ok) {\n          reject(new Error(response?.error || 'Operation failed'));\n          return;\n        }\n        resolve(response as T);\n      });\n    });\n  }\n\n  /**\n   * Get all starred messages from storage\n   */\n  static async getAllStarredMessages(): Promise<StarredMessagesData> {\n    try {\n      const response = await this.sendMessage<{ ok: boolean; data: StarredMessagesData }>(\n        'gv.starred.getAll',\n      );\n      return response.data || { messages: {} };\n    } catch (error) {\n      console.error('[StarredMessagesService] Failed to get starred messages:', error);\n      return { messages: {} };\n    }\n  }\n\n  /**\n   * Get starred messages for a specific conversation\n   */\n  static async getStarredMessagesForConversation(\n    conversationId: string,\n  ): Promise<StarredMessage[]> {\n    try {\n      const response = await this.sendMessage<{ ok: boolean; messages: StarredMessage[] }>(\n        'gv.starred.getForConversation',\n        { conversationId },\n      );\n      return response.messages || [];\n    } catch (error) {\n      console.error('[StarredMessagesService] Failed to get starred messages:', error);\n      return [];\n    }\n  }\n\n  /**\n   * Add a starred message - delegated to background script\n   */\n  static async addStarredMessage(message: StarredMessage): Promise<void> {\n    try {\n      const response = await this.sendMessage<{ ok: boolean; added: boolean }>(\n        'gv.starred.add',\n        message,\n      );\n\n      if (response.added) {\n        // Emit event for cross-component synchronization\n        eventBus.emit('starred:added', {\n          conversationId: message.conversationId,\n          turnId: message.turnId,\n        });\n\n        // Also update localStorage for backward compatibility\n        this.updateLegacyStorage(message.conversationId, message.turnId, 'add');\n      }\n    } catch (error) {\n      console.error('[StarredMessagesService] Failed to add starred message:', error);\n    }\n  }\n\n  /**\n   * Remove a starred message - delegated to background script\n   */\n  static async removeStarredMessage(conversationId: string, turnId: string): Promise<void> {\n    try {\n      const response = await this.sendMessage<{ ok: boolean; removed: boolean }>(\n        'gv.starred.remove',\n        { conversationId, turnId },\n      );\n\n      if (response.removed) {\n        // Emit event for cross-component synchronization\n        eventBus.emit('starred:removed', {\n          conversationId,\n          turnId,\n        });\n\n        // Also update localStorage for backward compatibility\n        this.updateLegacyStorage(conversationId, turnId, 'remove');\n      }\n    } catch (error) {\n      console.error('[StarredMessagesService] Failed to remove starred message:', error);\n    }\n  }\n\n  /**\n   * Update legacy localStorage format for backward compatibility\n   * This ensures TimelineManager's storage event listener works\n   */\n  private static updateLegacyStorage(\n    conversationId: string,\n    turnId: string,\n    action: 'add' | 'remove',\n  ): void {\n    try {\n      const key = `geminiTimelineStars:${conversationId}`;\n      const raw = localStorage.getItem(key);\n      let ids: string[] = [];\n\n      if (raw) {\n        try {\n          ids = JSON.parse(raw);\n          if (!Array.isArray(ids)) ids = [];\n        } catch {\n          ids = [];\n        }\n      }\n\n      if (action === 'add') {\n        if (!ids.includes(turnId)) {\n          ids.push(turnId);\n        }\n      } else {\n        ids = ids.filter((id) => id !== turnId);\n      }\n\n      localStorage.setItem(key, JSON.stringify(ids));\n    } catch (error) {\n      console.debug('[StarredMessagesService] Failed to update legacy storage:', error);\n    }\n  }\n\n  /**\n   * Check if a message is starred\n   */\n  static async isMessageStarred(conversationId: string, turnId: string): Promise<boolean> {\n    const messages = await this.getStarredMessagesForConversation(conversationId);\n    return messages.some((m) => m.turnId === turnId);\n  }\n\n  /**\n   * Get all starred messages sorted by timestamp (newest first)\n   */\n  static async getAllStarredMessagesSorted(): Promise<StarredMessage[]> {\n    const data = await this.getAllStarredMessages();\n    const allMessages: StarredMessage[] = [];\n\n    Object.values(data.messages).forEach((messages) => {\n      allMessages.push(...messages);\n    });\n\n    return allMessages.sort((a, b) => b.starredAt - a.starredAt);\n  }\n}\n"
  },
  {
    "path": "src/pages/content/timeline/TimelinePreviewPanel.ts",
    "content": "import browser from 'webextension-polyfill';\n\nimport { StorageKeys } from '@/core/types/common';\nimport { GV_RTL_CLASS, detectRTL } from '@/core/utils/rtl';\n\nimport { getTranslationSync } from '../../../utils/i18n';\nimport type { PreviewMarkerData } from './types';\n\nconst SEARCH_DEBOUNCE_MS = 200;\n\nconst LIST_ICON_SVG = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"8\" y1=\"6\" x2=\"21\" y2=\"6\"/><line x1=\"8\" y1=\"12\" x2=\"21\" y2=\"12\"/><line x1=\"8\" y1=\"18\" x2=\"21\" y2=\"18\"/><line x1=\"3\" y1=\"6\" x2=\"3.01\" y2=\"6\"/><line x1=\"3\" y1=\"12\" x2=\"3.01\" y2=\"12\"/><line x1=\"3\" y1=\"18\" x2=\"3.01\" y2=\"18\"/></svg>`;\n\nexport class TimelinePreviewPanel {\n  private panelEl: HTMLElement | null = null;\n  private listEl: HTMLElement | null = null;\n  private searchInput: HTMLInputElement | null = null;\n  private toggleBtn: HTMLElement | null = null;\n  private _isOpen = false;\n  private markers: ReadonlyArray<PreviewMarkerData> = [];\n  private filteredMarkers: ReadonlyArray<PreviewMarkerData> = [];\n  private activeTurnId: string | null = null;\n  private searchQuery = '';\n  private searchDebounceTimer: number | null = null;\n  private onNavigate: ((turnId: string, index: number) => void) | null = null;\n  private onSearchChange: ((query: string) => void) | null = null;\n  private onDocumentPointerDown: ((e: PointerEvent) => void) | null = null;\n  private onKeyDown: ((e: KeyboardEvent) => void) | null = null;\n  private onWindowResize: (() => void) | null = null;\n  private onStorageChanged:\n    | ((changes: Record<string, browser.Storage.StorageChange>, areaName: string) => void)\n    | null = null;\n\n  constructor(private readonly anchorElement: HTMLElement) {}\n\n  get isOpen(): boolean {\n    return this._isOpen;\n  }\n\n  init(\n    onNavigate: (turnId: string, index: number) => void,\n    onSearchChange?: (query: string) => void,\n  ): void {\n    this.onNavigate = onNavigate;\n    this.onSearchChange = onSearchChange ?? null;\n    this.createDOM();\n    this.applyDirection();\n    this.positionToggle();\n    this.setupEventListeners();\n  }\n\n  updateMarkers(markers: ReadonlyArray<PreviewMarkerData>): void {\n    if (this.markersEqual(markers)) return;\n    this.markers = markers;\n    this.applyFilter();\n  }\n\n  updateActiveTurn(turnId: string | null): void {\n    if (this.activeTurnId === turnId) return;\n    this.activeTurnId = turnId;\n    if (!this._isOpen || !this.listEl) return;\n    this.updateActiveHighlight();\n    this.scrollActiveIntoView();\n  }\n\n  /** Reposition toggle and panel after layout changes (e.g. RTL switch, resize). */\n  reposition(): void {\n    this.applyDirection();\n    this.positionToggle();\n    if (this._isOpen) this.positionPanel();\n  }\n\n  toggle(): void {\n    if (this._isOpen) {\n      this.close();\n    } else {\n      this.open();\n    }\n  }\n\n  open(): void {\n    if (this._isOpen || !this.panelEl) return;\n    this._isOpen = true;\n    this.renderList();\n    this.positionPanel();\n    this.panelEl.classList.add('visible');\n    this.toggleBtn?.classList.add('active');\n    this.scrollActiveIntoView();\n  }\n\n  close(): void {\n    if (!this._isOpen || !this.panelEl) return;\n    this._isOpen = false;\n    this.panelEl.classList.remove('visible');\n    this.toggleBtn?.classList.remove('active');\n    if (this.searchInput) {\n      this.searchInput.value = '';\n      this.searchQuery = '';\n      this.filteredMarkers = this.markers;\n    }\n    this.onSearchChange?.('');\n  }\n\n  destroy(): void {\n    if (this.searchDebounceTimer) {\n      clearTimeout(this.searchDebounceTimer);\n      this.searchDebounceTimer = null;\n    }\n    if (this.onDocumentPointerDown) {\n      document.removeEventListener('pointerdown', this.onDocumentPointerDown);\n      this.onDocumentPointerDown = null;\n    }\n    if (this.onKeyDown) {\n      document.removeEventListener('keydown', this.onKeyDown);\n      this.onKeyDown = null;\n    }\n    if (this.onWindowResize) {\n      window.removeEventListener('resize', this.onWindowResize);\n      this.onWindowResize = null;\n    }\n    if (this.onStorageChanged) {\n      browser.storage.onChanged.removeListener(this.onStorageChanged);\n      this.onStorageChanged = null;\n    }\n    this.toggleBtn?.remove();\n    this.panelEl?.remove();\n    this.toggleBtn = null;\n    this.panelEl = null;\n    this.listEl = null;\n    this.searchInput = null;\n    this.onSearchChange?.('');\n    this.onNavigate = null;\n    this.onSearchChange = null;\n    this.markers = [];\n    this.filteredMarkers = [];\n  }\n\n  private createDOM(): void {\n    // Toggle button — fixed position to the left of the timeline bar\n    this.toggleBtn = document.createElement('button');\n    this.toggleBtn.className = 'timeline-preview-toggle';\n    this.toggleBtn.setAttribute('aria-label', 'Toggle preview panel');\n    this.toggleBtn.innerHTML = LIST_ICON_SVG;\n    this.toggleBtn.addEventListener('click', (e) => {\n      e.stopPropagation();\n      this.toggle();\n    });\n    document.body.appendChild(this.toggleBtn);\n\n    // Panel\n    this.panelEl = document.createElement('div');\n    this.panelEl.className = 'timeline-preview-panel';\n\n    // Search section\n    const searchWrapper = document.createElement('div');\n    searchWrapper.className = 'timeline-preview-search';\n    this.searchInput = document.createElement('input');\n    this.searchInput.type = 'text';\n    this.searchInput.setAttribute('dir', 'auto');\n    this.searchInput.placeholder = getTranslationSync('timelinePreviewSearch');\n    this.searchInput.addEventListener('input', () => {\n      this.handleSearchInput();\n    });\n    searchWrapper.appendChild(this.searchInput);\n    this.panelEl.appendChild(searchWrapper);\n\n    // List\n    this.listEl = document.createElement('div');\n    this.listEl.className = 'timeline-preview-list';\n    this.setupScrollIsolation();\n    this.panelEl.appendChild(this.listEl);\n\n    document.body.appendChild(this.panelEl);\n  }\n\n  private setupEventListeners(): void {\n    // Click outside to close\n    this.onDocumentPointerDown = (e: PointerEvent) => {\n      if (!this._isOpen) return;\n      const target = e.target as Node;\n      if (this.panelEl?.contains(target) || this.toggleBtn?.contains(target)) return;\n      this.close();\n    };\n    document.addEventListener('pointerdown', this.onDocumentPointerDown);\n\n    // Escape to close\n    this.onKeyDown = (e: KeyboardEvent) => {\n      if (!this._isOpen) return;\n      if (e.key === 'Escape') {\n        e.stopPropagation();\n        this.close();\n      }\n    };\n    document.addEventListener('keydown', this.onKeyDown);\n\n    // Reposition on resize\n    this.onWindowResize = () => {\n      this.positionToggle();\n      if (this._isOpen) this.positionPanel();\n    };\n    window.addEventListener('resize', this.onWindowResize);\n\n    // Re-render translated text on language change\n    this.onStorageChanged = (changes, areaName) => {\n      if ((areaName === 'sync' || areaName === 'local') && changes[StorageKeys.LANGUAGE]) {\n        this.updateTranslatedText();\n      }\n    };\n    browser.storage.onChanged.addListener(this.onStorageChanged);\n  }\n\n  private updateTranslatedText(): void {\n    this.applyDirection();\n    if (this.searchInput) {\n      this.searchInput.placeholder = getTranslationSync('timelinePreviewSearch');\n    }\n    if (this._isOpen) {\n      this.renderList();\n    }\n  }\n\n  private isRTLContext(): boolean {\n    return document.body.classList.contains(GV_RTL_CLASS) || detectRTL();\n  }\n\n  private applyDirection(): void {\n    const dir = this.isRTLContext() ? 'rtl' : 'ltr';\n    this.panelEl?.setAttribute('dir', dir);\n    this.listEl?.setAttribute('dir', dir);\n    this.toggleBtn?.setAttribute('dir', dir);\n  }\n\n  private setupScrollIsolation(): void {\n    if (!this.listEl) return;\n\n    this.listEl.addEventListener(\n      'wheel',\n      (e: WheelEvent) => {\n        e.stopPropagation();\n        const { scrollTop, scrollHeight, clientHeight } = this.listEl!;\n        const atTop = scrollTop <= 0 && e.deltaY < 0;\n        const atBottom = scrollTop + clientHeight >= scrollHeight - 1 && e.deltaY > 0;\n        if (atTop || atBottom) {\n          e.preventDefault();\n        }\n      },\n      { passive: false },\n    );\n  }\n\n  /** Position the toggle button beside the timeline bar, vertically centered.\n   *  Keep it on the bar's left side and clamp within viewport bounds. */\n  private positionToggle(): void {\n    if (!this.toggleBtn) return;\n    const barRect = this.anchorElement.getBoundingClientRect();\n    const btnSize = 24;\n    const gap = 4;\n    const minLeft = 8;\n    const maxLeft = Math.max(minLeft, window.innerWidth - btnSize - 8);\n    const leftPx = Math.max(minLeft, Math.min(Math.round(barRect.left - btnSize - gap), maxLeft));\n    this.toggleBtn.style.left = `${leftPx}px`;\n    this.toggleBtn.style.top = `${Math.round(barRect.top + barRect.height / 2 - btnSize / 2)}px`;\n  }\n\n  private positionPanel(): void {\n    if (!this.panelEl) return;\n    const barRect = this.anchorElement.getBoundingClientRect();\n    const panelWidth = 320;\n    const gap = 12;\n    const maxHeight = Math.min(500, window.innerHeight * 0.7);\n    const barCenterY = barRect.top + barRect.height / 2;\n    const isRTL = this.isRTLContext();\n\n    let left: number;\n    if (isRTL) {\n      // In RTL, bar is on the left — place panel to its right\n      left = barRect.right + gap;\n      if (left + panelWidth > window.innerWidth - 8) {\n        left = window.innerWidth - panelWidth - 8;\n      }\n    } else {\n      // In LTR, bar is on the right — place panel to its left\n      left = barRect.left - panelWidth - gap;\n      if (left < 8) left = 8;\n    }\n\n    this.panelEl.style.maxHeight = `${Math.round(maxHeight)}px`;\n    this.panelEl.style.left = `${Math.round(left)}px`;\n\n    // Measure actual rendered height to center properly (works for both few and many items)\n    const panelHeight = this.panelEl.offsetHeight || maxHeight;\n    let top = barCenterY - panelHeight / 2;\n    top = Math.max(8, Math.min(top, window.innerHeight - panelHeight - 8));\n\n    this.panelEl.style.top = `${Math.round(top)}px`;\n  }\n\n  private applyFilter(): void {\n    if (!this.searchQuery) {\n      this.filteredMarkers = this.markers;\n    } else {\n      const q = this.searchQuery.toLowerCase();\n      this.filteredMarkers = this.markers.filter((m) => m.summary.toLowerCase().includes(q));\n    }\n    if (this._isOpen) {\n      this.renderList();\n    }\n    this.onSearchChange?.(this.searchQuery);\n  }\n\n  private handleSearchInput(): void {\n    if (this.searchDebounceTimer) {\n      clearTimeout(this.searchDebounceTimer);\n    }\n    this.searchDebounceTimer = window.setTimeout(() => {\n      this.searchDebounceTimer = null;\n      this.searchQuery = this.searchInput?.value.trim() ?? '';\n      this.applyFilter();\n    }, SEARCH_DEBOUNCE_MS);\n  }\n\n  private renderList(): void {\n    if (!this.listEl) return;\n    this.listEl.textContent = '';\n\n    if (this.filteredMarkers.length === 0) {\n      const empty = document.createElement('div');\n      empty.className = 'timeline-preview-empty';\n      empty.textContent = this.searchQuery\n        ? getTranslationSync('timelinePreviewNoResults')\n        : getTranslationSync('timelinePreviewNoMessages');\n      this.listEl.appendChild(empty);\n      return;\n    }\n\n    const fragment = document.createDocumentFragment();\n    for (const marker of this.filteredMarkers) {\n      fragment.appendChild(this.createItem(marker));\n    }\n    this.listEl.appendChild(fragment);\n  }\n\n  private createItem(marker: PreviewMarkerData): HTMLElement {\n    const item = document.createElement('div');\n    item.className = 'timeline-preview-item';\n    item.dataset.turnId = marker.id;\n\n    if (marker.starred) {\n      item.classList.add('starred');\n    }\n    if (marker.id === this.activeTurnId) {\n      item.classList.add('active');\n    }\n\n    const indexLabel = document.createElement('span');\n    indexLabel.className = 'timeline-preview-index';\n    indexLabel.textContent = `${marker.index + 1}`;\n    item.appendChild(indexLabel);\n\n    const text = document.createElement('span');\n    text.className = 'timeline-preview-text';\n    text.setAttribute('dir', 'auto');\n    const displayText = this.truncateText(marker.summary, 80);\n    if (this.searchQuery) {\n      this.appendHighlighted(text, displayText, this.searchQuery);\n    } else {\n      text.textContent = displayText;\n    }\n    item.appendChild(text);\n\n    // Show starredAt timestamp for starred items\n    if (marker.starred && marker.starredAt) {\n      const timeLabel = document.createElement('span');\n      timeLabel.className = 'timeline-preview-starred-time';\n      timeLabel.textContent = this.formatStarredTime(marker.starredAt);\n      item.appendChild(timeLabel);\n    }\n\n    item.addEventListener('click', () => {\n      this.onNavigate?.(marker.id, marker.index);\n    });\n\n    return item;\n  }\n\n  /** Split text around case-insensitive query matches and wrap each match in <mark>. */\n  private appendHighlighted(container: HTMLElement, text: string, query: string): void {\n    const lowerText = text.toLowerCase();\n    const lowerQuery = query.toLowerCase();\n    let cursor = 0;\n    let idx = lowerText.indexOf(lowerQuery, cursor);\n    while (idx !== -1) {\n      if (idx > cursor) {\n        container.appendChild(document.createTextNode(text.slice(cursor, idx)));\n      }\n      const mark = document.createElement('mark');\n      mark.className = 'timeline-preview-highlight';\n      mark.textContent = text.slice(idx, idx + query.length);\n      container.appendChild(mark);\n      cursor = idx + query.length;\n      idx = lowerText.indexOf(lowerQuery, cursor);\n    }\n    if (cursor < text.length) {\n      container.appendChild(document.createTextNode(text.slice(cursor)));\n    }\n  }\n\n  private truncateText(text: string, maxLen: number): string {\n    if (text.length <= maxLen) return text;\n    return text.slice(0, maxLen - 1) + '\\u2026';\n  }\n\n  private updateActiveHighlight(): void {\n    if (!this.listEl) return;\n    const items = this.listEl.querySelectorAll('.timeline-preview-item');\n    items.forEach((item) => {\n      const el = item as HTMLElement;\n      el.classList.toggle('active', el.dataset.turnId === this.activeTurnId);\n    });\n  }\n\n  private scrollActiveIntoView(): void {\n    if (!this.listEl || !this.activeTurnId) return;\n    const activeItem = this.listEl.querySelector(\n      '.timeline-preview-item.active',\n    ) as HTMLElement | null;\n    activeItem?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });\n  }\n\n  /** Format starredAt timestamp as compact date+time (MM/DD HH:mm). */\n  private formatStarredTime(timestamp: number): string {\n    const d = new Date(timestamp);\n    const mm = String(d.getMonth() + 1).padStart(2, '0');\n    const dd = String(d.getDate()).padStart(2, '0');\n    const hh = String(d.getHours()).padStart(2, '0');\n    const min = String(d.getMinutes()).padStart(2, '0');\n    return `${mm}/${dd} ${hh}:${min}`;\n  }\n\n  private markersEqual(newMarkers: ReadonlyArray<PreviewMarkerData>): boolean {\n    if (newMarkers.length !== this.markers.length) return false;\n    for (let i = 0; i < newMarkers.length; i++) {\n      const a = this.markers[i];\n      const b = newMarkers[i];\n      if (\n        a.id !== b.id ||\n        a.summary !== b.summary ||\n        a.starred !== b.starred ||\n        a.starredAt !== b.starredAt\n      )\n        return false;\n    }\n    return true;\n  }\n}\n"
  },
  {
    "path": "src/pages/content/timeline/__tests__/TimelineBootstrap.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\ndescribe('Timeline bootstrap', () => {\n  beforeEach(async () => {\n    vi.resetModules();\n    vi.restoreAllMocks();\n\n    document.body.innerHTML = '<main></main>';\n\n    history.replaceState({}, '', '/app');\n  });\n\n  afterEach(() => {\n    window.dispatchEvent(new Event('beforeunload'));\n  });\n\n  it('startTimeline initializes only once when body already exists', async () => {\n    const managerModule = await import('../manager');\n    const initSpy = vi\n      .spyOn(managerModule.TimelineManager.prototype, 'init')\n      .mockResolvedValue(undefined);\n    const { startTimeline } = await import('../index');\n\n    startTimeline();\n    expect(initSpy).toHaveBeenCalledTimes(1);\n\n    // Trigger DOM mutations; should not re-initialize\n    document.body.appendChild(document.createElement('div'));\n    await Promise.resolve();\n\n    expect(initSpy).toHaveBeenCalledTimes(1);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/timeline/__tests__/TimelineManagerActiveIndex.test.ts",
    "content": "import { describe, expect, it, vi } from 'vitest';\n\nimport { TimelineManager } from '../manager';\n\ndescribe('TimelineManager active marker', () => {\n  it('uses cached marker tops when available', () => {\n    const manager = new TimelineManager();\n\n    const scrollContainer = document.createElement('div');\n    Object.defineProperty(scrollContainer, 'clientHeight', { value: 400, configurable: true });\n    scrollContainer.scrollTop = 0;\n\n    const elements = [\n      document.createElement('div'),\n      document.createElement('div'),\n      document.createElement('div'),\n    ];\n    const rectSpies = elements.map((el) => vi.spyOn(el, 'getBoundingClientRect'));\n\n    const markers = elements.map((element, index) => ({\n      id: `m${index}`,\n      element,\n      summary: '',\n      n: 0,\n      baseN: 0,\n      dotElement: null,\n      starred: false,\n    }));\n\n    const internal = manager as unknown as {\n      scrollContainer: HTMLElement | null;\n      markers: typeof markers;\n      markerTops: number[];\n      activeTurnId: string | null;\n      computeActiveByScroll: () => void;\n    };\n\n    internal.scrollContainer = scrollContainer;\n    internal.markers = markers;\n    internal.markerTops = [0, 100, 200];\n    internal.activeTurnId = null;\n\n    internal.computeActiveByScroll();\n\n    expect(internal.activeTurnId).toBe('m1');\n    rectSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled());\n  });\n});\n"
  },
  {
    "path": "src/pages/content/timeline/__tests__/TimelineManagerFlowClickActiveReset.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { TimelineManager } from '../manager';\nimport type { DotElement } from '../types';\n\ntype TimelineMarker = {\n  id: string;\n  element: HTMLElement;\n  summary: string;\n  n: number;\n  baseN: number;\n  dotElement: DotElement | null;\n  starred: boolean;\n};\n\ndescribe('TimelineManager flow click highlight behavior', () => {\n  beforeEach(() => {\n    document.body.innerHTML = '';\n    vi.restoreAllMocks();\n  });\n\n  afterEach(() => {\n    document.body.innerHTML = '';\n  });\n\n  it('clears previous active highlight immediately when clicking another node in flow mode', () => {\n    const manager = new TimelineManager();\n    const timelineBar = document.createElement('div');\n    document.body.appendChild(timelineBar);\n\n    const scrollContainer = document.createElement('div');\n    document.body.appendChild(scrollContainer);\n\n    const firstTarget = document.createElement('div');\n    const secondTarget = document.createElement('div');\n\n    const firstDot = document.createElement('button') as DotElement;\n    firstDot.className = 'timeline-dot';\n    firstDot.dataset.targetTurnId = 'm0';\n    firstDot.dataset.markerIndex = '0';\n\n    const secondDot = document.createElement('button') as DotElement;\n    secondDot.className = 'timeline-dot';\n    secondDot.dataset.targetTurnId = 'm1';\n    secondDot.dataset.markerIndex = '1';\n    timelineBar.appendChild(secondDot);\n\n    const markers: TimelineMarker[] = [\n      {\n        id: 'm0',\n        element: firstTarget,\n        summary: 'first',\n        n: 0,\n        baseN: 0,\n        dotElement: firstDot,\n        starred: false,\n      },\n      {\n        id: 'm1',\n        element: secondTarget,\n        summary: 'second',\n        n: 1,\n        baseN: 1,\n        dotElement: secondDot,\n        starred: false,\n      },\n    ];\n\n    const internal = manager as unknown as {\n      ui: {\n        timelineBar: HTMLElement | null;\n        tooltip: HTMLElement | null;\n        trackContent?: HTMLElement | null;\n        slider: HTMLElement | null;\n        sliderHandle: HTMLElement | null;\n      };\n      scrollContainer: HTMLElement | null;\n      conversationContainer: HTMLElement | null;\n      scrollMode: 'flow' | 'jump';\n      markers: TimelineMarker[];\n      activeTurnId: string | null;\n      setupEventListeners: () => void;\n      updateActiveDotUI: () => void;\n      startRunner: (fromIdx: number, toIdx: number, duration: number) => void;\n      smoothScrollTo: (targetElement: HTMLElement, duration: number) => void;\n      computeFlowDuration: (fromIdx: number, toIdx: number) => number;\n      userTurnSelector: string;\n      recalculateAndRenderMarkers: () => void;\n    };\n\n    internal.ui.timelineBar = timelineBar;\n    internal.ui.tooltip = null;\n    internal.ui.slider = null;\n    internal.ui.sliderHandle = null;\n    internal.scrollContainer = scrollContainer;\n    internal.conversationContainer = document.body;\n    internal.scrollMode = 'flow';\n    internal.markers = markers;\n    internal.activeTurnId = 'm0';\n    internal.updateActiveDotUI();\n\n    expect(firstDot.classList.contains('active')).toBe(true);\n\n    const startRunnerSpy = vi.fn();\n    const smoothScrollSpy = vi.fn();\n    const flowDurationSpy = vi.fn(() => 520);\n    internal.startRunner = startRunnerSpy;\n    internal.smoothScrollTo = smoothScrollSpy;\n    internal.computeFlowDuration = flowDurationSpy;\n\n    internal.setupEventListeners();\n    secondDot.dispatchEvent(new MouseEvent('click', { bubbles: true }));\n\n    expect(internal.activeTurnId).toBeNull();\n    expect(firstDot.classList.contains('active')).toBe(false);\n    expect(startRunnerSpy).toHaveBeenCalledWith(0, 1, 520);\n    expect(smoothScrollSpy).toHaveBeenCalledWith(secondTarget, 520);\n\n    manager.destroy();\n  });\n\n  it('refreshes stale markers before click navigation when target element is detached', () => {\n    const manager = new TimelineManager();\n\n    const timelineBar = document.createElement('div');\n    const trackContent = document.createElement('div');\n    timelineBar.appendChild(trackContent);\n    document.body.appendChild(timelineBar);\n\n    const staleScrollContainer = document.createElement('div');\n    const staleConversationContainer = document.createElement('div');\n\n    const main = document.createElement('main');\n    const freshScrollContainer = document.createElement('div');\n    freshScrollContainer.style.overflowY = 'auto';\n    main.appendChild(freshScrollContainer);\n\n    const freshTarget = document.createElement('div');\n    freshTarget.className = 'user';\n    freshTarget.dataset.turnId = 'm1';\n    freshTarget.textContent = 'fresh target';\n    freshScrollContainer.appendChild(freshTarget);\n    document.body.appendChild(main);\n\n    const staleTarget = document.createElement('div');\n    staleTarget.dataset.turnId = 'm1';\n\n    const dot = document.createElement('button') as DotElement;\n    dot.className = 'timeline-dot';\n    dot.dataset.targetTurnId = 'm1';\n    dot.dataset.markerIndex = '0';\n    timelineBar.appendChild(dot);\n\n    const internal = manager as unknown as {\n      ui: {\n        timelineBar: HTMLElement | null;\n        tooltip: HTMLElement | null;\n        trackContent?: HTMLElement | null;\n        slider: HTMLElement | null;\n        sliderHandle: HTMLElement | null;\n      };\n      scrollContainer: HTMLElement | null;\n      conversationContainer: HTMLElement | null;\n      scrollMode: 'flow' | 'jump';\n      markers: TimelineMarker[];\n      activeTurnId: string | null;\n      setupEventListeners: () => void;\n      updateActiveDotUI: () => void;\n      smoothScrollTo: (targetElement: HTMLElement, duration: number) => void;\n      computeFlowDuration: (fromIdx: number, toIdx: number) => number;\n      userTurnSelector: string;\n      recalculateAndRenderMarkers: () => void;\n    };\n\n    internal.ui.timelineBar = timelineBar;\n    internal.ui.tooltip = null;\n    internal.ui.trackContent = trackContent;\n    internal.ui.slider = null;\n    internal.ui.sliderHandle = null;\n    internal.scrollContainer = staleScrollContainer;\n    internal.conversationContainer = staleConversationContainer;\n    internal.scrollMode = 'jump';\n    internal.userTurnSelector = '.user';\n    internal.markers = [\n      {\n        id: 'm1',\n        element: staleTarget,\n        summary: 'stale',\n        n: 0,\n        baseN: 0,\n        dotElement: dot,\n        starred: false,\n      },\n    ];\n    internal.activeTurnId = 'm1';\n    internal.updateActiveDotUI();\n\n    const smoothScrollSpy = vi.fn();\n    const flowDurationSpy = vi.fn(() => 0);\n    internal.smoothScrollTo = smoothScrollSpy;\n    internal.computeFlowDuration = flowDurationSpy;\n\n    const recalcSpy = vi.fn(() => {\n      internal.markers = [\n        {\n          id: 'm1',\n          element: freshTarget,\n          summary: 'fresh',\n          n: 0,\n          baseN: 0,\n          dotElement: dot,\n          starred: false,\n        },\n      ];\n    });\n    internal.recalculateAndRenderMarkers = recalcSpy;\n\n    internal.setupEventListeners();\n    dot.dispatchEvent(new MouseEvent('click', { bubbles: true }));\n\n    expect(recalcSpy).toHaveBeenCalledTimes(1);\n    expect(smoothScrollSpy).toHaveBeenCalledWith(freshTarget, 0);\n\n    manager.destroy();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/timeline/__tests__/TimelineManagerNavigationRefresh.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { TimelineManager } from '../manager';\n\nfunction setElementTop(el: HTMLElement, top: number): void {\n  Object.defineProperty(el, 'offsetTop', { value: top, configurable: true });\n  vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({\n    x: 0,\n    y: top,\n    top,\n    left: 0,\n    right: 0,\n    bottom: top,\n    width: 0,\n    height: 0,\n    toJSON: () => ({}),\n  } as DOMRect);\n}\n\ndescribe('TimelineManager navigation refresh', () => {\n  beforeEach(() => {\n    document.body.innerHTML = '';\n    vi.restoreAllMocks();\n  });\n\n  it('refreshes markers when at end and document has more turns', async () => {\n    const main = document.createElement('main');\n    document.body.appendChild(main);\n\n    const scrollContainer = document.createElement('div');\n    Object.defineProperty(scrollContainer, 'clientHeight', { value: 400, configurable: true });\n    scrollContainer.scrollTop = 0;\n    vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue({\n      x: 0,\n      y: 0,\n      top: 0,\n      left: 0,\n      right: 0,\n      bottom: 0,\n      width: 0,\n      height: 0,\n      toJSON: () => ({}),\n    } as DOMRect);\n    main.appendChild(scrollContainer);\n\n    const oldContainer = document.createElement('div');\n    scrollContainer.appendChild(oldContainer);\n\n    const a = document.createElement('div');\n    a.className = 'user';\n    a.textContent = 'A';\n    setElementTop(a, 0);\n    oldContainer.appendChild(a);\n\n    const b = document.createElement('div');\n    b.className = 'user';\n    b.textContent = 'B';\n    setElementTop(b, 100);\n    oldContainer.appendChild(b);\n\n    const timelineBar = document.createElement('div');\n    const trackContent = document.createElement('div');\n    timelineBar.appendChild(trackContent);\n    document.body.appendChild(timelineBar);\n\n    const manager = new TimelineManager();\n    const internal = manager as unknown as {\n      conversationContainer: HTMLElement | null;\n      scrollContainer: HTMLElement | null;\n      userTurnSelector: string | null;\n      ui: { timelineBar: HTMLElement | null; trackContent: HTMLElement | null };\n      scrollMode: 'flow' | 'jump';\n      activeTurnId: string | null;\n      markers: Array<{ id: string }>;\n      recalculateAndRenderMarkers: () => void;\n      navigateToNextNode: () => Promise<void>;\n      navigationQueue: Array<'previous' | 'next'>;\n      enqueueNavigation: (direction: 'previous' | 'next', isRepeat?: boolean) => void;\n      smoothScrollTo: (target: HTMLElement, duration: number) => void;\n      updateTimelineGeometry: () => void;\n      updateIntersectionObserverTargetsFromMarkers: () => void;\n      syncTimelineTrackToMain: () => void;\n      updateVirtualRangeAndRender: () => void;\n      updateActiveDotUI: () => void;\n      scheduleScrollSync: () => void;\n    };\n\n    internal.conversationContainer = oldContainer;\n    internal.scrollContainer = scrollContainer;\n    internal.userTurnSelector = '.user';\n    internal.ui.timelineBar = timelineBar;\n    internal.ui.trackContent = trackContent;\n    internal.scrollMode = 'jump';\n\n    internal.updateTimelineGeometry = vi.fn();\n    internal.updateIntersectionObserverTargetsFromMarkers = vi.fn();\n    internal.syncTimelineTrackToMain = vi.fn();\n    internal.updateVirtualRangeAndRender = vi.fn();\n    internal.updateActiveDotUI = vi.fn();\n    internal.scheduleScrollSync = vi.fn();\n    internal.smoothScrollTo = vi.fn();\n\n    internal.activeTurnId = null;\n    internal.recalculateAndRenderMarkers();\n\n    expect(internal.markers).toHaveLength(2);\n    expect(internal.activeTurnId).toBe(internal.markers[1]!.id);\n\n    const c = document.createElement('div');\n    c.className = 'user';\n    c.textContent = 'C';\n    setElementTop(c, 200);\n    main.appendChild(c);\n\n    await internal.navigateToNextNode();\n\n    expect(internal.markers).toHaveLength(3);\n    expect(internal.activeTurnId).toBe(internal.markers[2]!.id);\n  });\n\n  it('does not enqueue navigation beyond boundaries when no refresh is needed', () => {\n    const main = document.createElement('main');\n    document.body.appendChild(main);\n\n    const scrollContainer = document.createElement('div');\n    Object.defineProperty(scrollContainer, 'clientHeight', { value: 400, configurable: true });\n    scrollContainer.scrollTop = 0;\n    vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue({\n      x: 0,\n      y: 0,\n      top: 0,\n      left: 0,\n      right: 0,\n      bottom: 0,\n      width: 0,\n      height: 0,\n      toJSON: () => ({}),\n    } as DOMRect);\n    main.appendChild(scrollContainer);\n\n    const container = document.createElement('div');\n    scrollContainer.appendChild(container);\n\n    const a = document.createElement('div');\n    a.className = 'user';\n    a.textContent = 'A';\n    setElementTop(a, 0);\n    container.appendChild(a);\n\n    const b = document.createElement('div');\n    b.className = 'user';\n    b.textContent = 'B';\n    setElementTop(b, 100);\n    container.appendChild(b);\n\n    const timelineBar = document.createElement('div');\n    const trackContent = document.createElement('div');\n    timelineBar.appendChild(trackContent);\n    document.body.appendChild(timelineBar);\n\n    const manager = new TimelineManager();\n    const internal = manager as unknown as {\n      conversationContainer: HTMLElement | null;\n      scrollContainer: HTMLElement | null;\n      userTurnSelector: string | null;\n      ui: { timelineBar: HTMLElement | null; trackContent: HTMLElement | null };\n      scrollMode: 'flow' | 'jump';\n      activeTurnId: string | null;\n      markers: Array<{ id: string }>;\n      navigationQueue: Array<'previous' | 'next'>;\n      enqueueNavigation: (direction: 'previous' | 'next', isRepeat?: boolean) => void;\n      recalculateAndRenderMarkers: () => void;\n      smoothScrollTo: (target: HTMLElement, duration: number) => void;\n      updateTimelineGeometry: () => void;\n      updateIntersectionObserverTargetsFromMarkers: () => void;\n      syncTimelineTrackToMain: () => void;\n      updateVirtualRangeAndRender: () => void;\n      updateActiveDotUI: () => void;\n      scheduleScrollSync: () => void;\n    };\n\n    internal.conversationContainer = container;\n    internal.scrollContainer = scrollContainer;\n    internal.userTurnSelector = '.user';\n    internal.ui.timelineBar = timelineBar;\n    internal.ui.trackContent = trackContent;\n    internal.scrollMode = 'jump';\n\n    internal.updateTimelineGeometry = vi.fn();\n    internal.updateIntersectionObserverTargetsFromMarkers = vi.fn();\n    internal.syncTimelineTrackToMain = vi.fn();\n    internal.updateVirtualRangeAndRender = vi.fn();\n    internal.updateActiveDotUI = vi.fn();\n    internal.scheduleScrollSync = vi.fn();\n    internal.smoothScrollTo = vi.fn();\n\n    internal.activeTurnId = null;\n    internal.recalculateAndRenderMarkers();\n    expect(internal.markers).toHaveLength(2);\n    expect(internal.activeTurnId).toBe(internal.markers[1]!.id);\n\n    internal.enqueueNavigation('next');\n    expect(internal.navigationQueue).toHaveLength(0);\n    expect(internal.smoothScrollTo).not.toHaveBeenCalled();\n\n    internal.activeTurnId = internal.markers[0]!.id;\n    internal.enqueueNavigation('previous');\n    expect(internal.navigationQueue).toHaveLength(0);\n    expect(internal.smoothScrollTo).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/timeline/__tests__/TimelineManagerPreviewPanelReposition.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { TimelineManager } from '../manager';\n\ntype PreviewPanelLike = {\n  reposition: () => void;\n  destroy: () => void;\n};\n\ntype TimelineManagerInternal = {\n  ui: { timelineBar: HTMLElement | null };\n  previewPanel: PreviewPanelLike | null;\n  applyPosition: (top: number, left: number) => void;\n};\n\ndescribe('TimelineManager preview panel reposition', () => {\n  beforeEach(() => {\n    document.body.innerHTML = '';\n    vi.restoreAllMocks();\n  });\n\n  it('repositions preview toggle when timeline position is applied', () => {\n    const manager = new TimelineManager();\n    const internal = manager as unknown as TimelineManagerInternal;\n\n    const timelineBar = document.createElement('div');\n    Object.defineProperty(timelineBar, 'offsetWidth', { value: 24, configurable: true });\n    Object.defineProperty(timelineBar, 'offsetHeight', { value: 100, configurable: true });\n    document.body.appendChild(timelineBar);\n    internal.ui.timelineBar = timelineBar;\n\n    const reposition = vi.fn();\n    internal.previewPanel = { reposition, destroy: vi.fn() };\n\n    internal.applyPosition(120, 260);\n\n    expect(timelineBar.style.top).toBe('120px');\n    expect(timelineBar.style.left).toBe('260px');\n    expect(reposition).toHaveBeenCalledTimes(1);\n\n    manager.destroy();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/timeline/__tests__/TimelineManagerSelectorPriority.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { TimelineManager } from '../manager';\n\ndescribe('TimelineManager selector priority compatibility', () => {\n  beforeEach(() => {\n    document.body.innerHTML = '';\n    localStorage.clear();\n    vi.restoreAllMocks();\n  });\n\n  it('prefers built-in selectors over stale auto-detected selector cache', async () => {\n    const main = document.createElement('main');\n\n    const defaultTurn = document.createElement('div');\n    defaultTurn.className = 'user-query-bubble-with-background';\n    defaultTurn.textContent = 'default turn';\n    main.appendChild(defaultTurn);\n\n    const staleTurn = document.createElement('div');\n    staleTurn.className = 'stale-selector-target';\n    staleTurn.textContent = 'stale turn';\n    main.appendChild(staleTurn);\n\n    document.body.appendChild(main);\n    localStorage.setItem('geminiTimelineUserTurnSelectorAuto', '.stale-selector-target');\n\n    const manager = new TimelineManager();\n    const internal = manager as unknown as {\n      findCriticalElements: () => Promise<boolean>;\n      userTurnSelector: string;\n    };\n\n    const ok = await internal.findCriticalElements();\n    expect(ok).toBe(true);\n    expect(internal.userTurnSelector).toBe('.user-query-bubble-with-background');\n    expect(localStorage.getItem('geminiTimelineUserTurnSelectorAuto')).toBe(\n      '.user-query-bubble-with-background',\n    );\n  });\n\n  it('keeps explicit user override as highest priority', async () => {\n    const main = document.createElement('main');\n\n    const defaultTurn = document.createElement('div');\n    defaultTurn.className = 'user-query-bubble-with-background';\n    defaultTurn.textContent = 'default turn';\n    main.appendChild(defaultTurn);\n\n    const customTurn = document.createElement('div');\n    customTurn.className = 'custom-user-turn';\n    customTurn.textContent = 'custom turn';\n    main.appendChild(customTurn);\n\n    document.body.appendChild(main);\n    localStorage.setItem('geminiTimelineUserTurnSelector', '.custom-user-turn');\n\n    const manager = new TimelineManager();\n    const internal = manager as unknown as {\n      findCriticalElements: () => Promise<boolean>;\n      userTurnSelector: string;\n    };\n\n    const ok = await internal.findCriticalElements();\n    expect(ok).toBe(true);\n    expect(internal.userTurnSelector).toBe('.custom-user-turn');\n  });\n});\n"
  },
  {
    "path": "src/pages/content/timeline/__tests__/TimelineManagerSummaryExtraction.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { TimelineManager } from '../manager';\n\nfunction setElementTop(el: HTMLElement, top: number): void {\n  Object.defineProperty(el, 'offsetTop', { value: top, configurable: true });\n  vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({\n    x: 0,\n    y: top,\n    top,\n    left: 0,\n    right: 0,\n    bottom: top,\n    width: 0,\n    height: 0,\n    toJSON: () => ({}),\n  } as DOMRect);\n}\n\ntype TimelineMarker = {\n  id: string;\n  summary: string;\n};\n\ntype TimelineManagerInternal = {\n  conversationContainer: HTMLElement | null;\n  scrollContainer: HTMLElement | null;\n  userTurnSelector: string | null;\n  ui: { timelineBar: HTMLElement | null; trackContent: HTMLElement | null };\n  markers: TimelineMarker[];\n  recalculateAndRenderMarkers: () => void;\n  updateTimelineGeometry: () => void;\n  updateIntersectionObserverTargetsFromMarkers: () => void;\n  syncTimelineTrackToMain: () => void;\n  updateVirtualRangeAndRender: () => void;\n  updateActiveDotUI: () => void;\n  scheduleScrollSync: () => void;\n};\n\nfunction setupForRecalc(container: HTMLElement): {\n  manager: TimelineManager;\n  internal: TimelineManagerInternal;\n} {\n  const scrollContainer = document.createElement('div');\n  scrollContainer.scrollTop = 0;\n  Object.defineProperty(scrollContainer, 'clientHeight', { value: 400, configurable: true });\n  vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue({\n    x: 0,\n    y: 0,\n    top: 0,\n    left: 0,\n    right: 0,\n    bottom: 400,\n    width: 300,\n    height: 400,\n    toJSON: () => ({}),\n  } as DOMRect);\n  scrollContainer.appendChild(container);\n  document.body.appendChild(scrollContainer);\n\n  const timelineBar = document.createElement('div');\n  const trackContent = document.createElement('div');\n  timelineBar.appendChild(trackContent);\n  document.body.appendChild(timelineBar);\n\n  const manager = new TimelineManager();\n  const internal = manager as unknown as TimelineManagerInternal;\n  internal.conversationContainer = container;\n  internal.scrollContainer = scrollContainer;\n  internal.userTurnSelector = '.user-query-bubble-with-background';\n  internal.ui.timelineBar = timelineBar;\n  internal.ui.trackContent = trackContent;\n  internal.updateTimelineGeometry = vi.fn();\n  internal.updateIntersectionObserverTargetsFromMarkers = vi.fn();\n  internal.syncTimelineTrackToMain = vi.fn();\n  internal.updateVirtualRangeAndRender = vi.fn();\n  internal.updateActiveDotUI = vi.fn();\n  internal.scheduleScrollSync = vi.fn();\n  return { manager, internal };\n}\n\ndescribe('TimelineManager summary extraction', () => {\n  beforeEach(() => {\n    document.body.innerHTML = '';\n    vi.restoreAllMocks();\n  });\n\n  it('excludes cdk-visually-hidden text from marker summary', () => {\n    const container = document.createElement('div');\n    const turn = document.createElement('span');\n    turn.className = 'user-query-bubble-with-background';\n    turn.innerHTML = `\n      <span class=\"horizontal-container\">\n        <div role=\"heading\" aria-level=\"2\" class=\"query-text gds-body-l\" dir=\"ltr\">\n          <span class=\"cdk-visually-hidden\">你說了</span>\n          <p class=\"query-text-line\"> 嗯嗯！ </p>\n        </div>\n      </span>\n    `;\n    setElementTop(turn, 0);\n    container.appendChild(turn);\n\n    const { manager, internal } = setupForRecalc(container);\n    internal.recalculateAndRenderMarkers();\n\n    expect(internal.markers).toHaveLength(1);\n    expect(internal.markers[0]?.summary).toBe('嗯嗯！');\n    manager.destroy();\n  });\n\n  it('excludes .gv-fork-btn button text from marker summary', () => {\n    const container = document.createElement('div');\n    const turn = document.createElement('span');\n    turn.className = 'user-query-bubble-with-background';\n    turn.innerHTML = `\n      <p class=\"query-text-line\">Hello world</p>\n      <button class=\"gv-fork-btn\"><span>Fork</span></button>\n    `;\n    setElementTop(turn, 0);\n    container.appendChild(turn);\n\n    const { manager, internal } = setupForRecalc(container);\n    internal.recalculateAndRenderMarkers();\n\n    expect(internal.markers).toHaveLength(1);\n    expect(internal.markers[0]?.summary).toBe('Hello world');\n    expect(internal.markers[0]?.summary).not.toContain('Fork');\n    manager.destroy();\n  });\n\n  it('assigns unique marker IDs to turns with identical text at different positions', () => {\n    const container = document.createElement('div');\n\n    const first = document.createElement('div');\n    first.className = 'user-query-bubble-with-background';\n    first.innerHTML = '<p>好的，继续执行下一步</p>';\n    setElementTop(first, 0);\n\n    const second = document.createElement('div');\n    second.className = 'user-query-bubble-with-background';\n    second.innerHTML = '<p>好的，继续执行下一步</p>';\n    setElementTop(second, 200);\n\n    container.appendChild(first);\n    container.appendChild(second);\n\n    const { manager, internal } = setupForRecalc(container);\n    internal.recalculateAndRenderMarkers();\n\n    expect(internal.markers).toHaveLength(2);\n    expect(internal.markers[0]?.id).not.toBe(internal.markers[1]?.id);\n    expect(internal.markers[0]?.summary).toBe('好的，继续执行下一步');\n    expect(internal.markers[1]?.summary).toBe('好的，继续执行下一步');\n    manager.destroy();\n  });\n\n  it('deduplicates turns by visible text when visually-hidden labels differ', () => {\n    const container = document.createElement('div');\n    const first = document.createElement('div');\n    first.className = 'user-query-bubble-with-background';\n    first.innerHTML = '<span class=\"cdk-visually-hidden\">你說了</span><p>same content</p>';\n    setElementTop(first, 0);\n\n    const second = document.createElement('div');\n    second.className = 'user-query-bubble-with-background';\n    second.innerHTML = '<span class=\"visually-hidden\">you said</span><p>same content</p>';\n    setElementTop(second, 0);\n\n    container.appendChild(first);\n    container.appendChild(second);\n\n    const { manager, internal } = setupForRecalc(container);\n    internal.recalculateAndRenderMarkers();\n\n    expect(internal.markers).toHaveLength(1);\n    expect(internal.markers[0]?.summary).toBe('same content');\n    manager.destroy();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/timeline/__tests__/TimelineManagerTooltipDirection.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { TimelineManager } from '../manager';\nimport type { DotElement } from '../types';\n\ntype TimelineManagerInternal = {\n  ui: { tooltip: HTMLElement | null };\n  previewPanel: { isOpen: boolean } | null;\n  starred: Set<string>;\n  computePlacementInfo: (dot: HTMLElement) => { placement: 'left' | 'right'; width: number };\n  truncateToThreeLines: (text: string, targetWidth: number) => { text: string; height: number };\n  placeTooltipAt: (\n    dot: HTMLElement,\n    placement: 'left' | 'right',\n    width: number,\n    height: number,\n  ) => void;\n  showTooltipForDot: (dot: DotElement) => void;\n};\n\ndescribe('TimelineManager tooltip direction', () => {\n  beforeEach(() => {\n    document.body.innerHTML = '';\n    vi.restoreAllMocks();\n  });\n\n  it('sets tooltip dir to auto when showing preview text', () => {\n    const manager = new TimelineManager();\n    const internal = manager as unknown as TimelineManagerInternal;\n\n    const tooltip = document.createElement('div');\n    tooltip.className = 'timeline-tooltip';\n    document.body.appendChild(tooltip);\n    internal.ui.tooltip = tooltip;\n    internal.previewPanel = null;\n    internal.starred = new Set<string>();\n    internal.computePlacementInfo = vi.fn(() => ({ placement: 'left' as const, width: 240 }));\n    internal.truncateToThreeLines = vi.fn((text: string) => ({ text, height: 36 }));\n    internal.placeTooltipAt = vi.fn();\n\n    const dot = document.createElement('button') as DotElement;\n    dot.className = 'timeline-dot';\n    dot.setAttribute('aria-label', 'مرحبا بالعالم');\n    dot.dataset.targetTurnId = 'turn-1';\n    document.body.appendChild(dot);\n\n    internal.showTooltipForDot(dot);\n\n    expect(tooltip.getAttribute('dir')).toBe('auto');\n    manager.destroy();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/timeline/__tests__/TimelinePreviewPanel.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { GV_RTL_CLASS } from '@/core/utils/rtl';\n\nimport { TimelinePreviewPanel } from '../TimelinePreviewPanel';\nimport type { PreviewMarkerData } from '../types';\n\nvi.mock('webextension-polyfill', () => ({\n  default: {\n    storage: {\n      onChanged: { addListener: vi.fn(), removeListener: vi.fn() },\n    },\n  },\n}));\n\nvi.mock('../../../../utils/i18n', () => ({\n  getTranslationSync: (key: string) => {\n    const map: Record<string, string> = {\n      timelinePreviewSearch: 'Search...',\n      timelinePreviewNoResults: 'No results',\n      timelinePreviewNoMessages: 'No messages',\n    };\n    return map[key] ?? key;\n  },\n}));\n\nfunction makeMarkers(count: number): PreviewMarkerData[] {\n  return Array.from({ length: count }, (_, i) => ({\n    id: `turn-${i}`,\n    summary: `User message number ${i + 1}`,\n    index: i,\n    starred: i === 2,\n    starredAt: i === 2 ? 1710000000000 : undefined,\n  }));\n}\n\ndescribe('TimelinePreviewPanel', () => {\n  let anchor: HTMLElement;\n  let panel: TimelinePreviewPanel;\n  let onNavigate: (turnId: string, index: number) => void;\n\n  beforeEach(() => {\n    document.body.innerHTML = '';\n    document.body.className = '';\n    anchor = document.createElement('div');\n    anchor.className = 'gemini-timeline-bar';\n    document.body.appendChild(anchor);\n\n    onNavigate = vi.fn();\n    panel = new TimelinePreviewPanel(anchor);\n    panel.init(onNavigate);\n  });\n\n  afterEach(() => {\n    panel.destroy();\n    document.body.innerHTML = '';\n    document.body.className = '';\n  });\n\n  describe('DOM creation', () => {\n    it('creates toggle button on the page', () => {\n      const toggle = document.querySelector('.timeline-preview-toggle');\n      expect(toggle).not.toBeNull();\n      expect(toggle?.tagName).toBe('BUTTON');\n    });\n\n    it('creates panel with search and list', () => {\n      const panelEl = document.querySelector('.timeline-preview-panel');\n      expect(panelEl).not.toBeNull();\n      expect(panelEl?.querySelector('.timeline-preview-search input')).not.toBeNull();\n      expect(panelEl?.querySelector('.timeline-preview-list')).not.toBeNull();\n    });\n  });\n\n  describe('toggle', () => {\n    it('opens and closes panel', () => {\n      const panelEl = document.querySelector('.timeline-preview-panel')!;\n      expect(panelEl.classList.contains('visible')).toBe(false);\n      expect(panel.isOpen).toBe(false);\n\n      panel.toggle();\n      expect(panelEl.classList.contains('visible')).toBe(true);\n      expect(panel.isOpen).toBe(true);\n\n      panel.toggle();\n      expect(panelEl.classList.contains('visible')).toBe(false);\n      expect(panel.isOpen).toBe(false);\n    });\n\n    it('toggle button click opens/closes panel', () => {\n      const toggle = document.querySelector('.timeline-preview-toggle') as HTMLElement;\n      toggle.click();\n      expect(panel.isOpen).toBe(true);\n\n      toggle.click();\n      expect(panel.isOpen).toBe(false);\n    });\n  });\n\n  describe('updateMarkers', () => {\n    it('renders correct number of items when open', () => {\n      const markers = makeMarkers(5);\n      panel.updateMarkers(markers);\n      panel.open();\n\n      const items = document.querySelectorAll('.timeline-preview-item');\n      expect(items.length).toBe(5);\n    });\n\n    it('shows index numbers', () => {\n      panel.updateMarkers(makeMarkers(3));\n      panel.open();\n\n      const indices = document.querySelectorAll('.timeline-preview-index');\n      expect(indices[0]?.textContent).toBe('1');\n      expect(indices[2]?.textContent).toBe('3');\n    });\n\n    it('marks starred items', () => {\n      panel.updateMarkers(makeMarkers(5));\n      panel.open();\n\n      const items = document.querySelectorAll('.timeline-preview-item');\n      expect(items[2]?.classList.contains('starred')).toBe(true);\n      expect(items[0]?.classList.contains('starred')).toBe(false);\n    });\n\n    it('shows starredAt time label for starred items', () => {\n      panel.updateMarkers(makeMarkers(5));\n      panel.open();\n\n      const timeLabels = document.querySelectorAll('.timeline-preview-starred-time');\n      // Only the starred item (index 2) should have a time label\n      expect(timeLabels.length).toBe(1);\n      // Timestamp 1710000000000 → check it renders a date string\n      expect(timeLabels[0]?.textContent).toMatch(/\\d{2}\\/\\d{2} \\d{2}:\\d{2}/);\n    });\n\n    it('shows empty message when no markers', () => {\n      panel.updateMarkers([]);\n      panel.open();\n\n      const empty = document.querySelector('.timeline-preview-empty');\n      expect(empty).not.toBeNull();\n      expect(empty?.textContent).toBe('No messages');\n    });\n\n    it('sets preview text direction to auto for bidi-safe rendering', () => {\n      const markers: PreviewMarkerData[] = [\n        {\n          id: 'turn-ar',\n          summary: 'مرحبا بكم في Gemini Voyager',\n          index: 0,\n          starred: false,\n        },\n      ];\n      panel.updateMarkers(markers);\n      panel.open();\n\n      const text = document.querySelector('.timeline-preview-text') as HTMLElement | null;\n      expect(text?.getAttribute('dir')).toBe('auto');\n    });\n  });\n\n  describe('rtl adaptation', () => {\n    it('applies rtl direction when body has gv-rtl class', () => {\n      document.body.classList.add(GV_RTL_CLASS);\n      panel.reposition();\n\n      const panelEl = document.querySelector('.timeline-preview-panel');\n      const listEl = document.querySelector('.timeline-preview-list');\n      expect(panelEl?.getAttribute('dir')).toBe('rtl');\n      expect(listEl?.getAttribute('dir')).toBe('rtl');\n    });\n\n    it('keeps toggle on left side of timeline in rtl with viewport clamp', () => {\n      const rectSpy = vi\n        .spyOn(anchor, 'getBoundingClientRect')\n        .mockReturnValue(new DOMRect(15, 60, 24, 500));\n\n      document.body.classList.add(GV_RTL_CLASS);\n      panel.reposition();\n\n      const toggle = document.querySelector('.timeline-preview-toggle') as HTMLElement | null;\n      expect(toggle?.style.left).toBe('8px');\n      rectSpy.mockRestore();\n    });\n  });\n\n  describe('updateActiveTurn', () => {\n    it('highlights the active item', () => {\n      panel.updateMarkers(makeMarkers(5));\n      panel.open();\n\n      panel.updateActiveTurn('turn-1');\n\n      const items = document.querySelectorAll('.timeline-preview-item');\n      expect(items[1]?.classList.contains('active')).toBe(true);\n      expect(items[0]?.classList.contains('active')).toBe(false);\n    });\n\n    it('switches active highlight on update', () => {\n      panel.updateMarkers(makeMarkers(5));\n      panel.open();\n\n      panel.updateActiveTurn('turn-0');\n      let items = document.querySelectorAll('.timeline-preview-item');\n      expect(items[0]?.classList.contains('active')).toBe(true);\n\n      panel.updateActiveTurn('turn-3');\n      items = document.querySelectorAll('.timeline-preview-item');\n      expect(items[0]?.classList.contains('active')).toBe(false);\n      expect(items[3]?.classList.contains('active')).toBe(true);\n    });\n  });\n\n  describe('search filtering', () => {\n    it('filters by substring (case-insensitive)', async () => {\n      panel.updateMarkers(makeMarkers(10));\n      panel.open();\n\n      const input = document.querySelector('.timeline-preview-search input') as HTMLInputElement;\n      input.value = 'number 3';\n      input.dispatchEvent(new Event('input'));\n\n      // Wait for debounce\n      await new Promise((resolve) => setTimeout(resolve, 250));\n\n      const items = document.querySelectorAll('.timeline-preview-item');\n      expect(items.length).toBe(1);\n      expect(items[0]?.querySelector('.timeline-preview-text')?.textContent).toContain('3');\n    });\n\n    it('shows all items when search is cleared', async () => {\n      panel.updateMarkers(makeMarkers(5));\n      panel.open();\n\n      const input = document.querySelector('.timeline-preview-search input') as HTMLInputElement;\n      input.value = 'number 1';\n      input.dispatchEvent(new Event('input'));\n      await new Promise((resolve) => setTimeout(resolve, 250));\n      expect(document.querySelectorAll('.timeline-preview-item').length).toBe(1);\n\n      input.value = '';\n      input.dispatchEvent(new Event('input'));\n      await new Promise((resolve) => setTimeout(resolve, 250));\n      expect(document.querySelectorAll('.timeline-preview-item').length).toBe(5);\n    });\n\n    it('shows \"No results\" when search has no match', async () => {\n      panel.updateMarkers(makeMarkers(3));\n      panel.open();\n\n      const input = document.querySelector('.timeline-preview-search input') as HTMLInputElement;\n      input.value = 'zzzznonexistent';\n      input.dispatchEvent(new Event('input'));\n      await new Promise((resolve) => setTimeout(resolve, 250));\n\n      const empty = document.querySelector('.timeline-preview-empty');\n      expect(empty?.textContent).toBe('No results');\n    });\n  });\n\n  describe('navigation', () => {\n    it('calls onNavigate when item is clicked', () => {\n      panel.updateMarkers(makeMarkers(5));\n      panel.open();\n\n      const items = document.querySelectorAll('.timeline-preview-item');\n      (items[2] as HTMLElement).click();\n\n      expect(onNavigate).toHaveBeenCalledWith('turn-2', 2);\n    });\n  });\n\n  describe('close behavior', () => {\n    it('closes on Escape key', () => {\n      panel.open();\n      expect(panel.isOpen).toBe(true);\n\n      document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));\n      expect(panel.isOpen).toBe(false);\n    });\n\n    it('closes on click outside', () => {\n      panel.open();\n      expect(panel.isOpen).toBe(true);\n\n      document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));\n      expect(panel.isOpen).toBe(false);\n    });\n\n    it('does not close on click inside panel', () => {\n      panel.updateMarkers(makeMarkers(3));\n      panel.open();\n\n      const panelEl = document.querySelector('.timeline-preview-panel')!;\n      panelEl.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));\n      expect(panel.isOpen).toBe(true);\n    });\n  });\n\n  describe('scroll isolation', () => {\n    it('stops wheel event propagation on list', () => {\n      panel.updateMarkers(makeMarkers(20));\n      panel.open();\n\n      const list = document.querySelector('.timeline-preview-list') as HTMLElement;\n      const wheelEvent = new WheelEvent('wheel', { deltaY: 10, bubbles: true, cancelable: true });\n      const stopSpy = vi.spyOn(wheelEvent, 'stopPropagation');\n      list.dispatchEvent(wheelEvent);\n\n      expect(stopSpy).toHaveBeenCalled();\n    });\n  });\n\n  describe('destroy', () => {\n    it('removes all DOM elements', () => {\n      panel.destroy();\n\n      expect(document.querySelector('.timeline-preview-toggle')).toBeNull();\n      expect(document.querySelector('.timeline-preview-panel')).toBeNull();\n    });\n\n    it('can be called multiple times safely', () => {\n      panel.destroy();\n      expect(() => panel.destroy()).not.toThrow();\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/content/timeline/index.ts",
    "content": "import { TimelineManager } from './manager';\n\nfunction isGeminiConversationRoute(pathname = location.pathname): boolean {\n  // Support account-scoped routes like /u/1/app or /u/0/gem/\n  // Matches: \"/app\", \"/gem/\", \"/u/<num>/app\", \"/u/<num>/gem/\"\n  return /^\\/(?:u\\/\\d+\\/)?(app|gem)(\\/|$)/.test(pathname);\n}\n\ntype HistoryStateArgs = Parameters<History['pushState']>;\n\nlet timelineManagerInstance: TimelineManager | null = null;\nlet currentUrl = location.href;\nlet currentPathAndSearch = location.pathname + location.search;\nlet routeCheckIntervalId: number | null = null;\nlet routeListenersAttached = false;\nlet activeObservers: MutationObserver[] = [];\nlet cleanupHandlers: (() => void)[] = [];\nlet historyPatched = false;\nlet originalPushState: History['pushState'] | null = null;\nlet originalReplaceState: History['replaceState'] | null = null;\n\nfunction initializeTimeline(): void {\n  if (timelineManagerInstance) {\n    try {\n      timelineManagerInstance.destroy();\n    } catch {}\n    timelineManagerInstance = null;\n  }\n  try {\n    document.querySelector('.gemini-timeline-bar')?.remove();\n  } catch {}\n  try {\n    document.querySelector('.timeline-left-slider')?.remove();\n  } catch {}\n  try {\n    document.getElementById('gemini-timeline-tooltip')?.remove();\n  } catch {}\n  timelineManagerInstance = new TimelineManager();\n  timelineManagerInstance\n    .init()\n    .catch((err) => console.error('Timeline initialization failed:', err));\n}\n\nlet urlChangeTimer: number | null = null;\n\nfunction handleUrlChange(): void {\n  if (location.href === currentUrl) return;\n\n  const newPathAndSearch = location.pathname + location.search;\n  const pathChanged = newPathAndSearch !== currentPathAndSearch;\n\n  // Update current URL\n  currentUrl = location.href;\n\n  // Only reinitialize if pathname or search changed, not just hash\n  if (!pathChanged) {\n    console.log('[Timeline] Only hash changed, keeping existing timeline');\n    return;\n  }\n\n  currentPathAndSearch = newPathAndSearch;\n\n  // Clear any pending initialization\n  if (urlChangeTimer) {\n    clearTimeout(urlChangeTimer);\n    urlChangeTimer = null;\n  }\n\n  if (isGeminiConversationRoute()) {\n    // Add delay to allow DOM to update after SPA navigation\n    console.log('[Timeline] URL changed to conversation route, scheduling initialization');\n    urlChangeTimer = window.setTimeout(() => {\n      console.log('[Timeline] Initializing timeline after URL change');\n      initializeTimeline();\n      urlChangeTimer = null;\n    }, 500); // Wait for DOM to settle\n  } else {\n    console.log('[Timeline] URL changed to non-conversation route, cleaning up');\n    if (timelineManagerInstance) {\n      try {\n        timelineManagerInstance.destroy();\n      } catch {}\n      timelineManagerInstance = null;\n    }\n    try {\n      document.querySelector('.gemini-timeline-bar')?.remove();\n    } catch {}\n    try {\n      document.querySelector('.timeline-left-slider')?.remove();\n    } catch {}\n    try {\n      document.getElementById('gemini-timeline-tooltip')?.remove();\n    } catch {}\n  }\n}\n\nfunction patchHistoryOnce(): void {\n  if (historyPatched) return;\n  try {\n    originalPushState = history.pushState;\n    originalReplaceState = history.replaceState;\n\n    history.pushState = (...args: HistoryStateArgs): void => {\n      originalPushState?.apply(history, args);\n      handleUrlChange();\n    };\n    history.replaceState = (...args: HistoryStateArgs): void => {\n      originalReplaceState?.apply(history, args);\n      handleUrlChange();\n    };\n\n    historyPatched = true;\n    cleanupHandlers.push(() => {\n      if (!historyPatched) return;\n      if (originalPushState) history.pushState = originalPushState;\n      if (originalReplaceState) history.replaceState = originalReplaceState;\n      historyPatched = false;\n      originalPushState = null;\n      originalReplaceState = null;\n    });\n  } catch (e) {\n    console.warn('[Timeline] Failed to patch history API:', e);\n  }\n}\n\nfunction attachRouteListenersOnce(): void {\n  if (routeListenersAttached) return;\n  routeListenersAttached = true;\n  patchHistoryOnce();\n  window.addEventListener('popstate', handleUrlChange);\n  window.addEventListener('hashchange', handleUrlChange);\n  routeCheckIntervalId = window.setInterval(() => {\n    if (location.href !== currentUrl) handleUrlChange();\n  }, 800);\n\n  // Register cleanup handlers for proper resource management\n  cleanupHandlers.push(() => {\n    window.removeEventListener('popstate', handleUrlChange);\n    window.removeEventListener('hashchange', handleUrlChange);\n  });\n}\n\n/**\n * Cleanup function to prevent memory leaks\n * Disconnects all observers, clears intervals, and removes event listeners\n */\nfunction cleanup(): void {\n  // Cancel any pending delayed initialization\n  if (urlChangeTimer) {\n    clearTimeout(urlChangeTimer);\n    urlChangeTimer = null;\n  }\n\n  // Disconnect all active MutationObservers\n  activeObservers.forEach((observer) => {\n    try {\n      observer.disconnect();\n    } catch (e) {\n      console.error('[Gemini Voyager] Failed to disconnect observer during cleanup:', e);\n    }\n  });\n  activeObservers = [];\n\n  // Clear the route check interval\n  if (routeCheckIntervalId !== null) {\n    clearInterval(routeCheckIntervalId);\n    routeCheckIntervalId = null;\n  }\n\n  // Execute all registered cleanup handlers\n  cleanupHandlers.forEach((handler) => {\n    try {\n      handler();\n    } catch (e) {\n      console.error('[Gemini Voyager] Failed to run cleanup handler:', e);\n    }\n  });\n  cleanupHandlers = [];\n\n  // Reset flag\n  routeListenersAttached = false;\n}\n\nexport function startTimeline(): void {\n  const setup = (): void => {\n    attachRouteListenersOnce();\n    if (isGeminiConversationRoute() && !timelineManagerInstance) {\n      initializeTimeline();\n    }\n  };\n\n  if (document.body) {\n    setup();\n  } else {\n    const initialObserver = new MutationObserver(() => {\n      if (!document.body) return;\n\n      // Disconnect and remove from tracking\n      initialObserver.disconnect();\n      activeObservers = activeObservers.filter((obs) => obs !== initialObserver);\n\n      setup();\n    });\n\n    // Track observer for cleanup\n    activeObservers.push(initialObserver);\n    initialObserver.observe(document.documentElement || document.body, {\n      childList: true,\n      subtree: true,\n    });\n  }\n\n  // Setup cleanup on page unload\n  window.addEventListener('beforeunload', cleanup, { once: true });\n\n  // Also cleanup on extension unload (if content script is removed)\n  if (typeof chrome !== 'undefined' && chrome.runtime) {\n    chrome.runtime.onSuspend?.addListener?.(cleanup);\n  }\n}\n"
  },
  {
    "path": "src/pages/content/timeline/manager.ts",
    "content": "import { keyboardShortcutService } from '@/core/services/KeyboardShortcutService';\nimport { storageService } from '@/core/services/StorageService';\nimport { StorageKeys, type TurnId } from '@/core/types/common';\nimport { GV_RTL_CLASS, applyRTLClass } from '@/core/utils/rtl';\n\nimport { getTranslationSync, initI18n } from '../../../utils/i18n';\nimport { TimestampService } from '../timestamp/TimestampService';\nimport { eventBus } from './EventBus';\nimport { StarredMessagesService } from './StarredMessagesService';\nimport { TimelinePreviewPanel } from './TimelinePreviewPanel';\nimport type { StarredMessage, StarredMessagesData } from './starredTypes';\nimport type { DotElement, MarkerLevel } from './types';\n\nfunction hashString(input: string): string {\n  let h = 2166136261 >>> 0;\n  for (let i = 0; i < input.length; i++) {\n    h ^= input.charCodeAt(i);\n    h = Math.imul(h, 16777619);\n  }\n  return (h >>> 0).toString(36);\n}\n\n/** Accessibility prefixes injected by Gemini's DOM that should be stripped from previews effectively globally. */\nconst TURN_LABEL_PREFIXES =\n  /^[\\u200B\\u200C\\u200D\\u200E\\u200F\\uFEFF]*(?:you said|you wrote|user message|your prompt|you asked)[:\\s]*/i;\nconst VISUALLY_HIDDEN_CLASS_FRAGMENT = 'visually-hidden';\nconst INJECTED_UI_SELECTOR = '.gv-fork-btn, .gv-fork-confirm, .gv-fork-indicator-group';\n\ntype ExtGlobal = typeof globalThis & {\n  chrome?: {\n    storage?: {\n      sync?: {\n        get(k: Record<string, unknown>, cb: (items: Record<string, unknown>) => void): void;\n        set?(items: Record<string, unknown>): void;\n      };\n      onChanged?: {\n        addListener(\n          cb: (changes: Record<string, { newValue: unknown }>, area: string) => void,\n        ): void;\n      };\n    };\n    runtime?: { lastError?: { message: string } };\n  };\n  browser?: {\n    storage?: {\n      sync?: {\n        get(k: Record<string, unknown>): Promise<Record<string, unknown>>;\n        set?(items: Record<string, unknown>): void;\n      };\n      onChanged?: {\n        addListener(\n          cb: (changes: Record<string, { newValue: unknown }>, area: string) => void,\n        ): void;\n      };\n    };\n  };\n};\n\nexport class TimelineManager {\n  private scrollContainer: HTMLElement | null = null;\n  private conversationContainer: HTMLElement | null = null;\n  private markers: Array<{\n    id: string;\n    element: HTMLElement;\n    summary: string;\n    n: number;\n    baseN: number;\n    dotElement: DotElement | null;\n    starred: boolean;\n  }> = [];\n  private activeTurnId: string | null = null;\n  private ui: {\n    timelineBar: HTMLElement | null;\n    tooltip: HTMLElement | null;\n    track?: HTMLElement | null;\n    trackContent?: HTMLElement | null;\n    slider?: HTMLElement | null;\n    sliderHandle?: HTMLElement | null;\n  } = { timelineBar: null, tooltip: null };\n  private isScrolling = false;\n\n  private mutationObserver: MutationObserver | null = null;\n  private resizeObserver: ResizeObserver | null = null;\n  private intersectionObserver: IntersectionObserver | null = null;\n  private visibleUserTurns: Set<Element> = new Set();\n  private onTimelineBarClick: ((e: Event) => void) | null = null;\n  private onScroll: (() => void) | null = null;\n  private onTimelineWheel: ((e: WheelEvent) => void) | null = null;\n  private onWindowResize: (() => void) | null = null;\n  private onTimelineBarOver: ((e: MouseEvent) => void) | null = null;\n  private onTimelineBarOut: ((e: MouseEvent) => void) | null = null;\n  private scrollRafId: number | null = null;\n  private lastActiveChangeTime = 0;\n  private minActiveChangeInterval = 120;\n  private pendingActiveId: string | null = null;\n  private activeChangeTimer: number | null = null;\n  private tooltipHideDelay = 100;\n  private scrollMode: 'jump' | 'flow' = 'flow';\n  private hideContainer: boolean = false;\n  private barWidth: number = 24;\n  private readonly barWidthMin = 4;\n  private readonly barWidthMax = 24;\n  private resizing = false;\n  private onResizeMove: ((ev: PointerEvent) => void) | null = null;\n  private onResizeUp: ((ev: PointerEvent) => void) | null = null;\n  private onBarCursorMove: ((ev: PointerEvent) => void) | null = null;\n  private runnerRing: HTMLElement | null = null;\n  private flowAnimating = false;\n  private tooltipHideTimer: number | null = null;\n  private tooltipDotId: string | null = null;\n  private measureEl: HTMLElement | null = null;\n  private measureCanvas: HTMLCanvasElement | null = null;\n  private measureCtx: CanvasRenderingContext2D | null = null;\n  private showRafId: number | null = null;\n  private scale = 1;\n  private contentHeight = 0;\n  private yPositions: number[] = [];\n  private markerTops: number[] = [];\n  private visibleRange: { start: number; end: number } = { start: 0, end: -1 };\n  private firstUserTurnOffset = 0;\n  private contentSpanPx = 1;\n  private usePixelTop = false;\n  private _cssVarTopSupported: boolean | null = null;\n  private sliderDragging = false;\n  private sliderFadeTimer: number | null = null;\n  private sliderFadeDelay = 1000;\n  private sliderAlwaysVisible = false;\n  private onSliderDown: ((ev: PointerEvent) => void) | null = null;\n  private onSliderMove: ((ev: PointerEvent) => void) | null = null;\n  private onSliderUp: ((ev: PointerEvent) => void) | null = null;\n  private sliderStartClientY = 0;\n  private sliderStartTop = 0;\n  private markersVersion = 0;\n  private resizeIdleTimer: number | null = null;\n  private resizeIdleDelay = 140;\n  private resizeIdleRICId: number | null = null;\n  private onVisualViewportResize: (() => void) | null = null;\n  private zeroTurnsTimer: number | null = null;\n  private onStorage: ((e: StorageEvent) => void) | null = null;\n  private onChromeStorageChanged:\n    | ((changes: Record<string, chrome.storage.StorageChange>, areaName: string) => void)\n    | null = null;\n  private starred: Set<string> = new Set();\n  /** Map of turnId → starredAt timestamp (ms). Populated from service/storage; used for preview labels. */\n  private starredAtMap: Map<string, number> = new Map();\n  private markerMap: Map<\n    string,\n    {\n      id: string;\n      element: HTMLElement;\n      dotElement: DotElement | null;\n      starred: boolean;\n      n: number;\n      baseN: number;\n      summary: string;\n    }\n  > = new Map();\n  private conversationId: string | null = null;\n  private userTurnSelector: string = '';\n  private markerLevels: Map<string, MarkerLevel> = new Map();\n  private collapsedMarkers: Set<string> = new Set();\n  private markerLevelEnabled = false;\n  private contextMenu: HTMLElement | null = null;\n  private onContextMenu: ((ev: MouseEvent) => void) | null = null;\n  private onDocumentClick: ((ev: MouseEvent) => void) | null = null;\n  private onPointerDown: ((ev: PointerEvent) => void) | null = null;\n  private onPointerMove: ((ev: PointerEvent) => void) | null = null;\n  private onPointerUp: ((ev: PointerEvent) => void) | null = null;\n  private onPointerCancel: ((ev: PointerEvent) => void) | null = null;\n  private onPointerLeave: ((ev: PointerEvent) => void) | null = null;\n  private pressTargetDot: DotElement | null = null;\n  private pressStartPos: { x: number; y: number } | null = null;\n  private longPressTimer: number | null = null;\n  private longPressTriggered = false;\n  private suppressClickUntil = 0;\n  private longPressDuration = 550;\n  private longPressMoveTolerance = 6;\n  private onBarEnter: (() => void) | null = null;\n  private onBarLeave: (() => void) | null = null;\n  private onSliderEnter: (() => void) | null = null;\n  private onSliderLeave: (() => void) | null = null;\n  private draggable = false;\n  private barDragging = false;\n  private barStartPos = { x: 0, y: 0 };\n  private barStartOffset = { x: 0, y: 0 };\n  private onBarPointerDown: ((ev: PointerEvent) => void) | null = null;\n  private onBarPointerMove: ((ev: PointerEvent) => void) | null = null;\n  private onBarPointerUp: ((ev: PointerEvent) => void) | null = null;\n  private eventBusUnsubscribers: Array<() => void> = [];\n  private shortcutUnsubscribe: (() => void) | null = null;\n  private navigationQueue: Array<'previous' | 'next'> = [];\n  private isNavigating: boolean = false;\n  private previewPanel: TimelinePreviewPanel | null = null;\n  private rtl = false;\n  private timestampService: TimestampService | null = null;\n  private showMessageTimestampsEnabled = false;\n  private timestampInjectionGeneration = 0;\n\n  async init(): Promise<void> {\n    await initI18n();\n    const ok = await this.findCriticalElements();\n    if (!ok) return;\n    this.injectTimelineUI();\n    this.setupEventListeners();\n    this.setupObservers();\n    this.conversationId = this.computeConversationId();\n    await this.loadStars();\n    await this.syncStarredFromService();\n    this.loadMarkerLevels();\n    this.loadCollapsedMarkers();\n    // Initialize timestamp service\n    this.timestampService = new TimestampService();\n    await this.timestampService.initialize();\n    await this.loadMessageTimestampsEnabledSetting();\n    // Ensure initial render even when Gemini DOM is already stable (no mutations after observer attaches)\n    this.recalculateAndRenderMarkers();\n    // Handle URL hash for starred message navigation\n    this.handleStarredMessageNavigation();\n    // Initialize keyboard shortcuts\n    await this.initKeyboardShortcuts();\n    try {\n      const g = globalThis as ExtGlobal;\n      const defaults = {\n        geminiTimelineScrollMode: 'flow',\n        geminiTimelineHideContainer: false,\n        geminiTimelineBarWidth: null,\n        geminiTimelineDraggable: false,\n        geminiTimelineMarkerLevel: false,\n        geminiTimelinePosition: null,\n        [StorageKeys.LANGUAGE]: null,\n      };\n\n      let res: Record<string, unknown> | null = null;\n      // prefer chrome.storage or browser.storage if available to sync with popup\n      if (g.chrome?.storage?.sync || g.browser?.storage?.sync) {\n        res = await new Promise((resolve) => {\n          if (g.chrome?.storage?.sync?.get) {\n            g.chrome.storage.sync.get(\n              defaults as Record<string, unknown>,\n              (items: Record<string, unknown>) => {\n                if (g.chrome.runtime.lastError) {\n                  console.error(\n                    `[Timeline] chrome.storage.get failed: ${g.chrome.runtime.lastError.message}`,\n                  );\n                  resolve(null);\n                } else {\n                  resolve(items);\n                }\n              },\n            );\n          } else {\n            g.browser?.storage?.sync\n              ?.get(defaults)\n              .then(resolve)\n              .catch((error: Error) => {\n                console.error(`[Timeline] browser.storage.get failed: ${error.message}`);\n                resolve(null);\n              });\n          }\n        });\n      } else {\n        // No extension storage available, try to load critical fallback from localStorage\n        const saved = localStorage.getItem('geminiTimelineScrollMode');\n        if (saved === 'flow' || saved === 'jump') res = { geminiTimelineScrollMode: saved };\n      }\n\n      const m = res?.geminiTimelineScrollMode;\n      if (m === 'flow' || m === 'jump') this.scrollMode = m;\n      this.hideContainer = !!res?.geminiTimelineHideContainer;\n      const storedWidth = res?.geminiTimelineBarWidth;\n      if (\n        typeof storedWidth === 'number' &&\n        storedWidth >= this.barWidthMin &&\n        storedWidth <= this.barWidthMax\n      ) {\n        this.barWidth = storedWidth;\n      }\n      this.applyContainerVisibility();\n      this.toggleDraggable(!!res?.geminiTimelineDraggable);\n      this.toggleMarkerLevel(!!res?.geminiTimelineMarkerLevel);\n      this.rtl = applyRTLClass(res?.[StorageKeys.LANGUAGE] as string | null | undefined);\n\n      // Load position with auto-migration from v1 to v2\n      const position = res?.geminiTimelinePosition as\n        | {\n            version?: number;\n            topPercent?: number;\n            leftPercent?: number;\n            top?: number;\n            left?: number;\n          }\n        | undefined;\n      if (position) {\n        const viewportWidth = window.innerWidth;\n        const viewportHeight = window.innerHeight;\n\n        // v2 format: use percentage (responsive)\n        if (\n          position.version === 2 &&\n          position.topPercent !== undefined &&\n          position.leftPercent !== undefined\n        ) {\n          const top = (position.topPercent / 100) * viewportHeight;\n          const left = (position.leftPercent / 100) * viewportWidth;\n          this.applyPosition(top, left);\n        }\n        // v1 format: migrate to v2 (auto-upgrade)\n        else if (position.top !== undefined && position.left !== undefined) {\n          // Apply old position first\n          this.applyPosition(position.top, position.left);\n\n          // Migrate to v2 format (percentage-based)\n          const migratedPosition = {\n            version: 2,\n            topPercent: (position.top / viewportHeight) * 100,\n            leftPercent: (position.left / viewportWidth) * 100,\n          };\n          (g.chrome?.storage?.sync || g.browser?.storage?.sync)?.set?.({\n            geminiTimelinePosition: migratedPosition,\n          });\n        }\n      }\n      this.previewPanel?.reposition();\n\n      // listen for changes from popup and update mode live\n      try {\n        const onChanged = g.chrome?.storage?.onChanged || g.browser?.storage?.onChanged;\n        if (onChanged) {\n          onChanged.addListener((changes: Record<string, { newValue: unknown }>, area: string) => {\n            if (area !== 'sync') return;\n            if (changes?.geminiTimelineScrollMode) {\n              const n = changes.geminiTimelineScrollMode.newValue;\n              if (n === 'flow' || n === 'jump') this.scrollMode = n;\n            }\n            if (changes?.geminiTimelineHideContainer) {\n              this.hideContainer = !!changes.geminiTimelineHideContainer.newValue;\n              this.applyContainerVisibility();\n            }\n            if (changes?.geminiTimelineBarWidth) {\n              const w = changes.geminiTimelineBarWidth.newValue;\n              if (typeof w === 'number' && w >= this.barWidthMin && w <= this.barWidthMax) {\n                this.barWidth = w;\n                this.applyContainerVisibility();\n              }\n            }\n            if (changes?.geminiTimelineDraggable) {\n              this.toggleDraggable(!!changes.geminiTimelineDraggable.newValue);\n            }\n            if (changes?.geminiTimelineMarkerLevel) {\n              this.toggleMarkerLevel(!!changes.geminiTimelineMarkerLevel.newValue);\n            }\n            if (changes?.geminiTimelinePosition && !changes.geminiTimelinePosition.newValue) {\n              if (this.ui.timelineBar) {\n                this.ui.timelineBar.style.top = '';\n                this.ui.timelineBar.style.left = '';\n              }\n              this.previewPanel?.reposition();\n            }\n            if (changes?.[StorageKeys.LANGUAGE]) {\n              const newLang = changes[StorageKeys.LANGUAGE].newValue as string | null | undefined;\n              this.applyRTLUpdate(newLang);\n            }\n          });\n        }\n      } catch {}\n    } catch (err) {\n      console.error('[Timeline] Init storage error:', err);\n    }\n  }\n\n  private computeElementTopsInScrollContainer(elements: HTMLElement[]): number[] {\n    if (!this.scrollContainer || elements.length === 0) return [];\n\n    const containerRect = this.scrollContainer.getBoundingClientRect();\n    const scrollTop = this.scrollContainer.scrollTop;\n\n    const first = elements[0];\n    const firstOffsetParent = first.offsetParent;\n    const firstOffsetTop = first.offsetTop;\n    const firstTop = first.getBoundingClientRect().top - containerRect.top + scrollTop;\n\n    const sameOffsetParent =\n      firstOffsetParent !== null && elements.every((el) => el.offsetParent === firstOffsetParent);\n\n    const tops = elements.map((el) => {\n      if (sameOffsetParent) {\n        return firstTop + (el.offsetTop - firstOffsetTop);\n      }\n      return el.getBoundingClientRect().top - containerRect.top + scrollTop;\n    });\n\n    for (let i = 1; i < tops.length; i++) {\n      if (tops[i] < tops[i - 1]) return [];\n    }\n\n    return tops;\n  }\n\n  private updateIntersectionObserverTargetsFromMarkers(): void {\n    if (!this.intersectionObserver) return;\n    this.intersectionObserver.disconnect();\n    this.markers.forEach((m) => this.intersectionObserver!.observe(m.element));\n  }\n\n  private applyContainerVisibility(): void {\n    if (!this.ui.timelineBar) return;\n    const bar = this.ui.timelineBar;\n    // Visual background width (::before is centered, bar stays 24px for dots)\n    bar.style.setProperty('--timeline-bar-width', `${this.barWidth}px`);\n    // hideContainer is an independent binary toggle\n    bar.classList.toggle('timeline-no-container', !!this.hideContainer);\n  }\n\n  /** Check if pointer is near either edge of the visual background (::before, centered in the 24px bar). */\n  private isInResizeEdge(ev: PointerEvent): boolean {\n    if (!this.ui.timelineBar) return false;\n    const rect = this.ui.timelineBar.getBoundingClientRect();\n    const barCenter = rect.left + rect.width / 2;\n    const halfWidth = this.barWidth / 2;\n    const ZONE = 6;\n\n    const leftEdge = barCenter - halfWidth;\n    const rightEdge = barCenter + halfWidth;\n    const nearLeft = ev.clientX >= leftEdge - 2 && ev.clientX <= leftEdge + ZONE;\n    const nearRight = ev.clientX >= rightEdge - ZONE && ev.clientX <= rightEdge + 2;\n    return nearLeft || nearRight;\n  }\n\n  private startResize(ev: PointerEvent): void {\n    this.resizing = true;\n    this.ui.timelineBar!.classList.add('timeline-resizing');\n    this.ui.timelineBar!.setPointerCapture(ev.pointerId);\n    const barRect = this.ui.timelineBar!.getBoundingClientRect();\n    const barCenterX = barRect.left + barRect.width / 2;\n\n    this.onResizeMove = (e: PointerEvent) => {\n      // Width = 2 × distance from pointer to bar center (symmetric expansion)\n      const dist = Math.abs(e.clientX - barCenterX);\n      this.barWidth = Math.max(this.barWidthMin, Math.min(this.barWidthMax, dist * 2));\n      this.applyContainerVisibility();\n    };\n\n    this.onResizeUp = (_e: PointerEvent) => {\n      this.resizing = false;\n      this.ui.timelineBar?.classList.remove('timeline-resizing');\n      window.removeEventListener('pointermove', this.onResizeMove!);\n      window.removeEventListener('pointerup', this.onResizeUp!);\n      this.onResizeMove = null;\n      this.onResizeUp = null;\n      this.saveBarWidth();\n    };\n\n    window.addEventListener('pointermove', this.onResizeMove);\n    window.addEventListener('pointerup', this.onResizeUp, { once: true });\n    ev.preventDefault();\n    ev.stopPropagation();\n  }\n\n  private saveBarWidth(): void {\n    const g = globalThis as ExtGlobal;\n    const value = Math.round(this.barWidth);\n    if (g.chrome?.storage?.sync?.set) {\n      g.chrome.storage.sync.set({ geminiTimelineBarWidth: value });\n    } else if (g.browser?.storage?.sync?.set) {\n      g.browser.storage.sync.set({ geminiTimelineBarWidth: value });\n    }\n  }\n\n  private computeConversationId(): string {\n    const raw = `${location.host}${location.pathname}${location.search}`;\n    return `gemini:${hashString(raw)}`;\n  }\n\n  /**\n   * DRY helper: Get storage key for starred messages\n   */\n  private getStarsStorageKey(): string | null {\n    return this.conversationId ? `geminiTimelineStars:${this.conversationId}` : null;\n  }\n\n  /**\n   * DRY helper: Safe localStorage getItem with try-catch\n   */\n  private safeLocalStorageGet(key: string): string | null {\n    try {\n      return localStorage.getItem(key);\n    } catch (error) {\n      console.warn('[Timeline] Failed to read from localStorage:', error);\n      return null;\n    }\n  }\n\n  /**\n   * DRY helper: Safe localStorage setItem with try-catch\n   */\n  private safeLocalStorageSet(key: string, value: string): boolean {\n    try {\n      localStorage.setItem(key, value);\n      return true;\n    } catch (error) {\n      console.warn('[Timeline] Failed to write to localStorage:', error);\n      return false;\n    }\n  }\n\n  private areStarredSetsEqual(a: Set<string>, b: Set<string>): boolean {\n    if (a.size !== b.size) return false;\n    for (const value of a) {\n      if (!b.has(value)) return false;\n    }\n    return true;\n  }\n\n  private applyStarredIdSet(nextSet: Set<string>, persistLocal = true): void {\n    if (this.areStarredSetsEqual(this.starred, nextSet)) return;\n\n    // Clean up starredAtMap for removed entries\n    for (const id of this.starred) {\n      if (!nextSet.has(id)) this.starredAtMap.delete(id);\n    }\n\n    this.starred = new Set(nextSet);\n\n    if (persistLocal) this.saveStars();\n\n    for (const marker of this.markers) {\n      const want = this.starred.has(marker.id);\n      if (marker.starred !== want) {\n        marker.starred = want;\n        if (marker.dotElement) {\n          marker.dotElement.classList.toggle('starred', want);\n          marker.dotElement.setAttribute('aria-pressed', want ? 'true' : 'false');\n        }\n      }\n    }\n\n    if (this.ui.tooltip?.classList.contains('visible')) {\n      const currentDot = this.ui.timelineBar?.querySelector(\n        '.timeline-dot:hover, .timeline-dot:focus',\n      ) as DotElement | null;\n      if (currentDot) this.refreshTooltipForDot(currentDot);\n    }\n  }\n\n  private applySharedStarredData(data?: StarredMessagesData | null): void {\n    if (!this.conversationId) return;\n\n    const rawMessages = data?.messages?.[this.conversationId];\n    const conversationMessages = Array.isArray(rawMessages) ? rawMessages : [];\n    const nextSet = new Set(conversationMessages.map((message) => String(message.turnId)));\n\n    // Update starredAt map from shared data\n    for (const msg of conversationMessages) {\n      if (msg.starredAt) this.starredAtMap.set(String(msg.turnId), msg.starredAt);\n    }\n\n    this.applyStarredIdSet(nextSet);\n  }\n\n  private async syncStarredFromService(): Promise<void> {\n    if (!this.conversationId) return;\n    try {\n      const messages = await StarredMessagesService.getStarredMessagesForConversation(\n        this.conversationId,\n      );\n      const nextSet = new Set(messages.map((message) => String(message.turnId)));\n\n      // Update starredAt map from service data\n      for (const msg of messages) {\n        if (msg.starredAt) this.starredAtMap.set(String(msg.turnId), msg.starredAt);\n      }\n\n      this.applyStarredIdSet(nextSet);\n    } catch (error) {\n      console.warn('[Timeline] Failed to sync starred messages from shared storage:', error);\n    }\n  }\n\n  private getConversationTitle(): string {\n    const getText = (el: Element | null | undefined): string | null => {\n      const text = el?.textContent?.trim();\n      return text && text.length > 0 ? text : null;\n    };\n\n    // Strategy 1: Prefer the currently selected conversation in folder view\n    try {\n      const selected = document.querySelector(\n        '.gv-folder-conversation-selected .gv-conversation-title',\n      );\n      const title = getText(selected);\n      if (title) return title;\n    } catch (error) {\n      console.debug('[Timeline] Failed to get title from selected folder conversation:', error);\n    }\n\n    // Strategy 2: Try to get from page title\n    const titleElement = document.querySelector('title');\n    if (titleElement) {\n      const title = titleElement.textContent?.trim();\n      // Filter out generic titles\n      if (\n        title &&\n        title !== 'Gemini' &&\n        title !== 'Google Gemini' &&\n        title !== 'Google AI Studio' &&\n        !title.startsWith('Gemini -') &&\n        !title.startsWith('Google AI Studio -') &&\n        title.length > 0\n      ) {\n        return title;\n      }\n    }\n\n    // Strategy 3: Try to get from sidebar conversation list\n    // Look for the active conversation in the sidebar\n    try {\n      // Gemini uses various selectors for conversation titles\n      const selectors = [\n        // Gemini sidebar active conversation\n        'mat-list-item.mdc-list-item--activated [mat-line]',\n        'mat-list-item[aria-current=\"page\"] [mat-line]',\n        // AI Studio active conversation\n        '.conversation-list-item.active .conversation-title',\n        '.active-conversation .title',\n      ];\n\n      for (const selector of selectors) {\n        const element = document.querySelector(selector);\n        if (element && element.textContent) {\n          const text = element.textContent.trim();\n          if (text && text.length > 0 && text !== 'New chat') {\n            return text;\n          }\n        }\n      }\n    } catch (error) {\n      console.debug('[Timeline] Failed to get title from sidebar:', error);\n    }\n\n    // Strategy 4: Use first user message as title (fallback)\n    const firstMarker = this.markers[0];\n    if (firstMarker && firstMarker.summary) {\n      const preview = firstMarker.summary.slice(0, 50);\n      return preview.length < firstMarker.summary.length ? `${preview}...` : preview;\n    }\n\n    // Strategy 5: Extract from URL if it contains conversation ID\n    try {\n      const urlPath = window.location.pathname;\n      const match = urlPath.match(/\\/app\\/([a-zA-Z0-9_-]+)/);\n      if (match && match[1]) {\n        return `Conversation ${match[1].slice(0, 8)}...`;\n      }\n    } catch (error) {\n      console.debug('[Timeline] Failed to extract from URL:', error);\n    }\n\n    // Final fallback: generic name\n    return 'Untitled Conversation';\n  }\n\n  private waitForElement(selector: string, timeoutMs: number = 5000): Promise<Element | null> {\n    return new Promise((resolve) => {\n      const found = document.querySelector(selector);\n      if (found) return resolve(found);\n      const obs = new MutationObserver(() => {\n        const el = document.querySelector(selector);\n        if (el) {\n          try {\n            obs.disconnect();\n          } catch {}\n          resolve(el);\n        }\n      });\n      try {\n        obs.observe(document.body, { childList: true, subtree: true });\n      } catch {}\n      if (timeoutMs > 0) {\n        setTimeout(() => {\n          try {\n            obs.disconnect();\n          } catch {}\n          resolve(null);\n        }, timeoutMs);\n      }\n    });\n  }\n\n  private waitForAnyElement(\n    selectors: string[],\n    timeoutMs: number = 5000,\n  ): Promise<{ element: Element; selector: string } | null> {\n    return new Promise((resolve) => {\n      for (const selector of selectors) {\n        const found = document.querySelector(selector);\n        if (found) return resolve({ element: found, selector });\n      }\n\n      const obs = new MutationObserver(() => {\n        for (const selector of selectors) {\n          const el = document.querySelector(selector);\n          if (el) {\n            try {\n              obs.disconnect();\n            } catch {}\n            resolve({ element: el, selector });\n            return;\n          }\n        }\n      });\n\n      try {\n        obs.observe(document.body, { childList: true, subtree: true });\n      } catch {}\n\n      if (timeoutMs > 0) {\n        setTimeout(() => {\n          try {\n            obs.disconnect();\n          } catch {}\n          resolve(null);\n        }, timeoutMs);\n      }\n    });\n  }\n\n  private async findCriticalElements(): Promise<boolean> {\n    const configured = this.getConfiguredUserTurnSelector();\n    let userOverride = '';\n    let autoDetected = '';\n    try {\n      userOverride = localStorage.getItem('geminiTimelineUserTurnSelector') || '';\n      autoDetected = localStorage.getItem('geminiTimelineUserTurnSelectorAuto') || '';\n    } catch {}\n    const defaultCandidates = [\n      // Angular-based Gemini UI user bubble (primary)\n      '.user-query-bubble-with-background',\n      // Angular containers (fallbacks if bubble selector changes)\n      '.user-query-bubble-container',\n      '.user-query-container',\n      'user-query-content .user-query-bubble-with-background',\n      // Attribute-based fallbacks for other Gemini variants\n      'div[aria-label=\"User message\"]',\n      'article[data-author=\"user\"]',\n      'article[data-turn=\"user\"]',\n      '[data-message-author-role=\"user\"]',\n      'div[role=\"listitem\"][data-user=\"true\"]',\n    ];\n    // Compatibility strategy:\n    // - Keep explicit user override as highest priority.\n    // - Prefer built-in defaults over auto-detected cache, so stale auto cache can self-heal after refresh.\n    let candidates = [...defaultCandidates];\n    if (userOverride.length) {\n      candidates = [userOverride, ...defaultCandidates.filter((s) => s !== userOverride)];\n    } else {\n      const cached = autoDetected || configured;\n      if (cached && !candidates.includes(cached)) candidates.push(cached);\n    }\n    let firstTurn: Element | null = null;\n    let matchedSelector = '';\n    const found = await this.waitForAnyElement(candidates, 4000);\n    if (found) {\n      firstTurn = found.element;\n      matchedSelector = found.selector;\n      this.userTurnSelector = matchedSelector;\n    }\n    if (!firstTurn) {\n      this.conversationContainer =\n        (document.querySelector('main') as HTMLElement) || (document.body as HTMLElement);\n      this.userTurnSelector = defaultCandidates.join(',');\n    } else {\n      // Scope selection/observers:\n      // - Broad scope (main/body) if:\n      //   a) user provided an explicit override, or\n      //   b) auto-detected selector suggests Angular-based user query DOM (contains 'user-query')\n      // - Otherwise, scope to the immediate parent for performance\n      const looksAngularUserQuery = /user-query/i.test(matchedSelector || '');\n      if ((userOverride && matchedSelector === userOverride) || looksAngularUserQuery) {\n        this.conversationContainer =\n          (document.querySelector('main') as HTMLElement) || (document.body as HTMLElement);\n      } else {\n        const parent = firstTurn.parentElement as HTMLElement | null;\n        if (!parent) return false;\n        this.conversationContainer = parent;\n      }\n      // Persist auto-detected selector for future sessions when no explicit user override exists\n      if (!userOverride && matchedSelector) {\n        try {\n          localStorage.setItem('geminiTimelineUserTurnSelectorAuto', matchedSelector);\n        } catch {}\n      }\n      // If a stale user override failed (matchedSelector differs), clear it so we don't keep retrying it\n      if (userOverride && matchedSelector && matchedSelector !== userOverride) {\n        try {\n          localStorage.removeItem('geminiTimelineUserTurnSelector');\n        } catch {}\n      }\n    }\n    let p: HTMLElement | null = (firstTurn as HTMLElement) || this.conversationContainer;\n    while (p && p !== document.body) {\n      const st = getComputedStyle(p);\n      if (st.overflowY === 'auto' || st.overflowY === 'scroll') {\n        this.scrollContainer = p;\n        break;\n      }\n      p = p.parentElement;\n    }\n    if (!this.scrollContainer)\n      this.scrollContainer =\n        (document.scrollingElement as HTMLElement) ||\n        document.documentElement ||\n        (document.body as unknown as HTMLElement);\n    return true;\n  }\n\n  private getConfiguredUserTurnSelector(): string {\n    try {\n      const user = localStorage.getItem('geminiTimelineUserTurnSelector');\n      if (user && typeof user === 'string') return user;\n      const auto = localStorage.getItem('geminiTimelineUserTurnSelectorAuto');\n      return auto && typeof auto === 'string' ? auto : '';\n    } catch {\n      return '';\n    }\n  }\n\n  private injectTimelineUI(): void {\n    let bar = document.querySelector('.gemini-timeline-bar') as HTMLElement | null;\n    if (!bar) {\n      bar = document.createElement('div');\n      bar.className = 'gemini-timeline-bar';\n      document.body.appendChild(bar);\n    }\n    this.ui.timelineBar = bar;\n    let track = bar.querySelector('.timeline-track') as HTMLElement | null;\n    if (!track) {\n      track = document.createElement('div');\n      track.className = 'timeline-track';\n      bar.appendChild(track);\n    }\n    let content = track.querySelector('.timeline-track-content') as HTMLElement | null;\n    if (!content) {\n      content = document.createElement('div');\n      content.className = 'timeline-track-content';\n      track.appendChild(content);\n    }\n    this.ui.track = track;\n    this.ui.trackContent = content;\n\n    let slider = document.querySelector('.timeline-left-slider') as HTMLElement | null;\n    if (!slider) {\n      slider = document.createElement('div');\n      slider.className = 'timeline-left-slider';\n      const handle = document.createElement('div');\n      handle.className = 'timeline-left-handle';\n      slider.appendChild(handle);\n      document.body.appendChild(slider);\n    }\n    this.ui.slider = slider;\n    this.ui.sliderHandle = slider.querySelector('.timeline-left-handle') as HTMLElement | null;\n\n    if (!this.ui.tooltip) {\n      const tip = document.createElement('div');\n      tip.className = 'timeline-tooltip';\n      tip.id = 'gemini-timeline-tooltip';\n      tip.setAttribute('dir', 'auto');\n      document.body.appendChild(tip);\n      this.ui.tooltip = tip;\n      if (!this.measureEl) {\n        const m = document.createElement('div');\n        m.setAttribute('aria-hidden', 'true');\n        m.setAttribute('dir', 'auto');\n        Object.assign(m.style, {\n          position: 'fixed',\n          left: '-9999px',\n          top: '0',\n          visibility: 'hidden',\n          pointerEvents: 'none',\n        });\n        const cs = getComputedStyle(tip);\n        Object.assign(m.style, {\n          backgroundColor: cs.backgroundColor,\n          color: cs.color,\n          fontFamily: cs.fontFamily,\n          fontSize: cs.fontSize,\n          lineHeight: cs.lineHeight,\n          padding: cs.padding,\n          border: cs.border,\n          borderRadius: cs.borderRadius,\n          whiteSpace: 'pre-line',\n          wordBreak: 'break-word',\n          maxWidth: 'none',\n          display: 'block',\n        });\n        document.body.appendChild(m);\n        this.measureEl = m;\n      }\n      if (!this.measureCanvas) {\n        this.measureCanvas = document.createElement('canvas');\n        this.measureCtx = this.measureCanvas.getContext('2d');\n      }\n    }\n\n    // Preview panel\n    if (!this.previewPanel && this.ui.timelineBar) {\n      this.previewPanel = new TimelinePreviewPanel(this.ui.timelineBar);\n      this.previewPanel.init(\n        (turnId, index) => {\n          const marker = this.markers[index];\n          if (!marker?.element) return;\n          const fromIdx = this.getActiveIndex();\n          const dur = this.computeFlowDuration(fromIdx, index);\n          if (this.scrollMode === 'flow' && fromIdx >= 0 && index >= 0 && fromIdx !== index) {\n            this.activeTurnId = null;\n            this.updateActiveDotUI();\n            this.startRunner(fromIdx, index, dur);\n          }\n          this.smoothScrollTo(marker.element, dur);\n        },\n        (query) => this.highlightSearchInDOM(query),\n      );\n    }\n  }\n\n  private updateIntersectionObserverTargets(): void {\n    if (!this.intersectionObserver || !this.conversationContainer || !this.userTurnSelector) return;\n    this.intersectionObserver.disconnect();\n    this.visibleUserTurns.clear();\n    const nodeList = this.conversationContainer.querySelectorAll(this.userTurnSelector);\n    const topLevel = this.filterTopLevel(Array.from(nodeList));\n    topLevel.forEach((el) => this.intersectionObserver!.observe(el));\n  }\n\n  private normalizeText(text: string | null): string {\n    try {\n      if (!text) return '';\n      // 1. Collapse whitespace\n      const collapsed = String(text).replace(/\\s+/g, ' ').trim();\n      // 2. Strip prefixes (You said, etc.)\n      return collapsed.replace(TURN_LABEL_PREFIXES, '');\n    } catch {\n      return '';\n    }\n  }\n\n  private hasVisuallyHiddenClass(el: Element): boolean {\n    if (!(el instanceof HTMLElement) || el.classList.length === 0) return false;\n    for (const cls of el.classList) {\n      if (cls.toLowerCase().includes(VISUALLY_HIDDEN_CLASS_FRAGMENT)) return true;\n    }\n    return false;\n  }\n\n  private extractTurnText(element: HTMLElement | null): string {\n    if (!element) return '';\n    try {\n      const clone = element.cloneNode(true) as HTMLElement;\n      if (this.hasVisuallyHiddenClass(clone)) return '';\n\n      // Remove visually-hidden descendants\n      const descendants = clone.getElementsByTagName('*');\n      for (let i = descendants.length - 1; i >= 0; i--) {\n        if (this.hasVisuallyHiddenClass(descendants[i])) {\n          descendants[i].remove();\n        }\n      }\n\n      // Remove extension-injected UI elements (e.g. fork button)\n      clone.querySelectorAll(INJECTED_UI_SELECTOR).forEach((el) => el.remove());\n\n      return this.normalizeText(clone.textContent || '');\n    } catch {\n      return this.normalizeText(element.textContent || '');\n    }\n  }\n\n  /**\n   * Performance-optimized filter to remove nested elements.\n   * Sorts elements by depth first, which can prune the search space in the average case.\n   * Worst-case complexity: O(n²), but average case is improved over naive implementation.\n   */\n  private filterTopLevel(elements: Element[]): HTMLElement[] {\n    const arr = elements.map((e) => e as HTMLElement);\n    if (arr.length === 0) return arr;\n\n    // Use Set for O(1) lookup of descendants\n    const descendants = new Set<HTMLElement>();\n\n    // Sort by depth (shallower first) to optimize checking\n    const sorted = arr.slice().sort((a, b) => {\n      let aDepth = 0,\n        bDepth = 0;\n      let node: Element | null = a;\n      while (node.parentElement) {\n        aDepth++;\n        node = node.parentElement;\n      }\n      node = b;\n      while (node.parentElement) {\n        bDepth++;\n        node = node.parentElement;\n      }\n      return aDepth - bDepth;\n    });\n\n    // Only check if element is descendant of earlier elements\n    for (let i = 0; i < sorted.length; i++) {\n      const el = sorted[i];\n      for (let j = 0; j < i; j++) {\n        if (sorted[j].contains(el)) {\n          descendants.add(el);\n          break;\n        }\n      }\n    }\n\n    return arr.filter((el) => !descendants.has(el));\n  }\n\n  /**\n   * Performance-optimized deduplication with cached text normalization\n   */\n  private dedupeByTextAndOffset(elements: HTMLElement[], firstTurnOffset: number): HTMLElement[] {\n    const seen = new Set<string>();\n    const out: HTMLElement[] = [];\n\n    // Cache normalized text to avoid repeated processing\n    const normalizedCache = new Map<HTMLElement, string>();\n\n    for (const el of elements) {\n      // Get or compute normalized text\n      let normalizedText = normalizedCache.get(el);\n      if (normalizedText === undefined) {\n        normalizedText = this.extractTurnText(el);\n        normalizedCache.set(el, normalizedText);\n      }\n\n      const offsetFromStart = (el.offsetTop || 0) - firstTurnOffset;\n      const key = `${normalizedText}|${Math.round(offsetFromStart)}`;\n\n      if (seen.has(key)) continue;\n      seen.add(key);\n      out.push(el);\n    }\n    return out;\n  }\n\n  private getCSSVarNumber(el: Element, name: string, fallback: number): number {\n    const v = getComputedStyle(el).getPropertyValue(name).trim();\n    const n = parseFloat(v);\n    return Number.isFinite(n) ? n : fallback;\n  }\n\n  private getTrackPadding(): number {\n    return this.ui.timelineBar\n      ? this.getCSSVarNumber(this.ui.timelineBar, '--timeline-track-padding', 12)\n      : 12;\n  }\n  private getMinGap(): number {\n    return this.ui.timelineBar\n      ? this.getCSSVarNumber(this.ui.timelineBar, '--timeline-min-gap', 12)\n      : 12;\n  }\n\n  private ensureTurnId(el: Element, index: number): string {\n    const asEl = el as HTMLElement & { dataset?: DOMStringMap & { turnId?: string } };\n    let id = asEl.dataset?.turnId || '';\n    if (!id) {\n      const basis = this.extractTurnText(asEl) || `user-${index}`;\n      // Append index to ensure unique IDs for identical text content\n      id = `u-${hashString(basis + '|' + index)}`;\n      try {\n        if (asEl.dataset) asEl.dataset.turnId = id;\n      } catch {}\n    }\n    return id;\n  }\n\n  private detectCssVarTopSupport(pad: number, usableC: number): boolean {\n    try {\n      const test = document.createElement('button');\n      test.className = 'timeline-dot';\n      test.style.visibility = 'hidden';\n      test.setAttribute('aria-hidden', 'true');\n      test.style.setProperty('--n', '0.5');\n      this.ui.trackContent!.appendChild(test);\n      const cs = getComputedStyle(test);\n      const px = parseFloat(cs.top || '');\n      test.remove();\n      const expected = pad + 0.5 * usableC;\n      return Number.isFinite(px) && Math.abs(px - expected) <= 2;\n    } catch {\n      return false;\n    }\n  }\n\n  private updateTimelineGeometry(): void {\n    if (!this.ui.timelineBar || !this.ui.trackContent) return;\n    const H = this.ui.timelineBar.clientHeight || 0;\n    const pad = this.getTrackPadding();\n    const minGap = this.getMinGap();\n    const N = this.markers.length;\n    // Get hidden markers for collapse feature\n    const hiddenIndices = this.getHiddenMarkerIndices();\n    const visibleCount = N - hiddenIndices.size;\n    const desired = Math.max(\n      H,\n      visibleCount > 0 ? 2 * pad + Math.max(0, visibleCount - 1) * minGap : H,\n    );\n    this.contentHeight = Math.ceil(desired);\n    this.scale = H > 0 ? this.contentHeight / H : 1;\n    this.ui.trackContent.style.height = `${this.contentHeight}px`;\n\n    const usableC = Math.max(1, this.contentHeight - 2 * pad);\n    // Calculate Y positions with collapse - using effective baseN for repositioning\n    const { desiredY } = this.calculateCollapsedPositions(hiddenIndices, pad, usableC);\n\n    // Apply min gap only to visible markers\n    const gapMultipliers: number[] = new Array(N).fill(1.0);\n    const adjusted = this.applyMinGapWithHidden(\n      desiredY,\n      pad,\n      pad + usableC,\n      minGap,\n      hiddenIndices,\n      gapMultipliers,\n    );\n    this.yPositions = adjusted;\n\n    for (let i = 0; i < N; i++) {\n      if (hiddenIndices.has(i)) {\n        this.markers[i].n = -1;\n        continue;\n      }\n      const top = adjusted[i];\n      const n = (top - pad) / usableC;\n      this.markers[i].n = Math.max(0, Math.min(1, n));\n      const dot = this.markers[i].dotElement;\n      if (dot && !this.usePixelTop) {\n        dot.style.setProperty('--n', String(this.markers[i].n));\n      }\n    }\n    if (this._cssVarTopSupported === null) {\n      this._cssVarTopSupported = this.detectCssVarTopSupport(pad, usableC);\n      this.usePixelTop = !this._cssVarTopSupported;\n    }\n    this.updateSlider();\n    const barH = this.ui.timelineBar.clientHeight || 0;\n    this.sliderAlwaysVisible = this.contentHeight > barH + 1;\n    if (this.sliderAlwaysVisible) this.showSlider();\n  }\n\n  /* Apply minimum gap between visible markers, skipping hidden ones */\n  private applyMinGapWithHidden(\n    positions: number[],\n    minTop: number,\n    maxTop: number,\n    gap: number,\n    hiddenIndices: Set<number>,\n    gapMultipliers: number[],\n  ): number[] {\n    const n = positions.length;\n    if (n === 0) return positions;\n\n    const out = positions.slice();\n    let prevVisibleIdx = -1;\n    for (let i = 0; i < n; i++) {\n      if (hiddenIndices.has(i)) continue;\n\n      if (prevVisibleIdx === -1) {\n        out[i] = Math.max(minTop, Math.min(positions[i], maxTop));\n      } else {\n        const currentGap = gap * gapMultipliers[i];\n        const minAllowed = out[prevVisibleIdx] + currentGap;\n        out[i] = Math.max(positions[i], minAllowed);\n      }\n      prevVisibleIdx = i;\n    }\n    let lastVisibleIdx = -1;\n    for (let i = n - 1; i >= 0; i--) {\n      if (!hiddenIndices.has(i)) {\n        lastVisibleIdx = i;\n        break;\n      }\n    }\n\n    if (lastVisibleIdx >= 0 && out[lastVisibleIdx] > maxTop) {\n      out[lastVisibleIdx] = maxTop;\n\n      let nextVisibleIdx = lastVisibleIdx;\n      for (let i = lastVisibleIdx - 1; i >= 0; i--) {\n        if (hiddenIndices.has(i)) continue;\n\n        const currentGap = gap * gapMultipliers[nextVisibleIdx];\n        const maxAllowed = out[nextVisibleIdx] - currentGap;\n        out[i] = Math.min(out[i], maxAllowed);\n        nextVisibleIdx = i;\n      }\n    }\n\n    // Clamp all visible markers\n    for (let i = 0; i < n; i++) {\n      if (hiddenIndices.has(i)) continue;\n      if (out[i] < minTop) out[i] = minTop;\n      if (out[i] > maxTop) out[i] = maxTop;\n    }\n\n    return out;\n  }\n\n  private applyMinGap(positions: number[], minTop: number, maxTop: number, gap: number): number[] {\n    const n = positions.length;\n    if (n === 0) return positions;\n    const out = positions.slice();\n    out[0] = Math.max(minTop, Math.min(positions[0], maxTop));\n    for (let i = 1; i < n; i++) {\n      const minAllowed = out[i - 1] + gap;\n      out[i] = Math.max(positions[i], minAllowed);\n    }\n    if (out[n - 1] > maxTop) {\n      out[n - 1] = maxTop;\n      for (let i = n - 2; i >= 0; i--) {\n        const maxAllowed = out[i + 1] - gap;\n        out[i] = Math.min(out[i], maxAllowed);\n      }\n      if (out[0] < minTop) {\n        out[0] = minTop;\n        for (let i = 1; i < n; i++) {\n          const minAllowed = out[i - 1] + gap;\n          out[i] = Math.max(out[i], minAllowed);\n        }\n      }\n    }\n    for (let i = 0; i < n; i++) {\n      if (out[i] < minTop) out[i] = minTop;\n      if (out[i] > maxTop) out[i] = maxTop;\n    }\n    return out;\n  }\n\n  private recalculateAndRenderMarkers = (): void => {\n    if (\n      !this.conversationContainer ||\n      !this.ui.timelineBar ||\n      !this.scrollContainer ||\n      !this.userTurnSelector\n    )\n      return;\n    const userTurnNodeList = this.conversationContainer.querySelectorAll(this.userTurnSelector);\n    this.visibleRange = { start: 0, end: -1 };\n    if (userTurnNodeList.length === 0) {\n      if (!this.zeroTurnsTimer) {\n        // Optimized retry interval: reduced from 350ms to 200ms\n        this.zeroTurnsTimer = window.setTimeout(() => {\n          this.zeroTurnsTimer = null;\n          this.recalculateAndRenderMarkers();\n        }, 200);\n      }\n      return;\n    }\n    if (this.zeroTurnsTimer) {\n      clearTimeout(this.zeroTurnsTimer);\n      this.zeroTurnsTimer = null;\n    }\n\n    // Clear all existing dots before rebuilding\n    (this.ui.trackContent || this.ui.timelineBar)!\n      .querySelectorAll('.timeline-dot')\n      .forEach((n) => n.remove());\n\n    // Filter to top-level matches first to avoid nested duplicates, then dedupe by text+offset\n    let allEls = Array.from(userTurnNodeList) as HTMLElement[];\n    allEls = this.filterTopLevel(allEls);\n    if (allEls.length === 0) return;\n\n    const firstTurnOffset = (allEls[0] as HTMLElement).offsetTop;\n    allEls = this.dedupeByTextAndOffset(allEls, firstTurnOffset);\n    this.markerTops = this.computeElementTopsInScrollContainer(allEls);\n\n    let contentSpan: number;\n    if (allEls.length < 2) {\n      contentSpan = 1;\n    } else {\n      const lastTurnOffset = (allEls[allEls.length - 1] as HTMLElement).offsetTop;\n      contentSpan = lastTurnOffset - firstTurnOffset;\n    }\n    if (contentSpan <= 0) contentSpan = 1;\n    this.firstUserTurnOffset = firstTurnOffset;\n    this.contentSpanPx = contentSpan;\n\n    this.markerMap.clear();\n    this.markers = Array.from(allEls).map((el, idx) => {\n      const element = el as HTMLElement;\n      const offsetFromStart = element.offsetTop - firstTurnOffset;\n      let n = offsetFromStart / contentSpan;\n      n = Math.max(0, Math.min(1, n));\n      const id = this.ensureTurnId(element, idx);\n      // Record timestamp for new messages\n      if (this.timestampService && this.timestampService.getTimestamp(id as TurnId) === null) {\n        this.timestampService.recordTimestamp(id as TurnId).catch(() => {});\n      }\n      const m = {\n        id,\n        element,\n        summary: this.extractTurnText(element),\n        n,\n        baseN: n,\n        dotElement: null,\n        starred: this.starred.has(id),\n      };\n      this.markerMap.set(id, m);\n      return m;\n    });\n    this.markersVersion++;\n    this.updateTimelineGeometry();\n    if (!this.activeTurnId && this.markers.length > 0)\n      this.activeTurnId = this.markers[this.markers.length - 1].id;\n    this.updateIntersectionObserverTargetsFromMarkers();\n    this.syncTimelineTrackToMain();\n    this.updateVirtualRangeAndRender();\n    this.updateActiveDotUI();\n    this.scheduleScrollSync();\n    this.previewPanel?.updateMarkers(\n      this.markers.map((m, i) => ({\n        id: m.id,\n        summary: m.summary,\n        index: i,\n        starred: m.starred,\n        starredAt: m.starred ? this.starredAtMap.get(m.id) : undefined,\n      })),\n    );\n    // Inject timestamps after markers are ready\n    this.injectMessageTimestamps().catch(() => {});\n  };\n\n  private async injectMessageTimestamps(): Promise<void> {\n    if (!this.timestampService) return;\n    const injectionGeneration = ++this.timestampInjectionGeneration;\n    if (!this.showMessageTimestampsEnabled) {\n      // Remove any existing timestamps if feature is disabled\n      document.querySelectorAll('.gv-timestamp').forEach((el) => el.remove());\n      return;\n    }\n\n    document.querySelectorAll('.gv-timestamp').forEach((el) => el.remove());\n\n    // Use markers instead of querying DOM - markers already have the correct elements\n    this.markers.forEach((marker) => {\n      const msgEl = marker.element;\n      const parent = msgEl.parentElement;\n      if (!parent) return;\n\n      let insertionParent: HTMLElement | null = parent;\n      let insertionAnchor: HTMLElement = msgEl;\n      let alignClass = 'gv-timestamp-assistant';\n      try {\n        // Walk up to find the nearest horizontal row wrapper (avatar + bubble).\n        // Then insert timestamp before that row so it is always above the whole message row.\n        let rowWrapper: HTMLElement | null = null;\n        let cursor: HTMLElement | null = parent;\n        for (let i = 0; i < 4 && cursor; i++) {\n          const style = getComputedStyle(cursor);\n          if (style.display.includes('flex') && style.flexDirection.startsWith('row')) {\n            rowWrapper = cursor;\n            break;\n          }\n          cursor = cursor.parentElement;\n        }\n        if (rowWrapper && rowWrapper.parentElement) {\n          insertionParent = rowWrapper.parentElement as HTMLElement;\n          insertionAnchor = rowWrapper;\n          const rowStyle = getComputedStyle(rowWrapper);\n          if (rowStyle.justifyContent.includes('flex-end')) {\n            alignClass = 'gv-timestamp-user';\n          }\n        }\n      } catch {}\n      if (!insertionParent) return;\n\n      // Format and inject timestamp\n      this.timestampService!.formatTimestamp(marker.id as TurnId)\n        .then((formattedTime) => {\n          if (!formattedTime) return;\n          if (injectionGeneration !== this.timestampInjectionGeneration) return;\n          if (!this.showMessageTimestampsEnabled) return;\n          if (!insertionParent || !insertionAnchor) return;\n          if (!insertionParent.isConnected || !insertionAnchor.isConnected) return;\n\n          const timestampEl = document.createElement('div');\n          timestampEl.className = `gv-timestamp ${alignClass}`;\n          timestampEl.textContent = formattedTime;\n          timestampEl.setAttribute('data-gv-turn-id', marker.id);\n\n          // Render timestamp above the message container (outside the bubble)\n          insertionParent.insertBefore(timestampEl, insertionAnchor);\n        })\n        .catch(() => {});\n    });\n  }\n\n  private async loadMessageTimestampsEnabledSetting(): Promise<void> {\n    const enabledResult = await storageService.get<boolean>(StorageKeys.GV_SHOW_MESSAGE_TIMESTAMPS);\n    this.showMessageTimestampsEnabled = enabledResult.success && enabledResult.data === true;\n  }\n\n  private setupObservers(): void {\n    this.mutationObserver = new MutationObserver(() => {\n      this.debouncedRecalc();\n    });\n    if (this.conversationContainer)\n      this.mutationObserver.observe(this.conversationContainer, { childList: true, subtree: true });\n\n    this.resizeObserver = new ResizeObserver(() => {\n      this.updateTimelineGeometry();\n      this.syncTimelineTrackToMain();\n      this.updateVirtualRangeAndRender();\n    });\n    if (this.ui.timelineBar) this.resizeObserver.observe(this.ui.timelineBar);\n\n    this.intersectionObserver = new IntersectionObserver(\n      () => {\n        this.scheduleScrollSync();\n      },\n      { root: this.scrollContainer, threshold: 0.1, rootMargin: '-40% 0px -59% 0px' },\n    );\n  }\n\n  private setupEventListeners(): void {\n    this.onTimelineBarClick = (e: Event) => {\n      const dot = (e.target as HTMLElement).closest('.timeline-dot') as DotElement | null;\n      if (!dot) return;\n      const now = Date.now();\n      if (now < (this.suppressClickUntil || 0)) {\n        e.preventDefault();\n        e.stopPropagation();\n        return;\n      }\n\n      const resolveTargetFromDot = (): { targetElement: HTMLElement | null; toIdx: number } => {\n        // Use index lookup if available for robust handling of duplicate content\n        const indexStr = dot.dataset.markerIndex;\n        let targetElement: HTMLElement | null = null;\n        let toIdx = -1;\n\n        if (indexStr) {\n          toIdx = parseInt(indexStr, 10);\n          const marker = this.markers[toIdx];\n          if (marker) {\n            targetElement = marker.element;\n          }\n        }\n\n        // Fallback to ID-based lookup if index fails\n        if (!targetElement) {\n          const targetId = dot.dataset.targetTurnId || '';\n          if (!targetId) return { targetElement: null, toIdx: -1 };\n\n          targetElement =\n            (this.conversationContainer?.querySelector(\n              `[data-turn-id=\"${targetId}\"]`,\n            ) as HTMLElement | null) ||\n            this.markers.find((m) => m.id === targetId)?.element ||\n            null;\n          toIdx = this.markers.findIndex((m) => m.id === targetId);\n        }\n\n        return { targetElement, toIdx };\n      };\n\n      let { targetElement, toIdx } = resolveTargetFromDot();\n\n      // On Gemini reload/rehydration, marker nodes or scroll container may become stale.\n      // Refresh once and resolve target again to keep click navigation reliable.\n      if (this.maybeRefreshMarkersForInteraction(targetElement)) {\n        ({ targetElement, toIdx } = resolveTargetFromDot());\n      }\n\n      if (targetElement) {\n        const fromIdx = this.getActiveIndex();\n        // toIdx is already determined above\n        const dur = this.computeFlowDuration(fromIdx, toIdx);\n        if (this.scrollMode === 'flow' && fromIdx >= 0 && toIdx >= 0 && fromIdx !== toIdx) {\n          // Clear previous highlight immediately so runner motion is visually obvious.\n          this.activeTurnId = null;\n          this.updateActiveDotUI();\n          this.startRunner(fromIdx, toIdx, dur);\n        }\n        this.smoothScrollTo(targetElement, dur);\n      }\n    };\n    this.ui.timelineBar!.addEventListener('click', this.onTimelineBarClick);\n\n    this.onScroll = () => this.scheduleScrollSync();\n    this.scrollContainer!.addEventListener('scroll', this.onScroll, { passive: true });\n\n    this.onTimelineWheel = (e: WheelEvent) => {\n      e.preventDefault();\n      const delta = e.deltaY || 0;\n      this.scrollContainer!.scrollTop += delta;\n      this.scheduleScrollSync();\n      this.showSlider();\n    };\n    this.ui.timelineBar!.addEventListener('wheel', this.onTimelineWheel, { passive: false });\n\n    this.onTimelineBarOver = (e: MouseEvent) => {\n      const dot = (e.target as HTMLElement).closest('.timeline-dot') as DotElement | null;\n      if (dot) this.showTooltipForDot(dot);\n    };\n    this.onTimelineBarOut = (e: MouseEvent) => {\n      const fromDot = (e.target as HTMLElement).closest('.timeline-dot');\n      const toDot = (e.relatedTarget as HTMLElement | null)?.closest?.('.timeline-dot');\n      if (fromDot && !toDot) {\n        const stillHoveringDot = this.ui.timelineBar?.querySelector('.timeline-dot:hover');\n        if (!stillHoveringDot) this.hideTooltip();\n      }\n    };\n    this.ui.timelineBar!.addEventListener('mouseover', this.onTimelineBarOver);\n    this.ui.timelineBar!.addEventListener('mouseout', this.onTimelineBarOut);\n\n    // Right-click context menu for level selection\n    this.onContextMenu = (ev: MouseEvent) => {\n      if (!this.markerLevelEnabled) return;\n      const dot = (ev.target as HTMLElement).closest('.timeline-dot') as DotElement | null;\n      if (!dot) return;\n      ev.preventDefault();\n      ev.stopPropagation();\n      this.showContextMenu(dot, ev.clientX, ev.clientY);\n    };\n    this.ui.timelineBar!.addEventListener('contextmenu', this.onContextMenu);\n\n    // Close context menu when clicking elsewhere\n    this.onDocumentClick = (ev: MouseEvent) => {\n      if (this.contextMenu && !this.contextMenu.contains(ev.target as Node)) {\n        this.hideContextMenu();\n      }\n    };\n    document.addEventListener('click', this.onDocumentClick);\n\n    this.onPointerDown = (ev: PointerEvent) => {\n      const dot = (ev.target as HTMLElement).closest('.timeline-dot') as DotElement | null;\n      if (!dot) return;\n      if (typeof ev.button === 'number' && ev.button !== 0) return;\n      this.cancelLongPress();\n      this.pressTargetDot = dot;\n      this.pressStartPos = { x: ev.clientX, y: ev.clientY };\n      dot.classList.add('holding');\n      this.longPressTriggered = false;\n      this.longPressTimer = window.setTimeout(() => {\n        this.longPressTimer = null;\n        if (!this.pressTargetDot) return;\n        const id = this.pressTargetDot.dataset.targetTurnId!;\n        this.toggleStar(id);\n        this.longPressTriggered = true;\n        this.suppressClickUntil = Date.now() + 350;\n        this.refreshTooltipForDot(this.pressTargetDot!);\n        this.pressTargetDot.classList.remove('holding');\n      }, this.longPressDuration);\n    };\n    this.onPointerMove = (ev: PointerEvent) => {\n      if (!this.pressTargetDot || !this.pressStartPos) return;\n      const dx = ev.clientX - this.pressStartPos.x;\n      const dy = ev.clientY - this.pressStartPos.y;\n      if (dx * dx + dy * dy > this.longPressMoveTolerance * this.longPressMoveTolerance)\n        this.cancelLongPress();\n    };\n    this.onPointerUp = () => this.cancelLongPress();\n    this.onPointerCancel = () => this.cancelLongPress();\n    this.onPointerLeave = (ev: PointerEvent) => {\n      const dot = (ev.target as HTMLElement).closest('.timeline-dot') as DotElement | null;\n      if (dot && dot === this.pressTargetDot) this.cancelLongPress();\n    };\n    this.ui.timelineBar!.addEventListener('pointerdown', this.onPointerDown);\n    window.addEventListener('pointermove', this.onPointerMove, { passive: true });\n    window.addEventListener('pointerup', this.onPointerUp, { passive: true });\n    window.addEventListener('pointercancel', this.onPointerCancel, { passive: true });\n    this.ui.timelineBar!.addEventListener('pointerleave', this.onPointerLeave);\n\n    this.onWindowResize = () => {\n      if (this.ui.tooltip?.classList.contains('visible')) {\n        const activeDot = this.ui.timelineBar!.querySelector(\n          '.timeline-dot:hover, .timeline-dot:focus',\n        ) as DotElement | null;\n        if (activeDot) this.refreshTooltipForDot(activeDot);\n      }\n      this.updateTimelineGeometry();\n      this.syncTimelineTrackToMain();\n      this.updateVirtualRangeAndRender();\n      // Reapply position for responsive design (v2 format only)\n      this.reapplyPosition();\n    };\n    window.addEventListener('resize', this.onWindowResize);\n    if (window.visualViewport) {\n      this.onVisualViewportResize = () => {\n        this.updateTimelineGeometry();\n        this.syncTimelineTrackToMain();\n        this.updateVirtualRangeAndRender();\n        // Reapply position for responsive design (v2 format only)\n        this.reapplyPosition();\n      };\n      window.visualViewport.addEventListener('resize', this.onVisualViewportResize);\n    }\n\n    this.onSliderDown = (ev: PointerEvent) => {\n      if (!this.ui.sliderHandle) return;\n      try {\n        this.ui.sliderHandle.setPointerCapture(ev.pointerId);\n      } catch {}\n      this.sliderDragging = true;\n      this.showSlider();\n      this.sliderStartClientY = ev.clientY;\n      const rect = this.ui.sliderHandle.getBoundingClientRect();\n      this.sliderStartTop = rect.top;\n      this.onSliderMove = (e: PointerEvent) => this.handleSliderDrag(e);\n      this.onSliderUp = (e: PointerEvent) => this.endSliderDrag(e);\n      window.addEventListener('pointermove', this.onSliderMove);\n      window.addEventListener('pointerup', this.onSliderUp, { once: true });\n    };\n    this.ui.sliderHandle?.addEventListener('pointerdown', this.onSliderDown);\n\n    this.onBarEnter = () => this.showSlider();\n    this.onBarLeave = () => this.hideSliderDeferred();\n    this.onSliderEnter = () => this.showSlider();\n    this.onSliderLeave = () => this.hideSliderDeferred();\n    this.ui.timelineBar!.addEventListener('pointerenter', this.onBarEnter);\n    this.ui.timelineBar!.addEventListener('pointerleave', this.onBarLeave);\n    this.ui.slider?.addEventListener('pointerenter', this.onSliderEnter);\n    this.ui.slider?.addEventListener('pointerleave', this.onSliderLeave);\n\n    this.onBarPointerDown = (ev: PointerEvent) => {\n      if ((ev.target as HTMLElement).closest('.timeline-dot, .timeline-thumb')) {\n        return;\n      }\n      // Resize takes priority over position drag\n      if (this.isInResizeEdge(ev)) {\n        this.startResize(ev);\n        return;\n      }\n      // Position drag only when enabled\n      if (!this.draggable) return;\n      this.barDragging = true;\n      this.barStartPos = { x: ev.clientX, y: ev.clientY };\n      const rect = this.ui.timelineBar!.getBoundingClientRect();\n      this.barStartOffset = { x: rect.left, y: rect.top };\n      this.ui.timelineBar!.setPointerCapture(ev.pointerId);\n      this.onBarPointerMove = (e: PointerEvent) => this.handleBarDrag(e);\n      this.onBarPointerUp = (e: PointerEvent) => this.endBarDrag(e);\n      window.addEventListener('pointermove', this.onBarPointerMove);\n      window.addEventListener('pointerup', this.onBarPointerUp, { once: true });\n    };\n    // Always attach pointerdown for resize (drag is gated by this.draggable inside)\n    this.ui.timelineBar!.addEventListener('pointerdown', this.onBarPointerDown);\n\n    // Cursor management: show resize cursor near inner edge\n    this.onBarCursorMove = (ev: PointerEvent) => {\n      if (this.resizing || this.barDragging) return;\n      if (this.isInResizeEdge(ev)) {\n        this.ui.timelineBar!.style.cursor = 'ew-resize';\n      } else if (this.draggable) {\n        this.ui.timelineBar!.style.cursor = 'move';\n      } else {\n        this.ui.timelineBar!.style.cursor = '';\n      }\n    };\n    this.ui.timelineBar!.addEventListener('pointermove', this.onBarCursorMove);\n\n    this.onStorage = (e: StorageEvent) => {\n      if (!e || e.storageArea !== localStorage) return;\n      const expectedKey = this.getStarsStorageKey();\n      if (!expectedKey || e.key !== expectedKey) return;\n      let nextArr: string[] = [];\n      try {\n        nextArr = JSON.parse(e.newValue || '[]') || [];\n      } catch {\n        nextArr = [];\n      }\n      const nextSet = new Set(nextArr.map(String));\n      this.applyStarredIdSet(nextSet, false);\n    };\n    window.addEventListener('storage', this.onStorage);\n\n    if (typeof chrome !== 'undefined' && chrome.storage?.onChanged) {\n      this.onChromeStorageChanged = (changes, areaName) => {\n        if (areaName === 'local') {\n          const starredChange = changes[StorageKeys.TIMELINE_STARRED_MESSAGES];\n          if (starredChange) {\n            this.applySharedStarredData(starredChange.newValue as StarredMessagesData | null);\n          }\n        }\n        if (areaName === 'sync' || areaName === 'local') {\n          const tsEnabledChange = changes[StorageKeys.GV_SHOW_MESSAGE_TIMESTAMPS];\n          if (tsEnabledChange) {\n            this.showMessageTimestampsEnabled = tsEnabledChange.newValue === true;\n            this.injectMessageTimestamps().catch(() => {});\n          }\n        }\n      };\n      chrome.storage.onChanged.addListener(this.onChromeStorageChanged);\n    }\n\n    // Subscribe to EventBus for cross-component starred state synchronization\n    this.eventBusUnsubscribers.push(\n      eventBus.on('starred:removed', ({ conversationId, turnId }) => {\n        // Only handle events for current conversation\n        if (conversationId !== this.conversationId) return;\n\n        // Update local starred set\n        if (this.starred.has(turnId)) {\n          this.starred.delete(turnId);\n          this.starredAtMap.delete(turnId);\n          this.saveStars();\n\n          // Update marker UI\n          const marker = this.markerMap.get(turnId);\n          if (marker && marker.dotElement) {\n            marker.starred = false;\n            marker.dotElement.classList.remove('starred');\n            marker.dotElement.setAttribute('aria-pressed', 'false');\n          }\n\n          console.log('[Timeline] Starred removed via EventBus:', turnId);\n        }\n      }),\n    );\n\n    this.eventBusUnsubscribers.push(\n      eventBus.on('starred:added', ({ conversationId, turnId }) => {\n        // Only handle events for current conversation\n        if (conversationId !== this.conversationId) return;\n\n        // Update local starred set\n        if (!this.starred.has(turnId)) {\n          this.starred.add(turnId);\n          this.starredAtMap.set(turnId, Date.now());\n          this.saveStars();\n\n          // Update marker UI\n          const marker = this.markerMap.get(turnId);\n          if (marker && marker.dotElement) {\n            marker.starred = true;\n            marker.dotElement.classList.add('starred');\n            marker.dotElement.setAttribute('aria-pressed', 'true');\n          }\n\n          console.log('[Timeline] Starred added via EventBus:', turnId);\n        }\n      }),\n    );\n  }\n\n  private smoothScrollTo(targetElement: HTMLElement, duration = 600): void {\n    const containerRect = this.scrollContainer!.getBoundingClientRect();\n    const targetRect = targetElement.getBoundingClientRect();\n    const targetPosition = targetRect.top - containerRect.top + this.scrollContainer!.scrollTop;\n    const startPosition = this.scrollContainer!.scrollTop;\n    const distance = targetPosition - startPosition;\n    let startTime: number | null = null;\n\n    if (this.scrollMode === 'jump') {\n      this.scrollContainer!.scrollTop = targetPosition;\n      return;\n    }\n    const animation = (currentTime: number) => {\n      this.isScrolling = true;\n      if (startTime === null) startTime = currentTime;\n      const timeElapsed = currentTime - startTime;\n      const run = this.easeInOutQuad(timeElapsed, startPosition, distance, duration);\n      this.scrollContainer!.scrollTop = run;\n      if (timeElapsed < duration) {\n        requestAnimationFrame(animation);\n      } else {\n        this.scrollContainer!.scrollTop = targetPosition;\n        this.isScrolling = false;\n      }\n    };\n    requestAnimationFrame(animation);\n  }\n\n  private easeInOutQuad(t: number, b: number, c: number, d: number): number {\n    // Overridable via spring profile\n    const spring = (() => {\n      try {\n        return localStorage.getItem('geminiTimelineSpring') || 'ios';\n      } catch {\n        return 'ios';\n      }\n    })();\n    const clamp = (x: number) => Math.max(0, Math.min(1, x));\n    const u = clamp(t / d);\n    if (spring === 'snappy') {\n      // Ease out back a bit then settle\n      const s = 1.15; // overshoot\n      const x = u < 0.6 ? u / 0.6 : 1 + (0.6 - u) * 0.15;\n      return b + c * clamp(x * s - (s - 1));\n    }\n    if (spring === 'gentle') {\n      // Smooth cubic ease-in-out\n      return b + c * (u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2);\n    }\n    // iOS-like spring-ish: ease out with slight acceleration then decel\n    const k1 = 0.42,\n      k2 = 0.58; // pseudo cubic bezier\n    const s = u * u * (3 - 2 * u); // smoothstep baseline\n    const mix = (a: number, b: number, m: number) => a + (b - a) * m;\n    const shaped = mix(Math.pow(u, k1), Math.pow(u, k2), 0.5) * 0.15 + s * 0.85;\n    return b + c * clamp(shaped);\n  }\n\n  private updateActiveDotUI(): void {\n    this.markers.forEach((marker) => {\n      marker.dotElement?.classList.toggle('active', marker.id === this.activeTurnId);\n    });\n    this.previewPanel?.updateActiveTurn(this.activeTurnId);\n  }\n\n  private static readonly SEARCH_HIGHLIGHT_CLASS = 'timeline-search-highlight';\n\n  private clearSearchHighlights(): void {\n    const cls = TimelineManager.SEARCH_HIGHLIGHT_CLASS;\n    const marks = this.conversationContainer?.querySelectorAll(`mark.${cls}`);\n    if (!marks) return;\n    marks.forEach((mark) => {\n      const parent = mark.parentNode;\n      if (!parent) return;\n      parent.replaceChild(document.createTextNode(mark.textContent || ''), mark);\n      parent.normalize();\n    });\n  }\n\n  private highlightSearchInDOM(query: string): void {\n    this.clearSearchHighlights();\n    if (!query || !this.conversationContainer) return;\n    const lowerQuery = query.toLowerCase();\n    for (const marker of this.markers) {\n      if (!marker.element) continue;\n      const walker = document.createTreeWalker(marker.element, NodeFilter.SHOW_TEXT);\n      const matches: { node: Text; index: number }[] = [];\n      let node: Text | null;\n      while ((node = walker.nextNode() as Text | null)) {\n        const idx = node.textContent?.toLowerCase().indexOf(lowerQuery) ?? -1;\n        if (idx !== -1) matches.push({ node, index: idx });\n      }\n      // Process in reverse to keep offsets stable\n      for (let i = matches.length - 1; i >= 0; i--) {\n        const { node: textNode, index: matchIdx } = matches[i];\n        const after = textNode.splitText(matchIdx + query.length);\n        const matchText = textNode.splitText(matchIdx);\n        const mark = document.createElement('mark');\n        mark.className = TimelineManager.SEARCH_HIGHLIGHT_CLASS;\n        mark.textContent = matchText.textContent;\n        matchText.parentNode!.replaceChild(mark, matchText);\n        // keep reference to 'after' to avoid TS unused warning\n        void after;\n      }\n    }\n  }\n\n  /**\n   * Optimized debounce delay: reduced from 350ms to 200ms for better responsiveness\n   * while still preventing excessive recalculations during rapid DOM changes\n   */\n  private debouncedRecalc = this.debounce(() => this.recalculateAndRenderMarkers(), 200);\n\n  private debounce<T extends (...args: unknown[]) => void>(func: T, delay: number): T {\n    let timeout: number | null = null;\n    return ((...args: unknown[]) => {\n      if (timeout) clearTimeout(timeout);\n      timeout = window.setTimeout(() => func.apply(this, args), delay);\n    }) as unknown as T;\n  }\n\n  private getActiveIndex(): number {\n    if (!this.activeTurnId) return -1;\n    return this.markers.findIndex((m) => m.id === this.activeTurnId);\n  }\n\n  private getFlowDurationMs(): number {\n    try {\n      const d = parseInt(localStorage.getItem('geminiTimelineFlowDurationMs') || '650', 10);\n      return Math.max(300, Math.min(1800, Number.isFinite(d) ? d : 650));\n    } catch {\n      return 650;\n    }\n  }\n\n  private computeFlowDuration(fromIdx: number, toIdx: number): number {\n    const base = this.getFlowDurationMs();\n    if (fromIdx < 0 || toIdx < 0) return base;\n    const span = Math.abs(this.yPositions[toIdx] - this.yPositions[fromIdx]);\n    const H = Math.max(1, this.ui.timelineBar?.clientHeight || 1);\n    // Scale duration by normalized travel distance inside the bar (bounded)\n    const scale = Math.max(0.6, Math.min(1.6, span / H));\n    return Math.round(base * scale);\n  }\n\n  private ensureRunnerRing(): void {\n    if (!this.ui.trackContent) return;\n    if (!this.runnerRing) {\n      const ring = document.createElement('div');\n      ring.className = 'timeline-runner-ring';\n      Object.assign(ring.style, {\n        position: 'absolute',\n        left: '50%',\n        width: '20px',\n        height: '20px',\n        transform: 'translate(-50%, -50%)',\n        borderRadius: '9999px',\n        boxShadow: '0 0 0 2px var(--timeline-dot-active-color), 0 0 12px rgba(59,130,246,.45)',\n        background: 'transparent',\n        pointerEvents: 'none',\n        zIndex: '4',\n        opacity: '0',\n        transition: 'opacity 120ms ease',\n      } as CSSStyleDeclaration);\n      this.ui.trackContent.appendChild(ring);\n      this.runnerRing = ring;\n    }\n  }\n\n  private startRunner(fromIdx: number, toIdx: number, duration: number): void {\n    this.ensureRunnerRing();\n    if (!this.runnerRing) return;\n    const y1 = Math.round(this.yPositions[fromIdx]);\n    const y2 = Math.round(this.yPositions[toIdx]);\n    const t0 =\n      typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();\n    this.runnerRing.style.opacity = '1';\n    const animate = () => {\n      const now =\n        typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();\n      const t = Math.min(1, (now - t0) / Math.max(1, duration));\n      // Use the same spring shaping as easeInOutQuad override\n      const spring = (() => {\n        try {\n          return localStorage.getItem('geminiTimelineSpring') || 'ios';\n        } catch {\n          return 'ios';\n        }\n      })();\n      let eased: number;\n      if (spring === 'snappy') eased = Math.min(1, t + 0.08 * Math.sin(t * 8));\n      else if (spring === 'gentle') eased = t * t * (3 - 2 * t);\n      else eased = t * t * (3 - 2 * t) * 0.85 + t * 0.15;\n      const y = Math.round(y1 + (y2 - y1) * eased);\n      if (this.runnerRing) {\n        this.runnerRing.style.top = `${y}px`;\n      }\n      if (t < 1) {\n        this.flowAnimating = true;\n        requestAnimationFrame(animate);\n      } else {\n        this.flowAnimating = false;\n        if (this.runnerRing) {\n          this.runnerRing.style.opacity = '0';\n        }\n      }\n    };\n    animate();\n  }\n\n  private truncateToThreeLines(\n    text: string,\n    targetWidth: number,\n  ): { text: string; height: number } {\n    if (!this.measureEl || !this.ui.tooltip) return { text, height: 0 };\n    const tip = this.ui.tooltip;\n    const lineH = this.getCSSVarNumber(tip, '--timeline-tooltip-lh', 18);\n    const padY = this.getCSSVarNumber(tip, '--timeline-tooltip-pad-y', 10);\n    const borderW = this.getCSSVarNumber(tip, '--timeline-tooltip-border-w', 1);\n    const maxH = Math.round(3 * lineH + 2 * padY + 2 * borderW);\n    const ell = '…';\n    const el = this.measureEl;\n    el.style.width = `${Math.max(0, Math.floor(targetWidth))}px`;\n    const normalized = String(text || '')\n      .split('\\n')\n      .map((line) => line.replace(/[ \\t]+/g, ' ').trim())\n      .join('\\n')\n      .trim();\n    el.textContent = normalized;\n    let h = el.offsetHeight;\n    if (h <= maxH) return { text: el.textContent, height: h };\n    const raw = el.textContent;\n    let lo = 0,\n      hi = raw.length,\n      ans = 0;\n    while (lo <= hi) {\n      const mid = (lo + hi) >> 1;\n      el.textContent = raw.slice(0, mid).trimEnd() + ell;\n      h = el.offsetHeight;\n      if (h <= maxH) {\n        ans = mid;\n        lo = mid + 1;\n      } else {\n        hi = mid - 1;\n      }\n    }\n    const out = ans >= raw.length ? raw : raw.slice(0, ans).trimEnd() + ell;\n    el.textContent = out;\n    h = el.offsetHeight;\n    return { text: out, height: Math.min(h, maxH) };\n  }\n\n  private computePlacementInfo(dot: HTMLElement): { placement: 'left' | 'right'; width: number } {\n    const tip = this.ui.tooltip || document.body;\n    const dotRect = dot.getBoundingClientRect();\n    const vw = window.innerWidth;\n    const arrowOut = this.getCSSVarNumber(tip, '--timeline-tooltip-arrow-outside', 6);\n    const baseGap = this.getCSSVarNumber(tip, '--timeline-tooltip-gap-visual', 12);\n    const boxGap = this.getCSSVarNumber(tip, '--timeline-tooltip-gap-box', 8);\n    const gap = baseGap + Math.max(0, arrowOut) + Math.max(0, boxGap);\n    const viewportPad = 8;\n    const maxW = this.getCSSVarNumber(tip, '--timeline-tooltip-max', 288);\n    const minW = 160;\n    const leftAvail = Math.max(0, dotRect.left - gap - viewportPad);\n    const rightAvail = Math.max(0, vw - dotRect.right - gap - viewportPad);\n    let placement: 'left' | 'right' = rightAvail > leftAvail ? 'right' : 'left';\n    let avail = placement === 'right' ? rightAvail : leftAvail;\n    const tiers = [280, 240, 200, 160];\n    const hardMax = Math.max(minW, Math.min(maxW, Math.floor(avail)));\n    let width = tiers.find((t) => t <= hardMax) || Math.max(minW, Math.min(hardMax, 160));\n    if (width < minW && placement === 'left' && rightAvail > leftAvail) {\n      placement = 'right';\n      avail = rightAvail;\n      const hardMax2 = Math.max(minW, Math.min(maxW, Math.floor(avail)));\n      width = tiers.find((t) => t <= hardMax2) || Math.max(120, Math.min(hardMax2, minW));\n    } else if (width < minW && placement === 'right' && leftAvail >= rightAvail) {\n      placement = 'left';\n      avail = leftAvail;\n      const hardMax2 = Math.max(minW, Math.min(maxW, Math.floor(avail)));\n      width = tiers.find((t) => t <= hardMax2) || Math.max(120, Math.min(hardMax2, minW));\n    }\n    width = Math.max(120, Math.min(width, maxW));\n    return { placement, width };\n  }\n\n  private showTooltipForDot(dot: DotElement): void {\n    if (!this.ui.tooltip) return;\n    if (this.previewPanel?.isOpen) return;\n    if (this.tooltipHideTimer) {\n      clearTimeout(this.tooltipHideTimer);\n      this.tooltipHideTimer = null;\n    }\n    const tip = this.ui.tooltip;\n    tip.setAttribute('dir', 'auto');\n    const dotId = dot.dataset.targetTurnId || '';\n    if (tip.classList.contains('visible') && this.tooltipDotId === dotId) {\n      this.refreshTooltipForDot(dot);\n      return;\n    }\n    this.tooltipDotId = dotId;\n    tip.classList.remove('visible');\n    const fullText = this.buildTooltipText(dot);\n    const p = this.computePlacementInfo(dot);\n    const layout = this.truncateToThreeLines(fullText, p.width);\n    tip.textContent = layout.text;\n    this.placeTooltipAt(dot, p.placement, p.width, layout.height);\n    tip.setAttribute('aria-hidden', 'false');\n    if (this.showRafId !== null) {\n      cancelAnimationFrame(this.showRafId);\n      this.showRafId = null;\n    }\n    this.showRafId = requestAnimationFrame(() => {\n      this.showRafId = null;\n      tip.classList.add('visible');\n    });\n  }\n\n  private placeTooltipAt(\n    dot: HTMLElement,\n    placement: 'left' | 'right',\n    width: number,\n    height: number,\n  ): void {\n    if (!this.ui.tooltip) return;\n    const tip = this.ui.tooltip;\n    const dotRect = dot.getBoundingClientRect();\n    const vw = window.innerWidth;\n    const vh = window.innerHeight;\n    const arrowOut = this.getCSSVarNumber(tip, '--timeline-tooltip-arrow-outside', 6);\n    const baseGap = this.getCSSVarNumber(tip, '--timeline-tooltip-gap-visual', 12);\n    const boxGap = this.getCSSVarNumber(tip, '--timeline-tooltip-gap-box', 8);\n    const gap = baseGap + Math.max(0, arrowOut) + Math.max(0, boxGap);\n    const viewportPad = 8;\n    let left: number;\n    if (placement === 'left') {\n      left = Math.round(dotRect.left - gap - width);\n      if (left < viewportPad) {\n        const altLeft = Math.round(dotRect.right + gap);\n        if (altLeft + width <= vw - viewportPad) {\n          placement = 'right';\n          left = altLeft;\n        } else {\n          const fitWidth = Math.max(120, vw - viewportPad - altLeft);\n          left = altLeft;\n          width = fitWidth;\n        }\n      }\n    } else {\n      left = Math.round(dotRect.right + gap);\n      if (left + width > vw - viewportPad) {\n        const altLeft = Math.round(dotRect.left - gap - width);\n        if (altLeft >= viewportPad) {\n          placement = 'left';\n          left = altLeft;\n        } else {\n          const fitWidth = Math.max(120, vw - viewportPad - left);\n          width = fitWidth;\n        }\n      }\n    }\n    // Set width first, let height auto-size to text\n    tip.style.width = `${Math.floor(width)}px`;\n    // If height not provided, measure after width + content set\n    const autoH = !height || height <= 0 ? tip.offsetHeight : height;\n    let top = Math.round(dotRect.top + dotRect.height / 2 - autoH / 2);\n    top = Math.max(viewportPad, Math.min(vh - height - viewportPad, top));\n    tip.style.left = `${left}px`;\n    tip.style.top = `${top}px`;\n    tip.setAttribute('data-placement', placement);\n  }\n\n  private refreshTooltipForDot(dot: DotElement): void {\n    if (!this.ui.tooltip) return;\n    const tip = this.ui.tooltip;\n    tip.setAttribute('dir', 'auto');\n    if (!tip.classList.contains('visible')) return;\n    const fullText = this.buildTooltipText(dot);\n    const p = this.computePlacementInfo(dot);\n    const layout = this.truncateToThreeLines(fullText, p.width);\n    tip.textContent = layout.text;\n    this.placeTooltipAt(dot, p.placement, p.width, layout.height);\n  }\n\n  private buildTooltipText(dot: DotElement): string {\n    let fullText = (dot.getAttribute('aria-label') || '').trim();\n    const id = dot.dataset.targetTurnId || '';\n    if (id && this.starred.has(id)) fullText = `★ ${fullText}`;\n\n    if (this.showMessageTimestampsEnabled && id && this.timestampService) {\n      const ts = this.timestampService.getTimestamp(id as TurnId);\n      if (typeof ts === 'number') {\n        fullText = `${this.timestampService.formatAbsoluteTime(ts)}\\n${fullText}`;\n      }\n    }\n    return fullText;\n  }\n\n  private scheduleScrollSync(): void {\n    if (this.scrollRafId !== null) return;\n    this.scrollRafId = requestAnimationFrame(() => {\n      this.scrollRafId = null;\n      this.syncTimelineTrackToMain();\n      this.updateVirtualRangeAndRender();\n      this.computeActiveByScroll();\n      this.updateSlider();\n    });\n  }\n\n  private computeActiveByScroll(): void {\n    if (this.isScrolling || !this.scrollContainer || this.markers.length === 0) return;\n    const scrollTop = this.scrollContainer.scrollTop;\n    const ref = scrollTop + this.scrollContainer.clientHeight * 0.45;\n    let activeId = this.markers[0].id;\n\n    if (this.markerTops.length === this.markers.length && this.markerTops.length > 0) {\n      const idx = Math.max(\n        0,\n        Math.min(this.markers.length - 1, this.upperBound(this.markerTops, ref)),\n      );\n      activeId = this.markers[idx].id;\n    } else {\n      const containerRect = this.scrollContainer.getBoundingClientRect();\n      for (let i = 0; i < this.markers.length; i++) {\n        const m = this.markers[i];\n        const top = m.element.getBoundingClientRect().top - containerRect.top + scrollTop;\n        if (top <= ref) activeId = m.id;\n        else break;\n      }\n    }\n    if (this.activeTurnId !== activeId) {\n      const now =\n        typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();\n      const since = now - this.lastActiveChangeTime;\n      if (since < this.minActiveChangeInterval) {\n        this.pendingActiveId = activeId;\n        if (!this.activeChangeTimer) {\n          const delay = Math.max(this.minActiveChangeInterval - since, 0);\n          this.activeChangeTimer = window.setTimeout(() => {\n            this.activeChangeTimer = null;\n            if (this.pendingActiveId && this.pendingActiveId !== this.activeTurnId) {\n              this.activeTurnId = this.pendingActiveId;\n              this.updateActiveDotUI();\n              this.lastActiveChangeTime =\n                typeof performance !== 'undefined' && performance.now\n                  ? performance.now()\n                  : Date.now();\n            }\n            this.pendingActiveId = null;\n          }, delay);\n        }\n      } else {\n        this.activeTurnId = activeId;\n        this.updateActiveDotUI();\n        this.lastActiveChangeTime = now;\n      }\n    }\n  }\n\n  private syncTimelineTrackToMain(): void {\n    if (this.sliderDragging) return;\n    if (!this.ui.track || !this.scrollContainer || !this.contentHeight) return;\n    const scrollTop = this.scrollContainer.scrollTop;\n    const ref = scrollTop + this.scrollContainer.clientHeight * 0.45;\n    const span = Math.max(1, this.contentSpanPx || 1);\n    const r = Math.max(0, Math.min(1, (ref - (this.firstUserTurnOffset || 0)) / span));\n    const maxScroll = Math.max(0, this.contentHeight - (this.ui.track.clientHeight || 0));\n    const target = Math.round(r * maxScroll);\n    if (Math.abs((this.ui.track.scrollTop || 0) - target) > 1) this.ui.track.scrollTop = target;\n  }\n\n  private lowerBound(arr: number[], x: number): number {\n    let lo = 0,\n      hi = arr.length;\n    while (lo < hi) {\n      const mid = (lo + hi) >> 1;\n      if (arr[mid] < x) lo = mid + 1;\n      else hi = mid;\n    }\n    return lo;\n  }\n  private upperBound(arr: number[], x: number): number {\n    let lo = 0,\n      hi = arr.length;\n    while (lo < hi) {\n      const mid = (lo + hi) >> 1;\n      if (arr[mid] <= x) lo = mid + 1;\n      else hi = mid;\n    }\n    return lo - 1;\n  }\n\n  private updateVirtualRangeAndRender(): void {\n    const localVersion = this.markersVersion;\n    if (!this.ui.track || !this.ui.trackContent || this.markers.length === 0) return;\n    const st = this.ui.track.scrollTop || 0;\n    const vh = this.ui.track.clientHeight || 0;\n    const buffer = Math.max(100, vh);\n    const minY = st - buffer;\n    const maxY = st + vh + buffer;\n    const start = this.lowerBound(this.yPositions, minY);\n    const end = Math.max(start - 1, this.upperBound(this.yPositions, maxY));\n\n    const hiddenIndices = this.getHiddenMarkerIndices();\n\n    let prevStart = this.visibleRange.start;\n    let prevEnd = this.visibleRange.end;\n    const len = this.markers.length;\n    if (len > 0) {\n      prevStart = Math.max(0, Math.min(prevStart, len - 1));\n      prevEnd = Math.max(-1, Math.min(prevEnd, len - 1));\n    }\n    if (prevEnd >= prevStart) {\n      for (let i = prevStart; i < Math.min(start, prevEnd + 1); i++) {\n        const m = this.markers[i];\n        if (m && m.dotElement) {\n          m.dotElement.remove();\n          m.dotElement = null;\n        }\n      }\n      for (let i = Math.max(end + 1, prevStart); i <= prevEnd; i++) {\n        const m = this.markers[i];\n        if (m && m.dotElement) {\n          m.dotElement.remove();\n          m.dotElement = null;\n        }\n      }\n    } else {\n      // Clear all dots and reset references\n      (this.ui.trackContent || this.ui.timelineBar)!\n        .querySelectorAll('.timeline-dot')\n        .forEach((n) => n.remove());\n      this.markers.forEach((m) => {\n        m.dotElement = null;\n      });\n    }\n\n    const frag = document.createDocumentFragment();\n    for (let i = start; i <= end; i++) {\n      const marker = this.markers[i];\n      if (!marker) continue;\n\n      if (hiddenIndices.has(i)) {\n        if (marker.dotElement) {\n          marker.dotElement.remove();\n          marker.dotElement = null;\n        }\n        continue;\n      }\n\n      const isCollapsed = this.isMarkerCollapsed(marker.id);\n\n      if (!marker.dotElement) {\n        const dot = document.createElement('button') as DotElement;\n        dot.className = 'timeline-dot';\n        dot.dataset.targetTurnId = marker.id;\n        dot.dataset.markerIndex = String(i);\n        dot.setAttribute('aria-label', marker.summary);\n        dot.setAttribute('tabindex', '0');\n        dot.setAttribute('aria-describedby', 'gemini-timeline-tooltip');\n        dot.style.setProperty('--n', String(marker.n || 0));\n        if (this.usePixelTop) dot.style.top = `${Math.round(this.yPositions[i])}px`;\n        dot.classList.toggle('active', marker.id === this.activeTurnId);\n        dot.classList.toggle('starred', !!marker.starred);\n        dot.classList.toggle('collapsed', isCollapsed);\n        dot.setAttribute('aria-pressed', marker.starred ? 'true' : 'false');\n        dot.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');\n        // Apply marker level\n        const level = this.getMarkerLevel(marker.id);\n        dot.setAttribute('data-level', String(level));\n        marker.dotElement = dot;\n        frag.appendChild(dot);\n      } else {\n        marker.dotElement.dataset.markerIndex = String(i);\n        marker.dotElement.style.setProperty('--n', String(marker.n || 0));\n        if (this.usePixelTop) marker.dotElement.style.top = `${Math.round(this.yPositions[i])}px`;\n        marker.dotElement.classList.toggle('starred', !!marker.starred);\n        marker.dotElement.classList.toggle('collapsed', isCollapsed);\n        marker.dotElement.setAttribute('aria-pressed', marker.starred ? 'true' : 'false');\n        marker.dotElement.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');\n        // Apply marker level\n        const level = this.getMarkerLevel(marker.id);\n        marker.dotElement.setAttribute('data-level', String(level));\n      }\n    }\n    if (localVersion !== this.markersVersion) return;\n    if (frag.childNodes.length) this.ui.trackContent.appendChild(frag);\n    this.visibleRange = { start, end };\n    this.updateSlider();\n  }\n\n  private updateSlider(): void {\n    if (!this.ui.slider || !this.ui.sliderHandle) return;\n    if (!this.contentHeight || !this.ui.timelineBar || !this.ui.track) return;\n    const barRect = this.ui.timelineBar.getBoundingClientRect();\n    const barH = barRect.height || 0;\n    const pad = this.getTrackPadding();\n    const innerH = Math.max(0, barH - 2 * pad);\n    if (this.contentHeight <= barH + 1 || innerH <= 0) {\n      this.sliderAlwaysVisible = false;\n      this.ui.slider.classList.remove('visible');\n      this.ui.slider.style.opacity = '';\n      return;\n    }\n    this.sliderAlwaysVisible = true;\n    const railLen = Math.max(120, Math.min(240, Math.floor(barH * 0.45)));\n    const railTop = Math.round(barRect.top + pad + (innerH - railLen) / 2);\n    const railLeftGap = 8;\n    const sliderWidth = 12;\n    // In RTL, bar is on the left side — position slider to its right instead\n    const left = this.rtl\n      ? Math.round(barRect.right + railLeftGap)\n      : Math.round(barRect.left - railLeftGap - sliderWidth);\n    this.ui.slider.style.left = `${left}px`;\n    this.ui.slider.style.top = `${railTop}px`;\n    this.ui.slider.style.height = `${railLen}px`;\n    const handleH = 22;\n    const maxTop = Math.max(0, railLen - handleH);\n    const range = Math.max(1, this.contentHeight - barH);\n    const st = this.ui.track.scrollTop || 0;\n    const r = Math.max(0, Math.min(1, st / range));\n    const top = Math.round(r * maxTop);\n    this.ui.sliderHandle.style.height = `${handleH}px`;\n    this.ui.sliderHandle.style.top = `${top}px`;\n    this.ui.slider.classList.add('visible');\n    this.ui.slider.style.opacity = '';\n  }\n\n  private showSlider(): void {\n    if (!this.ui.slider) return;\n    this.ui.slider.classList.add('visible');\n    if (this.sliderFadeTimer) {\n      clearTimeout(this.sliderFadeTimer);\n      this.sliderFadeTimer = null;\n    }\n    this.updateSlider();\n  }\n\n  private hideSliderDeferred(): void {\n    if (this.sliderDragging || this.sliderAlwaysVisible) return;\n    if (this.sliderFadeTimer) clearTimeout(this.sliderFadeTimer);\n    this.sliderFadeTimer = window.setTimeout(() => {\n      this.sliderFadeTimer = null;\n      this.ui.slider?.classList.remove('visible');\n    }, this.sliderFadeDelay);\n  }\n\n  private handleSliderDrag(e: PointerEvent): void {\n    if (!this.sliderDragging || !this.ui.timelineBar || !this.ui.track) return;\n    const barRect = this.ui.timelineBar.getBoundingClientRect();\n    const barH = barRect.height || 0;\n    const railLen =\n      parseFloat(this.ui.slider!.style.height || '0') ||\n      Math.max(120, Math.min(240, Math.floor(barH * 0.45)));\n    const handleH = this.ui.sliderHandle!.getBoundingClientRect().height || 22;\n    const maxTop = Math.max(0, railLen - handleH);\n    const delta = e.clientY - this.sliderStartClientY;\n    let top = Math.max(\n      0,\n      Math.min(maxTop, this.sliderStartTop + delta - (parseFloat(this.ui.slider!.style.top) || 0)),\n    );\n    const r = maxTop > 0 ? top / maxTop : 0;\n    const range = Math.max(1, this.contentHeight - barH);\n    this.ui.track.scrollTop = Math.round(r * range);\n    this.updateVirtualRangeAndRender();\n    this.showSlider();\n    this.updateSlider();\n  }\n\n  private endSliderDrag(_e: PointerEvent): void {\n    this.sliderDragging = false;\n    try {\n      window.removeEventListener('pointermove', this.onSliderMove!);\n    } catch {}\n    this.onSliderMove = null;\n    this.onSliderUp = null;\n    this.hideSliderDeferred();\n  }\n\n  private toggleDraggable(enabled: boolean): void {\n    this.draggable = enabled;\n    // Cursor is managed dynamically by onBarCursorMove; just update the flag\n    if (!this.ui.timelineBar) return;\n    if (!this.draggable) {\n      this.ui.timelineBar.style.cursor = '';\n    }\n  }\n\n  private toggleMarkerLevel(enabled: boolean): void {\n    this.markerLevelEnabled = enabled;\n    // Hide context menu when feature is disabled\n    if (!enabled) {\n      this.hideContextMenu();\n    }\n    // Trigger re-layout to show/hide collapsed states\n    this.updateTimelineGeometry();\n    this.updateVirtualRangeAndRender();\n  }\n\n  private handleBarDrag(e: PointerEvent): void {\n    if (!this.barDragging) return;\n    const dx = e.clientX - this.barStartPos.x;\n    const dy = e.clientY - this.barStartPos.y;\n    this.ui.timelineBar!.style.left = `${this.barStartOffset.x + dx}px`;\n    this.ui.timelineBar!.style.top = `${this.barStartOffset.y + dy}px`;\n  }\n\n  private endBarDrag(_e: PointerEvent): void {\n    this.barDragging = false;\n    this.savePosition();\n    window.removeEventListener('pointermove', this.onBarPointerMove!);\n  }\n\n  private savePosition(): void {\n    if (!this.ui.timelineBar) return;\n    const rect = this.ui.timelineBar.getBoundingClientRect();\n    const viewportWidth = window.innerWidth;\n    const viewportHeight = window.innerHeight;\n\n    // Save position as percentage of viewport for responsive design\n    const position = {\n      version: 2,\n      topPercent: (rect.top / viewportHeight) * 100,\n      leftPercent: (rect.left / viewportWidth) * 100,\n    };\n\n    const g = globalThis as ExtGlobal;\n    if (g.chrome?.storage?.sync?.set) {\n      g.chrome.storage.sync.set({ geminiTimelinePosition: position });\n    } else if (g.browser?.storage?.sync?.set) {\n      g.browser.storage.sync.set({ geminiTimelinePosition: position });\n    }\n  }\n\n  /**\n   * Apply position with boundary checks to keep timeline visible\n   */\n  private applyRTLUpdate(language?: string | null): void {\n    const wasRTL = this.rtl;\n    this.rtl = applyRTLClass(language);\n    if (wasRTL !== this.rtl) {\n      // Reset inline position so the CSS default for the new direction takes effect\n      if (this.ui.timelineBar) {\n        this.ui.timelineBar.style.top = '';\n        this.ui.timelineBar.style.left = '';\n      }\n      this.updateSlider();\n      this.previewPanel?.reposition();\n    }\n  }\n\n  private applyPosition(top: number, left: number): void {\n    if (!this.ui.timelineBar) return;\n\n    const barWidth = this.ui.timelineBar.offsetWidth || 24; // fallback to default width\n    const barHeight = this.ui.timelineBar.offsetHeight || 100;\n    const viewportWidth = window.innerWidth;\n    const viewportHeight = window.innerHeight;\n\n    // Clamp to viewport bounds (with small padding)\n    const padding = 10;\n    const clampedTop = Math.max(padding, Math.min(top, viewportHeight - barHeight - padding));\n    const clampedLeft = Math.max(padding, Math.min(left, viewportWidth - barWidth - padding));\n\n    this.ui.timelineBar.style.top = `${clampedTop}px`;\n    this.ui.timelineBar.style.left = `${clampedLeft}px`;\n    this.previewPanel?.reposition();\n  }\n\n  /**\n   * Reapply position from storage (for window resize)\n   */\n  private async reapplyPosition(): Promise<void> {\n    if (!this.ui.timelineBar) return;\n\n    const g = globalThis as ExtGlobal;\n    if (!g.chrome?.storage?.sync && !g.browser?.storage?.sync) return;\n\n    let res: Record<string, unknown> | null = null;\n    try {\n      res = await new Promise((resolve) => {\n        if (g.chrome?.storage?.sync?.get) {\n          g.chrome.storage.sync.get(\n            { geminiTimelinePosition: null },\n            (items: Record<string, unknown>) => {\n              if (g.chrome.runtime?.lastError) {\n                console.error(\n                  `[Timeline] chrome.storage.get failed: ${g.chrome.runtime.lastError.message}`,\n                );\n                resolve(null);\n              } else {\n                resolve(items);\n              }\n            },\n          );\n        } else {\n          g.browser?.storage?.sync\n            ?.get({ geminiTimelinePosition: null })\n            .then(resolve)\n            .catch((error: Error) => {\n              console.error(`[Timeline] browser.storage.get failed: ${error.message}`);\n              resolve(null);\n            });\n        }\n      });\n    } catch (error) {\n      console.error('[Timeline] reapplyPosition storage access failed:', error);\n      return;\n    }\n\n    const position = res?.geminiTimelinePosition as\n      | { version?: number; topPercent?: number; leftPercent?: number; top?: number; left?: number }\n      | undefined;\n    if (!position) return;\n\n    const viewportWidth = window.innerWidth;\n    const viewportHeight = window.innerHeight;\n\n    // v2 format: use percentage (responsive)\n    if (\n      position.version === 2 &&\n      position.topPercent !== undefined &&\n      position.leftPercent !== undefined\n    ) {\n      const top = (position.topPercent / 100) * viewportHeight;\n      const left = (position.leftPercent / 100) * viewportWidth;\n      this.applyPosition(top, left);\n    }\n    // v1 format: keep absolute position (no resize adjustment for legacy)\n    else if (position.top !== undefined && position.left !== undefined) {\n      this.applyPosition(position.top, position.left);\n    }\n  }\n\n  private hideTooltip(immediate = false): void {\n    if (!this.ui.tooltip) return;\n    const doHide = () => {\n      this.ui.tooltip!.classList.remove('visible');\n      this.ui.tooltip!.setAttribute('aria-hidden', 'true');\n      this.tooltipDotId = null;\n      this.tooltipHideTimer = null;\n    };\n    if (immediate) return doHide();\n    if (this.tooltipHideTimer) clearTimeout(this.tooltipHideTimer);\n    this.tooltipHideTimer = window.setTimeout(doHide, this.tooltipHideDelay);\n  }\n\n  private async toggleStar(turnId: string): Promise<void> {\n    const id = String(turnId || '');\n    if (!id) return;\n\n    const wasStarred = this.starred.has(id);\n\n    if (wasStarred) {\n      this.starred.delete(id);\n      this.starredAtMap.delete(id);\n    } else {\n      this.starred.add(id);\n    }\n\n    this.saveStars();\n\n    // Update global starred messages service\n    if (wasStarred) {\n      // Remove from global storage\n      await StarredMessagesService.removeStarredMessage(this.conversationId!, id);\n    } else {\n      // Add to global storage with full message info\n      const m = this.markerMap.get(id);\n      if (m) {\n        const conversationTitle = this.getConversationTitle();\n        const now = Date.now();\n        const message: StarredMessage = {\n          turnId: id,\n          content: m.summary,\n          conversationId: this.conversationId!,\n          conversationUrl: window.location.href,\n          conversationTitle,\n          starredAt: now,\n        };\n        this.starredAtMap.set(id, now);\n        await StarredMessagesService.addStarredMessage(message);\n      }\n    }\n\n    // Update UI for ALL markers with this ID (handle duplicates)\n    const isStarredNow = this.starred.has(id);\n    this.markers.forEach((m) => {\n      if (m.id === id) {\n        m.starred = isStarredNow;\n        if (m.dotElement) {\n          m.dotElement.classList.toggle('starred', isStarredNow);\n          m.dotElement.setAttribute('aria-pressed', isStarredNow ? 'true' : 'false');\n          // Only refresh tooltip if this specific dot is actively hovered/focused\n          // (checked internally by refreshTooltipForDot)\n          this.refreshTooltipForDot(m.dotElement);\n        }\n      }\n    });\n  }\n\n  /**\n   * Save starred messages to localStorage using DRY helper\n   */\n  private saveStars(): void {\n    const key = this.getStarsStorageKey();\n    if (!key) return;\n    this.safeLocalStorageSet(key, JSON.stringify(Array.from(this.starred)));\n  }\n\n  /**\n   * Load starred messages from localStorage using DRY helper\n   */\n  private async loadStars(): Promise<void> {\n    this.starred.clear();\n    const key = this.getStarsStorageKey();\n    if (!key) return;\n\n    const raw = this.safeLocalStorageGet(key);\n    if (!raw) return;\n\n    try {\n      const arr = JSON.parse(raw);\n      if (Array.isArray(arr)) {\n        arr.forEach((id: unknown) => this.starred.add(String(id)));\n      }\n    } catch (error) {\n      console.warn('[Timeline] Failed to parse starred messages:', error);\n    }\n  }\n\n  // ===== Marker Level Methods =====\n\n  private getLevelsStorageKey(): string | null {\n    return this.conversationId ? `geminiTimelineLevels:${this.conversationId}` : null;\n  }\n\n  /* Load marker levels from localStorage */\n  private loadMarkerLevels(): void {\n    this.markerLevels.clear();\n    const key = this.getLevelsStorageKey();\n    if (!key) return;\n\n    const raw = this.safeLocalStorageGet(key);\n    if (!raw) return;\n\n    try {\n      const obj = JSON.parse(raw);\n      if (obj && typeof obj === 'object') {\n        Object.entries(obj).forEach(([turnId, level]) => {\n          if (typeof level === 'number' && level >= 1 && level <= 4) {\n            this.markerLevels.set(turnId, level as MarkerLevel);\n          }\n        });\n      }\n    } catch (error) {\n      console.warn('[Timeline] Failed to parse marker levels:', error);\n    }\n  }\n\n  /* Save marker levels to localStorage */\n  private saveMarkerLevels(): void {\n    const key = this.getLevelsStorageKey();\n    if (!key) return;\n\n    const obj: Record<string, MarkerLevel> = {};\n    this.markerLevels.forEach((level, turnId) => {\n      obj[turnId] = level;\n    });\n\n    this.safeLocalStorageSet(key, JSON.stringify(obj));\n  }\n\n  // ===== Collapsed Markers Methods =====\n\n  private getCollapsedStorageKey(): string | null {\n    return this.conversationId ? `geminiTimelineCollapsed:${this.conversationId}` : null;\n  }\n\n  private loadCollapsedMarkers(): void {\n    this.collapsedMarkers.clear();\n    const key = this.getCollapsedStorageKey();\n    if (!key) return;\n\n    const raw = this.safeLocalStorageGet(key);\n    if (!raw) return;\n\n    try {\n      const arr = JSON.parse(raw);\n      if (Array.isArray(arr)) {\n        arr.forEach((id: unknown) => this.collapsedMarkers.add(String(id)));\n      }\n    } catch (error) {\n      console.warn('[Timeline] Failed to parse collapsed markers:', error);\n    }\n  }\n\n  private saveCollapsedMarkers(): void {\n    const key = this.getCollapsedStorageKey();\n    if (!key) return;\n    this.safeLocalStorageSet(key, JSON.stringify(Array.from(this.collapsedMarkers)));\n  }\n\n  private isMarkerCollapsed(turnId: string): boolean {\n    return this.collapsedMarkers.has(turnId);\n  }\n\n  private toggleCollapse(turnId: string): void {\n    if (this.collapsedMarkers.has(turnId)) {\n      this.collapsedMarkers.delete(turnId);\n    } else {\n      this.collapsedMarkers.add(turnId);\n    }\n    this.saveCollapsedMarkers();\n    this.updateTimelineGeometry();\n    this.updateVirtualRangeAndRender();\n  }\n\n  private getHiddenMarkerIndices(): Set<number> {\n    const hidden = new Set<number>();\n\n    // If marker level feature is disabled, no markers are hidden\n    if (!this.markerLevelEnabled) {\n      return hidden;\n    }\n\n    for (let i = 0; i < this.markers.length; i++) {\n      // Skip markers that are already hidden by a parent collapse\n      if (hidden.has(i)) continue;\n\n      const marker = this.markers[i];\n      const level = this.getMarkerLevel(marker.id);\n\n      // If this marker is collapsed, hide all subsequent lower-level markers\n      if (this.collapsedMarkers.has(marker.id)) {\n        for (let j = i + 1; j < this.markers.length; j++) {\n          const nextMarker = this.markers[j];\n          const nextLevel = this.getMarkerLevel(nextMarker.id);\n\n          // Stop when we reach a marker of same or higher level (lower number)\n          if (nextLevel <= level) {\n            break;\n          }\n\n          // Hide this marker (only direct descendants of this collapsed parent)\n          hidden.add(j);\n        }\n      }\n    }\n\n    return hidden;\n  }\n\n  private calculateEffectiveBaseN(markerIndex: number, _hiddenIndices: Set<number>): number {\n    const marker = this.markers[markerIndex];\n    if (!marker) return 0;\n\n    const baseN = marker.baseN ?? marker.n ?? 0;\n\n    // If this marker is not collapsed, just return its baseN\n    if (!this.collapsedMarkers.has(marker.id)) {\n      return baseN;\n    }\n\n    // Find the range of hidden children\n    const level = this.getMarkerLevel(marker.id);\n    let childContribution = 0;\n\n    for (let j = markerIndex + 1; j < this.markers.length; j++) {\n      const nextMarker = this.markers[j];\n      const nextLevel = this.getMarkerLevel(nextMarker.id);\n\n      // Stop when we reach a marker of same or higher level\n      if (nextLevel <= level) {\n        break;\n      }\n\n      // Add half of child's contribution based on level difference\n      const childBaseN = nextMarker.baseN ?? nextMarker.n ?? 0;\n      const prevBaseN = j > 0 ? (this.markers[j - 1].baseN ?? this.markers[j - 1].n ?? 0) : 0;\n      const childLength = childBaseN - prevBaseN;\n      const levelDiff = nextLevel - level;\n      childContribution += childLength * Math.pow(0.5, levelDiff);\n    }\n\n    return baseN + childContribution;\n  }\n\n  private calculateCollapsedPositions(\n    hiddenIndices: Set<number>,\n    pad: number,\n    usableC: number,\n  ): { desiredY: number[]; effectiveBaseNs: number[] } {\n    const N = this.markers.length;\n    const desiredY: number[] = new Array(N).fill(-1);\n    const effectiveBaseNs: number[] = new Array(N).fill(0);\n\n    // First pass: calculate effective baseN for all visible markers\n    const visibleMarkers: { index: number; effectiveN: number }[] = [];\n\n    for (let i = 0; i < N; i++) {\n      if (hiddenIndices.has(i)) continue;\n\n      const effectiveN = this.calculateEffectiveBaseN(i, hiddenIndices);\n      effectiveBaseNs[i] = effectiveN;\n      visibleMarkers.push({ index: i, effectiveN });\n    }\n\n    // Sort visible markers by their effective baseN (maintains relative order based on length)\n    visibleMarkers.sort((a, b) => a.effectiveN - b.effectiveN);\n\n    // Calculate total effective range\n    if (visibleMarkers.length === 0) {\n      return { desiredY, effectiveBaseNs };\n    }\n\n    const minEffectiveN = visibleMarkers[0].effectiveN;\n    const maxEffectiveN = visibleMarkers[visibleMarkers.length - 1].effectiveN;\n    const effectiveRange = maxEffectiveN - minEffectiveN;\n\n    // Distribute positions proportionally\n    for (const vm of visibleMarkers) {\n      let normalizedN: number;\n      if (effectiveRange > 0) {\n        normalizedN = (vm.effectiveN - minEffectiveN) / effectiveRange;\n      } else {\n        normalizedN = visibleMarkers.indexOf(vm) / Math.max(1, visibleMarkers.length - 1);\n      }\n\n      desiredY[vm.index] = pad + normalizedN * usableC;\n    }\n\n    return { desiredY, effectiveBaseNs };\n  }\n\n  /**\n   * Check if a marker can be collapsed (has lower-level children)\n   */\n  private canCollapseMarker(turnId: string): boolean {\n    const markerIndex = this.markers.findIndex((m) => m.id === turnId);\n    if (markerIndex < 0 || markerIndex >= this.markers.length - 1) return false;\n\n    const level = this.getMarkerLevel(turnId);\n\n    const nextMarker = this.markers[markerIndex + 1];\n    if (!nextMarker) return false;\n\n    const nextLevel = this.getMarkerLevel(nextMarker.id);\n    return nextLevel > level;\n  }\n\n  private getMarkerLevel(turnId: string): MarkerLevel {\n    return this.markerLevels.get(turnId) || 1;\n  }\n\n  private setMarkerLevel(turnId: string, level: MarkerLevel): void {\n    if (level === 1) {\n      // Level 1 is default, remove from storage to save space\n      this.markerLevels.delete(turnId);\n    } else {\n      this.markerLevels.set(turnId, level);\n    }\n    this.saveMarkerLevels();\n\n    // Update all dots with this turnId\n    this.markers.forEach((marker) => {\n      if (marker.id === turnId && marker.dotElement) {\n        marker.dotElement.setAttribute('data-level', String(level));\n      }\n    });\n  }\n\n  private showContextMenu(dot: DotElement, x: number, y: number): void {\n    this.hideContextMenu();\n\n    const turnId = dot.dataset.targetTurnId;\n    if (!turnId) return;\n\n    const currentLevel = this.getMarkerLevel(turnId);\n    const isCollapsed = this.isMarkerCollapsed(turnId);\n    const canCollapse = this.canCollapseMarker(turnId);\n\n    const menu = document.createElement('div');\n    menu.className = 'timeline-context-menu';\n\n    const title = document.createElement('div');\n    title.className = 'timeline-context-menu-title';\n    title.textContent = getTranslationSync('timelineLevelTitle');\n    menu.appendChild(title);\n\n    const levels: { level: MarkerLevel; label: string }[] = [\n      { level: 1, label: getTranslationSync('timelineLevel1') },\n      { level: 2, label: getTranslationSync('timelineLevel2') },\n      { level: 3, label: getTranslationSync('timelineLevel3') },\n    ];\n\n    levels.forEach(({ level, label }) => {\n      const item = document.createElement('button');\n      item.className = 'timeline-context-menu-item';\n      if (level === currentLevel) {\n        item.classList.add('active');\n      }\n      item.setAttribute('data-level', String(level));\n\n      const indicator = document.createElement('span');\n      indicator.className = 'level-indicator';\n      const dotEl = document.createElement('span');\n      dotEl.className = 'level-dot';\n      indicator.appendChild(dotEl);\n      item.appendChild(indicator);\n\n      const labelSpan = document.createElement('span');\n      labelSpan.textContent = label;\n      item.appendChild(labelSpan);\n\n      if (level === currentLevel) {\n        const check = document.createElement('span');\n        check.className = 'check-icon';\n        check.textContent = '✓';\n        item.appendChild(check);\n      }\n\n      item.addEventListener('click', (e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        this.setMarkerLevel(turnId, level);\n        this.hideContextMenu();\n      });\n\n      menu.appendChild(item);\n    });\n\n    if (canCollapse || isCollapsed) {\n      // Add separator\n      const separator = document.createElement('div');\n      separator.className = 'timeline-context-menu-separator';\n      menu.appendChild(separator);\n\n      const collapseItem = document.createElement('button');\n      collapseItem.className = 'timeline-context-menu-item collapse-item';\n\n      const icon = document.createElement('span');\n      icon.className = 'collapse-icon';\n      icon.textContent = isCollapsed ? '▶' : '▼';\n      collapseItem.appendChild(icon);\n\n      const collapseLabel = document.createElement('span');\n      collapseLabel.textContent = isCollapsed\n        ? getTranslationSync('timelineExpand')\n        : getTranslationSync('timelineCollapse');\n      collapseItem.appendChild(collapseLabel);\n\n      collapseItem.addEventListener('click', (e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        this.toggleCollapse(turnId);\n        this.hideContextMenu();\n      });\n\n      menu.appendChild(collapseItem);\n    }\n\n    const vw = window.innerWidth;\n    const vh = window.innerHeight;\n    document.body.appendChild(menu);\n    this.contextMenu = menu;\n    const menuWidth = menu.offsetWidth;\n    const menuHeight = menu.offsetHeight;\n\n    let left = x;\n    let top = y;\n\n    if (left + menuWidth > vw - 10) {\n      left = vw - menuWidth - 10;\n    }\n    if (top + menuHeight > vh - 10) {\n      top = vh - menuHeight - 10;\n    }\n\n    menu.style.left = `${left}px`;\n    menu.style.top = `${top}px`;\n\n    document.body.appendChild(menu);\n    this.contextMenu = menu;\n  }\n\n  private hideContextMenu(): void {\n    if (this.contextMenu) {\n      this.contextMenu.remove();\n      this.contextMenu = null;\n    }\n  }\n\n  private cancelLongPress(): void {\n    if (this.longPressTimer) {\n      clearTimeout(this.longPressTimer);\n      this.longPressTimer = null;\n    }\n    if (this.pressTargetDot) {\n      this.pressTargetDot.classList.remove('holding');\n    }\n    this.pressTargetDot = null;\n    this.pressStartPos = null;\n    this.longPressTriggered = false;\n  }\n\n  /**\n   * Initialize keyboard shortcuts for timeline navigation\n   */\n  private async initKeyboardShortcuts(): Promise<void> {\n    try {\n      await keyboardShortcutService.init();\n\n      // Register shortcut handler with queue support\n      this.shortcutUnsubscribe = keyboardShortcutService.on((action, event) => {\n        if (action === 'timeline:previous') {\n          this.enqueueNavigation('previous', event.repeat);\n        } else if (action === 'timeline:next') {\n          this.enqueueNavigation('next', event.repeat);\n        }\n      });\n    } catch (error) {\n      console.warn('[Timeline] Failed to initialize keyboard shortcuts:', error);\n    }\n  }\n\n  /**\n   * Enqueue navigation action (supports rapid key presses)\n   */\n  private enqueueNavigation(direction: 'previous' | 'next', isRepeat: boolean = false): void {\n    // Prevent accumulation during long presses\n    if (isRepeat && this.navigationQueue.length > 0) {\n      return;\n    }\n    // Limit queue size for rapid tapping as well\n    if (this.navigationQueue.length >= 3) {\n      return;\n    }\n\n    if (!this.canEnqueueNavigation(direction)) {\n      return;\n    }\n\n    this.navigationQueue.push(direction);\n    this.processNavigationQueue();\n  }\n\n  private canEnqueueNavigation(direction: 'previous' | 'next'): boolean {\n    if (this.markers.length === 0) return false;\n\n    const currentIndex = this.getActiveIndex();\n    if (currentIndex < 0) return true;\n\n    const isAtStart = currentIndex === 0;\n    const isAtEnd = currentIndex === this.markers.length - 1;\n\n    const isBoundaryBlocked =\n      (direction === 'previous' && isAtStart) || (direction === 'next' && isAtEnd);\n    if (!isBoundaryBlocked) return true;\n\n    return this.shouldAttemptRefreshForNavigation();\n  }\n\n  private shouldAttemptRefreshForNavigation(): boolean {\n    if (!this.userTurnSelector) return false;\n\n    const documentCount = document.querySelectorAll(this.userTurnSelector).length;\n    const containersDisconnected =\n      (this.conversationContainer ? !this.conversationContainer.isConnected : true) ||\n      (this.scrollContainer ? !this.scrollContainer.isConnected : true);\n\n    return containersDisconnected || documentCount > this.markers.length;\n  }\n\n  private getScrollContainerForElement(element: HTMLElement): HTMLElement {\n    let p: HTMLElement | null = element;\n    while (p && p !== document.body) {\n      const st = getComputedStyle(p);\n      if (st.overflowY === 'auto' || st.overflowY === 'scroll') {\n        return p;\n      }\n      p = p.parentElement;\n    }\n\n    return (\n      (document.scrollingElement as HTMLElement | null) ||\n      (document.documentElement as HTMLElement | null) ||\n      (document.body as unknown as HTMLElement)\n    );\n  }\n\n  private shouldRefreshForInteraction(targetElement: HTMLElement | null): boolean {\n    if (this.shouldAttemptRefreshForNavigation()) return true;\n\n    if (targetElement && !targetElement.isConnected) return true;\n\n    if (\n      targetElement &&\n      this.conversationContainer &&\n      !this.conversationContainer.contains(targetElement)\n    ) {\n      return true;\n    }\n\n    if (!targetElement || !this.scrollContainer) return false;\n\n    const expectedScrollContainer = this.getScrollContainerForElement(targetElement);\n    return expectedScrollContainer !== this.scrollContainer;\n  }\n\n  private maybeRefreshMarkersForInteraction(targetElement: HTMLElement | null): boolean {\n    if (!this.userTurnSelector) return false;\n    if (!this.shouldRefreshForInteraction(targetElement)) return false;\n\n    const refreshed = this.refreshCriticalElementsFromDocument();\n    if (!refreshed) return false;\n\n    this.recalculateAndRenderMarkers();\n    return true;\n  }\n\n  /**\n   * Process navigation queue (one at a time)\n   */\n  private async processNavigationQueue(): Promise<void> {\n    if (this.isNavigating || this.navigationQueue.length === 0) return;\n\n    this.isNavigating = true;\n    const direction = this.navigationQueue.shift()!;\n\n    if (direction === 'previous') {\n      await this.navigateToPreviousNode();\n    } else {\n      await this.navigateToNextNode();\n    }\n\n    this.isNavigating = false;\n\n    // Process next item in queue\n    if (this.navigationQueue.length > 0) {\n      this.processNavigationQueue();\n    }\n  }\n\n  /**\n   * Perform navigation to a target node\n   * Shared logic for previous/next navigation\n   */\n  private async performNodeNavigation(targetIndex: number, currentIndex: number): Promise<void> {\n    const markerBeforeRefresh = this.markers[targetIndex];\n    this.maybeRefreshMarkersForInteraction(markerBeforeRefresh?.element || null);\n\n    if (targetIndex < 0 || targetIndex >= this.markers.length) return;\n\n    // Clear any pending scroll updates to prevent interference\n    if (this.activeChangeTimer) {\n      clearTimeout(this.activeChangeTimer);\n      this.activeChangeTimer = null;\n      this.pendingActiveId = null;\n    }\n\n    const targetMarker = this.markers[targetIndex];\n    if (!targetMarker?.element) return;\n\n    if (this.scrollMode === 'flow' && currentIndex >= 0) {\n      // Flow mode: animate with queue support\n      const duration = this.computeFlowDuration(currentIndex, targetIndex);\n      this.startRunner(currentIndex, targetIndex, duration);\n      this.smoothScrollTo(targetMarker.element, duration);\n      await new Promise<void>((resolve) => setTimeout(resolve, duration));\n    } else {\n      // Jump mode: instant, no wait\n      this.smoothScrollTo(targetMarker.element, 0);\n    }\n\n    this.activeTurnId = targetMarker.id;\n    this.updateActiveDotUI();\n  }\n\n  /**\n   * Navigate to previous timeline node (k or custom shortcut)\n   */\n  private async navigateToPreviousNode(): Promise<void> {\n    if (this.markers.length === 0) return;\n\n    this.maybeRefreshMarkersForNavigation('previous');\n    const currentIndex = this.getActiveIndex();\n    const targetIndex = currentIndex <= 0 ? 0 : currentIndex - 1;\n\n    await this.performNodeNavigation(targetIndex, currentIndex);\n  }\n\n  /**\n   * Navigate to next timeline node (j or custom shortcut)\n   */\n  private async navigateToNextNode(): Promise<void> {\n    if (this.markers.length === 0) return;\n\n    this.maybeRefreshMarkersForNavigation('next');\n    const currentIndex = this.getActiveIndex();\n    const targetIndex = currentIndex < 0 ? 0 : Math.min(currentIndex + 1, this.markers.length - 1);\n\n    await this.performNodeNavigation(targetIndex, currentIndex);\n  }\n\n  private maybeRefreshMarkersForNavigation(direction: 'previous' | 'next'): void {\n    if (!this.userTurnSelector) return;\n\n    const currentIndex = this.getActiveIndex();\n    const isAtStart = currentIndex === 0;\n    const isAtEnd = currentIndex >= 0 && currentIndex === this.markers.length - 1;\n\n    const shouldAttemptRefresh =\n      (direction === 'previous' && isAtStart) || (direction === 'next' && isAtEnd);\n    if (!shouldAttemptRefresh) return;\n\n    if (!this.shouldAttemptRefreshForNavigation()) return;\n\n    const refreshed = this.refreshCriticalElementsFromDocument();\n    if (!refreshed) return;\n\n    this.recalculateAndRenderMarkers();\n  }\n\n  private refreshCriticalElementsFromDocument(): boolean {\n    if (!this.userTurnSelector) return false;\n\n    const firstTurn = document.querySelector(this.userTurnSelector) as HTMLElement | null;\n    if (!firstTurn) return false;\n\n    const nextConversationContainer =\n      (document.querySelector('main') as HTMLElement | null) || (document.body as HTMLElement);\n    this.conversationContainer = nextConversationContainer;\n\n    const nextScrollContainer = this.getScrollContainerForElement(firstTurn);\n\n    const scrollContainerChanged = this.scrollContainer !== nextScrollContainer;\n    if (scrollContainerChanged) {\n      if (this.scrollContainer && this.onScroll) {\n        try {\n          this.scrollContainer.removeEventListener('scroll', this.onScroll);\n        } catch {}\n      }\n      this.scrollContainer = nextScrollContainer;\n      if (this.scrollContainer && this.onScroll) {\n        this.scrollContainer.addEventListener('scroll', this.onScroll, { passive: true });\n      }\n    }\n\n    if (this.mutationObserver && this.conversationContainer) {\n      try {\n        this.mutationObserver.disconnect();\n        this.mutationObserver.observe(this.conversationContainer, {\n          childList: true,\n          subtree: true,\n        });\n      } catch {}\n    }\n\n    if (this.intersectionObserver && this.scrollContainer) {\n      try {\n        this.intersectionObserver.disconnect();\n        this.intersectionObserver = new IntersectionObserver(\n          () => {\n            this.scheduleScrollSync();\n          },\n          { root: this.scrollContainer, threshold: 0.1, rootMargin: '-40% 0px -59% 0px' },\n        );\n      } catch {}\n    }\n\n    return true;\n  }\n\n  /**\n   * Handle starred message navigation with optimized performance\n   * Strategy: Quick check if markers ready, otherwise retry with exponential backoff\n   */\n  private handleStarredMessageNavigation(): void {\n    try {\n      const hash = window.location.hash;\n      if (!hash.startsWith('#gv-turn-')) return;\n\n      const turnId = hash.replace('#gv-turn-', '');\n      if (!turnId) return;\n\n      console.log('[Timeline] Handling starred message navigation, turnId:', turnId);\n\n      let attempts = 0;\n      const maxAttempts = 20;\n\n      const checkAndScroll = (): boolean => {\n        if (this.markers.length === 0) return false;\n\n        const marker = this.markerMap.get(turnId);\n        if (marker && marker.element) {\n          console.log('[Timeline] Found target marker, scrolling');\n\n          // Minimal delay for DOM readiness\n          setTimeout(() => {\n            this.smoothScrollTo(marker.element, 800);\n\n            // Clear hash after scroll completes\n            setTimeout(() => {\n              window.history.replaceState(\n                null,\n                '',\n                window.location.pathname + window.location.search,\n              );\n            }, 900);\n          }, 100);\n          return true;\n        }\n        return false;\n      };\n\n      // Optimized retry logic with exponential backoff\n      const retry = () => {\n        if (checkAndScroll()) return;\n\n        attempts++;\n        if (attempts >= maxAttempts) {\n          console.warn('[Timeline] Failed to find starred message');\n          window.history.replaceState(null, '', window.location.pathname + window.location.search);\n          return;\n        }\n\n        // Exponential backoff: 100ms, 200ms, 300ms, 300ms, 300ms...\n        const delay = Math.min(attempts * 100, 300);\n        setTimeout(retry, delay);\n      };\n\n      // Quick first attempt if markers might be ready\n      if (this.markers.length > 0) {\n        if (checkAndScroll()) return;\n      }\n\n      // Start retry sequence with minimal initial delay\n      setTimeout(retry, 200);\n    } catch (error) {\n      console.error('[Timeline] Failed to handle starred message navigation:', error);\n    }\n  }\n\n  destroy(): void {\n    // Cleanup keyboard shortcuts\n    if (this.shortcutUnsubscribe) {\n      try {\n        this.shortcutUnsubscribe();\n        this.shortcutUnsubscribe = null;\n      } catch (error) {\n        console.error('[Timeline] Failed to unsubscribe from keyboard shortcuts:', error);\n      }\n    }\n\n    // Clear navigation queue\n    this.navigationQueue = [];\n    this.isNavigating = false;\n\n    // Cleanup EventBus subscriptions (Observer pattern cleanup)\n    this.eventBusUnsubscribers.forEach((unsubscribe) => {\n      try {\n        unsubscribe();\n      } catch (error) {\n        console.error('[Timeline] Failed to unsubscribe from EventBus:', error);\n      }\n    });\n    this.eventBusUnsubscribers = [];\n\n    // Ensure draggable listeners are removed\n    try {\n      this.toggleDraggable(false);\n    } catch {}\n    // Remove bar pointerdown and cursor listeners (always attached)\n    try {\n      if (this.onBarPointerDown)\n        this.ui.timelineBar?.removeEventListener('pointerdown', this.onBarPointerDown);\n    } catch {}\n    try {\n      if (this.onBarCursorMove)\n        this.ui.timelineBar?.removeEventListener('pointermove', this.onBarCursorMove);\n    } catch {}\n    // Remove any in-flight resize listeners\n    try {\n      if (this.onResizeMove) window.removeEventListener('pointermove', this.onResizeMove);\n    } catch {}\n    try {\n      if (this.onResizeUp) window.removeEventListener('pointerup', this.onResizeUp);\n    } catch {}\n    // Also remove any in-flight drag listeners\n    try {\n      if (this.onBarPointerMove) window.removeEventListener('pointermove', this.onBarPointerMove);\n    } catch {}\n    try {\n      if (this.onBarPointerUp) window.removeEventListener('pointerup', this.onBarPointerUp);\n    } catch {}\n    try {\n      this.mutationObserver?.disconnect();\n    } catch {}\n    try {\n      this.resizeObserver?.disconnect();\n    } catch {}\n    try {\n      this.intersectionObserver?.disconnect();\n    } catch {}\n    this.visibleUserTurns.clear();\n    if (this.ui.timelineBar && this.onTimelineBarClick) {\n      try {\n        this.ui.timelineBar.removeEventListener('click', this.onTimelineBarClick);\n      } catch {}\n    }\n    try {\n      window.removeEventListener('storage', this.onStorage!);\n    } catch {}\n    if (this.onChromeStorageChanged && typeof chrome !== 'undefined' && chrome.storage?.onChanged) {\n      try {\n        chrome.storage.onChanged.removeListener(this.onChromeStorageChanged);\n      } catch {}\n      this.onChromeStorageChanged = null;\n    }\n    // Cleanup context menu\n    this.hideContextMenu();\n    try {\n      this.ui.timelineBar?.removeEventListener('contextmenu', this.onContextMenu!);\n    } catch {}\n    try {\n      document.removeEventListener('click', this.onDocumentClick!);\n    } catch {}\n    try {\n      this.ui.timelineBar?.removeEventListener('pointerdown', this.onPointerDown!);\n    } catch {}\n    try {\n      window.removeEventListener('pointermove', this.onPointerMove!);\n    } catch {}\n    try {\n      window.removeEventListener('pointerup', this.onPointerUp!);\n    } catch {}\n    try {\n      window.removeEventListener('pointercancel', this.onPointerCancel!);\n    } catch {}\n    try {\n      this.ui.timelineBar?.removeEventListener('pointerleave', this.onPointerLeave!);\n    } catch {}\n    if (this.scrollContainer && this.onScroll) {\n      try {\n        this.scrollContainer.removeEventListener('scroll', this.onScroll);\n      } catch {}\n    }\n    if (this.ui.timelineBar) {\n      try {\n        this.ui.timelineBar.removeEventListener('wheel', this.onTimelineWheel!);\n      } catch {}\n      try {\n        this.ui.timelineBar.removeEventListener('pointerenter', this.onBarEnter!);\n      } catch {}\n      try {\n        this.ui.timelineBar.removeEventListener('pointerleave', this.onBarLeave!);\n      } catch {}\n      try {\n        this.ui.slider?.removeEventListener('pointerenter', this.onSliderEnter!);\n      } catch {}\n      try {\n        this.ui.slider?.removeEventListener('pointerleave', this.onSliderLeave!);\n      } catch {}\n    }\n    try {\n      this.ui.sliderHandle?.removeEventListener('pointerdown', this.onSliderDown!);\n    } catch {}\n    try {\n      window.removeEventListener('resize', this.onWindowResize!);\n    } catch {}\n    if (this.onVisualViewportResize && window.visualViewport) {\n      try {\n        window.visualViewport.removeEventListener('resize', this.onVisualViewportResize);\n      } catch {}\n      this.onVisualViewportResize = null;\n    }\n    if (this.scrollRafId !== null) {\n      try {\n        cancelAnimationFrame(this.scrollRafId);\n      } catch {}\n      this.scrollRafId = null;\n    }\n    try {\n      this.ui.timelineBar?.remove();\n    } catch {}\n    try {\n      this.ui.tooltip?.remove();\n    } catch {}\n    try {\n      this.measureEl?.remove();\n    } catch {}\n    try {\n      if (this.ui.slider) {\n        this.ui.slider.style.pointerEvents = 'none';\n        this.ui.slider.remove();\n      }\n      const stray = document.querySelector('.timeline-left-slider');\n      if (stray) {\n        (stray as HTMLElement).style.pointerEvents = 'none';\n        stray.remove();\n      }\n    } catch {}\n    this.ui.slider = null;\n    this.ui.sliderHandle = null;\n    this.clearSearchHighlights();\n    this.previewPanel?.destroy();\n    this.previewPanel = null;\n    document.body.classList.remove(GV_RTL_CLASS);\n    this.ui = { timelineBar: null, tooltip: null };\n    this.markers = [];\n    this.markerTops = [];\n    this.activeTurnId = null;\n    this.scrollContainer = null;\n    this.conversationContainer = null;\n    if (this.activeChangeTimer) {\n      clearTimeout(this.activeChangeTimer);\n      this.activeChangeTimer = null;\n    }\n    if (this.tooltipHideTimer) {\n      clearTimeout(this.tooltipHideTimer);\n      this.tooltipHideTimer = null;\n    }\n    if (this.resizeIdleTimer) {\n      clearTimeout(this.resizeIdleTimer);\n      this.resizeIdleTimer = null;\n    }\n    try {\n      if (this.resizeIdleRICId && 'cancelIdleCallback' in window) {\n        (window as Window & { cancelIdleCallback: (id: number) => void }).cancelIdleCallback(\n          this.resizeIdleRICId,\n        );\n        this.resizeIdleRICId = null;\n      }\n    } catch {}\n    if (this.sliderFadeTimer) {\n      clearTimeout(this.sliderFadeTimer);\n      this.sliderFadeTimer = null;\n    }\n    this.pendingActiveId = null;\n  }\n}\n"
  },
  {
    "path": "src/pages/content/timeline/starredTypes.ts",
    "content": "/**\n * Types for starred message history\n */\n\nexport interface StarredMessage {\n  /** Unique ID of the starred turn */\n  turnId: string;\n  /** Content preview of the message */\n  content: string;\n  /** Conversation ID (computed hash) */\n  conversationId: string;\n  /** Conversation URL */\n  conversationUrl: string;\n  /** Conversation title (optional) */\n  conversationTitle?: string;\n  /** Timestamp when the message was starred */\n  starredAt: number;\n}\n\nexport interface StarredMessagesData {\n  /** Map of conversationId -> array of starred messages */\n  messages: Record<string, StarredMessage[]>;\n}\n"
  },
  {
    "path": "src/pages/content/timeline/types.ts",
    "content": "export type DotElement = HTMLButtonElement & {\n  dataset: DOMStringMap & {\n    targetTurnId?: string;\n    markerIndex?: string;\n  };\n};\n\nexport type MarkerLevel = 1 | 2 | 3;\n\nexport interface PreviewMarkerData {\n  readonly id: string;\n  readonly summary: string;\n  readonly index: number;\n  readonly starred: boolean;\n  /** Timestamp (ms since epoch) when the message was starred; undefined if not starred. */\n  readonly starredAt?: number;\n}\n"
  },
  {
    "path": "src/pages/content/timestamp/TimestampService.ts",
    "content": "/**\n * Service for managing message timestamps\n */\nimport { type IStorageService, StorageFactory } from '@/core/services/StorageService';\nimport type { TurnId } from '@/core/types/common';\nimport { StorageKeys } from '@/core/types/common';\n\ninterface TimestampMap {\n  [turnId: string]: number;\n}\n\nexport class TimestampService {\n  private timestamps: Map<TurnId, number> = new Map();\n  private pendingPersist: {\n    promise: Promise<void>;\n    resolve: () => void;\n    reject: (reason?: unknown) => void;\n  } | null = null;\n\n  constructor(private storageService: IStorageService = StorageFactory.create('local')) {}\n\n  async initialize(): Promise<void> {\n    const result = await this.storageService.get<TimestampMap>(StorageKeys.GV_MESSAGE_TIMESTAMPS);\n    if (result.success && result.data) {\n      Object.entries(result.data).forEach(([turnId, timestamp]) => {\n        this.timestamps.set(turnId as TurnId, timestamp);\n      });\n    }\n  }\n\n  async recordTimestamp(turnId: TurnId, timestamp?: number): Promise<void> {\n    const ts = timestamp ?? Date.now();\n    this.timestamps.set(turnId, ts);\n    await this.schedulePersist();\n  }\n\n  getTimestamp(turnId: TurnId): number | null {\n    return this.timestamps.get(turnId) ?? null;\n  }\n\n  async formatTimestamp(turnId: TurnId): Promise<string> {\n    const timestamp = this.getTimestamp(turnId);\n    if (timestamp == null) return '';\n    return this.formatAbsoluteTime(timestamp);\n  }\n\n  formatAbsoluteTime(timestamp: number): string {\n    const date = new Date(timestamp);\n    const year = date.getFullYear();\n    const month = String(date.getMonth() + 1).padStart(2, '0');\n    const day = String(date.getDate()).padStart(2, '0');\n    const hours = String(date.getHours()).padStart(2, '0');\n    const minutes = String(date.getMinutes()).padStart(2, '0');\n    const seconds = String(date.getSeconds()).padStart(2, '0');\n    return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\n  }\n\n  private async persistTimestamps(): Promise<void> {\n    const obj: TimestampMap = {};\n    this.timestamps.forEach((timestamp, turnId) => {\n      obj[turnId] = timestamp;\n    });\n    await this.storageService.set(StorageKeys.GV_MESSAGE_TIMESTAMPS, obj);\n  }\n\n  private schedulePersist(): Promise<void> {\n    if (this.pendingPersist) {\n      return this.pendingPersist.promise;\n    }\n\n    let resolvePersist!: () => void;\n    let rejectPersist!: (reason?: unknown) => void;\n    const promise = new Promise<void>((resolve, reject) => {\n      resolvePersist = resolve;\n      rejectPersist = reject;\n    });\n    this.pendingPersist = {\n      promise,\n      resolve: resolvePersist,\n      reject: rejectPersist,\n    };\n\n    setTimeout(() => {\n      void this.flushPersist();\n    }, 0);\n\n    return promise;\n  }\n\n  private async flushPersist(): Promise<void> {\n    try {\n      await this.persistTimestamps();\n      this.pendingPersist?.resolve();\n    } catch (error) {\n      this.pendingPersist?.reject(error);\n    } finally {\n      this.pendingPersist = null;\n    }\n  }\n\n  async clearOldTimestamps(_conversationId: string): Promise<void> {\n    // TurnId currently does not encode conversationId. Keeping this as no-op avoids accidental data loss.\n    return;\n  }\n}\n"
  },
  {
    "path": "src/pages/content/timestamp/__tests__/TimestampService.test.ts",
    "content": "import { beforeEach, describe, expect, it } from 'vitest';\n\nimport type { IStorageService } from '@/core/services/StorageService';\nimport type { Result } from '@/core/types/common';\nimport type { TurnId } from '@/core/types/common';\nimport { StorageKeys } from '@/core/types/common';\n\nimport { TimestampService } from '../TimestampService';\n\n// Mock storage service\nclass MockStorageService implements IStorageService {\n  private storage = new Map<string, unknown>();\n\n  async get<T>(key: string): Promise<Result<T>> {\n    const value = this.storage.get(key);\n    if (value === undefined) {\n      return { success: false, error: new Error('Key not found') };\n    }\n    return { success: true, data: value as T };\n  }\n\n  async set<T>(key: string, value: T): Promise<Result<void>> {\n    this.storage.set(key, value);\n    return { success: true, data: undefined };\n  }\n\n  async remove(key: string): Promise<Result<void>> {\n    this.storage.delete(key);\n    return { success: true, data: undefined };\n  }\n\n  async clear(): Promise<Result<void>> {\n    this.storage.clear();\n    return { success: true, data: undefined };\n  }\n}\n\ndescribe('TimestampService', () => {\n  let storageService: MockStorageService;\n  let timestampService: TimestampService;\n\n  beforeEach(() => {\n    storageService = new MockStorageService();\n    timestampService = new TimestampService(storageService);\n  });\n\n  it('should initialize with empty timestamps', async () => {\n    await timestampService.initialize();\n    const timestamp = timestampService.getTimestamp(\n      'test-id' as import('@/core/types/common').TurnId,\n    );\n    expect(timestamp).toBeNull();\n  });\n\n  it('should record and retrieve timestamps', async () => {\n    await timestampService.initialize();\n    const testId = 'test-turn-id' as import('@/core/types/common').TurnId;\n    const testTime = 1672531200000;\n\n    await timestampService.recordTimestamp(testId, testTime);\n    const retrieved = timestampService.getTimestamp(testId);\n\n    expect(retrieved).toBe(testTime);\n  });\n\n  it('should persist timestamps to storage', async () => {\n    await timestampService.initialize();\n    const testId = 'test-turn-id' as import('@/core/types/common').TurnId;\n    const testTime = 1672531200000;\n\n    await timestampService.recordTimestamp(testId, testTime);\n\n    const result = await storageService.get<Record<string, number>>(\n      StorageKeys.GV_MESSAGE_TIMESTAMPS,\n    );\n    expect(result.success).toBe(true);\n    if (result.success) {\n      expect(result.data[testId]).toBe(testTime);\n    }\n  });\n\n  it('should load timestamps from storage on initialize', async () => {\n    const testId = 'test-turn-id' as import('@/core/types/common').TurnId;\n    const testTime = 1672531200000;\n\n    await storageService.set(StorageKeys.GV_MESSAGE_TIMESTAMPS, {\n      [testId]: testTime,\n    });\n\n    await timestampService.initialize();\n    const retrieved = timestampService.getTimestamp(testId);\n\n    expect(retrieved).toBe(testTime);\n  });\n\n  it('should return empty string for non-existent timestamp', async () => {\n    await timestampService.initialize();\n    const testId = 'non-existent' as TurnId;\n\n    const formatted = await timestampService.formatTimestamp(testId);\n    expect(formatted).toBe('');\n  });\n\n  it('should format epoch (0) timestamp as non-empty text', async () => {\n    await timestampService.initialize();\n    const testId = 'epoch-turn-id' as TurnId;\n\n    await timestampService.recordTimestamp(testId, 0);\n    const formatted = await timestampService.formatTimestamp(testId);\n\n    expect(formatted).not.toBe('');\n  });\n});\n"
  },
  {
    "path": "src/pages/content/titleUpdater/index.ts",
    "content": "/**\n * Feature: Auto Update Tab Title\n * Description: Automatically updates the browser tab title to match the current Gemini chat title.\n * Performance: Targeted observer on top-bar-actions + History API interception.\n */\n\nlet lastTitle = '';\nlet lastUrl = '';\nlet observer: MutationObserver | null = null;\n\n/**\n * Starts the title updater service.\n * Uses targeted MutationObserver + History API interception for best performance.\n */\nexport async function startTitleUpdater() {\n  const { gvTabTitleUpdateEnabled } = await chrome.storage.sync.get({\n    gvTabTitleUpdateEnabled: true,\n  });\n\n  if (!gvTabTitleUpdateEnabled) return;\n\n  lastUrl = location.href;\n\n  // Throttled update function (500ms)\n  let throttleTimer: ReturnType<typeof setTimeout> | null = null;\n  const throttledUpdate = () => {\n    if (throttleTimer) return;\n    throttleTimer = setTimeout(() => {\n      throttleTimer = null;\n      tryUpdateTitle();\n    }, 500);\n  };\n\n  // Handle URL changes - reset title cache and re-attach observer\n  const handleUrlChange = () => {\n    if (location.href !== lastUrl) {\n      lastUrl = location.href;\n      lastTitle = '';\n      attachObserver();\n      tryUpdateTitle();\n    }\n  };\n\n  // Smart observer attachment - targets top-bar-actions for minimal scope\n  const attachObserver = () => {\n    if (observer) observer.disconnect();\n\n    // Target the most specific container: top-bar-actions or conversation-title-container\n    const target =\n      document.querySelector('top-bar-actions') ||\n      document.querySelector('.conversation-title-container') ||\n      document.querySelector('.center-section') ||\n      document.querySelector('header');\n\n    if (!target) {\n      // Container not ready yet, watch for it\n      observer = new MutationObserver(() => {\n        if (document.querySelector('top-bar-actions') || document.querySelector('header')) {\n          attachObserver();\n        }\n      });\n      observer.observe(document.body, { childList: true, subtree: true });\n      return;\n    }\n\n    observer = new MutationObserver(throttledUpdate);\n    observer.observe(target, {\n      childList: true,\n      subtree: true,\n      characterData: true,\n    });\n  };\n\n  // Intercept History API for SPA navigation detection\n  const originalPushState = history.pushState.bind(history);\n  const originalReplaceState = history.replaceState.bind(history);\n\n  history.pushState = (...args) => {\n    originalPushState(...args);\n    handleUrlChange();\n  };\n\n  history.replaceState = (...args) => {\n    originalReplaceState(...args);\n    handleUrlChange();\n  };\n\n  // Also listen for browser back/forward\n  window.addEventListener('popstate', handleUrlChange);\n\n  // Initialize\n  attachObserver();\n  tryUpdateTitle();\n}\n\n/**\n * Updates document title based on current chat.\n * Restores default title when not on a conversation page.\n */\nfunction tryUpdateTitle() {\n  const currentTitle = findChatTitle();\n\n  // Restore default title if not on conversation page\n  if (!currentTitle) {\n    if (document.title !== 'Google Gemini') {\n      document.title = 'Google Gemini';\n      lastTitle = '';\n    }\n    return;\n  }\n\n  // Update only if title actually changed\n  if (currentTitle !== lastTitle) {\n    document.title = `${currentTitle} - Gemini`;\n    lastTitle = currentTitle;\n  }\n}\n\n/**\n * Extracts chat title from top bar area only.\n * Returns null if not on a conversation page or title not found.\n */\nfunction findChatTitle(): string | null {\n  // Only run on conversation pages: /app/<id> or /gem/<name>/<id>\n  // Also support multi-user prefix: /u/0/, /u/1/, etc.\n  if (!/^(?:\\/u\\/\\d+)?\\/(?:app|gem\\/[a-zA-Z0-9%\\-_]+)\\/[a-zA-Z0-9%\\-_]+/.test(location.pathname)) {\n    return null;\n  }\n\n  // Target the title using the stable data-test-id attribute, with class-based fallbacks\n  const titleEl = document.querySelector(\n    '.conversation-title-container [data-test-id=\"conversation-title\"], ' +\n      'top-bar-actions [data-test-id=\"conversation-title\"], ' +\n      '.top-bar-actions [data-test-id=\"conversation-title\"], ' +\n      '.conversation-title-container .conversation-title.gds-title-m, ' +\n      'top-bar-actions .conversation-title.gds-title-m',\n  );\n\n  if (titleEl) {\n    const text = titleEl.textContent?.trim();\n    if (text && text !== 'New chat' && text !== 'Gemini' && text !== 'Google Gemini') {\n      return text;\n    }\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "src/pages/content/visualEffects/__tests__/rain.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\ndescribe('rainEffect', () => {\n  beforeEach(() => {\n    vi.resetModules();\n    vi.useFakeTimers();\n    vi.clearAllMocks();\n    document.body.innerHTML = '';\n\n    const mockCtx = {\n      clearRect: vi.fn(),\n      beginPath: vi.fn(),\n      moveTo: vi.fn(),\n      lineTo: vi.fn(),\n      stroke: vi.fn(),\n      ellipse: vi.fn(),\n      fillStyle: '',\n      strokeStyle: '',\n      lineWidth: 1,\n      lineCap: 'butt',\n    };\n\n    vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(\n      mockCtx as unknown as CanvasRenderingContext2D,\n    );\n  });\n\n  afterEach(() => {\n    window.dispatchEvent(new Event('beforeunload'));\n    vi.useRealTimers();\n  });\n\n  it('creates canvas when enabled via gvVisualEffect storage', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'rain' });\n      },\n    );\n\n    const { startRainEffect } = await import('../rain');\n    startRainEffect();\n\n    const canvas = document.getElementById('gv-rain-effect-canvas');\n    expect(canvas).not.toBeNull();\n    expect(canvas?.tagName).toBe('CANVAS');\n    expect(canvas?.style.pointerEvents).toBe('none');\n    expect(canvas?.style.position).toBe('fixed');\n  });\n\n  it('does not create canvas when visual effect is snow', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'snow' });\n      },\n    );\n\n    const { startRainEffect } = await import('../rain');\n    startRainEffect();\n\n    expect(document.getElementById('gv-rain-effect-canvas')).toBeNull();\n  });\n\n  it('begins graceful drain when disabled via storage change (canvas persists)', async () => {\n    let storageListener: ((changes: Record<string, unknown>, area: string) => void) | null = null;\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation((listener: (changes: Record<string, unknown>, area: string) => void) => {\n      storageListener = listener;\n    });\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'rain' });\n      },\n    );\n\n    const { startRainEffect } = await import('../rain');\n    startRainEffect();\n\n    expect(document.getElementById('gv-rain-effect-canvas')).not.toBeNull();\n\n    // Canvas persists during drain\n    storageListener!({ gvVisualEffect: { newValue: 'off', oldValue: 'rain' } }, 'sync');\n\n    expect(document.getElementById('gv-rain-effect-canvas')).not.toBeNull();\n  });\n\n  it('creates canvas when enabled via storage change', async () => {\n    let storageListener: ((changes: Record<string, unknown>, area: string) => void) | null = null;\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation((listener: (changes: Record<string, unknown>, area: string) => void) => {\n      storageListener = listener;\n    });\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'off' });\n      },\n    );\n\n    const { startRainEffect } = await import('../rain');\n    startRainEffect();\n\n    expect(document.getElementById('gv-rain-effect-canvas')).toBeNull();\n\n    storageListener!({ gvVisualEffect: { newValue: 'rain', oldValue: 'off' } }, 'sync');\n\n    expect(document.getElementById('gv-rain-effect-canvas')).not.toBeNull();\n  });\n\n  it('cleans up on beforeunload', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'rain' });\n      },\n    );\n\n    const { startRainEffect } = await import('../rain');\n    startRainEffect();\n\n    expect(document.getElementById('gv-rain-effect-canvas')).not.toBeNull();\n\n    window.dispatchEvent(new Event('beforeunload'));\n\n    expect(document.getElementById('gv-rain-effect-canvas')).toBeNull();\n  });\n\n  it('cleans up on beforeunload even during drain', async () => {\n    let storageListener: ((changes: Record<string, unknown>, area: string) => void) | null = null;\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation((listener: (changes: Record<string, unknown>, area: string) => void) => {\n      storageListener = listener;\n    });\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'rain' });\n      },\n    );\n\n    const { startRainEffect } = await import('../rain');\n    startRainEffect();\n\n    // Enter drain mode\n    storageListener!({ gvVisualEffect: { newValue: 'off', oldValue: 'rain' } }, 'sync');\n    expect(document.getElementById('gv-rain-effect-canvas')).not.toBeNull();\n\n    // Force cleanup via beforeunload\n    window.dispatchEvent(new Event('beforeunload'));\n    expect(document.getElementById('gv-rain-effect-canvas')).toBeNull();\n  });\n\n  it('cancels drain when re-enabled via storage change', async () => {\n    let storageListener: ((changes: Record<string, unknown>, area: string) => void) | null = null;\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation((listener: (changes: Record<string, unknown>, area: string) => void) => {\n      storageListener = listener;\n    });\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'rain' });\n      },\n    );\n\n    const { startRainEffect } = await import('../rain');\n    startRainEffect();\n\n    // Enter drain mode\n    storageListener!({ gvVisualEffect: { newValue: 'off', oldValue: 'rain' } }, 'sync');\n    expect(document.getElementById('gv-rain-effect-canvas')).not.toBeNull();\n\n    // Re-enable — cancels drain, canvas stays\n    storageListener!({ gvVisualEffect: { newValue: 'rain', oldValue: 'off' } }, 'sync');\n    expect(document.getElementById('gv-rain-effect-canvas')).not.toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/visualEffects/__tests__/sakura.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\ndescribe('sakuraEffect', () => {\n  beforeEach(() => {\n    vi.resetModules();\n    vi.useFakeTimers();\n    vi.clearAllMocks();\n    document.body.innerHTML = '';\n\n    const mockCtx = {\n      clearRect: vi.fn(),\n      beginPath: vi.fn(),\n      moveTo: vi.fn(),\n      bezierCurveTo: vi.fn(),\n      quadraticCurveTo: vi.fn(),\n      closePath: vi.fn(),\n      fill: vi.fn(),\n      save: vi.fn(),\n      restore: vi.fn(),\n      translate: vi.fn(),\n      rotate: vi.fn(),\n      scale: vi.fn(),\n      fillStyle: '',\n    };\n\n    vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(\n      mockCtx as unknown as CanvasRenderingContext2D,\n    );\n  });\n\n  afterEach(() => {\n    window.dispatchEvent(new Event('beforeunload'));\n    vi.useRealTimers();\n  });\n\n  it('creates canvas when enabled via gvVisualEffect storage', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'sakura' });\n      },\n    );\n\n    const { startSakuraEffect } = await import('../sakura');\n    startSakuraEffect();\n\n    const canvas = document.getElementById('gv-sakura-effect-canvas');\n    expect(canvas).not.toBeNull();\n    expect(canvas?.tagName).toBe('CANVAS');\n    expect(canvas?.style.pointerEvents).toBe('none');\n    expect(canvas?.style.position).toBe('fixed');\n  });\n\n  it('does not create canvas when visual effect is snow', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'snow' });\n      },\n    );\n\n    const { startSakuraEffect } = await import('../sakura');\n    startSakuraEffect();\n\n    expect(document.getElementById('gv-sakura-effect-canvas')).toBeNull();\n  });\n\n  it('does not create canvas when visual effect is off', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'off' });\n      },\n    );\n\n    const { startSakuraEffect } = await import('../sakura');\n    startSakuraEffect();\n\n    expect(document.getElementById('gv-sakura-effect-canvas')).toBeNull();\n  });\n\n  it('begins graceful drain when disabled via storage change (canvas persists)', async () => {\n    let storageListener: ((changes: Record<string, unknown>, area: string) => void) | null = null;\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation((listener: (changes: Record<string, unknown>, area: string) => void) => {\n      storageListener = listener;\n    });\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'sakura' });\n      },\n    );\n\n    const { startSakuraEffect } = await import('../sakura');\n    startSakuraEffect();\n\n    expect(document.getElementById('gv-sakura-effect-canvas')).not.toBeNull();\n\n    // Canvas persists during drain\n    storageListener!({ gvVisualEffect: { newValue: 'off', oldValue: 'sakura' } }, 'sync');\n\n    expect(document.getElementById('gv-sakura-effect-canvas')).not.toBeNull();\n  });\n\n  it('creates canvas when enabled via storage change', async () => {\n    let storageListener: ((changes: Record<string, unknown>, area: string) => void) | null = null;\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation((listener: (changes: Record<string, unknown>, area: string) => void) => {\n      storageListener = listener;\n    });\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'off' });\n      },\n    );\n\n    const { startSakuraEffect } = await import('../sakura');\n    startSakuraEffect();\n\n    expect(document.getElementById('gv-sakura-effect-canvas')).toBeNull();\n\n    storageListener!({ gvVisualEffect: { newValue: 'sakura', oldValue: 'off' } }, 'sync');\n\n    expect(document.getElementById('gv-sakura-effect-canvas')).not.toBeNull();\n  });\n\n  it('switches from snow to sakura via storage change', async () => {\n    let storageListener: ((changes: Record<string, unknown>, area: string) => void) | null = null;\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation((listener: (changes: Record<string, unknown>, area: string) => void) => {\n      storageListener = listener;\n    });\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'off' });\n      },\n    );\n\n    const { startSakuraEffect } = await import('../sakura');\n    startSakuraEffect();\n\n    // Switch to sakura\n    storageListener!({ gvVisualEffect: { newValue: 'sakura', oldValue: 'snow' } }, 'sync');\n    expect(document.getElementById('gv-sakura-effect-canvas')).not.toBeNull();\n  });\n\n  it('cleans up on beforeunload', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'sakura' });\n      },\n    );\n\n    const { startSakuraEffect } = await import('../sakura');\n    startSakuraEffect();\n\n    expect(document.getElementById('gv-sakura-effect-canvas')).not.toBeNull();\n\n    window.dispatchEvent(new Event('beforeunload'));\n\n    expect(document.getElementById('gv-sakura-effect-canvas')).toBeNull();\n  });\n\n  it('cleans up on beforeunload even during drain', async () => {\n    let storageListener: ((changes: Record<string, unknown>, area: string) => void) | null = null;\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation((listener: (changes: Record<string, unknown>, area: string) => void) => {\n      storageListener = listener;\n    });\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'sakura' });\n      },\n    );\n\n    const { startSakuraEffect } = await import('../sakura');\n    startSakuraEffect();\n\n    // Enter drain mode\n    storageListener!({ gvVisualEffect: { newValue: 'off', oldValue: 'sakura' } }, 'sync');\n    expect(document.getElementById('gv-sakura-effect-canvas')).not.toBeNull();\n\n    // Force cleanup via beforeunload\n    window.dispatchEvent(new Event('beforeunload'));\n    expect(document.getElementById('gv-sakura-effect-canvas')).toBeNull();\n  });\n\n  it('cancels drain when re-enabled via storage change', async () => {\n    let storageListener: ((changes: Record<string, unknown>, area: string) => void) | null = null;\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation((listener: (changes: Record<string, unknown>, area: string) => void) => {\n      storageListener = listener;\n    });\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'sakura' });\n      },\n    );\n\n    const { startSakuraEffect } = await import('../sakura');\n    startSakuraEffect();\n\n    // Enter drain mode\n    storageListener!({ gvVisualEffect: { newValue: 'off', oldValue: 'sakura' } }, 'sync');\n    expect(document.getElementById('gv-sakura-effect-canvas')).not.toBeNull();\n\n    // Re-enable — cancels drain, canvas stays\n    storageListener!({ gvVisualEffect: { newValue: 'sakura', oldValue: 'off' } }, 'sync');\n    expect(document.getElementById('gv-sakura-effect-canvas')).not.toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/visualEffects/__tests__/snow.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\ndescribe('snowEffect', () => {\n  beforeEach(() => {\n    vi.resetModules();\n    vi.useFakeTimers();\n    vi.clearAllMocks();\n    document.body.innerHTML = '';\n\n    // Mock canvas context\n    const mockCtx = {\n      clearRect: vi.fn(),\n      beginPath: vi.fn(),\n      arc: vi.fn(),\n      fill: vi.fn(),\n      fillStyle: '',\n    };\n\n    vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(\n      mockCtx as unknown as CanvasRenderingContext2D,\n    );\n  });\n\n  afterEach(() => {\n    window.dispatchEvent(new Event('beforeunload'));\n    vi.useRealTimers();\n  });\n\n  it('creates canvas when enabled via gvVisualEffect storage', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'snow' });\n      },\n    );\n\n    const { startSnowEffect } = await import('../snow');\n    startSnowEffect();\n\n    const canvas = document.getElementById('gv-snow-effect-canvas');\n    expect(canvas).not.toBeNull();\n    expect(canvas?.tagName).toBe('CANVAS');\n    expect(canvas?.style.pointerEvents).toBe('none');\n    expect(canvas?.style.position).toBe('fixed');\n  });\n\n  it('creates canvas via legacy gvSnowEffect boolean (backward compat)', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvSnowEffect: true });\n      },\n    );\n\n    const { startSnowEffect } = await import('../snow');\n    startSnowEffect();\n\n    const canvas = document.getElementById('gv-snow-effect-canvas');\n    expect(canvas).not.toBeNull();\n  });\n\n  it('does not create canvas when disabled', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'off' });\n      },\n    );\n\n    const { startSnowEffect } = await import('../snow');\n    startSnowEffect();\n\n    const canvas = document.getElementById('gv-snow-effect-canvas');\n    expect(canvas).toBeNull();\n  });\n\n  it('begins graceful drain when disabled via storage change (canvas persists)', async () => {\n    let storageListener: ((changes: Record<string, unknown>, area: string) => void) | null = null;\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation((listener: (changes: Record<string, unknown>, area: string) => void) => {\n      storageListener = listener;\n    });\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'snow' });\n      },\n    );\n\n    const { startSnowEffect } = await import('../snow');\n    startSnowEffect();\n\n    expect(document.getElementById('gv-snow-effect-canvas')).not.toBeNull();\n\n    // Simulate storage change to disable — canvas persists during drain\n    storageListener!({ gvVisualEffect: { newValue: 'off', oldValue: 'snow' } }, 'sync');\n\n    expect(document.getElementById('gv-snow-effect-canvas')).not.toBeNull();\n  });\n\n  it('creates canvas when enabled via storage change', async () => {\n    let storageListener: ((changes: Record<string, unknown>, area: string) => void) | null = null;\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation((listener: (changes: Record<string, unknown>, area: string) => void) => {\n      storageListener = listener;\n    });\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'off' });\n      },\n    );\n\n    const { startSnowEffect } = await import('../snow');\n    startSnowEffect();\n\n    expect(document.getElementById('gv-snow-effect-canvas')).toBeNull();\n\n    // Simulate storage change to enable\n    storageListener!({ gvVisualEffect: { newValue: 'snow', oldValue: 'off' } }, 'sync');\n\n    expect(document.getElementById('gv-snow-effect-canvas')).not.toBeNull();\n  });\n\n  it('cleans up on beforeunload', async () => {\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'snow' });\n      },\n    );\n\n    const { startSnowEffect } = await import('../snow');\n    startSnowEffect();\n\n    expect(document.getElementById('gv-snow-effect-canvas')).not.toBeNull();\n\n    window.dispatchEvent(new Event('beforeunload'));\n\n    expect(document.getElementById('gv-snow-effect-canvas')).toBeNull();\n  });\n\n  it('cleans up on beforeunload even during drain', async () => {\n    let storageListener: ((changes: Record<string, unknown>, area: string) => void) | null = null;\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation((listener: (changes: Record<string, unknown>, area: string) => void) => {\n      storageListener = listener;\n    });\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'snow' });\n      },\n    );\n\n    const { startSnowEffect } = await import('../snow');\n    startSnowEffect();\n\n    // Enter drain mode\n    storageListener!({ gvVisualEffect: { newValue: 'off', oldValue: 'snow' } }, 'sync');\n    expect(document.getElementById('gv-snow-effect-canvas')).not.toBeNull();\n\n    // Force cleanup via beforeunload\n    window.dispatchEvent(new Event('beforeunload'));\n    expect(document.getElementById('gv-snow-effect-canvas')).toBeNull();\n  });\n\n  it('cancels drain when re-enabled via storage change', async () => {\n    let storageListener: ((changes: Record<string, unknown>, area: string) => void) | null = null;\n\n    (\n      chrome.storage.onChanged.addListener as unknown as ReturnType<typeof vi.fn>\n    ).mockImplementation((listener: (changes: Record<string, unknown>, area: string) => void) => {\n      storageListener = listener;\n    });\n\n    (chrome.storage.sync.get as unknown as ReturnType<typeof vi.fn>).mockImplementation(\n      (_defaults: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => {\n        callback({ gvVisualEffect: 'snow' });\n      },\n    );\n\n    const { startSnowEffect } = await import('../snow');\n    startSnowEffect();\n\n    // Enter drain mode\n    storageListener!({ gvVisualEffect: { newValue: 'off', oldValue: 'snow' } }, 'sync');\n    expect(document.getElementById('gv-snow-effect-canvas')).not.toBeNull();\n\n    // Re-enable — cancels drain, canvas stays\n    storageListener!({ gvVisualEffect: { newValue: 'snow', oldValue: 'off' } }, 'sync');\n    expect(document.getElementById('gv-snow-effect-canvas')).not.toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/visualEffects/index.ts",
    "content": "export { startRainEffect } from './rain';\nexport { startSakuraEffect } from './sakura';\nexport { startSnowEffect } from './snow';\n"
  },
  {
    "path": "src/pages/content/visualEffects/rain.ts",
    "content": "/**\n * Rain Effect for Gemini — inspired by \"The Garden of Words\"\n *\n * A cinematic rain with depth layers, wind-angled streaks, and\n * tiny splash ripples where drops hit the viewport floor.\n *\n * Graceful transitions: when switching effects or disabling, existing\n * raindrops continue falling naturally with final splash ripples.\n * New drops stop spawning, and the canvas is cleaned up once all\n * particles and splashes have left the viewport.\n *\n * Visual approach:\n * - Raindrops are thin, semi-transparent lines (not dots), drawn\n *   at a slight wind angle (~8°) for realism.\n * - Three depth layers: far mist, mid rain, near foreground.\n *   Each layer has its own speed, length, opacity, and thickness.\n * - When a drop reaches the bottom, it spawns a short-lived splash\n *   ring that expands and fades out.\n * - Colour: cool blue-grey (hsl 210–220) to evoke the melancholy\n *   atmosphere of Shinjuku Gyoen in the rain.\n */\n\nconst CANVAS_ID = 'gv-rain-effect-canvas';\nconst STORAGE_KEY = 'gvVisualEffect';\nconst LEGACY_KEY = 'gvSnowEffect';\nconst EFFECT_VALUE = 'rain';\n\n/** Wind angle in radians (~8° from vertical) */\nconst WIND_ANGLE = 0.14;\nconst WIND_DX = Math.sin(WIND_ANGLE);\nconst WIND_DY = Math.cos(WIND_ANGLE);\n\nconst LAYERS = [\n  // far — misty fine drizzle\n  {\n    count: 80,\n    length: [6, 14],\n    speed: [3, 6],\n    opacity: [0.06, 0.14],\n    width: [0.3, 0.6],\n  },\n  // mid — main rain\n  {\n    count: 60,\n    length: [14, 28],\n    speed: [7, 13],\n    opacity: [0.12, 0.25],\n    width: [0.5, 1.0],\n  },\n  // near — heavy foreground streaks\n  {\n    count: 30,\n    length: [26, 48],\n    speed: [12, 20],\n    opacity: [0.2, 0.38],\n    width: [0.8, 1.5],\n  },\n] as const;\n\n/** Maximum concurrent splashes */\nconst MAX_SPLASHES = 24;\n\ninterface Raindrop {\n  x: number;\n  y: number;\n  length: number;\n  speed: number;\n  opacity: number;\n  lineWidth: number;\n  /** Whether this drop can spawn a splash (only near/mid layers) */\n  canSplash: boolean;\n}\n\ninterface Splash {\n  x: number;\n  y: number;\n  radius: number;\n  maxRadius: number;\n  opacity: number;\n  fadeSpeed: number;\n  expandSpeed: number;\n}\n\n/** Effect lifecycle: off → active ⇄ draining → off */\nlet state: 'off' | 'active' | 'draining' = 'off';\nlet canvas: HTMLCanvasElement | null = null;\nlet ctx: CanvasRenderingContext2D | null = null;\nlet animationFrameId: number | null = null;\nlet drops: Raindrop[] = [];\nlet splashes: Splash[] = [];\nlet resizeHandler: (() => void) | null = null;\nlet visibilityHandler: (() => void) | null = null;\n\nfunction rand(min: number, max: number): number {\n  return min + Math.random() * (max - min);\n}\n\nfunction createDrop(\n  canvasWidth: number,\n  canvasHeight: number,\n  layer: (typeof LAYERS)[number],\n  randomY: boolean,\n  canSplash: boolean,\n): Raindrop {\n  return {\n    x: Math.random() * (canvasWidth + 100) - 50,\n    y: randomY ? Math.random() * canvasHeight : -(Math.random() * canvasHeight * 0.3),\n    length: rand(layer.length[0], layer.length[1]),\n    speed: rand(layer.speed[0], layer.speed[1]),\n    opacity: rand(layer.opacity[0], layer.opacity[1]),\n    lineWidth: rand(layer.width[0], layer.width[1]),\n    canSplash,\n  };\n}\n\nfunction initDrops(width: number, height: number): void {\n  const items: Raindrop[] = [];\n  for (let li = 0; li < LAYERS.length; li++) {\n    const layer = LAYERS[li];\n    const canSplash = li >= 1; // mid + near\n    for (let i = 0; i < layer.count; i++) {\n      items.push(createDrop(width, height, layer, true, canSplash));\n    }\n  }\n  drops = items;\n  splashes = [];\n}\n\nfunction spawnSplash(x: number, y: number): void {\n  if (splashes.length >= MAX_SPLASHES) return;\n  splashes.push({\n    x,\n    y,\n    radius: 0.5,\n    maxRadius: rand(3, 8),\n    opacity: rand(0.15, 0.3),\n    fadeSpeed: rand(0.004, 0.01),\n    expandSpeed: rand(0.15, 0.4),\n  });\n}\n\nfunction updateAndDraw(_time: number): void {\n  if (!ctx || !canvas) return;\n\n  const { width, height } = canvas;\n  ctx.clearRect(0, 0, width, height);\n\n  // --- Draw rain streaks ---\n  ctx.lineCap = 'round';\n\n  let currentOpacity = -1;\n  let currentWidth = -1;\n  let visibleDropCount = 0;\n\n  for (const d of drops) {\n    const prevY = d.y;\n\n    // Move\n    d.x += d.speed * WIND_DX;\n    d.y += d.speed * WIND_DY;\n\n    // Off-screen bottom\n    if (d.y > height + d.length) {\n      if (state === 'draining') {\n        // Spawn one final splash as the drop exits (only on first crossing)\n        if (prevY <= height + d.length && d.canSplash && Math.random() < 0.35) {\n          spawnSplash(d.x, height - 1);\n        }\n        continue;\n      }\n      // Normal: recycle + splash\n      if (d.canSplash && Math.random() < 0.35) {\n        spawnSplash(d.x, height - 1);\n      }\n      d.y = -(d.length + Math.random() * height * 0.2);\n      d.x = Math.random() * (width + 100) - 50;\n    }\n\n    // Off-screen right (wind pushes drops rightward)\n    if (d.x > width + 50) {\n      if (state === 'draining') {\n        continue;\n      }\n      d.x = -50;\n    }\n\n    visibleDropCount++;\n\n    // Batch strokeStyle\n    const qo = Math.round(d.opacity * 30) / 30;\n    if (qo !== currentOpacity) {\n      currentOpacity = qo;\n      ctx.strokeStyle = `rgba(180,200,220,${currentOpacity})`;\n    }\n    const qw = Math.round(d.lineWidth * 4) / 4;\n    if (qw !== currentWidth) {\n      currentWidth = qw;\n      ctx.lineWidth = currentWidth;\n    }\n\n    ctx.beginPath();\n    ctx.moveTo(d.x, d.y);\n    ctx.lineTo(d.x + d.length * WIND_DX, d.y + d.length * WIND_DY);\n    ctx.stroke();\n  }\n\n  // --- Draw splashes ---\n  ctx.lineWidth = 0.6;\n  for (let i = splashes.length - 1; i >= 0; i--) {\n    const s = splashes[i];\n    s.radius += s.expandSpeed;\n    s.opacity -= s.fadeSpeed;\n\n    if (s.opacity <= 0 || s.radius >= s.maxRadius) {\n      splashes.splice(i, 1);\n      continue;\n    }\n\n    ctx.strokeStyle = `rgba(180,200,220,${s.opacity})`;\n    ctx.beginPath();\n    ctx.ellipse(s.x, s.y, s.radius, s.radius * 0.35, 0, 0, Math.PI * 2);\n    ctx.stroke();\n  }\n\n  // All drops off-screen and all splashes faded — finish draining\n  if (state === 'draining' && visibleDropCount === 0 && splashes.length === 0) {\n    finalizeDrain();\n    return;\n  }\n\n  animationFrameId = requestAnimationFrame(updateAndDraw);\n}\n\nfunction resizeCanvas(): void {\n  if (!canvas) return;\n  canvas.width = window.innerWidth;\n  canvas.height = window.innerHeight;\n}\n\nfunction startAnimation(): void {\n  if (animationFrameId !== null) return;\n  animationFrameId = requestAnimationFrame(updateAndDraw);\n}\n\nfunction stopAnimation(): void {\n  if (animationFrameId !== null) {\n    cancelAnimationFrame(animationFrameId);\n    animationFrameId = null;\n  }\n}\n\nfunction handleVisibilityChange(): void {\n  if (document.visibilityState === 'visible') {\n    startAnimation();\n  } else {\n    stopAnimation();\n  }\n}\n\nfunction enable(): void {\n  if (state === 'active') return;\n  if (state === 'draining') {\n    // Cancel drain — resume normal drop recycling\n    state = 'active';\n    return;\n  }\n  state = 'active';\n\n  canvas = document.createElement('canvas');\n  canvas.id = CANVAS_ID;\n  canvas.style.cssText =\n    'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:2147483647;';\n  document.documentElement.appendChild(canvas);\n\n  ctx = canvas.getContext('2d');\n  if (!ctx) {\n    forceDisable();\n    return;\n  }\n\n  resizeCanvas();\n  initDrops(canvas.width, canvas.height);\n  startAnimation();\n\n  resizeHandler = resizeCanvas;\n  window.addEventListener('resize', resizeHandler);\n\n  visibilityHandler = handleVisibilityChange;\n  document.addEventListener('visibilitychange', visibilityHandler);\n}\n\n/**\n * Graceful disable: stop spawning new drops and let existing ones\n * fall off with final splash ripples.\n */\nfunction disable(): void {\n  if (state !== 'active') return;\n  state = 'draining';\n}\n\n/** Complete the drain: remove canvas and clean up all resources. */\nfunction finalizeDrain(): void {\n  state = 'off';\n  stopAnimation();\n\n  if (resizeHandler) {\n    window.removeEventListener('resize', resizeHandler);\n    resizeHandler = null;\n  }\n\n  if (visibilityHandler) {\n    document.removeEventListener('visibilitychange', visibilityHandler);\n    visibilityHandler = null;\n  }\n\n  if (canvas) {\n    canvas.remove();\n    canvas = null;\n  }\n\n  ctx = null;\n  drops = [];\n  splashes = [];\n}\n\n/** Immediate disable: remove everything without draining (e.g. page unload). */\nfunction forceDisable(): void {\n  if (state === 'off') return;\n  finalizeDrain();\n}\n\nfunction resolveEffect(res: Record<string, unknown>): string {\n  if (typeof res[STORAGE_KEY] === 'string') return res[STORAGE_KEY] as string;\n  if (res[LEGACY_KEY] === true) return 'snow';\n  return 'off';\n}\n\nexport function startRainEffect(): void {\n  try {\n    chrome.storage?.sync?.get({ [STORAGE_KEY]: null, [LEGACY_KEY]: false }, (res) => {\n      if (resolveEffect(res) === EFFECT_VALUE) {\n        enable();\n      }\n    });\n  } catch (e) {\n    console.error('[Gemini Voyager] Failed to get rain effect setting:', e);\n  }\n\n  try {\n    chrome.storage?.onChanged?.addListener((changes, area) => {\n      if (area === 'sync' && changes[STORAGE_KEY]) {\n        if (changes[STORAGE_KEY].newValue === EFFECT_VALUE) {\n          enable();\n        } else {\n          disable();\n        }\n      }\n    });\n  } catch (e) {\n    console.error('[Gemini Voyager] Failed to add storage listener for rain effect:', e);\n  }\n\n  window.addEventListener('beforeunload', () => {\n    forceDisable();\n  });\n}\n"
  },
  {
    "path": "src/pages/content/visualEffects/sakura.ts",
    "content": "/**\n * Sakura (Cherry Blossom) Effect for Gemini\n *\n * Renders a fullscreen canvas with gently falling sakura petals.\n * Uses `pointer-events: none` so it never blocks page interactions.\n * Pauses when the tab is hidden to save CPU.\n *\n * Graceful transitions: when switching effects or disabling, existing\n * petals continue falling naturally instead of vanishing instantly.\n * New petals stop spawning, and the canvas is cleaned up once all\n * particles have left the viewport.\n *\n * Visual approach:\n * - Petal shape: wide, rounded heart-like silhouette with a small\n *   V-notch — drawn via quadratic bezier curves. Width ≈ height\n *   so it reads as a petal, not a leaf.\n * - 3D flutter: gentle oscillating scaleX (never fully flips) to\n *   simulate a petal wobbling in the air, not aggressively tumbling.\n * - Colour: very pale pink, almost white — the hallmark of somei\n *   yoshino cherry blossoms.\n * - Motion: slow fall, wide lazy drift + tiny fast flutter. Petals\n *   feel like they're floating, not dropping.\n */\n\nconst CANVAS_ID = 'gv-sakura-effect-canvas';\nconst STORAGE_KEY = 'gvVisualEffect';\nconst LEGACY_KEY = 'gvSnowEffect';\nconst EFFECT_VALUE = 'sakura';\n\nconst LAYERS = [\n  // far — tiny, slow, ghostly\n  { count: 40, size: [2.5, 4.5], speed: [0.1, 0.3], opacity: [0.1, 0.25], drift: [0.15, 0.4] },\n  // mid — main visible petals\n  { count: 32, size: [4.5, 7.5], speed: [0.25, 0.55], opacity: [0.25, 0.5], drift: [0.35, 0.8] },\n  // near — large, soft foreground\n  { count: 16, size: [7.5, 11], speed: [0.4, 0.75], opacity: [0.4, 0.65], drift: [0.5, 1.0] },\n] as const;\n\n/**\n * Somei-yoshino palette: extremely pale pinks, almost white.\n * Pre-built fill prefixes — append opacity + `)`.\n */\nconst PALETTE = [\n  'hsla(350,50%,94%,', // near-white blush\n  'hsla(348,55%,92%,', // faint pink\n  'hsla(345,60%,90%,', // soft petal\n  'hsla(340,50%,92%,', // warm white-pink\n  'hsla(346,65%,88%,', // gentle sakura\n  'hsla(342,45%,93%,', // whisper pink\n  'hsla(352,40%,95%,', // almost white\n  'hsla(338,55%,89%,', // subtle rose\n] as const;\n\ninterface Petal {\n  x: number;\n  y: number;\n  /** Overall size scale of this petal */\n  size: number;\n  opacity: number;\n  speedY: number;\n\n  // primary sway — slow, wide\n  drift: number;\n  driftFreq: number;\n  phase: number;\n\n  // secondary flutter — fast, tiny\n  flutter: number;\n  flutterFreq: number;\n\n  // 2D spin\n  rotation: number;\n  rotationSpeed: number;\n\n  // 3D wobble — gentle scaleX oscillation (never fully flips)\n  wobblePhase: number;\n  wobbleSpeed: number;\n  /** Baseline scaleX (0.6–1.0); wobble oscillates around this */\n  wobbleBase: number;\n  /** Amplitude of scaleX wobble (0.15–0.35) */\n  wobbleAmp: number;\n\n  colorIdx: number;\n}\n\n/** Effect lifecycle: off → active ⇄ draining → off */\nlet state: 'off' | 'active' | 'draining' = 'off';\nlet canvas: HTMLCanvasElement | null = null;\nlet ctx: CanvasRenderingContext2D | null = null;\nlet animationFrameId: number | null = null;\nlet petals: Petal[] = [];\nlet resizeHandler: (() => void) | null = null;\nlet visibilityHandler: (() => void) | null = null;\n\nfunction rand(min: number, max: number): number {\n  return min + Math.random() * (max - min);\n}\n\nfunction createPetal(\n  canvasWidth: number,\n  canvasHeight: number,\n  layer: (typeof LAYERS)[number],\n  randomY: boolean,\n): Petal {\n  return {\n    x: Math.random() * canvasWidth,\n    y: randomY ? Math.random() * canvasHeight : -(Math.random() * canvasHeight * 0.4),\n    size: rand(layer.size[0], layer.size[1]),\n    opacity: rand(layer.opacity[0], layer.opacity[1]),\n    speedY: rand(layer.speed[0], layer.speed[1]),\n\n    drift: rand(layer.drift[0], layer.drift[1]),\n    driftFreq: rand(0.0003, 0.0009),\n    phase: Math.random() * Math.PI * 2,\n\n    flutter: rand(0.04, 0.15),\n    flutterFreq: rand(0.002, 0.006),\n\n    rotation: Math.random() * Math.PI * 2,\n    rotationSpeed: rand(0.001, 0.008) * (Math.random() > 0.5 ? 1 : -1),\n\n    wobblePhase: Math.random() * Math.PI * 2,\n    wobbleSpeed: rand(0.0006, 0.002),\n    wobbleBase: rand(0.6, 0.9),\n    wobbleAmp: rand(0.15, 0.35),\n\n    colorIdx: Math.floor(Math.random() * PALETTE.length),\n  };\n}\n\nfunction initPetals(width: number, height: number): void {\n  const items: Petal[] = [];\n  for (const layer of LAYERS) {\n    for (let i = 0; i < layer.count; i++) {\n      items.push(createPetal(width, height, layer, true));\n    }\n  }\n  items.sort((a, b) => a.colorIdx - b.colorIdx || a.opacity - b.opacity);\n  petals = items;\n}\n\n/**\n * Draw a sakura petal centred at the origin.\n *\n * Shape: wide, rounded, heart-like with a small notch at the top.\n * Width ≈ 85% of height — reads as a petal, not a leaf.\n *\n *        ╱ ‿ ╲        ← notch\n *      ╱       ╲\n *     (         )      ← round, fat body\n *      ╲       ╱\n *        ╲   ╱\n *          V           ← stem point\n */\nfunction tracePetal(c: CanvasRenderingContext2D, s: number): void {\n  // s = half-height; width is deliberately close to height\n  const w = s * 0.85;\n\n  c.beginPath();\n\n  // Bottom stem point\n  c.moveTo(0, s);\n\n  // Right side — sweeps up and out in a fat curve\n  c.quadraticCurveTo(w * 1.1, s * 0.15, w * 0.2, -s * 0.85);\n\n  // Top-right → notch centre\n  c.quadraticCurveTo(w * 0.05, -s * 0.55, 0, -s * 0.65);\n\n  // Notch centre → top-left\n  c.quadraticCurveTo(-w * 0.05, -s * 0.55, -w * 0.2, -s * 0.85);\n\n  // Left side — mirror sweep back to stem\n  c.quadraticCurveTo(-w * 1.1, s * 0.15, 0, s);\n\n  c.closePath();\n}\n\nfunction updateAndDraw(time: number): void {\n  if (!ctx || !canvas) return;\n\n  const { width, height } = canvas;\n  ctx.clearRect(0, 0, width, height);\n\n  let currentFill = '';\n  let visibleCount = 0;\n\n  for (const p of petals) {\n    // Gentle fall + dual-frequency sway\n    p.y += p.speedY;\n    p.x +=\n      Math.sin(p.phase + time * p.driftFreq) * p.drift +\n      Math.sin(p.phase * 2.7 + time * p.flutterFreq) * p.flutter;\n    p.rotation += p.rotationSpeed;\n\n    // Recycle off-screen (or skip during drain)\n    if (p.y > height + p.size * 2) {\n      if (state === 'draining') {\n        continue;\n      }\n      p.y = -p.size * 2;\n      p.x = Math.random() * width;\n    }\n\n    visibleCount++;\n\n    if (p.x > width + p.size * 2) {\n      p.x = -p.size * 2;\n    } else if (p.x < -p.size * 2) {\n      p.x = width + p.size * 2;\n    }\n\n    // Fill batching\n    const qOpacity = Math.round(p.opacity * 20) / 20;\n    const nextFill = PALETTE[p.colorIdx] + qOpacity + ')';\n    if (nextFill !== currentFill) {\n      currentFill = nextFill;\n      ctx.fillStyle = currentFill;\n    }\n\n    // 3D wobble — gentle scaleX oscillation, always positive (no full flip)\n    const wobble = p.wobbleBase + Math.sin(p.wobblePhase + time * p.wobbleSpeed) * p.wobbleAmp;\n\n    ctx.save();\n    ctx.translate(p.x, p.y);\n    ctx.rotate(p.rotation);\n    ctx.scale(wobble, 1);\n\n    tracePetal(ctx, p.size);\n    ctx.fill();\n\n    ctx.restore();\n  }\n\n  // All petals have left the viewport — finish draining\n  if (state === 'draining' && visibleCount === 0) {\n    finalizeDrain();\n    return;\n  }\n\n  animationFrameId = requestAnimationFrame(updateAndDraw);\n}\n\nfunction resizeCanvas(): void {\n  if (!canvas) return;\n  canvas.width = window.innerWidth;\n  canvas.height = window.innerHeight;\n}\n\nfunction startAnimation(): void {\n  if (animationFrameId !== null) return;\n  animationFrameId = requestAnimationFrame(updateAndDraw);\n}\n\nfunction stopAnimation(): void {\n  if (animationFrameId !== null) {\n    cancelAnimationFrame(animationFrameId);\n    animationFrameId = null;\n  }\n}\n\nfunction handleVisibilityChange(): void {\n  if (document.visibilityState === 'visible') {\n    startAnimation();\n  } else {\n    stopAnimation();\n  }\n}\n\nfunction enable(): void {\n  if (state === 'active') return;\n  if (state === 'draining') {\n    // Cancel drain — resume normal particle recycling\n    state = 'active';\n    return;\n  }\n  state = 'active';\n\n  canvas = document.createElement('canvas');\n  canvas.id = CANVAS_ID;\n  canvas.style.cssText =\n    'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:2147483647;';\n  document.documentElement.appendChild(canvas);\n\n  ctx = canvas.getContext('2d');\n  if (!ctx) {\n    forceDisable();\n    return;\n  }\n\n  resizeCanvas();\n  initPetals(canvas.width, canvas.height);\n  startAnimation();\n\n  resizeHandler = resizeCanvas;\n  window.addEventListener('resize', resizeHandler);\n\n  visibilityHandler = handleVisibilityChange;\n  document.addEventListener('visibilitychange', visibilityHandler);\n}\n\n/**\n * Graceful disable: stop spawning new petals and let existing ones\n * fall off the bottom of the viewport naturally.\n */\nfunction disable(): void {\n  if (state !== 'active') return;\n  state = 'draining';\n}\n\n/** Complete the drain: remove canvas and clean up all resources. */\nfunction finalizeDrain(): void {\n  state = 'off';\n  stopAnimation();\n\n  if (resizeHandler) {\n    window.removeEventListener('resize', resizeHandler);\n    resizeHandler = null;\n  }\n\n  if (visibilityHandler) {\n    document.removeEventListener('visibilitychange', visibilityHandler);\n    visibilityHandler = null;\n  }\n\n  if (canvas) {\n    canvas.remove();\n    canvas = null;\n  }\n\n  ctx = null;\n  petals = [];\n}\n\n/** Immediate disable: remove everything without draining (e.g. page unload). */\nfunction forceDisable(): void {\n  if (state === 'off') return;\n  finalizeDrain();\n}\n\nfunction resolveEffect(res: Record<string, unknown>): string {\n  if (typeof res[STORAGE_KEY] === 'string') return res[STORAGE_KEY] as string;\n  if (res[LEGACY_KEY] === true) return 'snow';\n  return 'off';\n}\n\nexport function startSakuraEffect(): void {\n  try {\n    chrome.storage?.sync?.get({ [STORAGE_KEY]: null, [LEGACY_KEY]: false }, (res) => {\n      if (resolveEffect(res) === EFFECT_VALUE) {\n        enable();\n      }\n    });\n  } catch (e) {\n    console.error('[Gemini Voyager] Failed to get sakura effect setting:', e);\n  }\n\n  try {\n    chrome.storage?.onChanged?.addListener((changes, area) => {\n      if (area === 'sync' && changes[STORAGE_KEY]) {\n        if (changes[STORAGE_KEY].newValue === EFFECT_VALUE) {\n          enable();\n        } else {\n          disable();\n        }\n      }\n    });\n  } catch (e) {\n    console.error('[Gemini Voyager] Failed to add storage listener for sakura effect:', e);\n  }\n\n  window.addEventListener('beforeunload', () => {\n    forceDisable();\n  });\n}\n"
  },
  {
    "path": "src/pages/content/visualEffects/snow.ts",
    "content": "/**\n * Snow Effect for Gemini\n *\n * When enabled, renders a fullscreen canvas snow animation.\n * Uses `pointer-events: none` so it never blocks page interactions.\n * Pauses when the tab is hidden to save CPU.\n *\n * Graceful transitions: when switching effects or disabling, existing\n * snowflakes continue falling naturally instead of vanishing instantly.\n * New snowflakes stop spawning, and the canvas is cleaned up once all\n * particles have left the viewport.\n *\n * Performance notes:\n * - Single canvas with simple arc draws (no images, no shadows)\n * - Snowflakes sorted by opacity at init; drawn in batches to minimize fillStyle switches\n * - Animation pauses on hidden tabs via visibilitychange\n * - ~160 particles total — negligible GPU/CPU overhead\n */\n\nconst CANVAS_ID = 'gv-snow-effect-canvas';\nconst STORAGE_KEY = 'gvVisualEffect';\nconst LEGACY_KEY = 'gvSnowEffect';\nconst EFFECT_VALUE = 'snow';\n\n/**\n * Three layers simulate depth-of-field:\n *   dust  – tiny background particles, slow, faint\n *   mid   – main visible snowflakes\n *   large – sparse foreground flakes, faster, more opaque\n */\nconst LAYERS = [\n  // dust\n  {\n    count: 100,\n    radius: [0.15, 0.45],\n    speed: [0.15, 0.4],\n    opacity: [0.15, 0.35],\n    drift: [0.05, 0.2],\n  },\n  // mid\n  { count: 80, radius: [0.5, 1.0], speed: [0.4, 1.0], opacity: [0.3, 0.6], drift: [0.15, 0.45] },\n  // large\n  { count: 60, radius: [1.2, 2.5], speed: [0.8, 1.6], opacity: [0.5, 0.8], drift: [0.25, 0.6] },\n] as const;\n\ninterface Snowflake {\n  x: number;\n  y: number;\n  radius: number;\n  opacity: number;\n  speedY: number;\n  drift: number;\n  /** Individual oscillation frequency so flakes don't sway in unison */\n  driftFreq: number;\n  phase: number;\n}\n\n/** Effect lifecycle: off → active ⇄ draining → off */\nlet state: 'off' | 'active' | 'draining' = 'off';\nlet canvas: HTMLCanvasElement | null = null;\nlet ctx: CanvasRenderingContext2D | null = null;\nlet animationFrameId: number | null = null;\nlet snowflakes: Snowflake[] = [];\nlet resizeHandler: (() => void) | null = null;\nlet visibilityHandler: (() => void) | null = null;\n\n/** Random float in [min, max) */\nfunction rand(min: number, max: number): number {\n  return min + Math.random() * (max - min);\n}\n\nfunction createSnowflake(\n  canvasWidth: number,\n  canvasHeight: number,\n  layer: (typeof LAYERS)[number],\n  randomY: boolean,\n): Snowflake {\n  return {\n    x: Math.random() * canvasWidth,\n    y: randomY ? Math.random() * canvasHeight : -(Math.random() * canvasHeight),\n    radius: rand(layer.radius[0], layer.radius[1]),\n    opacity: rand(layer.opacity[0], layer.opacity[1]),\n    speedY: rand(layer.speed[0], layer.speed[1]),\n    drift: rand(layer.drift[0], layer.drift[1]),\n    driftFreq: rand(0.0003, 0.0012),\n    phase: Math.random() * Math.PI * 2,\n  };\n}\n\nfunction initSnowflakes(width: number, height: number): void {\n  const flakes: Snowflake[] = [];\n  for (const layer of LAYERS) {\n    for (let i = 0; i < layer.count; i++) {\n      flakes.push(createSnowflake(width, height, layer, true));\n    }\n  }\n  // Sort by opacity so we can batch fillStyle changes during draw\n  flakes.sort((a, b) => a.opacity - b.opacity);\n  snowflakes = flakes;\n}\n\nfunction updateAndDraw(time: number): void {\n  if (!ctx || !canvas) return;\n\n  const { width, height } = canvas;\n  ctx.clearRect(0, 0, width, height);\n\n  let currentOpacity = -1;\n  let visibleCount = 0;\n\n  for (const flake of snowflakes) {\n    flake.y += flake.speedY;\n    flake.x += Math.sin(flake.phase + time * flake.driftFreq) * flake.drift;\n\n    // Recycle when off-screen bottom (or skip during drain)\n    if (flake.y > height + flake.radius) {\n      if (state === 'draining') {\n        continue;\n      }\n      flake.y = -flake.radius;\n      flake.x = Math.random() * width;\n    }\n\n    visibleCount++;\n\n    // Wrap horizontal\n    if (flake.x > width + flake.radius) {\n      flake.x = -flake.radius;\n    } else if (flake.x < -flake.radius) {\n      flake.x = width + flake.radius;\n    }\n\n    // Batch fillStyle: only update when opacity changes (quantised to 2 decimals)\n    const quantised = Math.round(flake.opacity * 50) / 50;\n    if (quantised !== currentOpacity) {\n      currentOpacity = quantised;\n      ctx.fillStyle = `rgba(255,255,255,${currentOpacity})`;\n    }\n\n    ctx.beginPath();\n    ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2);\n    ctx.fill();\n  }\n\n  // All particles have left the viewport — finish draining\n  if (state === 'draining' && visibleCount === 0) {\n    finalizeDrain();\n    return;\n  }\n\n  animationFrameId = requestAnimationFrame(updateAndDraw);\n}\n\nfunction resizeCanvas(): void {\n  if (!canvas) return;\n  canvas.width = window.innerWidth;\n  canvas.height = window.innerHeight;\n}\n\nfunction startAnimation(): void {\n  if (animationFrameId !== null) return;\n  animationFrameId = requestAnimationFrame(updateAndDraw);\n}\n\nfunction stopAnimation(): void {\n  if (animationFrameId !== null) {\n    cancelAnimationFrame(animationFrameId);\n    animationFrameId = null;\n  }\n}\n\nfunction handleVisibilityChange(): void {\n  if (document.visibilityState === 'visible') {\n    startAnimation();\n  } else {\n    stopAnimation();\n  }\n}\n\nfunction enable(): void {\n  if (state === 'active') return;\n  if (state === 'draining') {\n    // Cancel drain — resume normal particle recycling\n    state = 'active';\n    return;\n  }\n  state = 'active';\n\n  canvas = document.createElement('canvas');\n  canvas.id = CANVAS_ID;\n  canvas.style.cssText =\n    'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:2147483647;';\n  document.documentElement.appendChild(canvas);\n\n  ctx = canvas.getContext('2d');\n  if (!ctx) {\n    forceDisable();\n    return;\n  }\n\n  resizeCanvas();\n  initSnowflakes(canvas.width, canvas.height);\n  startAnimation();\n\n  resizeHandler = resizeCanvas;\n  window.addEventListener('resize', resizeHandler);\n\n  visibilityHandler = handleVisibilityChange;\n  document.addEventListener('visibilitychange', visibilityHandler);\n}\n\n/**\n * Graceful disable: stop spawning new snowflakes and let existing ones\n * fall off the bottom of the viewport naturally.\n */\nfunction disable(): void {\n  if (state !== 'active') return;\n  state = 'draining';\n}\n\n/** Complete the drain: remove canvas and clean up all resources. */\nfunction finalizeDrain(): void {\n  state = 'off';\n  stopAnimation();\n\n  if (resizeHandler) {\n    window.removeEventListener('resize', resizeHandler);\n    resizeHandler = null;\n  }\n\n  if (visibilityHandler) {\n    document.removeEventListener('visibilitychange', visibilityHandler);\n    visibilityHandler = null;\n  }\n\n  if (canvas) {\n    canvas.remove();\n    canvas = null;\n  }\n\n  ctx = null;\n  snowflakes = [];\n}\n\n/** Immediate disable: remove everything without draining (e.g. page unload). */\nfunction forceDisable(): void {\n  if (state === 'off') return;\n  finalizeDrain();\n}\n\nfunction resolveEffect(res: Record<string, unknown>): string {\n  if (typeof res[STORAGE_KEY] === 'string') return res[STORAGE_KEY] as string;\n  // Backward compat: old boolean snow key -> 'snow'\n  if (res[LEGACY_KEY] === true) return 'snow';\n  return 'off';\n}\n\n/**\n * Initialize and start the snow effect feature\n */\nexport function startSnowEffect(): void {\n  // 1) Read initial setting\n  try {\n    chrome.storage?.sync?.get({ [STORAGE_KEY]: null, [LEGACY_KEY]: false }, (res) => {\n      if (resolveEffect(res) === EFFECT_VALUE) {\n        enable();\n      }\n    });\n  } catch (e) {\n    console.error('[Gemini Voyager] Failed to get snow effect setting:', e);\n  }\n\n  // 2) Respond to storage changes\n  try {\n    chrome.storage?.onChanged?.addListener((changes, area) => {\n      if (area === 'sync' && changes[STORAGE_KEY]) {\n        if (changes[STORAGE_KEY].newValue === EFFECT_VALUE) {\n          enable();\n        } else {\n          disable();\n        }\n      }\n    });\n  } catch (e) {\n    console.error('[Gemini Voyager] Failed to add storage listener for snow effect:', e);\n  }\n\n  // 3) Immediate cleanup on page unload (no need to drain)\n  window.addEventListener('beforeunload', () => {\n    forceDisable();\n  });\n}\n"
  },
  {
    "path": "src/pages/content/watermarkRemover/__tests__/downloadButton.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { DOWNLOAD_ICON_SELECTOR, findNativeDownloadButton } from '../downloadButton';\n\ndescribe('findNativeDownloadButton', () => {\n  it('finds button by data-test-id within generated-image container', () => {\n    document.body.innerHTML = `\n      <generated-image>\n        <button data-test-id=\"download-generated-image-button\">\n          <span class=\"child\"></span>\n        </button>\n      </generated-image>\n    `;\n    const target = document.querySelector('.child');\n    const button = findNativeDownloadButton(target);\n    expect(button?.getAttribute('data-test-id')).toBe('download-generated-image-button');\n  });\n\n  it('finds button inside download-generated-image-button host within container', () => {\n    document.body.innerHTML = `\n      <div class=\"generated-image-container\">\n        <download-generated-image-button>\n          <button class=\"inner\">\n            <span class=\"target\"></span>\n          </button>\n        </download-generated-image-button>\n      </div>\n    `;\n    const target = document.querySelector('.target');\n    const button = findNativeDownloadButton(target);\n    expect(button?.classList.contains('inner')).toBe(true);\n  });\n\n  it('finds button via download icon selector within generated-image container', () => {\n    document.body.innerHTML = `\n      <generated-image>\n        <button class=\"icon-button\">\n          <span class=\"button-icon-wrapper\">\n            <mat-icon fonticon=\"download\" class=\"mat-icon\"></mat-icon>\n          </span>\n        </button>\n      </generated-image>\n    `;\n    const icon = document.querySelector(DOWNLOAD_ICON_SELECTOR);\n    const button = findNativeDownloadButton(icon);\n    expect(button?.classList.contains('icon-button')).toBe(true);\n  });\n\n  it('returns null for download button outside generated-image container', () => {\n    document.body.innerHTML = `\n      <div class=\"user-uploaded-image\">\n        <button data-test-id=\"download-generated-image-button\">\n          <span class=\"child\"></span>\n        </button>\n      </div>\n    `;\n    const target = document.querySelector('.child');\n    const button = findNativeDownloadButton(target);\n    expect(button).toBeNull();\n  });\n\n  it('returns null for download icon outside generated-image container', () => {\n    document.body.innerHTML = `\n      <div class=\"image-preview-dialog\">\n        <button class=\"icon-button\">\n          <mat-icon fonticon=\"download\" class=\"mat-icon\"></mat-icon>\n        </button>\n      </div>\n    `;\n    const icon = document.querySelector(DOWNLOAD_ICON_SELECTOR);\n    const button = findNativeDownloadButton(icon);\n    expect(button).toBeNull();\n  });\n\n  it('returns null for click on user-uploaded image area', () => {\n    document.body.innerHTML = `\n      <div class=\"uploaded-image-container\">\n        <img src=\"user-image.jpg\" class=\"user-image\" />\n        <button class=\"preview-button\">\n          <span class=\"click-target\"></span>\n        </button>\n      </div>\n    `;\n    const target = document.querySelector('.click-target');\n    const button = findNativeDownloadButton(target);\n    expect(button).toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/pages/content/watermarkRemover/__tests__/downloadToasts.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { startWatermarkRemover } from '../index';\n\nvi.mock('@/utils/i18n', () => ({\n  getTranslationSync: (key: string) => key,\n}));\n\nvi.mock('../downloadButton', () => ({\n  DOWNLOAD_ICON_SELECTOR: '.gv-test-download-icon',\n  findNativeDownloadButton: (target: unknown) =>\n    target instanceof HTMLButtonElement ? target : null,\n}));\n\nvi.mock('../watermarkEngine', () => ({\n  WatermarkEngine: {\n    create: vi.fn(async () => ({\n      removeWatermarkFromImage: vi.fn(async () => document.createElement('canvas')),\n    })),\n  },\n}));\n\nconst flushMutationObservers = async (): Promise<void> => {\n  await Promise.resolve();\n  await Promise.resolve();\n};\n\ndescribe('watermarkRemover download toasts', () => {\n  beforeEach(() => {\n    document.head.innerHTML = '';\n    document.body.innerHTML = '';\n    vi.useFakeTimers();\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it('does not show large file warning until DOWNLOADING_LARGE arrives', async () => {\n    await startWatermarkRemover();\n\n    const button = document.createElement('button');\n    document.body.appendChild(button);\n    button.dispatchEvent(new MouseEvent('click', { bubbles: true }));\n\n    const toastsBefore = document.querySelectorAll('.gv-status-toast');\n    expect(toastsBefore.length).toBeGreaterThan(0);\n    expect([...toastsBefore].some((toast) => toast.textContent === '大文件警告')).toBe(false);\n\n    const bridge = document.getElementById('gv-watermark-bridge');\n    expect(bridge).not.toBeNull();\n    if (!bridge) return;\n\n    (bridge as HTMLElement).dataset.status = JSON.stringify({ type: 'DOWNLOADING_LARGE' });\n    await flushMutationObservers();\n\n    const toastsAfter = document.querySelectorAll('.gv-status-toast');\n    expect([...toastsAfter].some((toast) => toast.textContent === '大文件警告')).toBe(true);\n\n    vi.advanceTimersByTime(8000);\n    const toastsFinal = document.querySelectorAll('.gv-status-toast');\n    expect([...toastsFinal].some((toast) => toast.textContent === '大文件警告')).toBe(false);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/watermarkRemover/__tests__/fetchInterceptor.test.ts",
    "content": "import { readFileSync } from 'node:fs';\nimport { resolve } from 'node:path';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nconst scriptPath = resolve(process.cwd(), 'public/fetchInterceptor.js');\nconst interceptorScript = readFileSync(scriptPath, 'utf-8');\n\nfunction installInterceptor(): void {\n  (0, eval)(interceptorScript);\n}\n\ndescribe('fetchInterceptor (MAIN world script)', () => {\n  let originalFetch: ReturnType<typeof vi.fn>;\n\n  beforeEach(() => {\n    vi.restoreAllMocks();\n    vi.spyOn(console, 'log').mockImplementation(() => {});\n    vi.spyOn(console, 'warn').mockImplementation(() => {});\n\n    delete (window as Window & { __gvFetchInterceptorInstalled?: boolean })\n      .__gvFetchInterceptorInstalled;\n\n    document.documentElement.innerHTML = '';\n\n    originalFetch = vi.fn().mockResolvedValue(new Response('ok', { status: 200 }));\n    Object.defineProperty(window, 'fetch', {\n      value: originalFetch,\n      writable: true,\n      configurable: true,\n    });\n  });\n\n  it('short-circuits known CSP-blocked GTM telemetry requests', async () => {\n    installInterceptor();\n\n    const response = await window.fetch('https://www.googletagmanager.com/td?id=G-TEST');\n\n    expect(response.status).toBe(204);\n    expect(originalFetch).not.toHaveBeenCalled();\n  });\n\n  it('passes through non-target requests to original fetch', async () => {\n    const originalFetch = vi.fn().mockResolvedValue(new Response('ok', { status: 200 }));\n    Object.defineProperty(window, 'fetch', {\n      value: originalFetch,\n      writable: true,\n      configurable: true,\n    });\n\n    installInterceptor();\n\n    const response = await window.fetch('https://example.com/api');\n\n    expect(originalFetch).toHaveBeenCalledTimes(1);\n    expect(response.status).toBe(200);\n  });\n});\n"
  },
  {
    "path": "src/pages/content/watermarkRemover/__tests__/statusToast.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { createStatusToastManager } from '../statusToast';\n\ndescribe('StatusToastManager', () => {\n  beforeEach(() => {\n    document.head.innerHTML = '';\n    document.body.innerHTML = '';\n    vi.useFakeTimers();\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it('adds a toast with level styling', () => {\n    const manager = createStatusToastManager();\n\n    manager.addToast('Hello', 'info');\n    vi.runAllTimers();\n\n    const toasts = manager.getToastElements();\n    expect(toasts).toHaveLength(1);\n    expect(toasts[0].textContent).toBe('Hello');\n    expect(toasts[0].classList.contains('gv-status-toast--info')).toBe(true);\n  });\n\n  it('updates the latest pending toast', () => {\n    const manager = createStatusToastManager();\n\n    manager.addToast('Starting', 'info', { pending: true });\n    const updated = manager.updateLatestPending('Done', 'success', { markFinal: true });\n    vi.runAllTimers();\n\n    expect(updated).toBe(true);\n    const toasts = manager.getToastElements();\n    expect(toasts).toHaveLength(1);\n    expect(toasts[0].textContent).toBe('Done');\n    expect(toasts[0].classList.contains('gv-status-toast--success')).toBe(true);\n  });\n\n  it('returns false when no pending toast exists', () => {\n    const manager = createStatusToastManager();\n\n    const updated = manager.updateLatestPending('No pending', 'warning');\n\n    expect(updated).toBe(false);\n  });\n\n  it('auto-dismisses a toast after the configured time', () => {\n    const manager = createStatusToastManager();\n\n    manager.addToast('Bye', 'info', { autoDismissMs: 1000 });\n    vi.advanceTimersByTime(999);\n    expect(manager.getToastElements()).toHaveLength(1);\n\n    vi.advanceTimersByTime(1);\n    expect(manager.getToastElements()).toHaveLength(0);\n  });\n\n  it('dismisses a toast when clicked', () => {\n    const manager = createStatusToastManager();\n\n    manager.addToast('Click me', 'warning');\n    const [toast] = manager.getToastElements();\n    expect(toast).toBeDefined();\n\n    toast.click();\n    expect(manager.getToastElements()).toHaveLength(0);\n  });\n\n  it('positions the container near the anchor element', () => {\n    const manager = createStatusToastManager();\n    const anchor = document.createElement('button');\n    document.body.appendChild(anchor);\n    anchor.getBoundingClientRect = () =>\n      ({\n        left: 100,\n        right: 120,\n        top: 200,\n        bottom: 220,\n        width: 20,\n        height: 20,\n        x: 100,\n        y: 200,\n        toJSON: () => {},\n      }) as DOMRect;\n\n    manager.setAnchorElement(anchor);\n    manager.addToast('Anchor', 'info');\n    vi.runAllTimers();\n\n    const container = document.getElementById('gv-status-toast-container');\n    expect(container).not.toBeNull();\n    if (container) {\n      expect(container.style.left).not.toBe('');\n      expect(container.style.top).not.toBe('');\n      expect(container.style.right).toBe('auto');\n    }\n  });\n});\n"
  },
  {
    "path": "src/pages/content/watermarkRemover/alphaMap.ts",
    "content": "/**\n * Alpha Map Calculator\n *\n * This module is ported from gemini-watermark-remover by journey-ad (Jad).\n * Original: https://github.com/journey-ad/gemini-watermark-remover/blob/main/src/core/alphaMap.js\n * License: MIT - Copyright (c) 2025 Jad\n *\n * Calculates alpha map from captured background watermark images.\n */\n\n/**\n * Calculate alpha map from background captured image\n * @param bgCaptureImageData - ImageData object for background capture\n * @returns Float32Array containing alpha values (0.0-1.0)\n */\nexport function calculateAlphaMap(bgCaptureImageData: ImageData): Float32Array {\n  const { width, height, data } = bgCaptureImageData;\n  const alphaMap = new Float32Array(width * height);\n\n  // For each pixel, take the maximum value of the three RGB channels and normalize it to [0, 1]\n  for (let i = 0; i < alphaMap.length; i++) {\n    const idx = i * 4; // RGBA format, 4 bytes per pixel\n    const r = data[idx];\n    const g = data[idx + 1];\n    const b = data[idx + 2];\n\n    // Take the maximum value of the three RGB channels as the brightness value\n    const maxChannel = Math.max(r, g, b);\n\n    // Normalize to [0, 1] range\n    alphaMap[i] = maxChannel / 255.0;\n  }\n\n  return alphaMap;\n}\n"
  },
  {
    "path": "src/pages/content/watermarkRemover/blendModes.ts",
    "content": "/**\n * Reverse Alpha Blending Module\n *\n * This module is ported from gemini-watermark-remover by journey-ad (Jad).\n * Original: https://github.com/journey-ad/gemini-watermark-remover/blob/main/src/core/blendModes.js\n * License: MIT - Copyright (c) 2025 Jad\n *\n * Core algorithm for removing watermarks using reverse alpha blending.\n */\n\n// Constants definition\nconst ALPHA_THRESHOLD = 0.002; // Ignore very small alpha values (noise)\nconst MAX_ALPHA = 0.99; // Avoid division by near-zero values\nconst LOGO_VALUE = 255; // Color value for white watermark\n\nexport interface WatermarkPosition {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}\n\n/**\n * Remove watermark using reverse alpha blending\n *\n * Principle:\n * Gemini adds watermark: watermarked = α × logo + (1 - α) × original\n * Reverse solve: original = (watermarked - α × logo) / (1 - α)\n *\n * @param imageData - Image data to process (will be modified in place)\n * @param alphaMap - Alpha channel data\n * @param position - Watermark position {x, y, width, height}\n */\nexport function removeWatermark(\n  imageData: ImageData,\n  alphaMap: Float32Array,\n  position: WatermarkPosition,\n): void {\n  const { x, y, width, height } = position;\n\n  // Process each pixel in the watermark area\n  for (let row = 0; row < height; row++) {\n    for (let col = 0; col < width; col++) {\n      // Calculate index in original image (RGBA format, 4 bytes per pixel)\n      const imgIdx = ((y + row) * imageData.width + (x + col)) * 4;\n\n      // Calculate index in alpha map\n      const alphaIdx = row * width + col;\n\n      // Get alpha value\n      let alpha = alphaMap[alphaIdx];\n\n      // Skip very small alpha values (noise)\n      if (alpha < ALPHA_THRESHOLD) {\n        continue;\n      }\n\n      // Limit alpha value to avoid division by near-zero\n      alpha = Math.min(alpha, MAX_ALPHA);\n      const oneMinusAlpha = 1.0 - alpha;\n\n      // Apply reverse alpha blending to each RGB channel\n      for (let c = 0; c < 3; c++) {\n        const watermarked = imageData.data[imgIdx + c];\n\n        // Reverse alpha blending formula\n        const original = (watermarked - alpha * LOGO_VALUE) / oneMinusAlpha;\n\n        // Clip to [0, 255] range\n        imageData.data[imgIdx + c] = Math.max(0, Math.min(255, Math.round(original)));\n      }\n\n      // Alpha channel remains unchanged\n      // imageData.data[imgIdx + 3] does not need modification\n    }\n  }\n}\n"
  },
  {
    "path": "src/pages/content/watermarkRemover/credits.ts",
    "content": "/**\n * Watermark Removal Engine - Credits & Attribution\n *\n * This module is based on gemini-watermark-remover by journey-ad (Jad),\n * which is a JavaScript port of the original C++ implementation by allenk.\n *\n * JS Project: https://github.com/journey-ad/gemini-watermark-remover\n * C++ Project: https://github.com/allenk/GeminiWatermarkTool\n * Original Author: journey-ad (Jad)\n * Original C++ Author: allenk\n * License: MIT License\n * Copyright (c) 2025 Jad\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n *\n * ---\n *\n * Algorithm Overview:\n * The algorithm implements Reverse Alpha Blending to remove Gemini AI watermarks:\n * - Gemini watermark formula: watermarked = α × logo + (1 - α) × original\n * - Reverse formula: original = (watermarked - α × logo) / (1 - α)\n *\n * By capturing the watermark on a known solid background, we reconstruct the exact\n * Alpha map and apply the inverse formula to restore the original pixels with zero loss.\n */\n\nexport const WATERMARK_REMOVER_CREDITS = {\n  author: 'journey-ad (Jad)',\n  repository: 'https://github.com/journey-ad/gemini-watermark-remover',\n  license: 'MIT',\n  copyright: 'Copyright (c) 2025 Jad',\n} as const;\n"
  },
  {
    "path": "src/pages/content/watermarkRemover/downloadButton.ts",
    "content": "export const DOWNLOAD_ICON_SELECTOR =\n  'mat-icon[fonticon=\"download\"], .google-symbols[data-mat-icon-name=\"download\"]';\n\n/**\n * Selector for the generated image container\n * Only buttons within this container should trigger watermark removal download progress\n */\nconst GENERATED_IMAGE_CONTAINER_SELECTOR = 'generated-image, .generated-image-container';\n\n/**\n * Check if an element is within a generated image container\n */\nfunction isWithinGeneratedImageContainer(element: Element): boolean {\n  return element.closest(GENERATED_IMAGE_CONTAINER_SELECTOR) !== null;\n}\n\nexport function findNativeDownloadButton(target: EventTarget | null): HTMLButtonElement | null {\n  if (!(target instanceof Element)) return null;\n\n  // First check: must be within a generated-image container\n  // This prevents triggering on user-uploaded image previews or other download buttons\n  if (!isWithinGeneratedImageContainer(target)) return null;\n\n  const dataTestButton = target.closest('button[data-test-id=\"download-generated-image-button\"]');\n  if (dataTestButton) return dataTestButton as HTMLButtonElement;\n\n  const hostButton = target.closest('download-generated-image-button button');\n  if (hostButton) return hostButton as HTMLButtonElement;\n\n  const icon = target.closest(DOWNLOAD_ICON_SELECTOR);\n  const buttonFromIcon = icon?.closest('button');\n  if (buttonFromIcon) return buttonFromIcon as HTMLButtonElement;\n\n  const button = target.closest('button');\n  if (button && button.querySelector(DOWNLOAD_ICON_SELECTOR)) {\n    return button as HTMLButtonElement;\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "src/pages/content/watermarkRemover/index.ts",
    "content": "/**\n * Watermark Remover - Content Script Integration\n *\n * This module is based on gemini-watermark-remover by journey-ad (Jad).\n * Original: https://github.com/journey-ad/gemini-watermark-remover/blob/main/src/userscript/index.js\n * License: MIT - Copyright (c) 2025 Jad\n *\n * Automatically detects and removes watermarks from Gemini-generated images on the page.\n *\n * The fetch interceptor (running in MAIN world) handles download requests:\n * - Intercepts download requests and modifies URL to get original size\n * - Sends image data to this content script for watermark removal\n * - Returns processed image to complete the download\n */\nimport { isExtensionContextInvalidatedError } from '@/core/utils/extensionContext';\nimport { getTranslationSync } from '@/utils/i18n';\nimport type { TranslationKey } from '@/utils/translations';\n\nimport { DOWNLOAD_ICON_SELECTOR, findNativeDownloadButton } from './downloadButton';\nimport { type StatusToastManager, createStatusToastManager } from './statusToast';\nimport { WatermarkEngine } from './watermarkEngine';\n\nlet engine: WatermarkEngine | null = null;\nconst processingQueue = new Set<HTMLImageElement>();\n\n/**\n * Debounce function to limit execution frequency\n */\nconst debounce = <T extends (...args: unknown[]) => void>(func: T, wait: number): T => {\n  let timeout: ReturnType<typeof setTimeout> | null = null;\n  return ((...args: unknown[]) => {\n    if (timeout) clearTimeout(timeout);\n    timeout = setTimeout(() => func(...args), wait);\n  }) as T;\n};\n\n/**\n * Fetch image via background script to bypass CORS\n * The background script has host_permissions that allow cross-origin requests\n */\nconst fetchImageViaBackground = async (url: string): Promise<HTMLImageElement> => {\n  return new Promise((resolve, reject) => {\n    chrome.runtime.sendMessage({ type: 'gv.fetchImage', url }, (response) => {\n      if (chrome.runtime.lastError) {\n        reject(new Error(chrome.runtime.lastError.message));\n        return;\n      }\n      if (!response || !response.ok) {\n        reject(new Error(response?.error || 'Failed to fetch image'));\n        return;\n      }\n\n      // Create image from base64 data\n      const img = new Image();\n      img.onload = () => resolve(img);\n      img.onerror = () => reject(new Error('Failed to decode image'));\n      // Set crossOrigin before src to prevent canvas tainting in Firefox\n      img.crossOrigin = 'anonymous';\n      img.src = `data:${response.contentType};base64,${response.base64}`;\n    });\n  });\n};\n\n/**\n * Convert canvas to blob\n */\nconst canvasToBlob = (canvas: HTMLCanvasElement, type = 'image/png'): Promise<Blob> =>\n  new Promise((resolve, reject) => {\n    canvas.toBlob((blob) => {\n      if (blob) resolve(blob);\n      else reject(new Error('Failed to convert canvas to blob'));\n    }, type);\n  });\n\n/**\n * Convert canvas to base64 data URL\n */\nconst canvasToDataURL = (canvas: HTMLCanvasElement, type = 'image/png'): string =>\n  canvas.toDataURL(type);\n\n/**\n * Check if an image element is a valid Gemini-generated image\n */\nconst isValidGeminiImage = (img: HTMLImageElement): boolean =>\n  img.closest('generated-image,.generated-image-container') !== null;\n\n/**\n * Find all Gemini-generated images on the page\n */\nconst findGeminiImages = (): HTMLImageElement[] =>\n  [...document.querySelectorAll<HTMLImageElement>('img[src*=\"googleusercontent.com\"]')].filter(\n    (img) => isValidGeminiImage(img) && img.dataset.watermarkProcessed !== 'true',\n  );\n\n/**\n * Replace image URL size parameter to get full resolution\n */\nconst replaceWithNormalSize = (src: string): string => {\n  // Use normal size image to fit watermark\n  return src.replace(/=s\\d+[^?#]*/, '=s0');\n};\n\n/**\n * Add a visual indicator (🍌) to the native download button\n * The click goes through to the native button, which triggers the fetch interceptor\n */\nfunction addDownloadIndicator(imgElement: HTMLImageElement): void {\n  const container = imgElement.closest('generated-image,.generated-image-container');\n  if (!container) return;\n\n  // Try to find Gemini's native download button area\n  const nativeDownloadIcon = container.querySelector(DOWNLOAD_ICON_SELECTOR);\n  const nativeButton = nativeDownloadIcon?.closest('button');\n\n  if (!nativeButton) return;\n\n  // Check if indicator already exists\n  if (container.querySelector('.nanobanana-indicator')) return;\n\n  // Create the banana indicator badge\n  const indicator = document.createElement('span');\n  indicator.className = 'nanobanana-indicator';\n  indicator.textContent = '🍌';\n  indicator.title =\n    chrome.i18n.getMessage('nanobananaDownloadTooltip') ||\n    'NanoBanana: Downloads will have watermark removed';\n\n  // Style it as a small badge on the button\n  Object.assign(indicator.style, {\n    position: 'absolute',\n    top: '-4px',\n    right: '-4px',\n    fontSize: '12px',\n    pointerEvents: 'none', // Let clicks pass through to the native button\n    zIndex: '10',\n    filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))',\n  });\n\n  // Make the button container relative for absolute positioning\n  const buttonContainer = nativeButton.parentElement;\n  if (buttonContainer) {\n    const currentPosition = getComputedStyle(buttonContainer).position;\n    if (currentPosition === 'static') {\n      (buttonContainer as HTMLElement).style.position = 'relative';\n    }\n    buttonContainer.appendChild(indicator);\n  }\n}\n\n/**\n * Process a single image to remove watermark (for preview images)\n */\nasync function processImage(imgElement: HTMLImageElement): Promise<void> {\n  if (!engine || processingQueue.has(imgElement)) return;\n\n  processingQueue.add(imgElement);\n  imgElement.dataset.watermarkProcessed = 'processing';\n\n  const originalSrc = imgElement.src;\n  try {\n    // Fetch full resolution image via background script (bypasses CORS)\n    const normalSizeSrc = replaceWithNormalSize(originalSrc);\n    const normalSizeImg = await fetchImageViaBackground(normalSizeSrc);\n\n    // Process image to remove watermark\n    const processedCanvas = await engine.removeWatermarkFromImage(normalSizeImg);\n    const processedBlob = await canvasToBlob(processedCanvas);\n\n    // Replace image source with processed blob URL\n    const processedUrl = URL.createObjectURL(processedBlob);\n    imgElement.src = processedUrl;\n    imgElement.dataset.watermarkProcessed = 'true';\n    imgElement.dataset.processedUrl = processedUrl; // Store for reference\n\n    console.log('[Gemini Voyager] Watermark removed from preview image');\n\n    // Add indicator to download button\n    addDownloadIndicator(imgElement);\n  } catch (error) {\n    console.warn('[Gemini Voyager] Failed to process image for watermark removal:', error);\n    imgElement.dataset.watermarkProcessed = 'failed';\n  } finally {\n    processingQueue.delete(imgElement);\n  }\n}\n\n/**\n * Process all Gemini-generated images on the page\n */\nconst processAllImages = (): void => {\n  const images = findGeminiImages();\n  images.forEach(processImage);\n\n  // Also check existing processed images to see if they need an indicator\n  // (e.g. if the native buttons loaded after the image was processed)\n  const processedImages = document.querySelectorAll<HTMLImageElement>(\n    'img[data-watermark-processed=\"true\"]',\n  );\n  processedImages.forEach((img) => {\n    addDownloadIndicator(img);\n  });\n};\n\n/**\n * Setup MutationObserver to watch for new images\n */\nconst setupMutationObserver = (): void => {\n  const debouncedProcess = debounce(processAllImages, 100);\n  new MutationObserver(debouncedProcess).observe(document.body, {\n    childList: true,\n    subtree: true,\n    attributes: true, // Watch for attribute changes (like native buttons appearing)\n    attributeFilter: ['class', 'src'],\n  });\n  console.log('[Gemini Voyager] Watermark remover MutationObserver active');\n};\n/**\n * DOM-based communication bridge ID (must match fetchInterceptor.js)\n * CustomEvents don't cross world boundaries in Firefox, so we use a hidden DOM element\n */\nconst GV_BRIDGE_ID = 'gv-watermark-bridge';\n\nfunction getBridgeElement(): HTMLElement {\n  let bridge = document.getElementById(GV_BRIDGE_ID);\n  if (!bridge) {\n    bridge = document.createElement('div');\n    bridge.id = GV_BRIDGE_ID;\n    bridge.style.display = 'none';\n    document.documentElement.appendChild(bridge);\n  }\n  return bridge;\n}\n\n/**\n * Notify the MAIN world fetch interceptor about watermark remover state\n * Uses DOM element to communicate across worlds (works in Firefox)\n */\nfunction notifyFetchInterceptor(enabled: boolean): void {\n  const bridge = getBridgeElement();\n  bridge.dataset.enabled = String(enabled);\n}\n\n/**\n * Setup DOM-based bridge to handle image processing requests from MAIN world\n * Uses MutationObserver to watch for requests in the bridge element\n */\nfunction setupFetchInterceptorBridge(): void {\n  const bridge = getBridgeElement();\n\n  // Watch for requests from MAIN world via MutationObserver\n  const observer = new MutationObserver(async () => {\n    const requestData = bridge.dataset.request;\n    if (requestData) {\n      bridge.removeAttribute('data-request');\n      try {\n        const { requestId, base64 } = JSON.parse(requestData);\n        await processImageRequest(requestId, base64, bridge);\n      } catch (e) {\n        console.error('[Gemini Voyager] Failed to parse request:', e);\n      }\n    }\n  });\n\n  observer.observe(bridge, { attributes: true, attributeFilter: ['data-request'] });\n  console.log('[Gemini Voyager] Fetch interceptor bridge ready');\n}\n\n/**\n * Process an image request from the fetch interceptor\n */\nasync function processImageRequest(\n  requestId: string,\n  base64: string,\n  bridge: HTMLElement,\n): Promise<void> {\n  if (!engine) {\n    bridge.dataset.response = JSON.stringify({\n      requestId,\n      error: 'Watermark engine not initialized',\n    });\n    return;\n  }\n\n  try {\n    // Convert base64 to image element\n    const img = new Image();\n    await new Promise<void>((resolve, reject) => {\n      img.onload = () => resolve();\n      img.onerror = () => reject(new Error('Failed to load image'));\n      img.crossOrigin = 'anonymous';\n      img.src = base64;\n    });\n\n    // Process image to remove watermark\n    const processedCanvas = await engine.removeWatermarkFromImage(img);\n    const processedDataUrl = canvasToDataURL(processedCanvas);\n\n    // Send response via bridge element\n    bridge.dataset.response = JSON.stringify({ requestId, base64: processedDataUrl });\n  } catch (error) {\n    console.error('[Gemini Voyager] Failed to process image:', error);\n    bridge.dataset.response = JSON.stringify({ requestId, error: String(error) });\n  }\n}\n\n/**\n * Start the watermark remover\n */\nexport async function startWatermarkRemover(): Promise<void> {\n  try {\n    // Initialize bridge element first (so it exists when fetch interceptor loads)\n    getBridgeElement();\n\n    // Check if feature is enabled\n    const result = await chrome.storage?.sync?.get({ geminiWatermarkRemoverEnabled: true });\n    const isEnabled = result?.geminiWatermarkRemoverEnabled !== false;\n\n    // Notify MAIN world fetch interceptor about state\n    notifyFetchInterceptor(isEnabled);\n\n    if (!isEnabled) {\n      console.log('[Gemini Voyager] Watermark remover is disabled');\n      return;\n    }\n\n    // Setup status listener for UI feedback ASAP (avoid missing early signals)\n    setupStatusListener();\n    setupDownloadButtonTracking();\n\n    console.log('[Gemini Voyager] Initializing watermark remover...');\n    engine = await WatermarkEngine.create();\n\n    // Setup bridge to handle requests from fetch interceptor\n    setupFetchInterceptorBridge();\n\n    // Process preview images\n    processAllImages();\n    setupMutationObserver();\n\n    console.log('[Gemini Voyager] Watermark remover ready');\n  } catch (error) {\n    if (isExtensionContextInvalidatedError(error)) {\n      return;\n    }\n    console.error('[Gemini Voyager] Watermark remover initialization failed:', error);\n  }\n}\n\nlet statusToastManager: StatusToastManager | null = null;\nlet downloadTrackingReady = false;\nlet lastImmediateToastAt = 0;\nlet sequenceCounter = 0;\n\nconst LARGE_WARNING_AUTO_DISMISS_MS = 8000;\nconst PROCESSING_FALLBACK_AUTO_DISMISS_MS = 35000;\n\ntype DownloadToastSequence = {\n  id: number;\n  downloadToastId: string | null;\n  warningToastId: string | null;\n  processingToastId: string | null;\n  processingTimer: ReturnType<typeof setTimeout> | null;\n};\n\nlet activeSequence: DownloadToastSequence | null = null;\n\nconst getStatusToastManager = (): StatusToastManager => {\n  if (!statusToastManager) {\n    statusToastManager = createStatusToastManager({ maxToasts: 4, anchorTtlMs: 30000 });\n  }\n  return statusToastManager;\n};\n\nconst t = (key: TranslationKey, fallback: string): string => {\n  const value = getTranslationSync(key);\n  return value === key ? fallback : value;\n};\n\nfunction showImmediateDownloadToast(button: HTMLButtonElement): void {\n  const now = Date.now();\n  if (now - lastImmediateToastAt < 300) return;\n  lastImmediateToastAt = now;\n\n  const manager = getStatusToastManager();\n  manager.setAnchorElement(button);\n\n  const downloadMessage = t('downloadingOriginal', '正在下载原始图片');\n  const processingMessage = t('downloadProcessing', '正在处理水印中');\n\n  if (activeSequence?.processingTimer) {\n    clearTimeout(activeSequence.processingTimer);\n  }\n\n  const sequenceId = ++sequenceCounter;\n  const downloadToastId = manager.addToast(downloadMessage, 'info', { autoDismissMs: 3000 });\n\n  const processingTimer = setTimeout(() => {\n    if (!activeSequence || activeSequence.id !== sequenceId) return;\n    if (activeSequence.downloadToastId) {\n      manager.removeToast(activeSequence.downloadToastId);\n      activeSequence.downloadToastId = null;\n    }\n    if (!activeSequence.processingToastId) {\n      activeSequence.processingToastId = manager.addToast(processingMessage, 'info', {\n        pending: true,\n        autoDismissMs: PROCESSING_FALLBACK_AUTO_DISMISS_MS,\n      });\n    }\n  }, 3000);\n\n  activeSequence = {\n    id: sequenceId,\n    downloadToastId,\n    warningToastId: null,\n    processingToastId: null,\n    processingTimer,\n  };\n}\n\nfunction setupDownloadButtonTracking(): void {\n  if (downloadTrackingReady) return;\n  downloadTrackingReady = true;\n\n  const captureAnchor = (event: Event): void => {\n    const button = findNativeDownloadButton(event.target);\n    if (!button) return;\n    showImmediateDownloadToast(button);\n  };\n\n  document.addEventListener('pointerdown', captureAnchor, true);\n  document.addEventListener('click', captureAnchor, true);\n}\n\n/**\n * Setup listener for status events from fetchInterceptor\n */\nfunction setupStatusListener(): void {\n  const bridge = getBridgeElement();\n  const manager = getStatusToastManager();\n  const downloadMessage = t('downloadingOriginal', '正在下载原始图片');\n  const downloadLargeMessage = t('downloadingOriginalLarge', '正在下载原始图片（大文件）');\n  const warningMessage = t('downloadLargeWarning', '大文件警告');\n  const processingMessage = t('downloadProcessing', '正在处理水印中');\n  const successMessage = t('downloadSuccess', '正在下载');\n  const errorPrefix = t('downloadError', '失败');\n\n  const finalizeSequence = (level: 'success' | 'error', message: string): void => {\n    if (activeSequence?.processingTimer) {\n      clearTimeout(activeSequence.processingTimer);\n      activeSequence.processingTimer = null;\n    }\n    if (activeSequence?.warningToastId) {\n      manager.removeToast(activeSequence.warningToastId);\n      activeSequence.warningToastId = null;\n    }\n    if (activeSequence?.downloadToastId) {\n      manager.removeToast(activeSequence.downloadToastId);\n      activeSequence.downloadToastId = null;\n    }\n\n    if (\n      activeSequence?.processingToastId &&\n      manager.updateToast(activeSequence.processingToastId, message, level, {\n        autoDismissMs: level === 'success' ? 2500 : 4000,\n        markFinal: true,\n      })\n    ) {\n      return;\n    }\n\n    if (\n      !manager.updateLatestPending(message, level, {\n        autoDismissMs: level === 'success' ? 2500 : 4000,\n        markFinal: true,\n      })\n    ) {\n      manager.addToast(message, level, {\n        autoDismissMs: level === 'success' ? 2500 : 4000,\n      });\n    }\n  };\n\n  const handleStatus = (statusData: string): void => {\n    console.log('[Gemini Voyager] Status data received:', statusData);\n    if (!statusData) return;\n\n    try {\n      const { type, message } = JSON.parse(statusData);\n      bridge.removeAttribute('data-status');\n\n      switch (type) {\n        case 'DOWNLOADING':\n          // Step 1: Downloading original image\n          if (activeSequence) {\n            if (activeSequence.warningToastId) {\n              manager.removeToast(activeSequence.warningToastId);\n              activeSequence.warningToastId = null;\n            }\n            if (!activeSequence.downloadToastId) {\n              activeSequence.downloadToastId = manager.addToast(downloadMessage, 'info', {\n                autoDismissMs: 3000,\n              });\n            }\n          }\n          break;\n        case 'DOWNLOADING_LARGE':\n          // Step 1 with large file warning\n          if (activeSequence) {\n            if (!activeSequence.downloadToastId) {\n              activeSequence.downloadToastId = manager.addToast(downloadLargeMessage, 'info', {\n                autoDismissMs: 3000,\n              });\n            } else {\n              manager.updateToast(activeSequence.downloadToastId, downloadLargeMessage, 'info');\n            }\n            if (!activeSequence.warningToastId) {\n              activeSequence.warningToastId = manager.addToast(warningMessage, 'warning', {\n                autoDismissMs: LARGE_WARNING_AUTO_DISMISS_MS,\n              });\n            }\n          }\n          break;\n        case 'PROCESSING':\n          // Step 2: Processing watermark\n          if (activeSequence?.processingToastId) {\n            manager.updateToast(activeSequence.processingToastId, processingMessage, 'info');\n            break;\n          }\n          if (!activeSequence?.processingTimer) {\n            const processingToastId = manager.addToast(processingMessage, 'info', {\n              pending: true,\n              autoDismissMs: PROCESSING_FALLBACK_AUTO_DISMISS_MS,\n            });\n            if (activeSequence) activeSequence.processingToastId = processingToastId;\n          }\n          break;\n        case 'SUCCESS':\n          // Step 3: Done, auto-dismiss after 2s\n          finalizeSequence('success', successMessage);\n          break;\n        case 'ERROR':\n          finalizeSequence('error', `${errorPrefix}: ${message}`);\n          break;\n      }\n    } catch (e) {\n      console.error('[Gemini Voyager] Failed to parse status:', e);\n    }\n  };\n\n  const observer = new MutationObserver(() => {\n    const statusData = bridge.dataset.status;\n    if (!statusData) return;\n    handleStatus(statusData);\n  });\n\n  observer.observe(bridge, { attributes: true, attributeFilter: ['data-status'] });\n  if (bridge.dataset.status) {\n    handleStatus(bridge.dataset.status);\n  }\n}\n"
  },
  {
    "path": "src/pages/content/watermarkRemover/statusToast.ts",
    "content": "export type StatusToastLevel = 'info' | 'warning' | 'success' | 'error';\n\ntype ToastRecord = {\n  id: string;\n  element: HTMLDivElement;\n  isFinal: boolean;\n  timeoutId: ReturnType<typeof setTimeout> | null;\n};\n\ntype ToastOptions = {\n  autoDismissMs?: number;\n  pending?: boolean;\n  markFinal?: boolean;\n};\n\nexport type StatusToastManager = {\n  addToast: (message: string, level: StatusToastLevel, options?: ToastOptions) => string;\n  removeToast: (id: string) => boolean;\n  updateToast: (\n    id: string,\n    message: string,\n    level: StatusToastLevel,\n    options?: ToastOptions,\n  ) => boolean;\n  updateLatestPending: (\n    message: string,\n    level: StatusToastLevel,\n    options?: ToastOptions,\n  ) => boolean;\n  setAnchorElement: (element: HTMLElement | null) => void;\n  getToastElements: () => HTMLDivElement[];\n};\n\ntype StatusToastManagerOptions = {\n  containerId?: string;\n  anchorTtlMs?: number;\n  maxToasts?: number;\n};\n\nconst STYLE_ID = 'gv-status-toast-style';\nconst DEFAULT_CONTAINER_ID = 'gv-status-toast-container';\nconst LEVEL_CLASSES: StatusToastLevel[] = ['info', 'warning', 'success', 'error'];\n\nexport function createStatusToastManager(\n  options: StatusToastManagerOptions = {},\n): StatusToastManager {\n  const containerId = options.containerId ?? DEFAULT_CONTAINER_ID;\n  const anchorTtlMs = options.anchorTtlMs ?? 8000;\n  const maxToasts = options.maxToasts ?? 4;\n  const toasts: ToastRecord[] = [];\n  let anchorElement: HTMLElement | null = null;\n  let anchorUpdatedAt = 0;\n  let positionRaf: number | null = null;\n\n  const ensureStyles = (): void => {\n    if (document.getElementById(STYLE_ID)) return;\n    const style = document.createElement('style');\n    style.id = STYLE_ID;\n    style.textContent = `\n.gv-status-toast-container {\n  position: fixed;\n  z-index: 2147483647;\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n  pointer-events: none;\n  max-width: min(380px, calc(100vw - 32px));\n  /* Ensure it floats above everything */\n  isolation: isolate;\n}\n\n.gv-status-toast {\n  pointer-events: auto;\n  font-family: \"Google Sans\", Roboto, -apple-system, BlinkMacSystemFont, sans-serif;\n  font-size: 14px;\n  font-weight: 500;\n  line-height: 1.5;\n  padding: 12px 16px;\n  border-radius: 12px;\n  \n  /* Light Mode Default */\n  background: rgba(255, 255, 255, 0.95);\n  color: #1f2937;\n  border: 1px solid rgba(226, 232, 240, 0.8);\n  box-shadow: \n    0 4px 6px -1px rgba(0, 0, 0, 0.1), \n    0 2px 4px -1px rgba(0, 0, 0, 0.06),\n    0 0 0 1px rgba(0,0,0,0.02);\n  \n  opacity: 0;\n  transform: translateY(8px) scale(0.98);\n  transition: \n    opacity 200ms cubic-bezier(0.16, 1, 0.3, 1), \n    transform 200ms cubic-bezier(0.16, 1, 0.3, 1),\n    background-color 200ms, border-color 200ms, color 200ms;\n    \n  backdrop-filter: blur(8px);\n  -webkit-backdrop-filter: blur(8px);\n  \n  display: flex;\n  align-items: center;\n  gap: 12px;\n  width: fit-content;\n}\n\n.gv-status-toast.show {\n  opacity: 1;\n  transform: translateY(0) scale(1);\n}\n\n/* Status Indicators via Left Border & Emoji */\n.gv-status-toast--info {\n  border-left: 4px solid #3b82f6; \n}\n.gv-status-toast--info::before {\n  content: \"ℹ️\";\n}\n\n.gv-status-toast--warning {\n  border-left: 4px solid #f59e0b;\n}\n.gv-status-toast--warning::before {\n  content: \"⚠️\";\n}\n\n.gv-status-toast--success {\n  border-left: 4px solid #22c55e;\n}\n.gv-status-toast--success::before {\n  content: \"✅\";\n}\n\n.gv-status-toast--error {\n  border-left: 4px solid #ef4444;\n}\n.gv-status-toast--error::before {\n  content: \"❌\";\n}\n\n/* Dark Mode Support (System & Class-based) */\n@media (prefers-color-scheme: dark) {\n  .gv-status-toast {\n    background: rgba(30, 41, 59, 0.95);\n    color: #f1f5f9;\n    border-color: rgba(51, 65, 85, 0.8);\n    box-shadow: \n      0 10px 15px -3px rgba(0, 0, 0, 0.5), \n      0 4px 6px -4px rgba(0, 0, 0, 0.5),\n      0 0 0 1px rgba(255,255,255,0.05);\n  }\n}\n\n/* Explicit .dark class support (if Gemini adds it to body) */\nbody.dark .gv-status-toast, \nhtml.dark .gv-status-toast {\n  background: rgba(30, 41, 59, 0.95);\n  color: #f1f5f9;\n  border-color: rgba(51, 65, 85, 0.8);\n  box-shadow: \n    0 10px 15px -3px rgba(0, 0, 0, 0.5), \n    0 4px 6px -4px rgba(0, 0, 0, 0.5),\n    0 0 0 1px rgba(255,255,255,0.05);\n}\n`;\n    document.head.appendChild(style);\n  };\n\n  const ensureContainer = (): HTMLDivElement => {\n    const existing = document.getElementById(containerId);\n    if (existing instanceof HTMLDivElement) return existing;\n    const container = document.createElement('div');\n    container.id = containerId;\n    container.className = 'gv-status-toast-container';\n    document.body.appendChild(container);\n    return container;\n  };\n\n  const getAnchorRect = (): DOMRect | null => {\n    if (!anchorElement || !anchorElement.isConnected) return null;\n    if (Date.now() - anchorUpdatedAt > anchorTtlMs) return null;\n    return anchorElement.getBoundingClientRect();\n  };\n\n  const schedulePositionUpdate = (): void => {\n    if (positionRaf !== null) return;\n    positionRaf = window.requestAnimationFrame(() => {\n      positionRaf = null;\n      positionContainer();\n    });\n  };\n\n  const positionContainer = (): void => {\n    const container = ensureContainer();\n    const anchorRect = getAnchorRect();\n    if (!anchorRect) {\n      container.style.right = '24px';\n      container.style.bottom = '80px';\n      container.style.left = 'auto';\n      container.style.top = 'auto';\n      return;\n    }\n\n    const rect = container.getBoundingClientRect();\n    const estimatedToastHeight = 52;\n    const width = rect.width || container.offsetWidth || 300;\n    const height =\n      rect.height ||\n      container.offsetHeight ||\n      Math.max(\n        estimatedToastHeight,\n        toasts.length * estimatedToastHeight + (toasts.length - 1) * 10,\n      );\n    const gap = 14;\n    const padding = 12;\n\n    let left = anchorRect.right + gap;\n    if (left + width + padding > window.innerWidth) {\n      left = anchorRect.left - gap - width;\n    }\n    left = Math.max(padding, Math.min(left, window.innerWidth - width - padding));\n\n    const anchorCenterY = anchorRect.top + anchorRect.height / 2;\n    let top = anchorCenterY - height / 2;\n    top = Math.max(padding, Math.min(top, window.innerHeight - height - padding));\n\n    container.style.left = `${left}px`;\n    container.style.top = `${top}px`;\n    container.style.right = 'auto';\n    container.style.bottom = 'auto';\n  };\n\n  const applyLevelClass = (element: HTMLElement, level: StatusToastLevel): void => {\n    element.classList.remove(...LEVEL_CLASSES.map((value) => `gv-status-toast--${value}`));\n    element.classList.add(`gv-status-toast--${level}`);\n  };\n\n  const removeToast = (toast: ToastRecord): void => {\n    if (toast.timeoutId) {\n      clearTimeout(toast.timeoutId);\n      toast.timeoutId = null;\n    }\n    toast.element.remove();\n    const index = toasts.findIndex((item) => item.id === toast.id);\n    if (index >= 0) {\n      toasts.splice(index, 1);\n    }\n    schedulePositionUpdate();\n  };\n\n  const scheduleDismiss = (toast: ToastRecord, autoDismissMs: number): void => {\n    if (toast.timeoutId) clearTimeout(toast.timeoutId);\n    toast.timeoutId = setTimeout(() => removeToast(toast), autoDismissMs);\n  };\n\n  const addToast = (\n    message: string,\n    level: StatusToastLevel,\n    options: ToastOptions = {},\n  ): string => {\n    ensureStyles();\n    const container = ensureContainer();\n\n    const toast = document.createElement('div');\n    toast.className = 'gv-status-toast';\n    toast.textContent = message;\n    applyLevelClass(toast, level);\n    container.appendChild(toast);\n\n    const id = `gv-toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n    const record: ToastRecord = {\n      id,\n      element: toast,\n      isFinal: options.pending ? false : true,\n      timeoutId: null,\n    };\n    toasts.push(record);\n    toast.addEventListener('click', () => removeToast(record));\n\n    if (toasts.length > maxToasts) {\n      removeToast(toasts[0]);\n    }\n\n    window.requestAnimationFrame(() => toast.classList.add('show'));\n    schedulePositionUpdate();\n\n    if (options.autoDismissMs && options.autoDismissMs > 0) {\n      scheduleDismiss(record, options.autoDismissMs);\n    }\n    return id;\n  };\n\n  const removeToastById = (id: string): boolean => {\n    const record = toasts.find((toast) => toast.id === id);\n    if (!record) return false;\n    removeToast(record);\n    return true;\n  };\n\n  const updateToast = (\n    id: string,\n    message: string,\n    level: StatusToastLevel,\n    options: ToastOptions = {},\n  ): boolean => {\n    const record = toasts.find((toast) => toast.id === id);\n    if (!record) return false;\n    record.element.textContent = message;\n    applyLevelClass(record.element, level);\n    if (options.markFinal) {\n      record.isFinal = true;\n    }\n    if (options.autoDismissMs && options.autoDismissMs > 0) {\n      scheduleDismiss(record, options.autoDismissMs);\n    }\n    schedulePositionUpdate();\n    return true;\n  };\n\n  const updateLatestPending = (\n    message: string,\n    level: StatusToastLevel,\n    options: ToastOptions = {},\n  ): boolean => {\n    const record = [...toasts].reverse().find((toast) => !toast.isFinal);\n    if (!record) return false;\n\n    record.element.textContent = message;\n    applyLevelClass(record.element, level);\n    if (options.markFinal) {\n      record.isFinal = true;\n    }\n    if (options.autoDismissMs && options.autoDismissMs > 0) {\n      scheduleDismiss(record, options.autoDismissMs);\n    }\n    schedulePositionUpdate();\n    return true;\n  };\n\n  const setAnchorElement = (element: HTMLElement | null): void => {\n    if (!element) return;\n    anchorElement = element;\n    anchorUpdatedAt = Date.now();\n    schedulePositionUpdate();\n  };\n\n  return {\n    addToast,\n    removeToast: removeToastById,\n    updateToast,\n    updateLatestPending,\n    setAnchorElement,\n    getToastElements: () => toasts.map((toast) => toast.element),\n  };\n}\n"
  },
  {
    "path": "src/pages/content/watermarkRemover/watermarkEngine.ts",
    "content": "/**\n * Watermark Engine Main Module\n *\n * This module is ported from gemini-watermark-remover by journey-ad (Jad).\n * Original: https://github.com/journey-ad/gemini-watermark-remover/blob/main/src/core/watermarkEngine.js\n * License: MIT - Copyright (c) 2025 Jad\n *\n * Coordinates watermark detection, alpha map calculation, and removal operations.\n */\nimport { calculateAlphaMap } from './alphaMap';\n// Import watermark background capture images - Vite will bundle these\nimport BG_48_IMPORT from './assets/bg_48.png';\nimport BG_96_IMPORT from './assets/bg_96.png';\nimport { type WatermarkPosition, removeWatermark } from './blendModes';\n\n// For content scripts, we need to use chrome.runtime.getURL to resolve asset paths\n// The imported paths are relative to the bundle, which works in extension context\nconst getBgPath = (importedPath: string): string => {\n  // If it's already a data URL, use it directly\n  if (importedPath.startsWith('data:')) {\n    return importedPath;\n  }\n  // For file paths, use chrome.runtime.getURL in extension context\n  try {\n    // Extract just the filename from the path\n    const filename = importedPath.split('/').pop() || importedPath;\n    return chrome.runtime.getURL(`assets/${filename}`);\n  } catch {\n    // Fallback to the original path\n    return importedPath;\n  }\n};\n\nexport interface WatermarkConfig {\n  logoSize: number;\n  marginRight: number;\n  marginBottom: number;\n}\n\nexport interface WatermarkInfo {\n  size: number;\n  position: WatermarkPosition;\n  config: WatermarkConfig;\n}\n\n/**\n * Detect watermark configuration based on image size\n * @param imageWidth - Image width\n * @param imageHeight - Image height\n * @returns Watermark configuration {logoSize, marginRight, marginBottom}\n */\nexport function detectWatermarkConfig(imageWidth: number, imageHeight: number): WatermarkConfig {\n  // Gemini's watermark rules:\n  // If both image width and height are greater than 1024, use 96×96 watermark\n  // Otherwise, use 48×48 watermark\n  if (imageWidth > 1024 && imageHeight > 1024) {\n    return {\n      logoSize: 96,\n      marginRight: 64,\n      marginBottom: 64,\n    };\n  } else {\n    return {\n      logoSize: 48,\n      marginRight: 32,\n      marginBottom: 32,\n    };\n  }\n}\n\n/**\n * Calculate watermark position in image based on image size and watermark configuration\n * @param imageWidth - Image width\n * @param imageHeight - Image height\n * @param config - Watermark configuration {logoSize, marginRight, marginBottom}\n * @returns Watermark position {x, y, width, height}\n */\nexport function calculateWatermarkPosition(\n  imageWidth: number,\n  imageHeight: number,\n  config: WatermarkConfig,\n): WatermarkPosition {\n  const { logoSize, marginRight, marginBottom } = config;\n\n  return {\n    x: imageWidth - marginRight - logoSize,\n    y: imageHeight - marginBottom - logoSize,\n    width: logoSize,\n    height: logoSize,\n  };\n}\n\ninterface BgCaptures {\n  bg48: HTMLImageElement;\n  bg96: HTMLImageElement;\n}\n\n/**\n * Watermark engine class\n * Coordinates watermark detection, alpha map calculation, and removal operations\n */\nexport class WatermarkEngine {\n  private bgCaptures: BgCaptures;\n  private alphaMaps: Record<number, Float32Array>;\n\n  constructor(bgCaptures: BgCaptures) {\n    this.bgCaptures = bgCaptures;\n    this.alphaMaps = {};\n  }\n\n  static async create(): Promise<WatermarkEngine> {\n    const bg48 = new Image();\n    const bg96 = new Image();\n\n    const bg48Path = getBgPath(BG_48_IMPORT);\n    const bg96Path = getBgPath(BG_96_IMPORT);\n\n    console.log('[Gemini Voyager] Loading watermark assets:', { bg48Path, bg96Path });\n\n    await Promise.all([\n      new Promise<void>((resolve, reject) => {\n        bg48.onload = () => resolve();\n        bg48.onerror = (e) =>\n          reject(\n            new Error(\n              `Failed to load bg_48.png from ${bg48Path}: ${e instanceof Event ? 'Image load error' : e}`,\n            ),\n          );\n        // Set crossOrigin before src to prevent canvas tainting in Firefox\n        bg48.crossOrigin = 'anonymous';\n        bg48.src = bg48Path;\n      }),\n      new Promise<void>((resolve, reject) => {\n        bg96.onload = () => resolve();\n        bg96.onerror = (e) =>\n          reject(\n            new Error(\n              `Failed to load bg_96.png from ${bg96Path}: ${e instanceof Event ? 'Image load error' : e}`,\n            ),\n          );\n        // Set crossOrigin before src to prevent canvas tainting in Firefox\n        bg96.crossOrigin = 'anonymous';\n        bg96.src = bg96Path;\n      }),\n    ]);\n\n    return new WatermarkEngine({ bg48, bg96 });\n  }\n\n  /**\n   * Get alpha map from background captured image based on watermark size\n   * @param size - Watermark size (48 or 96)\n   * @returns Alpha map\n   */\n  async getAlphaMap(size: number): Promise<Float32Array> {\n    // If cached, return directly\n    if (this.alphaMaps[size]) {\n      return this.alphaMaps[size];\n    }\n\n    // Select corresponding background capture based on watermark size\n    const bgImage = size === 48 ? this.bgCaptures.bg48 : this.bgCaptures.bg96;\n\n    // Create temporary canvas to extract ImageData\n    const canvas = document.createElement('canvas');\n    canvas.width = size;\n    canvas.height = size;\n    const ctx = canvas.getContext('2d');\n    if (!ctx) {\n      throw new Error('Failed to get canvas 2d context');\n    }\n    ctx.drawImage(bgImage, 0, 0);\n\n    const imageData = ctx.getImageData(0, 0, size, size);\n\n    // Calculate alpha map\n    const alphaMap = calculateAlphaMap(imageData);\n\n    // Cache result\n    this.alphaMaps[size] = alphaMap;\n\n    return alphaMap;\n  }\n\n  /**\n   * Remove watermark from image based on watermark size\n   * @param image - Input image\n   * @returns Processed canvas\n   */\n  async removeWatermarkFromImage(\n    image: HTMLImageElement | HTMLCanvasElement,\n  ): Promise<HTMLCanvasElement> {\n    // Create canvas to process image\n    const canvas = document.createElement('canvas');\n    canvas.width = image.width;\n    canvas.height = image.height;\n    const ctx = canvas.getContext('2d');\n    if (!ctx) {\n      throw new Error('Failed to get canvas 2d context');\n    }\n\n    // Draw original image onto canvas\n    ctx.drawImage(image, 0, 0);\n\n    // Get image data\n    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);\n\n    // Detect watermark configuration\n    const config = detectWatermarkConfig(canvas.width, canvas.height);\n    const position = calculateWatermarkPosition(canvas.width, canvas.height, config);\n\n    // Get alpha map for watermark size\n    const alphaMap = await this.getAlphaMap(config.logoSize);\n\n    // Remove watermark from image data\n    removeWatermark(imageData, alphaMap, position);\n\n    // Write processed image data back to canvas\n    ctx.putImageData(imageData, 0, 0);\n\n    return canvas;\n  }\n\n  /**\n   * Get watermark information (for display)\n   * @param imageWidth - Image width\n   * @param imageHeight - Image height\n   * @returns Watermark information {size, position, config}\n   */\n  getWatermarkInfo(imageWidth: number, imageHeight: number): WatermarkInfo {\n    const config = detectWatermarkConfig(imageWidth, imageHeight);\n    const position = calculateWatermarkPosition(imageWidth, imageHeight, config);\n\n    return {\n      size: config.logoSize,\n      position: position,\n      config: config,\n    };\n  }\n}\n"
  },
  {
    "path": "src/pages/devtools/index.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\" />\n    <title>Devtools</title>\n  </head>\n  <body>\n    <script type=\"module\" src=\"./index.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "src/pages/devtools/index.ts",
    "content": "import Browser from 'webextension-polyfill';\n\nBrowser.devtools.panels\n  .create('Dev Tools', 'icon-32.png', 'src/pages/devtools/index.html')\n  .catch(console.error);\n"
  },
  {
    "path": "src/pages/options/Options.css",
    "content": ".container {\n  width: 100%;\n  height: 50vh;\n  font-size: 2rem;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n"
  },
  {
    "path": "src/pages/options/Options.tsx",
    "content": "import React from 'react';\n\nimport '@pages/options/Options.css';\n\nimport { LanguageProvider, useLanguage } from '../../contexts/LanguageContext';\n\nfunction OptionsContent() {\n  const { t } = useLanguage();\n\n  return (\n    <div className=\"bg-background text-foreground mx-auto min-h-screen max-w-4xl p-8\">\n      <div className=\"mb-8\">\n        <h1 className=\"mb-2 text-3xl font-bold\">{t('extName')}</h1>\n        <p className=\"text-muted-foreground\">{t('optionsPageSubtitle')}</p>\n      </div>\n\n      <div className=\"border-border bg-card rounded-lg border p-6\">\n        <p className=\"text-muted-foreground\">{t('optionsComingSoon')}</p>\n      </div>\n    </div>\n  );\n}\n\nexport default function Options() {\n  return (\n    <LanguageProvider>\n      <OptionsContent />\n    </LanguageProvider>\n  );\n}\n"
  },
  {
    "path": "src/pages/options/index.css",
    "content": ""
  },
  {
    "path": "src/pages/options/index.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\" />\n    <title>Options</title>\n  </head>\n\n  <body>\n    <div id=\"__root\"></div>\n    <script type=\"module\" src=\"./index.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "src/pages/options/index.tsx",
    "content": "import React from 'react';\nimport { createRoot } from 'react-dom/client';\n\nimport Options from '@pages/options/Options';\nimport '@pages/options/index.css';\n\nfunction init() {\n  const rootContainer = document.querySelector('#__root');\n  if (!rootContainer) throw new Error(\"Can't find Options root element\");\n  const root = createRoot(rootContainer);\n  root.render(<Options />);\n}\n\ninit();\n"
  },
  {
    "path": "src/pages/panel/Panel.css",
    "content": "body {\n  background-color: #242424;\n}\n\n.container {\n  color: #ffffff;\n}\n"
  },
  {
    "path": "src/pages/panel/Panel.tsx",
    "content": "import React from 'react';\n\nimport '@pages/panel/Panel.css';\n\nexport default function Panel() {\n  return (\n    <div className=\"container\">\n      <h1>Side Panel</h1>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/pages/panel/index.css",
    "content": ""
  },
  {
    "path": "src/pages/panel/index.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\" />\n    <title>Devtools Panel</title>\n  </head>\n\n  <body>\n    <div id=\"__root\"></div>\n    <script type=\"module\" src=\"./index.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "src/pages/panel/index.tsx",
    "content": "import React from 'react';\nimport { createRoot } from 'react-dom/client';\n\nimport Panel from '@pages/panel/Panel';\nimport '@pages/panel/index.css';\n\nimport '@assets/styles/tailwind.css';\n\nfunction init() {\n  const rootContainer = document.querySelector('#__root');\n  if (!rootContainer) throw new Error(\"Can't find Panel root element\");\n  const root = createRoot(rootContainer);\n  root.render(<Panel />);\n}\n\ninit();\n"
  },
  {
    "path": "src/pages/popup/Popup.tsx",
    "content": "import React, { useCallback, useEffect, useMemo, useState } from 'react';\n\nimport browser from 'webextension-polyfill';\n\nimport {\n  type AccountPlatform,\n  detectAccountPlatformFromUrl,\n  getAccountIsolationStorageKey,\n} from '@/core/services/AccountIsolationService';\nimport { StorageKeys } from '@/core/types/common';\nimport type { ConversationReference, Folder } from '@/core/types/folder';\nimport { getModifierKey, isSafari, shouldShowSafariUpdateReminder } from '@/core/utils/browser';\nimport { shouldShowUpdateReminderForCurrentVersion } from '@/core/utils/updateReminder';\nimport { compareVersions } from '@/core/utils/version';\nimport {\n  extractDmgDownloadUrl,\n  extractLatestReleaseVersion,\n  getCachedLatestVersion,\n  getManifestUpdateUrl,\n} from '@/pages/popup/utils/latestVersion';\n\nimport { DarkModeToggle } from '../../components/DarkModeToggle';\nimport { LanguageSwitcher } from '../../components/LanguageSwitcher';\nimport { Button } from '../../components/ui/button';\nimport { Card, CardContent, CardTitle } from '../../components/ui/card';\nimport { Label } from '../../components/ui/label';\nimport { Switch } from '../../components/ui/switch';\nimport { useLanguage } from '../../contexts/LanguageContext';\nimport { useWidthAdjuster } from '../../hooks/useWidthAdjuster';\nimport { CloudSyncSettings } from './components/CloudSyncSettings';\nimport { ContextSyncSettings } from './components/ContextSyncSettings';\nimport { KeyboardShortcutSettings } from './components/KeyboardShortcutSettings';\nimport { StarredHistory } from './components/StarredHistory';\nimport {\n  IconChatGPT,\n  IconClaude,\n  IconDeepSeek,\n  IconGrok,\n  IconKimi,\n  IconMidjourney,\n  IconNotebookLM,\n  IconQwen,\n} from './components/WebsiteLogos';\nimport WidthSlider from './components/WidthSlider';\n\ntype ScrollMode = 'jump' | 'flow';\n\n/**\n * Reorderable popup section IDs — order here is the default display order.\n */\nconst POPUP_SECTION_IDS = [\n  'cloudSync',\n  'contextSync',\n  'timeline',\n  'folder',\n  'folderSpacing',\n  'folderTreeIndent',\n  'chatWidth',\n  'editInputWidth',\n  'sidebarWidth',\n  'sidebarBehavior',\n  'visualEffect',\n  'formulaCopy',\n  'keyboardShortcuts',\n  'inputCollapse',\n  'promptManager',\n  'general',\n  'nanobanana',\n] as const;\n\ntype PopupSectionId = (typeof POPUP_SECTION_IDS)[number];\n\nconst DEFAULT_SECTION_ORDER: readonly PopupSectionId[] = POPUP_SECTION_IDS;\n\nconst ROOT_CONVERSATIONS_ID = '__root_conversations__';\n\n/**\n * Build a folder path string like \"Parent / Child / Grandchild\"\n */\nfunction buildFolderPath(folderId: string, foldersById: Map<string, Folder>): string {\n  const parts: string[] = [];\n  let current = foldersById.get(folderId);\n  while (current) {\n    parts.unshift(current.name);\n    current = current.parentId ? foldersById.get(current.parentId) : undefined;\n  }\n  return parts.join(' / ');\n}\n\n/**\n * Map language code to a human-readable language name for prompt instructions\n */\nfunction getLanguageName(lang: string): string {\n  const map: Record<string, string> = {\n    en: 'English',\n    zh: '中文',\n    zh_TW: '繁體中文',\n    ja: '日本語',\n    ko: '한국어',\n    ar: 'العربية',\n    es: 'Español',\n    fr: 'Français',\n    pt: 'Português',\n    ru: 'Русский',\n  };\n  return map[lang] || 'English';\n}\n\n/**\n * Format all conversations and folder structure as a prompt for AI organization.\n *\n * Key design: the output JSON should only contain INCREMENTAL changes —\n * new folders + new conversation-to-folder assignments for currently unfiled\n * conversations. Existing folders/conversations must NOT be re-emitted, so\n * a \"Merge\" import won't touch the user's carefully curated structure.\n */\nfunction formatFolderStructurePrompt(\n  sidebarConversations: Array<{ id: string; title: string; url: string }>,\n  folderData: { folders: Folder[]; folderContents: Record<string, ConversationReference[]> },\n  language: string,\n): string {\n  const lines: string[] = [];\n  const langName = getLanguageName(language);\n\n  // Build folder lookup\n  const foldersById = new Map<string, Folder>();\n  for (const folder of folderData.folders) {\n    foldersById.set(folder.id, folder);\n  }\n\n  // Collect IDs of conversations already in folders\n  const organizedIds = new Set<string>();\n  for (const [folderId, convs] of Object.entries(folderData.folderContents)) {\n    if (folderId === ROOT_CONVERSATIONS_ID) continue;\n    for (const conv of convs) {\n      organizedIds.add(conv.conversationId);\n    }\n  }\n\n  // Section 1: Existing folder names (reference only, no conversations listed)\n  const sortedFolders = [...folderData.folders].sort((a, b) => {\n    if (a.pinned && !b.pinned) return -1;\n    if (!a.pinned && b.pinned) return 1;\n    return (a.sortIndex ?? 0) - (b.sortIndex ?? 0);\n  });\n\n  if (sortedFolders.length > 0) {\n    lines.push('## Existing Folders (DO NOT re-create or modify)');\n    lines.push('');\n    for (const folder of sortedFolders) {\n      const path = buildFolderPath(folder.id, foldersById);\n      const convCount = (folderData.folderContents[folder.id] || []).length;\n      lines.push(`- ${path}  (id: ${folder.id}, ${convCount} conversations)`);\n    }\n    lines.push('');\n  }\n\n  // Section 2: Unfiled conversations — these are the ones to organize\n  const unfiledConvs = sidebarConversations.filter((c) => !organizedIds.has(c.id));\n  if (unfiledConvs.length > 0) {\n    lines.push('## Unfiled Conversations (to be organized)');\n    lines.push('');\n    for (const conv of unfiledConvs) {\n      lines.push(`- [${conv.id}] ${conv.title} | ${conv.url}`);\n    }\n    lines.push('');\n  }\n\n  // Section 3: Instructions\n  lines.push('## Instructions');\n  lines.push('');\n  lines.push(`Please respond in **${langName}** (folder names, explanations, etc.).`);\n  lines.push('');\n  lines.push('Organize the **unfiled conversations** above into folders. Rules:');\n  lines.push('');\n  lines.push(\n    '1. **Do NOT re-output existing folders or their conversations.** The result will be merged (not replaced), so anything you output will be added on top of the current structure.',\n  );\n  lines.push(\n    \"2. You MAY place an unfiled conversation into an **existing folder** — just reference that folder's id in `folderContents`.\",\n  );\n  lines.push(\n    '3. You MAY create **new folders** as needed. Use a short random hex string (8 chars) as the folder id. Name them in ' +\n      langName +\n      '.',\n  );\n  lines.push(\n    \"4. New folders can be nested under existing folders by setting `parentId` to the existing folder's id.\",\n  );\n  lines.push(\n    '5. Each conversation must keep its original `conversationId` and `url` exactly as shown above.',\n  );\n  lines.push(\n    '6. Only output the **incremental** JSON — new folders + new conversation assignments.',\n  );\n  lines.push('');\n  lines.push('Output format (paste-ready for Gemini Voyager import):');\n  lines.push('');\n  lines.push('```json');\n  lines.push('{');\n  lines.push('  \"format\": \"gemini-voyager.folders.v1\",');\n  lines.push(`  \"exportedAt\": \"${new Date().toISOString()}\",`);\n  lines.push('  \"version\": \"1.3.3\",');\n  lines.push('  \"data\": {');\n  lines.push('    \"folders\": [');\n  lines.push('      // ONLY new folders here (omit existing ones)');\n  lines.push('      {');\n  lines.push('        \"id\": \"<8-char-hex>\",');\n  lines.push(`        \"name\": \"<folder name in ${langName}>\",`);\n  lines.push('        \"parentId\": null,');\n  lines.push('        \"isExpanded\": true,');\n  lines.push('        \"createdAt\": <unix-ms>,');\n  lines.push('        \"updatedAt\": <unix-ms>');\n  lines.push('      }');\n  lines.push('    ],');\n  lines.push('    \"folderContents\": {');\n  lines.push('      // Can reference EXISTING folder ids or NEW folder ids');\n  lines.push('      \"<folder-id>\": [');\n  lines.push('        {');\n  lines.push('          \"conversationId\": \"<id from unfiled list>\",');\n  lines.push('          \"title\": \"<title>\",');\n  lines.push('          \"url\": \"<url>\",');\n  lines.push('          \"addedAt\": <unix-ms>');\n  lines.push('        }');\n  lines.push('      ]');\n  lines.push('    }');\n  lines.push('  }');\n  lines.push('}');\n  lines.push('```');\n\n  return lines.join('\\n');\n}\n\nconst LEGACY_BASELINE_PX = 1200; // used to migrate old px widths to %\nconst pxFromPercent = (percent: number) => (percent / 100) * LEGACY_BASELINE_PX;\n\nconst clampNumber = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, Math.round(value)));\n\nconst clampPercent = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, Math.round(value)));\n\nconst normalizePercent = (\n  value: number,\n  fallback: number,\n  min: number,\n  max: number,\n  legacyBaselinePx: number,\n) => {\n  if (!Number.isFinite(value)) return fallback;\n  if (value > max) {\n    const approx = (value / legacyBaselinePx) * 100;\n    return clampPercent(approx, min, max);\n  }\n  return clampPercent(value, min, max);\n};\n\nconst FOLDER_SPACING = { min: 0, max: 16, defaultValue: 2 };\nconst FOLDER_TREE_INDENT = { min: -8, max: 32, defaultValue: -8 };\nconst CHAT_PERCENT = { min: 30, max: 100, defaultValue: 70, legacyBaselinePx: LEGACY_BASELINE_PX };\nconst EDIT_PERCENT = { min: 30, max: 100, defaultValue: 60, legacyBaselinePx: LEGACY_BASELINE_PX };\nconst SIDEBAR_PERCENT = {\n  min: 15,\n  max: 45,\n  defaultValue: 26,\n  legacyBaselinePx: LEGACY_BASELINE_PX,\n};\nconst SIDEBAR_PX = {\n  min: Math.round(pxFromPercent(SIDEBAR_PERCENT.min)),\n  max: Math.round(pxFromPercent(SIDEBAR_PERCENT.max)),\n  defaultValue: Math.round(pxFromPercent(SIDEBAR_PERCENT.defaultValue)),\n};\nconst AI_STUDIO_SIDEBAR_PX = {\n  min: 240,\n  max: 600,\n  defaultValue: 280,\n};\n\nconst clampSidebarPx = (value: number) => clampNumber(value, SIDEBAR_PX.min, SIDEBAR_PX.max);\nconst normalizeSidebarPx = (value: number) => {\n  if (!Number.isFinite(value)) return SIDEBAR_PX.defaultValue;\n  // If the stored value looks like a legacy percent, convert to px first.\n  if (value <= SIDEBAR_PERCENT.max) {\n    const px = pxFromPercent(value);\n    return clampSidebarPx(px);\n  }\n  return clampSidebarPx(value);\n};\n\nconst LATEST_VERSION_CACHE_KEY = 'gvLatestVersionCache';\nconst LATEST_VERSION_MAX_AGE = 1000 * 60 * 60 * 6; // 6 hours\nconst SAFARI_DMG_RETRY_AGE = 1000 * 60 * 30; // 30 min — re-check for DMG if missing\n\nconst normalizeVersionString = (version?: string | null): string | null => {\n  if (!version) return null;\n  const trimmed = version.trim();\n  return trimmed ? trimmed.replace(/^v/i, '') : null;\n};\n\nconst toReleaseTag = (version?: string | null): string | null => {\n  if (!version) return null;\n  const trimmed = version.trim();\n  if (!trimmed) return null;\n  return trimmed.startsWith('v') ? trimmed : `v${trimmed}`;\n};\n\ninterface SettingsUpdate {\n  mode?: ScrollMode | null;\n  hideContainer?: boolean;\n  draggableTimeline?: boolean;\n  markerLevelEnabled?: boolean;\n  resetPosition?: boolean;\n  folderEnabled?: boolean;\n  hideArchivedConversations?: boolean;\n  customWebsites?: string[];\n  watermarkRemoverEnabled?: boolean;\n  hidePromptManager?: boolean;\n  inputCollapseEnabled?: boolean;\n  inputCollapseWhenNotEmpty?: boolean;\n  tabTitleUpdateEnabled?: boolean;\n  mermaidEnabled?: boolean;\n  quoteReplyEnabled?: boolean;\n  ctrlEnterSendEnabled?: boolean;\n  sidebarAutoHideEnabled?: boolean;\n  sidebarFullHideEnabled?: boolean;\n  visualEffect?: 'off' | 'snow' | 'sakura' | 'rain';\n  preventAutoScrollEnabled?: boolean;\n  forkEnabled?: boolean;\n  accountIsolationEnabled?: boolean;\n  accountIsolationPlatform?: AccountPlatform;\n  aiStudioEnabled?: boolean;\n  showMessageTimestamps?: boolean;\n}\n\nfunction SectionReorderControls({\n  isFirst,\n  isLast,\n  onMoveUp,\n  onMoveDown,\n  moveUpLabel,\n  moveDownLabel,\n}: {\n  isFirst: boolean;\n  isLast: boolean;\n  onMoveUp: () => void;\n  onMoveDown: () => void;\n  moveUpLabel: string;\n  moveDownLabel: string;\n}) {\n  return (\n    <div className=\"absolute -top-1 right-1 z-10 flex gap-px rounded-md opacity-0 transition-opacity group-hover/reorder:opacity-100\">\n      <button\n        type=\"button\"\n        onClick={(e) => {\n          e.stopPropagation();\n          onMoveUp();\n        }}\n        disabled={isFirst}\n        className=\"text-muted-foreground hover:text-foreground hover:bg-secondary/80 rounded-sm p-0.5 transition-colors disabled:cursor-not-allowed disabled:opacity-30\"\n        aria-label={moveUpLabel}\n        title={moveUpLabel}\n      >\n        <svg\n          width=\"14\"\n          height=\"14\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        >\n          <polyline points=\"18 15 12 9 6 15\" />\n        </svg>\n      </button>\n      <button\n        type=\"button\"\n        onClick={(e) => {\n          e.stopPropagation();\n          onMoveDown();\n        }}\n        disabled={isLast}\n        className=\"text-muted-foreground hover:text-foreground hover:bg-secondary/80 rounded-sm p-0.5 transition-colors disabled:cursor-not-allowed disabled:opacity-30\"\n        aria-label={moveDownLabel}\n        title={moveDownLabel}\n      >\n        <svg\n          width=\"14\"\n          height=\"14\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        >\n          <polyline points=\"6 9 12 15 18 9\" />\n        </svg>\n      </button>\n    </div>\n  );\n}\n\nexport default function Popup() {\n  const { t, language } = useLanguage();\n  const [mode, setMode] = useState<ScrollMode>('flow');\n  const [hideContainer, setHideContainer] = useState<boolean>(false);\n  const [draggableTimeline, setDraggableTimeline] = useState<boolean>(false);\n  const [markerLevelEnabled, setMarkerLevelEnabled] = useState<boolean>(false);\n  const [folderEnabled, setFolderEnabled] = useState<boolean>(true);\n  const [hideArchivedConversations, setHideArchivedConversations] = useState<boolean>(false);\n  const [customWebsites, setCustomWebsites] = useState<string[]>([]);\n  const [newWebsiteInput, setNewWebsiteInput] = useState<string>('');\n  const [websiteError, setWebsiteError] = useState<string>('');\n  const [showStarredHistory, setShowStarredHistory] = useState<boolean>(false);\n  const [formulaCopyFormat, setFormulaCopyFormat] = useState<'latex' | 'unicodemath' | 'no-dollar'>(\n    'latex',\n  );\n  const [extVersion, setExtVersion] = useState<string | null>(null);\n  const [latestVersion, setLatestVersion] = useState<string | null>(null);\n  const [safariDmgUrl, setSafariDmgUrl] = useState<string | null>(null);\n  const [watermarkRemoverEnabled, setWatermarkRemoverEnabled] = useState<boolean>(true);\n  const [hidePromptManager, setHidePromptManager] = useState<boolean>(false);\n  const [inputCollapseEnabled, setInputCollapseEnabled] = useState<boolean>(false);\n  const [inputCollapseWhenNotEmpty, setInputCollapseWhenNotEmpty] = useState<boolean>(false);\n  const [tabTitleUpdateEnabled, setTabTitleUpdateEnabled] = useState<boolean>(true);\n  const [mermaidEnabled, setMermaidEnabled] = useState<boolean>(true);\n  const [showMessageTimestamps, setShowMessageTimestamps] = useState<boolean>(false);\n  const [quoteReplyEnabled, setQuoteReplyEnabled] = useState<boolean>(true);\n  const [ctrlEnterSendEnabled, setCtrlEnterSendEnabled] = useState<boolean>(false);\n  const [sidebarAutoHideEnabled, setSidebarAutoHideEnabled] = useState<boolean>(false);\n  const [sidebarFullHideEnabled, setSidebarFullHideEnabled] = useState<boolean>(false);\n  const [visualEffect, setVisualEffect] = useState<'off' | 'snow' | 'sakura' | 'rain'>('off');\n  const [preventAutoScrollEnabled, setPreventAutoScrollEnabled] = useState<boolean>(false);\n  const [forkEnabled, setForkEnabled] = useState<boolean>(false);\n  const [chatWidthEnabled, setChatWidthEnabled] = useState<boolean>(false);\n  const [editInputWidthEnabled, setEditInputWidthEnabled] = useState<boolean>(false);\n  const [sidebarWidthEnabled, setSidebarWidthEnabled] = useState<boolean>(false);\n  const [accountIsolationEnabledGemini, setAccountIsolationEnabledGemini] =\n    useState<boolean>(false);\n  const [accountIsolationEnabledAIStudio, setAccountIsolationEnabledAIStudio] =\n    useState<boolean>(false);\n  const [aiStudioEnabled, setAiStudioEnabled] = useState<boolean>(true);\n  const [activeAccountPlatform, setActiveAccountPlatform] = useState<AccountPlatform>('gemini');\n  const [aiStructureCopyStatus, setAiStructureCopyStatus] = useState<\n    'idle' | 'loading' | 'copied' | 'error'\n  >('idle');\n  const [sectionOrder, setSectionOrder] = useState<PopupSectionId[]>([...DEFAULT_SECTION_ORDER]);\n\n  const isAIStudio = activeAccountPlatform === 'aistudio';\n  const currentIsolationPlatformLabel = isAIStudio ? t('platformAIStudio') : t('platformGemini');\n\n  useEffect(() => {\n    browser.tabs\n      .query({ active: true, currentWindow: true })\n      .then((tabs) => {\n        const url = tabs[0]?.url || '';\n        setActiveAccountPlatform(detectAccountPlatformFromUrl(url));\n      })\n      .catch(() => {});\n  }, []);\n\n  const handleFormulaCopyFormatChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {\n    const format = e.target.value as 'latex' | 'unicodemath' | 'no-dollar';\n    setFormulaCopyFormat(format);\n    try {\n      chrome.storage?.sync?.set({ gvFormulaCopyFormat: format });\n    } catch (err) {\n      console.error('[Gemini Voyager] Failed to save formula copy format:', err);\n    }\n  }, []);\n\n  const setSyncStorage = useCallback(async (payload: Record<string, unknown>) => {\n    try {\n      await browser.storage.sync.set(payload);\n      return;\n    } catch {\n      // Fallback to chrome.* if polyfill is unavailable in this context.\n    }\n\n    await new Promise<void>((resolve) => {\n      try {\n        chrome.storage?.sync?.set(payload, () => resolve());\n      } catch {\n        resolve();\n      }\n    });\n  }, []);\n\n  // Helper function to apply settings to storage\n  const apply = useCallback(\n    (settings: SettingsUpdate) => {\n      const payload: Record<string, unknown> = {};\n      if (settings.mode) payload.geminiTimelineScrollMode = settings.mode;\n      if (typeof settings.hideContainer === 'boolean')\n        payload.geminiTimelineHideContainer = settings.hideContainer;\n      if (typeof settings.draggableTimeline === 'boolean')\n        payload.geminiTimelineDraggable = settings.draggableTimeline;\n      if (typeof settings.markerLevelEnabled === 'boolean')\n        payload.geminiTimelineMarkerLevel = settings.markerLevelEnabled;\n      if (typeof settings.folderEnabled === 'boolean')\n        payload.geminiFolderEnabled = settings.folderEnabled;\n      if (typeof settings.hideArchivedConversations === 'boolean')\n        payload.geminiFolderHideArchivedConversations = settings.hideArchivedConversations;\n      if (settings.resetPosition) payload.geminiTimelinePosition = null;\n      if (settings.customWebsites) payload.gvPromptCustomWebsites = settings.customWebsites;\n      if (typeof settings.watermarkRemoverEnabled === 'boolean')\n        payload.geminiWatermarkRemoverEnabled = settings.watermarkRemoverEnabled;\n      if (typeof settings.hidePromptManager === 'boolean')\n        payload.gvHidePromptManager = settings.hidePromptManager;\n      if (typeof settings.inputCollapseEnabled === 'boolean')\n        payload.gvInputCollapseEnabled = settings.inputCollapseEnabled;\n      if (typeof settings.inputCollapseWhenNotEmpty === 'boolean')\n        payload.gvInputCollapseWhenNotEmpty = settings.inputCollapseWhenNotEmpty;\n      if (typeof settings.tabTitleUpdateEnabled === 'boolean')\n        payload.gvTabTitleUpdateEnabled = settings.tabTitleUpdateEnabled;\n      if (typeof settings.mermaidEnabled === 'boolean')\n        payload.gvMermaidEnabled = settings.mermaidEnabled;\n      if (typeof settings.quoteReplyEnabled === 'boolean')\n        payload.gvQuoteReplyEnabled = settings.quoteReplyEnabled;\n      if (typeof settings.ctrlEnterSendEnabled === 'boolean')\n        payload.gvCtrlEnterSend = settings.ctrlEnterSendEnabled;\n      if (typeof settings.sidebarAutoHideEnabled === 'boolean')\n        payload.gvSidebarAutoHide = settings.sidebarAutoHideEnabled;\n      if (typeof settings.sidebarFullHideEnabled === 'boolean')\n        payload.gvSidebarFullHide = settings.sidebarFullHideEnabled;\n      if (settings.visualEffect) {\n        payload.gvVisualEffect = settings.visualEffect;\n        // Clear legacy key\n        payload.gvSnowEffect = false;\n      }\n      if (typeof settings.preventAutoScrollEnabled === 'boolean')\n        payload.gvPreventAutoScrollEnabled = settings.preventAutoScrollEnabled;\n      if (typeof settings.forkEnabled === 'boolean')\n        payload[StorageKeys.FORK_ENABLED] = settings.forkEnabled;\n      if (typeof settings.accountIsolationEnabled === 'boolean') {\n        const isolationPlatform = settings.accountIsolationPlatform ?? activeAccountPlatform;\n        payload[getAccountIsolationStorageKey(isolationPlatform)] =\n          settings.accountIsolationEnabled;\n      }\n      if (typeof settings.aiStudioEnabled === 'boolean')\n        payload[StorageKeys.GV_AISTUDIO_ENABLED] = settings.aiStudioEnabled;\n      if (typeof settings.showMessageTimestamps === 'boolean')\n        payload[StorageKeys.GV_SHOW_MESSAGE_TIMESTAMPS] = settings.showMessageTimestamps;\n      void setSyncStorage(payload);\n    },\n    [activeAccountPlatform, setSyncStorage],\n  );\n\n  // Copy folder structure for AI organization\n  const handleCopyFolderStructureForAI = useCallback(async () => {\n    setAiStructureCopyStatus('loading');\n    try {\n      const tabs = await browser.tabs.query({ active: true, currentWindow: true });\n      const tabId = tabs[0]?.id;\n      if (!tabId) {\n        setAiStructureCopyStatus('error');\n        return;\n      }\n\n      const response = (await browser.tabs.sendMessage(tabId, {\n        type: 'gv.folders.getStructureForAI',\n      })) as {\n        ok: boolean;\n        sidebarConversations: Array<{ id: string; title: string; url: string }>;\n        folderData: { folders: Folder[]; folderContents: Record<string, ConversationReference[]> };\n      };\n\n      if (!response?.ok) {\n        setAiStructureCopyStatus('error');\n        return;\n      }\n\n      const { sidebarConversations, folderData } = response;\n      const prompt = formatFolderStructurePrompt(sidebarConversations, folderData, language);\n      await navigator.clipboard.writeText(prompt);\n      setAiStructureCopyStatus('copied');\n      setTimeout(() => setAiStructureCopyStatus('idle'), 2000);\n    } catch {\n      setAiStructureCopyStatus('error');\n      setTimeout(() => setAiStructureCopyStatus('idle'), 2000);\n    }\n  }, [language]);\n\n  // Width adjuster for chat width\n  const chatWidthAdjuster = useWidthAdjuster({\n    storageKey: 'geminiChatWidth',\n    defaultValue: CHAT_PERCENT.defaultValue,\n    normalize: (v) =>\n      normalizePercent(\n        v,\n        CHAT_PERCENT.defaultValue,\n        CHAT_PERCENT.min,\n        CHAT_PERCENT.max,\n        CHAT_PERCENT.legacyBaselinePx,\n      ),\n    onApply: useCallback((widthPercent: number) => {\n      const normalized = normalizePercent(\n        widthPercent,\n        CHAT_PERCENT.defaultValue,\n        CHAT_PERCENT.min,\n        CHAT_PERCENT.max,\n        CHAT_PERCENT.legacyBaselinePx,\n      );\n      try {\n        chrome.storage?.sync?.set({ geminiChatWidth: normalized });\n      } catch {}\n    }, []),\n  });\n\n  // Width adjuster for edit input width\n  const editInputWidthAdjuster = useWidthAdjuster({\n    storageKey: 'geminiEditInputWidth',\n    defaultValue: EDIT_PERCENT.defaultValue,\n    normalize: (v) =>\n      normalizePercent(\n        v,\n        EDIT_PERCENT.defaultValue,\n        EDIT_PERCENT.min,\n        EDIT_PERCENT.max,\n        EDIT_PERCENT.legacyBaselinePx,\n      ),\n    onApply: useCallback((widthPercent: number) => {\n      const normalized = normalizePercent(\n        widthPercent,\n        EDIT_PERCENT.defaultValue,\n        EDIT_PERCENT.min,\n        EDIT_PERCENT.max,\n        EDIT_PERCENT.legacyBaselinePx,\n      );\n      try {\n        chrome.storage?.sync?.set({ geminiEditInputWidth: normalized });\n      } catch {}\n    }, []),\n  });\n\n  // Width adjuster for sidebar width (Context-aware: Gemini vs AI Studio)\n  const sidebarConfig = useMemo(\n    () =>\n      isAIStudio\n        ? {\n            key: 'gvAIStudioSidebarWidth',\n            min: AI_STUDIO_SIDEBAR_PX.min,\n            max: AI_STUDIO_SIDEBAR_PX.max,\n            def: AI_STUDIO_SIDEBAR_PX.defaultValue,\n            norm: (v: number) => clampNumber(v, AI_STUDIO_SIDEBAR_PX.min, AI_STUDIO_SIDEBAR_PX.max),\n          }\n        : {\n            key: 'geminiSidebarWidth',\n            min: SIDEBAR_PX.min,\n            max: SIDEBAR_PX.max,\n            def: SIDEBAR_PX.defaultValue,\n            norm: normalizeSidebarPx,\n          },\n    [isAIStudio],\n  );\n\n  const sidebarWidthAdjuster = useWidthAdjuster({\n    storageKey: sidebarConfig.key,\n    defaultValue: sidebarConfig.def,\n    normalize: sidebarConfig.norm,\n    onApply: useCallback(\n      (widthPx: number) => {\n        const clamped = sidebarConfig.norm(widthPx);\n        try {\n          chrome.storage?.sync?.set({ [sidebarConfig.key]: clamped });\n        } catch {}\n      },\n      [sidebarConfig],\n    ),\n  });\n\n  // Folder spacing adjuster (Context-aware: Gemini vs AI Studio)\n  const folderSpacingKey = isAIStudio ? 'gvAIStudioFolderSpacing' : 'gvFolderSpacing';\n\n  const folderSpacingAdjuster = useWidthAdjuster({\n    storageKey: folderSpacingKey,\n    defaultValue: FOLDER_SPACING.defaultValue,\n    normalize: (v) => clampNumber(v, FOLDER_SPACING.min, FOLDER_SPACING.max),\n    onApply: useCallback(\n      (spacing: number) => {\n        const clamped = clampNumber(spacing, FOLDER_SPACING.min, FOLDER_SPACING.max);\n        try {\n          chrome.storage?.sync?.set({ [folderSpacingKey]: clamped });\n        } catch {}\n      },\n      [folderSpacingKey],\n    ),\n  });\n\n  const folderTreeIndentAdjuster = useWidthAdjuster({\n    storageKey: 'gvFolderTreeIndent',\n    defaultValue: FOLDER_TREE_INDENT.defaultValue,\n    normalize: (v) => clampNumber(v, FOLDER_TREE_INDENT.min, FOLDER_TREE_INDENT.max),\n    onApply: useCallback((indent: number) => {\n      const clamped = clampNumber(indent, FOLDER_TREE_INDENT.min, FOLDER_TREE_INDENT.max);\n      try {\n        chrome.storage?.sync?.set({ gvFolderTreeIndent: clamped });\n      } catch {}\n    }, []),\n  });\n\n  useEffect(() => {\n    try {\n      const version = chrome?.runtime?.getManifest?.()?.version;\n      if (version) {\n        setExtVersion(version);\n      }\n    } catch (err) {\n      console.error('[Gemini Voyager] Failed to get extension version:', err);\n    }\n  }, []);\n\n  useEffect(() => {\n    let cancelled = false;\n\n    const fetchLatestVersion = async () => {\n      if (!extVersion) return;\n\n      // Check for store installation (Chrome/Edge Web Store)\n      // Store-installed extensions have an 'update_url' in the manifest.\n      // We skip manual version checks for these users to rely on store auto-updates\n      // and prevent confusing \"new version\" prompts when GitHub is ahead of the store.\n      const manifest = chrome?.runtime?.getManifest?.();\n\n      // For Safari: only skip update check if the feature is disabled (default)\n      // If shouldShowSafariUpdateReminder() returns true, allow update checks\n      if (isSafari() && !shouldShowSafariUpdateReminder()) {\n        return;\n      }\n\n      // For other browsers: skip if they have update_url (store installation)\n      if (!isSafari() && getManifestUpdateUrl(manifest)) {\n        return;\n      }\n\n      try {\n        const cache = await browser.storage.local.get(LATEST_VERSION_CACHE_KEY);\n        const now = Date.now();\n\n        const cachedEntry = cache?.[LATEST_VERSION_CACHE_KEY];\n        let latest = getCachedLatestVersion(cachedEntry, now, LATEST_VERSION_MAX_AGE);\n        let dmgUrl: string | null = null;\n\n        if (latest && isSafari()) {\n          // Try to read cached DMG URL\n          if (\n            typeof cachedEntry === 'object' &&\n            cachedEntry !== null &&\n            'dmgUrl' in cachedEntry &&\n            typeof (cachedEntry as Record<string, unknown>).dmgUrl === 'string'\n          ) {\n            dmgUrl = (cachedEntry as Record<string, unknown>).dmgUrl as string;\n          }\n          // If DMG URL was not cached, re-fetch — but respect a 30 min cooldown\n          // to avoid hitting GitHub API rate limits\n          if (\n            !dmgUrl &&\n            typeof cachedEntry === 'object' &&\n            cachedEntry !== null &&\n            'fetchedAt' in cachedEntry &&\n            typeof (cachedEntry as Record<string, unknown>).fetchedAt === 'number' &&\n            now - ((cachedEntry as Record<string, unknown>).fetchedAt as number) >=\n              SAFARI_DMG_RETRY_AGE\n          ) {\n            latest = null;\n          }\n        }\n\n        if (!latest) {\n          const resp = await fetch(\n            'https://api.github.com/repos/Nagi-ovo/gemini-voyager/releases/latest',\n            {\n              headers: { Accept: 'application/vnd.github+json' },\n            },\n          );\n\n          if (!resp.ok) {\n            throw new Error(`HTTP ${resp.status}`);\n          }\n\n          const data: unknown = await resp.json();\n          const candidate = extractLatestReleaseVersion(data);\n\n          if (candidate) {\n            latest = candidate;\n            const isSafariFetch = isSafari();\n            if (isSafariFetch) {\n              dmgUrl = extractDmgDownloadUrl(data);\n            }\n            await browser.storage.local.set({\n              [LATEST_VERSION_CACHE_KEY]: {\n                version: candidate,\n                fetchedAt: now,\n                ...(isSafariFetch ? { dmgUrl } : {}),\n              },\n            });\n          }\n        }\n\n        if (cancelled || !latest) return;\n\n        setLatestVersion(latest);\n        if (isSafari()) {\n          setSafariDmgUrl(dmgUrl);\n        }\n      } catch (error) {\n        if (!cancelled) {\n          console.warn('[Gemini Voyager] Failed to check latest version:', error);\n        }\n      }\n    };\n\n    fetchLatestVersion();\n\n    return () => {\n      cancelled = true;\n    };\n  }, [extVersion]);\n\n  useEffect(() => {\n    try {\n      chrome.storage?.sync?.get(\n        {\n          geminiTimelineScrollMode: 'flow',\n          geminiTimelineHideContainer: false,\n          geminiTimelineDraggable: false,\n          geminiTimelineMarkerLevel: false,\n          geminiFolderEnabled: true,\n          geminiFolderHideArchivedConversations: false,\n          gvPromptCustomWebsites: [],\n          gvFormulaCopyFormat: 'latex',\n          geminiWatermarkRemoverEnabled: true,\n          gvHidePromptManager: false,\n          gvInputCollapseEnabled: false,\n          gvInputCollapseWhenNotEmpty: false,\n          gvTabTitleUpdateEnabled: true,\n          gvMermaidEnabled: true,\n          gvQuoteReplyEnabled: true,\n          gvCtrlEnterSend: false,\n          gvSidebarAutoHide: false,\n          gvSidebarFullHide: false,\n          gvSnowEffect: false,\n          gvPreventAutoScrollEnabled: false,\n          [StorageKeys.FORK_ENABLED]: false,\n          [StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED]: false,\n          [StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED_GEMINI]: null,\n          [StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED_AISTUDIO]: null,\n          [StorageKeys.GV_AISTUDIO_ENABLED]: true,\n          gvChatWidthEnabled: false,\n          gvEditInputWidthEnabled: false,\n          gvSidebarWidthEnabled: false,\n          geminiChatWidth: CHAT_PERCENT.defaultValue,\n          geminiEditInputWidth: EDIT_PERCENT.defaultValue,\n          [StorageKeys.GV_SHOW_MESSAGE_TIMESTAMPS]: false,\n          [StorageKeys.GV_POPUP_SECTION_ORDER]: null,\n        },\n        (res) => {\n          const m = res?.geminiTimelineScrollMode as ScrollMode;\n          if (m === 'jump' || m === 'flow') setMode(m);\n          const format = res?.gvFormulaCopyFormat as 'latex' | 'unicodemath' | 'no-dollar';\n          if (format === 'latex' || format === 'unicodemath' || format === 'no-dollar')\n            setFormulaCopyFormat(format);\n          setHideContainer(!!res?.geminiTimelineHideContainer);\n          setDraggableTimeline(!!res?.geminiTimelineDraggable);\n          setMarkerLevelEnabled(!!res?.geminiTimelineMarkerLevel);\n          setFolderEnabled(res?.geminiFolderEnabled !== false);\n          setHideArchivedConversations(!!res?.geminiFolderHideArchivedConversations);\n          const loadedCustomWebsites = Array.isArray(res?.gvPromptCustomWebsites)\n            ? res.gvPromptCustomWebsites.filter((w: unknown) => typeof w === 'string')\n            : [];\n          setCustomWebsites(loadedCustomWebsites);\n          setWatermarkRemoverEnabled(res?.geminiWatermarkRemoverEnabled !== false);\n          setHidePromptManager(!!res?.gvHidePromptManager);\n          setInputCollapseEnabled(res?.gvInputCollapseEnabled !== false);\n          setInputCollapseWhenNotEmpty(res?.gvInputCollapseWhenNotEmpty === true);\n          setTabTitleUpdateEnabled(res?.gvTabTitleUpdateEnabled !== false);\n          setMermaidEnabled(res?.gvMermaidEnabled !== false);\n          setQuoteReplyEnabled(res?.gvQuoteReplyEnabled !== false);\n          setCtrlEnterSendEnabled(res?.gvCtrlEnterSend === true);\n          setSidebarAutoHideEnabled(res?.gvSidebarAutoHide === true);\n          setSidebarFullHideEnabled(res?.gvSidebarFullHide === true);\n          // Resolve visual effect: new key takes precedence over legacy boolean\n          const storedVisualEffect = res?.gvVisualEffect;\n          if (\n            storedVisualEffect === 'snow' ||\n            storedVisualEffect === 'sakura' ||\n            storedVisualEffect === 'rain'\n          ) {\n            setVisualEffect(storedVisualEffect);\n          } else if (res?.gvSnowEffect === true) {\n            setVisualEffect('snow');\n          } else {\n            setVisualEffect('off');\n          }\n          setPreventAutoScrollEnabled(res?.gvPreventAutoScrollEnabled === true);\n          setForkEnabled(res?.[StorageKeys.FORK_ENABLED] === true);\n          setAiStudioEnabled(res?.[StorageKeys.GV_AISTUDIO_ENABLED] !== false);\n\n          // Width enabled flags — auto-enable if user previously customized the width\n          setChatWidthEnabled(\n            res?.gvChatWidthEnabled === true ||\n              (res?.gvChatWidthEnabled === false &&\n                typeof res?.geminiChatWidth === 'number' &&\n                res.geminiChatWidth !== CHAT_PERCENT.defaultValue),\n          );\n          setEditInputWidthEnabled(\n            res?.gvEditInputWidthEnabled === true ||\n              (res?.gvEditInputWidthEnabled === false &&\n                typeof res?.geminiEditInputWidth === 'number' &&\n                res.geminiEditInputWidth !== EDIT_PERCENT.defaultValue),\n          );\n          setSidebarWidthEnabled(res?.gvSidebarWidthEnabled === true);\n\n          const legacyIsolationEnabled = res?.[StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED] === true;\n          const geminiIsolationRaw = res?.[StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED_GEMINI];\n          const aiStudioIsolationRaw = res?.[StorageKeys.GV_ACCOUNT_ISOLATION_ENABLED_AISTUDIO];\n          setAccountIsolationEnabledGemini(\n            typeof geminiIsolationRaw === 'boolean' ? geminiIsolationRaw : legacyIsolationEnabled,\n          );\n          setAccountIsolationEnabledAIStudio(\n            typeof aiStudioIsolationRaw === 'boolean'\n              ? aiStudioIsolationRaw\n              : legacyIsolationEnabled,\n          );\n\n          // Timestamp settings\n          setShowMessageTimestamps(res?.[StorageKeys.GV_SHOW_MESSAGE_TIMESTAMPS] === true);\n\n          // Section order\n          const storedOrder = res?.[StorageKeys.GV_POPUP_SECTION_ORDER];\n          if (Array.isArray(storedOrder)) {\n            const validIds = new Set<string>(POPUP_SECTION_IDS);\n            const filtered = storedOrder.filter(\n              (id: unknown): id is PopupSectionId => typeof id === 'string' && validIds.has(id),\n            );\n            const seen = new Set(filtered);\n            const missing = POPUP_SECTION_IDS.filter((id) => !seen.has(id));\n            setSectionOrder([...filtered, ...missing]);\n          }\n\n          // Reconcile stored custom websites with actual granted permissions.\n          // If the user denied a permission request, the popup may have closed before we could revert storage.\n          void (async () => {\n            if (!loadedCustomWebsites.length) return;\n            if (!browser.permissions?.contains) return;\n\n            const hasAnyPermission = async (domain: string) => {\n              try {\n                const normalized = domain\n                  .trim()\n                  .toLowerCase()\n                  .replace(/^https?:\\/\\//, '')\n                  .replace(/^www\\./, '')\n                  .replace(/\\/.*$/, '')\n                  .replace(/^\\*\\./, '');\n                if (!normalized) return false;\n\n                const origins = [`https://*.${normalized}/*`, `http://*.${normalized}/*`];\n                for (const origin of origins) {\n                  if (await browser.permissions.contains({ origins: [origin] })) return true;\n                }\n                return false;\n              } catch {\n                return true; // fail open to avoid destructive cleanup on unexpected errors\n              }\n            };\n\n            const filtered = (\n              await Promise.all(\n                loadedCustomWebsites.map(async (domain: string) => ({\n                  domain,\n                  ok: await hasAnyPermission(domain),\n                })),\n              )\n            )\n              .filter((item) => item.ok)\n              .map((item) => item.domain);\n\n            if (filtered.length !== loadedCustomWebsites.length) {\n              setCustomWebsites(filtered);\n              await setSyncStorage({ gvPromptCustomWebsites: filtered });\n            }\n          })();\n        },\n      );\n    } catch {}\n  }, [setSyncStorage]);\n\n  // Validate and normalize URL\n  const normalizeUrl = useCallback((url: string): string | null => {\n    try {\n      let normalized = url.trim().toLowerCase();\n\n      // Remove protocol if present\n      normalized = normalized.replace(/^https?:\\/\\//, '');\n\n      // Remove trailing slash\n      normalized = normalized.replace(/\\/$/, '');\n\n      // Remove www. prefix\n      normalized = normalized.replace(/^www\\./, '');\n\n      // Basic validation: must contain at least one dot and valid characters\n      if (!/^[a-z0-9.-]+\\.[a-z]{2,}$/i.test(normalized)) {\n        return null;\n      }\n\n      return normalized;\n    } catch {\n      return null;\n    }\n  }, []);\n\n  const originPatternsForDomain = useCallback((domain: string): string[] | null => {\n    try {\n      const normalized = domain\n        .trim()\n        .toLowerCase()\n        .replace(/^https?:\\/\\//, '')\n        .replace(/^www\\./, '')\n        .replace(/\\/.*$/, '')\n        .replace(/^\\*\\./, '');\n      if (!normalized) return null;\n      return [`https://*.${normalized}/*`, `http://*.${normalized}/*`];\n    } catch {\n      return null;\n    }\n  }, []);\n\n  const requestCustomWebsitePermission = useCallback(\n    async (domain: string): Promise<boolean> => {\n      const originPatterns = originPatternsForDomain(domain);\n      if (!originPatterns) {\n        setWebsiteError(t('invalidUrl'));\n        return false;\n      }\n\n      if (!browser.permissions?.request || !browser.permissions?.contains) {\n        setWebsiteError(t('permissionRequestFailed'));\n        return false;\n      }\n\n      try {\n        const alreadyGranted = await browser.permissions.contains({ origins: originPatterns });\n        if (alreadyGranted) return true;\n\n        const granted = await browser.permissions.request({ origins: originPatterns });\n        if (!granted) {\n          setWebsiteError(t('permissionDenied'));\n        }\n        return granted;\n      } catch (err) {\n        console.error('[Gemini Voyager] Failed to request permissions for custom website:', err);\n        setWebsiteError(t('permissionRequestFailed'));\n        return false;\n      }\n    },\n    [originPatternsForDomain, t],\n  );\n\n  const revokeCustomWebsitePermission = useCallback(\n    async (domain: string) => {\n      const originPatterns = originPatternsForDomain(domain);\n      if (!originPatterns || !browser.permissions?.remove) return;\n\n      try {\n        await browser.permissions.remove({ origins: originPatterns });\n      } catch (err) {\n        console.warn('[Gemini Voyager] Failed to revoke permission for', domain, err);\n      }\n    },\n    [originPatternsForDomain],\n  );\n\n  // Add website handler\n  const handleAddWebsite = useCallback(async () => {\n    setWebsiteError('');\n\n    if (!newWebsiteInput.trim()) {\n      return;\n    }\n\n    const normalized = normalizeUrl(newWebsiteInput);\n\n    if (!normalized) {\n      setWebsiteError(t('invalidUrl'));\n      return;\n    }\n\n    // Check if already exists\n    if (customWebsites.includes(normalized)) {\n      setWebsiteError(t('invalidUrl'));\n      return;\n    }\n\n    // Persist the user's selection first. Popup may close during the permission prompt.\n    const updatedWebsites = [...customWebsites, normalized];\n    setCustomWebsites(updatedWebsites);\n    await setSyncStorage({ gvPromptCustomWebsites: updatedWebsites });\n    setNewWebsiteInput('');\n\n    const granted = await requestCustomWebsitePermission(normalized);\n    if (!granted) {\n      setCustomWebsites(customWebsites);\n      await setSyncStorage({ gvPromptCustomWebsites: customWebsites });\n    }\n  }, [\n    newWebsiteInput,\n    customWebsites,\n    normalizeUrl,\n    t,\n    requestCustomWebsitePermission,\n    setSyncStorage,\n  ]);\n\n  // Remove website handler\n  const handleRemoveWebsite = useCallback(\n    async (website: string) => {\n      const updatedWebsites = customWebsites.filter((w) => w !== website);\n      setCustomWebsites(updatedWebsites);\n      await setSyncStorage({ gvPromptCustomWebsites: updatedWebsites });\n      await revokeCustomWebsitePermission(website);\n    },\n    [customWebsites, revokeCustomWebsitePermission, setSyncStorage],\n  );\n\n  const toggleQuickWebsite = useCallback(\n    async (domain: string, isEnabled: boolean) => {\n      if (isEnabled) {\n        const updated = customWebsites.filter((w) => w !== domain);\n        setCustomWebsites(updated);\n        await setSyncStorage({ gvPromptCustomWebsites: updated });\n        await revokeCustomWebsitePermission(domain);\n        return;\n      }\n\n      // Persist the user's selection first. Popup may close during the permission prompt.\n      const updated = [...customWebsites, domain];\n      setCustomWebsites(updated);\n      await setSyncStorage({ gvPromptCustomWebsites: updated });\n\n      const granted = await requestCustomWebsitePermission(domain);\n      if (!granted) {\n        setCustomWebsites(customWebsites);\n        await setSyncStorage({ gvPromptCustomWebsites: customWebsites });\n      }\n    },\n    [customWebsites, requestCustomWebsitePermission, revokeCustomWebsitePermission, setSyncStorage],\n  );\n\n  const normalizedCurrentVersion = normalizeVersionString(extVersion);\n  const normalizedLatestVersion = normalizeVersionString(latestVersion);\n  const isSafariBrowser = isSafari();\n  const safariUpdateReminderEnabled = isSafariBrowser && shouldShowSafariUpdateReminder();\n  const shouldShowUpdateNotification = shouldShowUpdateReminderForCurrentVersion({\n    currentVersion: normalizedCurrentVersion,\n    isSafariBrowser,\n    safariReminderEnabled: safariUpdateReminderEnabled,\n  });\n  const hasUpdate =\n    shouldShowUpdateNotification && normalizedCurrentVersion && normalizedLatestVersion\n      ? compareVersions(normalizedLatestVersion, normalizedCurrentVersion) > 0\n      : false;\n  const latestReleaseTag = toReleaseTag(latestVersion ?? normalizedLatestVersion ?? undefined);\n  const latestReleaseUrl = latestReleaseTag\n    ? `https://github.com/Nagi-ovo/gemini-voyager/releases/tag/${latestReleaseTag}`\n    : 'https://github.com/Nagi-ovo/gemini-voyager/releases/latest';\n  const currentReleaseTag = toReleaseTag(extVersion);\n  const releaseUrl = extVersion\n    ? `https://github.com/Nagi-ovo/gemini-voyager/releases/tag/${currentReleaseTag ?? `v${extVersion}`}`\n    : 'https://github.com/Nagi-ovo/gemini-voyager/releases';\n\n  const websiteUrl =\n    language === 'zh' ? 'https://voyager.nagi.fun' : `https://voyager.nagi.fun/${language}`;\n\n  // ── Section reorder helpers ──────────────────────────────────\n  const isSectionVisible = (id: PopupSectionId): boolean => {\n    switch (id) {\n      case 'cloudSync':\n      case 'nanobanana':\n        return !isSafariBrowser;\n      case 'folderTreeIndent':\n      case 'sidebarBehavior':\n      case 'visualEffect':\n        return !isAIStudio;\n      default:\n        return true;\n    }\n  };\n\n  const visibleSections = sectionOrder.filter(isSectionVisible);\n\n  const moveSectionInOrder = (sectionId: PopupSectionId, direction: 'up' | 'down') => {\n    setSectionOrder((prev) => {\n      const idx = prev.indexOf(sectionId);\n      if (idx === -1) return prev;\n\n      const step = direction === 'up' ? -1 : 1;\n      let swapIdx = idx + step;\n      // Skip hidden sections so the swap targets the next visible one\n      while (swapIdx >= 0 && swapIdx < prev.length && !isSectionVisible(prev[swapIdx])) {\n        swapIdx += step;\n      }\n      if (swapIdx < 0 || swapIdx >= prev.length) return prev;\n\n      const next = [...prev];\n      [next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];\n      void setSyncStorage({ [StorageKeys.GV_POPUP_SECTION_ORDER]: next });\n      return next;\n    });\n  };\n\n  const wrapSection = (id: PopupSectionId, content: React.ReactNode) => (\n    <div key={id} style={{ order: sectionOrder.indexOf(id) }} className=\"group/reorder relative\">\n      <SectionReorderControls\n        isFirst={visibleSections[0] === id}\n        isLast={visibleSections[visibleSections.length - 1] === id}\n        onMoveUp={() => moveSectionInOrder(id, 'up')}\n        onMoveDown={() => moveSectionInOrder(id, 'down')}\n        moveUpLabel={t('moveSectionUp')}\n        moveDownLabel={t('moveSectionDown')}\n      />\n      {content}\n    </div>\n  );\n\n  // Show starred history if requested\n  if (showStarredHistory) {\n    return <StarredHistory onClose={() => setShowStarredHistory(false)} />;\n  }\n\n  return (\n    <div className=\"bg-background text-foreground w-[360px]\">\n      {/* Header */}\n      <div className=\"border-border/50 flex items-center justify-between border-b px-5 py-5\">\n        <h1 className=\"text-primary text-2xl font-extrabold tracking-tight\">{t('extName')}</h1>\n        <div className=\"flex items-center gap-1\">\n          <DarkModeToggle />\n          <LanguageSwitcher />\n        </div>\n      </div>\n\n      <div className=\"flex flex-col gap-4 p-5\">\n        {hasUpdate && normalizedLatestVersion && normalizedCurrentVersion && (\n          <Card\n            style={{ order: -2 }}\n            className=\"border-amber-200 bg-amber-50 p-3 text-amber-900 shadow-sm\"\n          >\n            <div className=\"flex items-start gap-3\">\n              <div className=\"mt-1 text-amber-600\">\n                <svg\n                  width=\"16\"\n                  height=\"16\"\n                  viewBox=\"0 0 24 24\"\n                  fill=\"currentColor\"\n                  aria-hidden=\"true\"\n                >\n                  <path d=\"M12 2l4 4h-3v7h-2V6H8l4-4zm6 11v6H6v-6H4v8h16v-8h-2z\" />\n                </svg>\n              </div>\n              <div className=\"flex-1 space-y-1\">\n                <p className=\"text-sm leading-tight font-semibold\">{t('newVersionAvailable')}</p>\n                <p className=\"text-xs leading-tight\">\n                  {t('currentVersionLabel')}: v{normalizedCurrentVersion} ·{' '}\n                  {t('latestVersionLabel')}: v{normalizedLatestVersion}\n                </p>\n              </div>\n              {isSafariBrowser ? (\n                safariDmgUrl ? (\n                  <a\n                    href={safariDmgUrl}\n                    target=\"_blank\"\n                    rel=\"noreferrer\"\n                    className=\"shrink-0 rounded-md bg-amber-100 px-3 py-1.5 text-xs font-semibold text-amber-900 transition-colors hover:bg-amber-200\"\n                  >\n                    {t('updateNow')}\n                  </a>\n                ) : (\n                  <span className=\"shrink-0 text-xs leading-tight text-amber-700\">\n                    {t('safariUpdateNotSynced')}\n                  </span>\n                )\n              ) : (\n                <a\n                  href={latestReleaseUrl}\n                  target=\"_blank\"\n                  rel=\"noreferrer\"\n                  className=\"shrink-0 rounded-md bg-amber-100 px-3 py-1.5 text-xs font-semibold text-amber-900 transition-colors hover:bg-amber-200\"\n                >\n                  {t('updateNow')}\n                </a>\n              )}\n            </div>\n          </Card>\n        )}\n        {/* AI Studio master toggle - only shown when on AI Studio */}\n        {isAIStudio && (\n          <Card\n            style={{ order: -1 }}\n            className=\"border-primary/20 p-4 transition-all hover:shadow-md\"\n          >\n            <CardContent className=\"p-0\">\n              <div className=\"group flex items-center justify-between\">\n                <div className=\"flex-1\">\n                  <Label\n                    htmlFor=\"aistudio-enabled\"\n                    className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                  >\n                    {t('enableOnAIStudio')}\n                  </Label>\n                  <p className=\"text-muted-foreground mt-1 text-xs\">{t('enableOnAIStudioHint')}</p>\n                </div>\n                <Switch\n                  id=\"aistudio-enabled\"\n                  checked={aiStudioEnabled}\n                  onChange={(e) => {\n                    setAiStudioEnabled(e.target.checked);\n                    apply({ aiStudioEnabled: e.target.checked });\n                  }}\n                />\n              </div>\n            </CardContent>\n          </Card>\n        )}\n        {/* Cloud Sync */}\n        {!isSafariBrowser && wrapSection('cloudSync', <CloudSyncSettings />)}\n        {/* Context Sync */}\n        {wrapSection('contextSync', <ContextSyncSettings />)}\n        {/* Timeline Options */}\n        {wrapSection(\n          'timeline',\n          <Card className=\"p-4 transition-all hover:shadow-md\">\n            <CardTitle className=\"mb-4\">{t('timelineOptions')}</CardTitle>\n            <CardContent className=\"space-y-4 p-0\">\n              {/* Scroll Mode */}\n              <div>\n                <Label className=\"mb-2 block text-sm font-medium\">{t('scrollMode')}</Label>\n                <div className=\"bg-secondary/60 relative grid grid-cols-2 gap-1 rounded-xl p-1\">\n                  <div\n                    className=\"bg-primary pointer-events-none absolute top-1 bottom-1 w-[calc(50%-4px)] rounded-lg shadow-sm transition-all duration-300 ease-out\"\n                    style={{ left: mode === 'flow' ? '4px' : 'calc(50% + 2px)' }}\n                  />\n                  <button\n                    className={`relative z-10 rounded-lg px-3 py-2 text-sm font-bold transition-all duration-200 ${\n                      mode === 'flow'\n                        ? 'text-primary-foreground'\n                        : 'text-muted-foreground hover:text-foreground'\n                    }`}\n                    onClick={() => {\n                      setMode('flow');\n                      apply({ mode: 'flow' });\n                    }}\n                  >\n                    {t('flow')}\n                  </button>\n                  <button\n                    className={`relative z-10 rounded-lg px-3 py-2 text-sm font-bold transition-all duration-200 ${\n                      mode === 'jump'\n                        ? 'text-primary-foreground'\n                        : 'text-muted-foreground hover:text-foreground'\n                    }`}\n                    onClick={() => {\n                      setMode('jump');\n                      apply({ mode: 'jump' });\n                    }}\n                  >\n                    {t('jump')}\n                  </button>\n                </div>\n              </div>\n              <div className=\"group flex items-center justify-between\">\n                <Label\n                  htmlFor=\"hide-container\"\n                  className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                >\n                  {t('hideOuterContainer')}\n                </Label>\n                <Switch\n                  id=\"hide-container\"\n                  checked={hideContainer}\n                  onChange={(e) => {\n                    setHideContainer(e.target.checked);\n                    apply({ hideContainer: e.target.checked });\n                  }}\n                />\n              </div>\n              <div className=\"group flex items-center justify-between\">\n                <Label\n                  htmlFor=\"draggable-timeline\"\n                  className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                >\n                  {t('draggableTimeline')}\n                </Label>\n                <Switch\n                  id=\"draggable-timeline\"\n                  checked={draggableTimeline}\n                  onChange={(e) => {\n                    setDraggableTimeline(e.target.checked);\n                    apply({ draggableTimeline: e.target.checked });\n                  }}\n                />\n              </div>\n              <div className=\"group flex items-center justify-between\">\n                <div className=\"flex-1\">\n                  <Label\n                    htmlFor=\"prevent-auto-scroll\"\n                    className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                  >\n                    {t('preventAutoScroll')}\n                  </Label>\n                  <p className=\"text-muted-foreground mt-1 text-xs\">{t('preventAutoScrollHint')}</p>\n                </div>\n                <Switch\n                  id=\"prevent-auto-scroll\"\n                  checked={preventAutoScrollEnabled}\n                  onChange={(e) => {\n                    setPreventAutoScrollEnabled(e.target.checked);\n                    apply({ preventAutoScrollEnabled: e.target.checked });\n                  }}\n                />\n              </div>\n              <div className=\"group flex items-center justify-between\">\n                <div className=\"flex-1\">\n                  <Label\n                    htmlFor=\"marker-level-enabled\"\n                    className=\"group-hover:text-primary flex cursor-pointer items-center gap-1 text-sm font-medium transition-colors\"\n                  >\n                    {t('enableMarkerLevel')}\n                    <span\n                      className=\"material-symbols-outlined cursor-help text-[16px] leading-none opacity-50 transition-opacity hover:opacity-100\"\n                      title={t('experimentalLabel')}\n                      style={{ fontVariationSettings: \"'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20\" }}\n                    >\n                      experiment\n                    </span>\n                  </Label>\n                  <p className=\"text-muted-foreground mt-1 text-xs\">{t('enableMarkerLevelHint')}</p>\n                </div>\n                <Switch\n                  id=\"marker-level-enabled\"\n                  checked={markerLevelEnabled}\n                  onChange={(e) => {\n                    setMarkerLevelEnabled(e.target.checked);\n                    apply({ markerLevelEnabled: e.target.checked });\n                  }}\n                />\n              </div>\n              {/* Message Timestamps */}\n              <div className=\"group flex items-center justify-between\">\n                <div className=\"flex-1\">\n                  <Label\n                    htmlFor=\"show-message-timestamps\"\n                    className=\"group-hover:text-primary flex cursor-pointer items-center gap-1 text-sm font-medium transition-colors\"\n                  >\n                    {t('showMessageTimestamps')}\n                    <span\n                      className=\"material-symbols-outlined cursor-help text-[16px] leading-none opacity-50 transition-opacity hover:opacity-100\"\n                      title={t('experimentalLabel')}\n                      style={{ fontVariationSettings: \"'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20\" }}\n                    >\n                      experiment\n                    </span>\n                  </Label>\n                  <p className=\"text-muted-foreground mt-1 text-xs\">\n                    {t('showMessageTimestampsHint')}\n                  </p>\n                </div>\n                <Switch\n                  id=\"show-message-timestamps\"\n                  checked={showMessageTimestamps}\n                  onChange={(e) => {\n                    setShowMessageTimestamps(e.target.checked);\n                    apply({ showMessageTimestamps: e.target.checked });\n                  }}\n                />\n              </div>\n              {/* Reset Timeline Position Button */}\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                className=\"group hover:border-primary/50 mt-2 w-full\"\n                onClick={() => {\n                  apply({ resetPosition: true });\n                }}\n              >\n                <span className=\"text-xs transition-transform group-hover:scale-105\">\n                  {t('resetTimelinePosition')}\n                </span>\n              </Button>\n              {/* View Starred History Button */}\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                className=\"group hover:border-primary/50 mt-2 w-full\"\n                onClick={() => setShowStarredHistory(true)}\n              >\n                <span className=\"flex items-center gap-1.5 text-xs transition-transform group-hover:scale-105\">\n                  <svg\n                    width=\"14\"\n                    height=\"14\"\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    className=\"text-primary\"\n                  >\n                    <path\n                      d=\"M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z\"\n                      fill=\"currentColor\"\n                    />\n                  </svg>\n                  {t('viewStarredHistory')}\n                </span>\n              </Button>\n            </CardContent>\n          </Card>,\n        )}\n        {/* Folder Options */}\n        {wrapSection(\n          'folder',\n          <Card className=\"p-4 transition-all hover:shadow-md\">\n            <CardTitle className=\"mb-4\">{t('folderOptions')}</CardTitle>\n            <CardContent className=\"space-y-4 p-0\">\n              <div className=\"group flex items-center justify-between\">\n                <Label\n                  htmlFor=\"folder-enabled\"\n                  className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                >\n                  {t('enableFolderFeature')}\n                </Label>\n                <Switch\n                  id=\"folder-enabled\"\n                  checked={folderEnabled}\n                  onChange={(e) => {\n                    setFolderEnabled(e.target.checked);\n                    apply({ folderEnabled: e.target.checked });\n                  }}\n                />\n              </div>\n              <div className=\"group flex items-center justify-between\">\n                <Label\n                  htmlFor=\"hide-archived\"\n                  className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                >\n                  {t('hideArchivedConversations')}\n                </Label>\n                <Switch\n                  id=\"hide-archived\"\n                  checked={hideArchivedConversations}\n                  onChange={(e) => {\n                    setHideArchivedConversations(e.target.checked);\n                    apply({ hideArchivedConversations: e.target.checked });\n                  }}\n                />\n              </div>\n              <div className=\"group flex items-center justify-between\">\n                <div className=\"flex-1\">\n                  <Label\n                    htmlFor=\"fork-enabled\"\n                    className=\"group-hover:text-primary flex cursor-pointer items-center gap-1 text-sm font-medium transition-colors\"\n                  >\n                    {t('enableForkFeature')}\n                    <span\n                      className=\"material-symbols-outlined cursor-help text-[16px] leading-none opacity-50 transition-opacity hover:opacity-100\"\n                      title={t('experimentalLabel')}\n                      style={{ fontVariationSettings: \"'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20\" }}\n                    >\n                      experiment\n                    </span>\n                  </Label>\n                  <p className=\"text-muted-foreground mt-1 text-xs\">{t('enableForkFeatureHint')}</p>\n                </div>\n                <Switch\n                  id=\"fork-enabled\"\n                  checked={forkEnabled}\n                  onChange={(e) => {\n                    setForkEnabled(e.target.checked);\n                    apply({ forkEnabled: e.target.checked });\n                  }}\n                />\n              </div>\n              <div className=\"group flex items-center justify-between\">\n                <div className=\"flex-1\">\n                  <Label\n                    htmlFor=\"account-isolation-enabled\"\n                    className=\"group-hover:text-primary flex cursor-pointer items-center gap-1 text-sm font-medium transition-colors\"\n                  >\n                    {t('enableAccountIsolation')}\n                    <span\n                      className=\"material-symbols-outlined cursor-help text-[16px] leading-none opacity-50 transition-opacity hover:opacity-100\"\n                      title={t('experimentalLabel')}\n                      style={{\n                        fontVariationSettings: \"'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20\",\n                      }}\n                    >\n                      experiment\n                    </span>\n                  </Label>\n                  <p className=\"text-muted-foreground mt-1 text-xs\">\n                    {t('enableAccountIsolationHint')}\n                  </p>\n                  <div className=\"mt-1 flex items-center gap-2 text-xs\">\n                    <span className=\"text-muted-foreground\">{t('currentPlatform')}:</span>\n                    <span className=\"bg-secondary text-foreground rounded px-1.5 py-0.5 font-medium\">\n                      {currentIsolationPlatformLabel}\n                    </span>\n                  </div>\n                </div>\n                <Switch\n                  id=\"account-isolation-enabled\"\n                  checked={\n                    isAIStudio ? accountIsolationEnabledAIStudio : accountIsolationEnabledGemini\n                  }\n                  onChange={(e) => {\n                    if (isAIStudio) {\n                      setAccountIsolationEnabledAIStudio(e.target.checked);\n                    } else {\n                      setAccountIsolationEnabledGemini(e.target.checked);\n                    }\n                    apply({\n                      accountIsolationEnabled: e.target.checked,\n                      accountIsolationPlatform: activeAccountPlatform,\n                    });\n                  }}\n                />\n              </div>\n              {/* Copy folder structure for AI organization */}\n              <div className=\"border-border/50 border-t pt-3\">\n                <Button\n                  variant=\"outline\"\n                  className=\"w-full text-sm\"\n                  onClick={handleCopyFolderStructureForAI}\n                  disabled={aiStructureCopyStatus === 'loading'}\n                >\n                  <span className=\"inline-flex items-center justify-center gap-1.5\">\n                    <span\n                      className=\"material-symbols-outlined translate-y-px text-[16px] leading-none\"\n                      style={{ fontVariationSettings: \"'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20\" }}\n                    >\n                      {aiStructureCopyStatus === 'copied' ? 'check' : 'content_copy'}\n                    </span>\n                    <span className=\"leading-5\">\n                      {aiStructureCopyStatus === 'copied'\n                        ? t('aiOrgCopied')\n                        : aiStructureCopyStatus === 'error'\n                          ? t('aiOrgError')\n                          : t('aiOrgCopyButton')}\n                    </span>\n                  </span>\n                </Button>\n                <p className=\"text-muted-foreground mt-1.5 text-center text-[11px] leading-tight\">\n                  {t('aiOrgCopyHint')}\n                </p>\n              </div>\n            </CardContent>\n          </Card>,\n        )}\n        {/* Folder Spacing */}\n        {wrapSection(\n          'folderSpacing',\n          <WidthSlider\n            label={t('folderSpacing')}\n            value={folderSpacingAdjuster.width}\n            min={FOLDER_SPACING.min}\n            max={FOLDER_SPACING.max}\n            step={1}\n            narrowLabel={t('folderSpacingCompact')}\n            wideLabel={t('folderSpacingSpacious')}\n            valueFormatter={(v) => `${v}px`}\n            onChange={folderSpacingAdjuster.handleChange}\n            onChangeComplete={folderSpacingAdjuster.handleChangeComplete}\n          />,\n        )}\n        {!isAIStudio &&\n          wrapSection(\n            'folderTreeIndent',\n            <WidthSlider\n              label={t('folderTreeIndent')}\n              value={folderTreeIndentAdjuster.width}\n              min={FOLDER_TREE_INDENT.min}\n              max={FOLDER_TREE_INDENT.max}\n              step={1}\n              narrowLabel={t('folderTreeIndentCompact')}\n              wideLabel={t('folderTreeIndentSpacious')}\n              valueFormatter={(v) => `${v}px`}\n              onChange={folderTreeIndentAdjuster.handleChange}\n              onChangeComplete={folderTreeIndentAdjuster.handleChangeComplete}\n            />,\n          )}\n        {/* Chat Width */}\n        {wrapSection(\n          'chatWidth',\n          <WidthSlider\n            label={t('chatWidth')}\n            value={chatWidthAdjuster.width}\n            min={CHAT_PERCENT.min}\n            max={CHAT_PERCENT.max}\n            step={1}\n            narrowLabel={t('chatWidthNarrow')}\n            wideLabel={t('chatWidthWide')}\n            onChange={chatWidthAdjuster.handleChange}\n            onChangeComplete={chatWidthAdjuster.handleChangeComplete}\n            enabled={chatWidthEnabled}\n            onToggle={(v) => {\n              setChatWidthEnabled(v);\n              try {\n                chrome.storage?.sync?.set({ gvChatWidthEnabled: v });\n              } catch {}\n            }}\n          />,\n        )}\n        {/* Edit Input Width */}\n        {wrapSection(\n          'editInputWidth',\n          <WidthSlider\n            label={t('editInputWidth')}\n            value={editInputWidthAdjuster.width}\n            min={EDIT_PERCENT.min}\n            max={EDIT_PERCENT.max}\n            step={1}\n            narrowLabel={t('editInputWidthNarrow')}\n            wideLabel={t('editInputWidthWide')}\n            onChange={editInputWidthAdjuster.handleChange}\n            onChangeComplete={editInputWidthAdjuster.handleChangeComplete}\n            enabled={editInputWidthEnabled}\n            onToggle={(v) => {\n              setEditInputWidthEnabled(v);\n              try {\n                chrome.storage?.sync?.set({ gvEditInputWidthEnabled: v });\n              } catch {}\n            }}\n          />,\n        )}\n\n        {/* Sidebar Width */}\n        {wrapSection(\n          'sidebarWidth',\n          <WidthSlider\n            label={isAIStudio ? 'AI Studio Sidebar' : t('sidebarWidth')}\n            value={sidebarWidthAdjuster.width}\n            min={sidebarConfig.min}\n            max={sidebarConfig.max}\n            step={8}\n            narrowLabel={t('sidebarWidthNarrow')}\n            wideLabel={t('sidebarWidthWide')}\n            valueFormatter={(v) => `${v}px`}\n            onChange={sidebarWidthAdjuster.handleChange}\n            onChangeComplete={sidebarWidthAdjuster.handleChangeComplete}\n            enabled={sidebarWidthEnabled}\n            onToggle={(v) => {\n              setSidebarWidthEnabled(v);\n              try {\n                chrome.storage?.sync?.set({ gvSidebarWidthEnabled: v });\n              } catch {}\n            }}\n          />,\n        )}\n\n        {/* Sidebar Auto-Hide & Full-Hide - Gemini only */}\n        {!isAIStudio &&\n          wrapSection(\n            'sidebarBehavior',\n            <Card className=\"p-4 transition-all hover:shadow-md\">\n              <CardContent className=\"space-y-3 p-0\">\n                <div className=\"group flex items-center justify-between\">\n                  <div className=\"flex-1\">\n                    <Label\n                      htmlFor=\"sidebar-auto-hide\"\n                      className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                    >\n                      {t('sidebarAutoHide')}\n                    </Label>\n                    <p className=\"text-muted-foreground mt-1 text-xs\">{t('sidebarAutoHideHint')}</p>\n                  </div>\n                  <Switch\n                    id=\"sidebar-auto-hide\"\n                    checked={sidebarAutoHideEnabled}\n                    onChange={(e) => {\n                      setSidebarAutoHideEnabled(e.target.checked);\n                      apply({ sidebarAutoHideEnabled: e.target.checked });\n                    }}\n                  />\n                </div>\n                <div className=\"group flex items-center justify-between\">\n                  <div className=\"flex-1\">\n                    <Label\n                      htmlFor=\"sidebar-full-hide\"\n                      className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                    >\n                      {t('sidebarFullHide')}\n                    </Label>\n                    <p className=\"text-muted-foreground mt-1 text-xs\">{t('sidebarFullHideHint')}</p>\n                  </div>\n                  <Switch\n                    id=\"sidebar-full-hide\"\n                    checked={sidebarFullHideEnabled}\n                    onChange={(e) => {\n                      setSidebarFullHideEnabled(e.target.checked);\n                      apply({ sidebarFullHideEnabled: e.target.checked });\n                    }}\n                  />\n                </div>\n              </CardContent>\n            </Card>,\n          )}\n\n        {/* Visual Effect - Gemini only */}\n        {!isAIStudio &&\n          wrapSection(\n            'visualEffect',\n            <Card className=\"p-4 transition-all hover:shadow-md\">\n              <CardContent className=\"p-0\">\n                <div className=\"flex-1\">\n                  <Label className=\"text-sm font-medium\">{t('visualEffect')}</Label>\n                  <p className=\"text-muted-foreground mt-1 text-xs\">{t('visualEffectHint')}</p>\n                </div>\n                <div className=\"bg-secondary/60 mt-3 flex items-center gap-0.5 rounded-full p-1\">\n                  {(\n                    [\n                      {\n                        value: 'off' as const,\n                        label: t('visualEffectOff'),\n                        icon: (\n                          <svg\n                            width=\"14\"\n                            height=\"14\"\n                            viewBox=\"0 0 24 24\"\n                            fill=\"none\"\n                            stroke=\"currentColor\"\n                            strokeWidth=\"2\"\n                            strokeLinecap=\"round\"\n                            strokeLinejoin=\"round\"\n                          >\n                            <circle cx=\"12\" cy=\"12\" r=\"10\" />\n                            <line x1=\"4.93\" y1=\"4.93\" x2=\"19.07\" y2=\"19.07\" />\n                          </svg>\n                        ),\n                      },\n                      {\n                        value: 'snow' as const,\n                        label: t('visualEffectSnow'),\n                        icon: (\n                          <svg\n                            width=\"14\"\n                            height=\"14\"\n                            viewBox=\"0 0 24 24\"\n                            fill=\"none\"\n                            stroke=\"currentColor\"\n                            strokeWidth=\"2\"\n                            strokeLinecap=\"round\"\n                            strokeLinejoin=\"round\"\n                          >\n                            <line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"22\" />\n                            <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\" />\n                            <line x1=\"4.93\" y1=\"4.93\" x2=\"19.07\" y2=\"19.07\" />\n                            <line x1=\"19.07\" y1=\"4.93\" x2=\"4.93\" y2=\"19.07\" />\n                            <line x1=\"12\" y1=\"2\" x2=\"14.5\" y2=\"4.5\" />\n                            <line x1=\"12\" y1=\"2\" x2=\"9.5\" y2=\"4.5\" />\n                            <line x1=\"12\" y1=\"22\" x2=\"14.5\" y2=\"19.5\" />\n                            <line x1=\"12\" y1=\"22\" x2=\"9.5\" y2=\"19.5\" />\n                            <line x1=\"2\" y1=\"12\" x2=\"4.5\" y2=\"9.5\" />\n                            <line x1=\"2\" y1=\"12\" x2=\"4.5\" y2=\"14.5\" />\n                            <line x1=\"22\" y1=\"12\" x2=\"19.5\" y2=\"9.5\" />\n                            <line x1=\"22\" y1=\"12\" x2=\"19.5\" y2=\"14.5\" />\n                          </svg>\n                        ),\n                      },\n                      {\n                        value: 'sakura' as const,\n                        label: t('visualEffectSakura'),\n                        icon: (\n                          <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n                            <g transform=\"translate(12,12)\">\n                              {[0, 72, 144, 216, 288].map((deg) => (\n                                <ellipse\n                                  key={deg}\n                                  cx=\"0\"\n                                  cy=\"-6\"\n                                  rx=\"2.8\"\n                                  ry=\"5.5\"\n                                  transform={`rotate(${deg})`}\n                                  opacity=\"0.85\"\n                                />\n                              ))}\n                              <circle cx=\"0\" cy=\"0\" r=\"2\" opacity=\"0.6\" />\n                            </g>\n                          </svg>\n                        ),\n                      },\n                      {\n                        value: 'rain' as const,\n                        label: t('visualEffectRain'),\n                        icon: (\n                          <svg\n                            width=\"14\"\n                            height=\"14\"\n                            viewBox=\"0 0 24 24\"\n                            fill=\"none\"\n                            stroke=\"currentColor\"\n                            strokeWidth=\"2\"\n                            strokeLinecap=\"round\"\n                          >\n                            <line x1=\"8\" y1=\"3\" x2=\"6.5\" y2=\"10\" />\n                            <line x1=\"14\" y1=\"2\" x2=\"12.5\" y2=\"9\" />\n                            <line x1=\"20\" y1=\"4\" x2=\"18.5\" y2=\"11\" />\n                            <line x1=\"5\" y1=\"12\" x2=\"3.5\" y2=\"19\" />\n                            <line x1=\"11\" y1=\"11\" x2=\"9.5\" y2=\"18\" />\n                            <line x1=\"17\" y1=\"13\" x2=\"15.5\" y2=\"20\" />\n                          </svg>\n                        ),\n                      },\n                    ] as const\n                  ).map((option) => (\n                    <button\n                      key={option.value}\n                      type=\"button\"\n                      onClick={() => {\n                        setVisualEffect(option.value);\n                        apply({ visualEffect: option.value });\n                      }}\n                      className={`flex flex-1 items-center justify-center gap-1.5 rounded-full py-1.5 text-xs font-bold transition-all duration-200 ${\n                        visualEffect === option.value\n                          ? 'bg-background text-foreground shadow-md'\n                          : 'text-muted-foreground hover:text-foreground'\n                      }`}\n                    >\n                      {option.icon}\n                      <span>{option.label}</span>\n                    </button>\n                  ))}\n                </div>\n              </CardContent>\n            </Card>,\n          )}\n\n        {/* Formula Copy Options */}\n        {wrapSection(\n          'formulaCopy',\n          <Card className=\"p-4 transition-all hover:shadow-md\">\n            <CardTitle className=\"mb-4\">{t('formulaCopyFormat')}</CardTitle>\n            <CardContent className=\"space-y-3 p-0\">\n              <p className=\"text-muted-foreground mb-3 text-xs\">{t('formulaCopyFormatHint')}</p>\n              <div className=\"space-y-2\">\n                <label className=\"flex cursor-pointer items-center space-x-3\">\n                  <input\n                    type=\"radio\"\n                    name=\"formulaCopyFormat\"\n                    value=\"latex\"\n                    checked={formulaCopyFormat === 'latex'}\n                    onChange={handleFormulaCopyFormatChange}\n                    className=\"h-4 w-4\"\n                  />\n                  <span className=\"text-sm\">{t('formulaCopyFormatLatex')}</span>\n                </label>\n                <label className=\"flex cursor-pointer items-center space-x-3\">\n                  <input\n                    type=\"radio\"\n                    name=\"formulaCopyFormat\"\n                    value=\"unicodemath\"\n                    checked={formulaCopyFormat === 'unicodemath'}\n                    onChange={handleFormulaCopyFormatChange}\n                    className=\"h-4 w-4\"\n                  />\n                  <span className=\"text-sm\">{t('formulaCopyFormatUnicodeMath')}</span>\n                </label>\n                <label className=\"flex cursor-pointer items-center space-x-3\">\n                  <input\n                    type=\"radio\"\n                    name=\"formulaCopyFormat\"\n                    value=\"no-dollar\"\n                    checked={formulaCopyFormat === 'no-dollar'}\n                    onChange={handleFormulaCopyFormatChange}\n                    className=\"h-4 w-4\"\n                  />\n                  <span className=\"text-sm\">{t('formulaCopyFormatNoDollar')}</span>\n                </label>\n              </div>\n            </CardContent>\n          </Card>,\n        )}\n\n        {/* Keyboard Shortcuts */}\n        {wrapSection('keyboardShortcuts', <KeyboardShortcutSettings />)}\n\n        {/* Input Collapse Options */}\n        {wrapSection(\n          'inputCollapse',\n          <Card className=\"p-4 transition-all hover:shadow-md\">\n            <CardTitle className=\"mb-4\">{t('inputCollapseOptions')}</CardTitle>\n            <CardContent className=\"space-y-4 p-0\">\n              <div className=\"group flex items-center justify-between\">\n                <div className=\"flex-1\">\n                  <Label\n                    htmlFor=\"input-collapse-enabled\"\n                    className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                  >\n                    {t('enableInputCollapse')}\n                  </Label>\n                  <p className=\"text-muted-foreground mt-1 text-xs\">\n                    {t('enableInputCollapseHint')}{' '}\n                    <span className=\"text-muted-foreground/70\">\n                      ({t('inputCollapseShortcutHint').replace('{modifier}', getModifierKey())})\n                    </span>\n                  </p>\n                </div>\n                <Switch\n                  id=\"input-collapse-enabled\"\n                  checked={inputCollapseEnabled}\n                  onChange={(e) => {\n                    setInputCollapseEnabled(e.target.checked);\n                    apply({ inputCollapseEnabled: e.target.checked });\n                  }}\n                />\n              </div>\n              {/* Second toggle - Allow collapse when not empty (only visible when first is enabled) */}\n              {inputCollapseEnabled && (\n                <div className=\"group mt-3 ml-4 flex items-center justify-between\">\n                  <div className=\"flex-1\">\n                    <Label\n                      htmlFor=\"input-collapse-when-not-empty\"\n                      className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                    >\n                      {t('allowCollapseWhenNotEmpty')}\n                    </Label>\n                    <p className=\"text-muted-foreground mt-1 text-xs\">\n                      {t('allowCollapseWhenNotEmptyHint')}\n                    </p>\n                  </div>\n                  <Switch\n                    id=\"input-collapse-when-not-empty\"\n                    checked={inputCollapseWhenNotEmpty}\n                    onChange={(e) => {\n                      setInputCollapseWhenNotEmpty(e.target.checked);\n                      apply({ inputCollapseWhenNotEmpty: e.target.checked });\n                    }}\n                  />\n                </div>\n              )}\n              <div className=\"group flex items-center justify-between\">\n                <div className=\"flex-1\">\n                  <Label\n                    htmlFor=\"ctrl-enter-send\"\n                    className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                  >\n                    {t('ctrlEnterSend').replace('{modifier}', getModifierKey())}\n                  </Label>\n                  <p className=\"text-muted-foreground mt-1 text-xs\">\n                    {t('ctrlEnterSendHint').replace('{modifier}', getModifierKey())}\n                  </p>\n                </div>\n                <Switch\n                  id=\"ctrl-enter-send\"\n                  checked={ctrlEnterSendEnabled}\n                  onChange={(e) => {\n                    setCtrlEnterSendEnabled(e.target.checked);\n                    apply({ ctrlEnterSendEnabled: e.target.checked });\n                  }}\n                />\n              </div>\n            </CardContent>\n          </Card>,\n        )}\n\n        {/* Prompt Manager Options */}\n        {wrapSection(\n          'promptManager',\n          <Card className=\"p-4 transition-all hover:shadow-md\">\n            <CardTitle className=\"mb-4\">{t('promptManagerOptions')}</CardTitle>\n            <CardContent className=\"space-y-3 p-0\">\n              {/* Hide Prompt Manager Toggle */}\n              <div className=\"group flex items-center justify-between\">\n                <div className=\"flex-1\">\n                  <Label\n                    htmlFor=\"hide-prompt-manager\"\n                    className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                  >\n                    {t('hidePromptManager')}\n                  </Label>\n                  <p className=\"text-muted-foreground mt-1 text-xs\">{t('hidePromptManagerHint')}</p>\n                </div>\n                <Switch\n                  id=\"hide-prompt-manager\"\n                  checked={hidePromptManager}\n                  onChange={(e) => {\n                    setHidePromptManager(e.target.checked);\n                    apply({ hidePromptManager: e.target.checked });\n                  }}\n                />\n              </div>\n              <div>\n                <Label className=\"mb-2 block text-sm font-medium\">{t('customWebsites')}</Label>\n                {/* Gemini Only Notice - moved here since it's about Prompt Manager */}\n                <div className=\"bg-primary/10 border-primary/20 mb-2 flex items-center gap-2 rounded-md border p-2\">\n                  <svg\n                    width=\"14\"\n                    height=\"14\"\n                    viewBox=\"0 0 16 16\"\n                    fill=\"none\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    className=\"text-primary shrink-0\"\n                  >\n                    <path\n                      d=\"M8 1C4.13 1 1 4.13 1 8s3.13 7 7 7 7-3.13 7-7-3.13-7-7-7zm0 11c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm1-4H7V5h2v3z\"\n                      fill=\"currentColor\"\n                    />\n                  </svg>\n                  <p className=\"text-primary text-xs font-medium\">{t('geminiOnlyNotice')}</p>\n                </div>\n\n                {/* Quick-select buttons for popular websites */}\n                <div className=\"mb-3 flex flex-wrap gap-1.5\">\n                  {[\n                    { domain: 'chatgpt.com', label: 'ChatGPT', Icon: IconChatGPT },\n                    { domain: 'claude.ai', label: 'Claude', Icon: IconClaude },\n                    { domain: 'grok.com', label: 'Grok', Icon: IconGrok },\n                    { domain: 'deepseek.com', label: 'DeepSeek', Icon: IconDeepSeek },\n                    { domain: 'qwen.ai', label: 'Qwen', Icon: IconQwen },\n                    { domain: 'kimi.com', label: 'Kimi', Icon: IconKimi },\n                    { domain: 'notebooklm.google.com', label: 'NotebookLM', Icon: IconNotebookLM },\n                    { domain: 'midjourney.com', label: 'Midjourney', Icon: IconMidjourney },\n                  ].map(({ domain, label, Icon }) => {\n                    const isEnabled = customWebsites.includes(domain);\n                    return (\n                      <button\n                        key={domain}\n                        onClick={() => {\n                          void toggleQuickWebsite(domain, isEnabled);\n                        }}\n                        className={`inline-flex min-w-[30%] grow items-center justify-center gap-1 rounded-full px-2 py-1.5 text-[11px] font-medium transition-all ${\n                          isEnabled\n                            ? 'bg-primary text-primary-foreground shadow-sm'\n                            : 'bg-secondary/50 text-muted-foreground hover:bg-secondary hover:text-foreground'\n                        }`}\n                        title={label}\n                      >\n                        <span className=\"flex h-3.5 w-3.5 shrink-0 items-center justify-center\">\n                          <Icon />\n                        </span>\n                        <span className=\"truncate\">{label}</span>\n                        <span\n                          className={`w-2.5 shrink-0 text-center text-[10px] transition-opacity ${isEnabled ? 'opacity-100' : 'opacity-0'}`}\n                        >\n                          ✓\n                        </span>\n                      </button>\n                    );\n                  })}\n                </div>\n\n                {/* Website List */}\n                {customWebsites.length > 0 && (\n                  <div className=\"mb-3 space-y-2\">\n                    {customWebsites.map((website) => (\n                      <div\n                        key={website}\n                        className=\"bg-secondary/30 group hover:bg-secondary/50 flex items-center justify-between rounded-md px-3 py-2 transition-colors\"\n                      >\n                        <span className=\"text-foreground/90 font-mono text-sm\">{website}</span>\n                        <button\n                          onClick={() => {\n                            void handleRemoveWebsite(website);\n                          }}\n                          className=\"text-destructive hover:text-destructive/80 text-xs font-medium opacity-70 transition-opacity group-hover:opacity-100\"\n                        >\n                          {t('removeWebsite')}\n                        </button>\n                      </div>\n                    ))}\n                  </div>\n                )}\n\n                {/* Add Website Input */}\n                <div className=\"space-y-2\">\n                  <div className=\"flex flex-wrap gap-2\">\n                    <input\n                      type=\"text\"\n                      value={newWebsiteInput}\n                      onChange={(e) => {\n                        setNewWebsiteInput(e.target.value);\n                        setWebsiteError('');\n                      }}\n                      onKeyDown={(e) => {\n                        if (e.key === 'Enter') {\n                          void handleAddWebsite();\n                        }\n                      }}\n                      placeholder={t('customWebsitesPlaceholder')}\n                      className=\"bg-background border-border focus:ring-primary/50 min-w-0 flex-1 rounded-md border px-3 py-2 text-sm transition-all focus:ring-2 focus:outline-none\"\n                    />\n                    <Button\n                      onClick={() => {\n                        void handleAddWebsite();\n                      }}\n                      size=\"sm\"\n                      className=\"shrink-0 whitespace-nowrap\"\n                    >\n                      {t('addWebsite')}\n                    </Button>\n                  </div>\n                  {websiteError && <p className=\"text-destructive text-xs\">{websiteError}</p>}\n                </div>\n\n                {/* Note about reloading */}\n                <div className=\"bg-primary/5 border-primary/20 mt-3 rounded-md border p-2\">\n                  <p className=\"text-muted-foreground text-xs\">{t('customWebsitesNote')}</p>\n                </div>\n              </div>\n            </CardContent>\n          </Card>,\n        )}\n\n        {/* General Options */}\n        {wrapSection(\n          'general',\n          <Card className=\"p-4 transition-all hover:shadow-md\">\n            <CardTitle className=\"mb-4\">{t('generalOptions')}</CardTitle>\n            <CardContent className=\"space-y-4 p-0\">\n              <div className=\"group flex items-center justify-between\">\n                <div className=\"flex-1\">\n                  <Label\n                    htmlFor=\"tab-title-update\"\n                    className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                  >\n                    {t('enableTabTitleUpdate')}\n                  </Label>\n                  <p className=\"text-muted-foreground mt-1 text-xs\">\n                    {t('enableTabTitleUpdateHint')}\n                  </p>\n                </div>\n                <Switch\n                  id=\"tab-title-update\"\n                  checked={tabTitleUpdateEnabled}\n                  onChange={(e) => {\n                    setTabTitleUpdateEnabled(e.target.checked);\n                    apply({ tabTitleUpdateEnabled: e.target.checked });\n                  }}\n                />\n              </div>\n              <div className=\"group flex items-center justify-between\">\n                <div className=\"flex-1\">\n                  <Label\n                    htmlFor=\"mermaid-enabled\"\n                    className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                  >\n                    {t('enableMermaidRendering')}\n                  </Label>\n                  <p className=\"text-muted-foreground mt-1 text-xs\">\n                    {t('enableMermaidRenderingHint')}\n                  </p>\n                </div>\n                <Switch\n                  id=\"mermaid-enabled\"\n                  checked={mermaidEnabled}\n                  onChange={(e) => {\n                    setMermaidEnabled(e.target.checked);\n                    apply({ mermaidEnabled: e.target.checked });\n                  }}\n                />\n              </div>\n              <div className=\"group flex items-center justify-between\">\n                <div className=\"flex-1\">\n                  <Label\n                    htmlFor=\"quote-reply-enabled\"\n                    className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                  >\n                    {t('enableQuoteReply')}\n                  </Label>\n                  <p className=\"text-muted-foreground mt-1 text-xs\">{t('enableQuoteReplyHint')}</p>\n                </div>\n                <Switch\n                  id=\"quote-reply-enabled\"\n                  checked={quoteReplyEnabled}\n                  onChange={(e) => {\n                    setQuoteReplyEnabled(e.target.checked);\n                    apply({ quoteReplyEnabled: e.target.checked });\n                  }}\n                />\n              </div>\n            </CardContent>\n          </Card>,\n        )}\n\n        {/* NanoBanana Options - Hidden on Safari due to fetch interceptor limitations */}\n        {!isSafariBrowser &&\n          wrapSection(\n            'nanobanana',\n            <Card className=\"p-4 transition-all hover:shadow-md\">\n              <CardTitle className=\"mb-4\">{t('nanobananaOptions')}</CardTitle>\n              <CardContent className=\"space-y-4 p-0\">\n                <div className=\"group flex items-center justify-between\">\n                  <div className=\"flex-1\">\n                    <Label\n                      htmlFor=\"watermark-remover\"\n                      className=\"group-hover:text-primary cursor-pointer text-sm font-medium transition-colors\"\n                    >\n                      {t('enableNanobananaWatermarkRemover')}\n                    </Label>\n                    <p className=\"text-muted-foreground mt-1 text-xs\">\n                      {t('nanobananaWatermarkRemoverHint')}\n                    </p>\n                  </div>\n                  <Switch\n                    id=\"watermark-remover\"\n                    checked={watermarkRemoverEnabled}\n                    onChange={(e) => {\n                      setWatermarkRemoverEnabled(e.target.checked);\n                      apply({ watermarkRemoverEnabled: e.target.checked });\n                    }}\n                  />\n                </div>\n              </CardContent>\n            </Card>,\n          )}\n      </div>\n\n      {/* Footer */}\n      <div className=\"border-border/50 flex flex-col gap-3 border-t px-5 py-4\">\n        <div className=\"flex w-full items-center justify-between\">\n          <div className=\"text-muted-foreground flex items-center gap-2 text-xs\">\n            <span className=\"text-foreground/80 font-semibold\">{t('extensionVersion')}</span>\n            <a\n              href={releaseUrl}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n              className=\"text-primary hover:text-primary/80 font-semibold transition-colors\"\n              title={extVersion ? extVersion : undefined}\n            >\n              {extVersion ?? '...'}\n            </a>\n          </div>\n\n          <a\n            href={websiteUrl}\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            className=\"text-muted-foreground hover:text-primary flex items-center gap-1.5 text-xs font-semibold transition-colors\"\n          >\n            <svg\n              width=\"14\"\n              height=\"14\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            >\n              <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\n              <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\n              <path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"></path>\n            </svg>\n            {t('officialDocs')}\n          </a>\n        </div>\n\n        <a\n          href=\"https://github.com/Nagi-ovo/gemini-voyager\"\n          target=\"_blank\"\n          rel=\"noreferrer\"\n          className=\"bg-primary hover:bg-primary/90 text-primary-foreground hover:shadow-primary/25 inline-flex w-full items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-bold tracking-wide transition-all hover:scale-[1.02] hover:shadow-lg active:scale-[0.97]\"\n          title={t('starProject')}\n        >\n          <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden=\"true\">\n            <path d=\"M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8 8 0 0016 8c0-4.42-3.58-8-8-8z\" />\n          </svg>\n          <span>{t('starProject')}</span>\n        </a>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/pages/popup/__tests__/latestVersion.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport {\n  extractDmgDownloadUrl,\n  extractLatestReleaseVersion,\n  getCachedLatestVersion,\n  getManifestUpdateUrl,\n} from '../utils/latestVersion';\n\ndescribe('Popup latest version helpers', () => {\n  describe('getManifestUpdateUrl', () => {\n    it('returns null for non-objects', () => {\n      expect(getManifestUpdateUrl(undefined)).toBeNull();\n      expect(getManifestUpdateUrl(null)).toBeNull();\n      expect(getManifestUpdateUrl('x')).toBeNull();\n      expect(getManifestUpdateUrl(123)).toBeNull();\n    });\n\n    it('returns null when update_url is missing/blank', () => {\n      expect(getManifestUpdateUrl({})).toBeNull();\n      expect(getManifestUpdateUrl({ update_url: '' })).toBeNull();\n      expect(getManifestUpdateUrl({ update_url: '   ' })).toBeNull();\n      expect(getManifestUpdateUrl({ update_url: 123 })).toBeNull();\n    });\n\n    it('returns update_url when present', () => {\n      expect(getManifestUpdateUrl({ update_url: 'https://example.com/update.xml' })).toBe(\n        'https://example.com/update.xml',\n      );\n    });\n  });\n\n  describe('extractLatestReleaseVersion', () => {\n    it('does not throw on unexpected JSON shapes', () => {\n      expect(extractLatestReleaseVersion(null)).toBeNull();\n      expect(extractLatestReleaseVersion('x')).toBeNull();\n      expect(extractLatestReleaseVersion([])).toBeNull();\n    });\n\n    it('prefers tag_name over name', () => {\n      expect(extractLatestReleaseVersion({ tag_name: 'v1.2.3', name: 'v9.9.9' })).toBe('v1.2.3');\n    });\n\n    it('falls back to name when tag_name is missing', () => {\n      expect(extractLatestReleaseVersion({ name: 'v1.2.3' })).toBe('v1.2.3');\n    });\n\n    it('returns null when neither tag_name nor name is usable', () => {\n      expect(extractLatestReleaseVersion({ tag_name: '', name: '' })).toBeNull();\n      expect(extractLatestReleaseVersion({ tag_name: 123, name: false })).toBeNull();\n    });\n  });\n\n  describe('extractDmgDownloadUrl', () => {\n    it('returns null for non-objects', () => {\n      expect(extractDmgDownloadUrl(null)).toBeNull();\n      expect(extractDmgDownloadUrl('x')).toBeNull();\n      expect(extractDmgDownloadUrl(123)).toBeNull();\n    });\n\n    it('returns null when assets is missing or not an array', () => {\n      expect(extractDmgDownloadUrl({})).toBeNull();\n      expect(extractDmgDownloadUrl({ assets: 'nope' })).toBeNull();\n      expect(extractDmgDownloadUrl({ assets: null })).toBeNull();\n    });\n\n    it('returns null when no .dmg asset exists', () => {\n      expect(\n        extractDmgDownloadUrl({\n          assets: [\n            { name: 'gemini-voyager-chrome-v1.3.3.zip', browser_download_url: 'https://x/a.zip' },\n            { name: 'gemini-voyager-firefox-v1.3.3.xpi', browser_download_url: 'https://x/a.xpi' },\n          ],\n        }),\n      ).toBeNull();\n    });\n\n    it('returns null when assets array is empty', () => {\n      expect(extractDmgDownloadUrl({ assets: [] })).toBeNull();\n    });\n\n    it('returns the DMG download URL when present', () => {\n      const url =\n        'https://github.com/Nagi-ovo/gemini-voyager/releases/download/v1.3.3/gemini-voyager-v1.3.3.dmg';\n      expect(\n        extractDmgDownloadUrl({\n          assets: [\n            { name: 'gemini-voyager-chrome-v1.3.3.zip', browser_download_url: 'https://x/a.zip' },\n            { name: 'gemini-voyager-v1.3.3.dmg', browser_download_url: url },\n          ],\n        }),\n      ).toBe(url);\n    });\n\n    it('skips assets with missing or blank download URL', () => {\n      expect(\n        extractDmgDownloadUrl({\n          assets: [{ name: 'foo.dmg', browser_download_url: '' }],\n        }),\n      ).toBeNull();\n      expect(\n        extractDmgDownloadUrl({\n          assets: [{ name: 'foo.dmg', browser_download_url: 123 }],\n        }),\n      ).toBeNull();\n    });\n\n    it('skips non-object assets', () => {\n      expect(extractDmgDownloadUrl({ assets: ['string', null, 123] })).toBeNull();\n    });\n  });\n\n  describe('getCachedLatestVersion', () => {\n    it('returns cached version when fresh', () => {\n      const now = 1_000_000;\n      const maxAgeMs = 10_000;\n      expect(getCachedLatestVersion({ version: 'v1.2.3', fetchedAt: now - 1 }, now, maxAgeMs)).toBe(\n        'v1.2.3',\n      );\n    });\n\n    it('returns null when stale or invalid', () => {\n      const now = 1_000_000;\n      const maxAgeMs = 10_000;\n\n      expect(\n        getCachedLatestVersion({ version: 'v1.2.3', fetchedAt: now - maxAgeMs }, now, maxAgeMs),\n      ).toBeNull();\n      expect(getCachedLatestVersion({ version: '', fetchedAt: now - 1 }, now, maxAgeMs)).toBeNull();\n      expect(\n        getCachedLatestVersion({ version: 'v1.2.3', fetchedAt: 'x' }, now, maxAgeMs),\n      ).toBeNull();\n      expect(getCachedLatestVersion('x', now, maxAgeMs)).toBeNull();\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/popup/components/CloudSyncSettings.tsx",
    "content": "import React, { useCallback, useEffect, useState } from 'react';\n\nimport {\n  accountIsolationService,\n  buildScopedStorageKey,\n  detectAccountPlatformFromUrl,\n  extractRouteUserIdFromUrl,\n} from '@/core/services/AccountIsolationService';\nimport { StorageKeys } from '@/core/types/common';\nimport type { FolderData } from '@/core/types/folder';\nimport type {\n  PromptItem,\n  SyncAccountScope,\n  SyncMode,\n  SyncPlatform,\n  SyncState,\n} from '@/core/types/sync';\nimport { DEFAULT_SYNC_STATE } from '@/core/types/sync';\nimport { isSafari } from '@/core/utils/browser';\nimport type { StarredMessagesData } from '@/pages/content/timeline/starredTypes';\n\nimport { Button } from '../../../components/ui/button';\nimport { Card, CardContent, CardTitle } from '../../../components/ui/card';\nimport { Label } from '../../../components/ui/label';\nimport { useLanguage } from '../../../contexts/LanguageContext';\nimport { mergeFolderData, mergePrompts, mergeStarredMessages } from '../../../utils/merge';\n\n/**\n * CloudSyncSettings component for popup\n * Allows users to configure Google Drive sync settings\n */\nexport function CloudSyncSettings() {\n  const { t } = useLanguage();\n  const isSafariBrowser = isSafari();\n\n  const [syncState, setSyncState] = useState<SyncState>(DEFAULT_SYNC_STATE);\n  const [statusMessage, setStatusMessage] = useState<{ text: string; kind: 'ok' | 'err' } | null>(\n    null,\n  );\n  const [isUploading, setIsUploading] = useState(false);\n  const [isDownloading, setIsDownloading] = useState(false);\n  const [platform, setPlatform] = useState<SyncPlatform>('gemini');\n\n  const getBaseFolderStorageKey = useCallback(\n    (targetPlatform: SyncPlatform) =>\n      targetPlatform === 'aistudio' ? StorageKeys.FOLDER_DATA_AISTUDIO : StorageKeys.FOLDER_DATA,\n    [],\n  );\n\n  // Detect current platform from active tab URL\n  const detectPlatform = useCallback(async (): Promise<SyncPlatform> => {\n    try {\n      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n      return detectAccountPlatformFromUrl(tab?.url ?? null);\n    } catch (e) {\n      console.warn('[CloudSyncSettings] Failed to detect platform:', e);\n    }\n    return 'gemini';\n  }, []);\n\n  const resolveAccountSyncContext = useCallback(async (): Promise<{\n    accountScope: SyncAccountScope | null;\n    folderStorageKey: string;\n  }> => {\n    const baseFolderStorageKey = getBaseFolderStorageKey(platform);\n\n    const isolationEnabled = await accountIsolationService.isIsolationEnabled({ platform });\n    if (!isolationEnabled) {\n      return { accountScope: null, folderStorageKey: baseFolderStorageKey };\n    }\n\n    let pageUrl = '';\n    let routeUserId: string | null = null;\n    let email: string | null = null;\n\n    try {\n      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n      pageUrl = tab?.url || '';\n      routeUserId = platform === 'gemini' ? extractRouteUserIdFromUrl(pageUrl) : null;\n\n      if (tab?.id) {\n        try {\n          const response = (await Promise.race([\n            chrome.tabs.sendMessage(tab.id, { type: 'gv.account.getContext' }),\n            new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 400)),\n          ])) as { ok?: boolean; context?: { routeUserId?: string | null; email?: string | null } };\n\n          if (response?.ok && response.context) {\n            routeUserId = response.context.routeUserId ?? routeUserId;\n            email = response.context.email ?? null;\n          }\n        } catch {\n          // Ignore content-script lookup failure; we'll resolve with URL fallback.\n        }\n      }\n    } catch {\n      // Ignore tab query failure; account service will fallback to default scope.\n    }\n\n    const resolvedScope = await accountIsolationService.resolveAccountScope({\n      pageUrl,\n      routeUserId,\n      email,\n    });\n\n    const accountScope: SyncAccountScope = {\n      accountKey: resolvedScope.accountKey,\n      accountId: resolvedScope.accountId,\n      routeUserId: resolvedScope.routeUserId,\n    };\n    return {\n      accountScope,\n      folderStorageKey: buildScopedStorageKey(baseFolderStorageKey, resolvedScope.accountKey),\n    };\n  }, [getBaseFolderStorageKey, platform]);\n\n  // Fetch sync state and detect platform on mount\n  useEffect(() => {\n    const fetchState = async () => {\n      try {\n        const response = await chrome.runtime.sendMessage({ type: 'gv.sync.getState' });\n        if (response?.ok && response.state) {\n          setSyncState(response.state);\n        }\n      } catch (error) {\n        console.error('[CloudSyncSettings] Failed to get sync state:', error);\n      }\n    };\n    const initPlatform = async () => {\n      const detected = await detectPlatform();\n      setPlatform(detected);\n      console.log('[CloudSyncSettings] Detected platform:', detected);\n    };\n    fetchState();\n    initPlatform();\n  }, [detectPlatform]);\n\n  // Format timestamp for display\n  const formatLastSync = useCallback(\n    (timestamp: number | null): string => {\n      if (!timestamp) return t('neverSynced');\n      const date = new Date(timestamp);\n      const now = new Date();\n      const diffMs = now.getTime() - date.getTime();\n      const diffMins = Math.floor(diffMs / 60000);\n      const diffHours = Math.floor(diffMs / 3600000);\n      const diffDays = Math.floor(diffMs / 86400000);\n\n      let timeStr: string;\n      if (diffMins < 1) {\n        timeStr = t('justNow');\n      } else if (diffMins < 60) {\n        timeStr = `${diffMins} ${t('minutesAgo')}`;\n      } else if (diffHours < 24) {\n        timeStr = `${diffHours} ${t('hoursAgo')}`;\n      } else if (diffDays === 1) {\n        timeStr = t('yesterday');\n      } else {\n        timeStr = date.toLocaleDateString();\n      }\n\n      return t('lastSynced').replace('{time}', timeStr);\n    },\n    [t],\n  );\n\n  // Format upload timestamp for display\n  const formatLastUpload = useCallback(\n    (timestamp: number | null): string => {\n      if (!timestamp) return t('neverUploaded') || 'Never uploaded';\n      const date = new Date(timestamp);\n      const now = new Date();\n      const diffMs = now.getTime() - date.getTime();\n      const diffMins = Math.floor(diffMs / 60000);\n      const diffHours = Math.floor(diffMs / 3600000);\n      const diffDays = Math.floor(diffMs / 86400000);\n\n      let timeStr: string;\n      if (diffMins < 1) {\n        timeStr = t('justNow');\n      } else if (diffMins < 60) {\n        timeStr = `${diffMins} ${t('minutesAgo')}`;\n      } else if (diffHours < 24) {\n        timeStr = `${diffHours} ${t('hoursAgo')}`;\n      } else if (diffDays === 1) {\n        timeStr = t('yesterday');\n      } else {\n        timeStr = date.toLocaleDateString();\n      }\n\n      return (t('lastUploaded') || 'Uploaded {time}').replace('{time}', timeStr);\n    },\n    [t],\n  );\n\n  // Handle mode change\n  const handleModeChange = useCallback(async (mode: SyncMode) => {\n    try {\n      const response = await chrome.runtime.sendMessage({\n        type: 'gv.sync.setMode',\n        payload: { mode },\n      });\n      if (response?.ok && response.state) {\n        setSyncState(response.state);\n      }\n    } catch (error) {\n      console.error('[CloudSyncSettings] Failed to set sync mode:', error);\n    }\n  }, []);\n\n  // Handle sign out\n  const handleSignOut = useCallback(async () => {\n    try {\n      const response = await chrome.runtime.sendMessage({ type: 'gv.sync.signOut' });\n      if (response?.ok && response.state) {\n        setSyncState(response.state);\n      }\n    } catch (error) {\n      console.error('[CloudSyncSettings] Sign out failed:', error);\n    }\n  }, []);\n\n  // Handle sync now (upload current data)\n  const handleSyncNow = useCallback(async () => {\n    setStatusMessage(null);\n    setIsUploading(true);\n\n    try {\n      const accountContext = await resolveAccountSyncContext();\n      let accountScope = accountContext.accountScope;\n      let folderStorageKey = accountContext.folderStorageKey;\n\n      // Get current data - prioritizing active tab content script for folders\n      let folders: FolderData = { folders: [], folderContents: {} };\n      let prompts: PromptItem[] = [];\n\n      // 1. Try to get fresh folder data from active tab\n      try {\n        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n        if (tab?.id) {\n          // Short timeout to avoid blocking\n          const response = (await Promise.race([\n            chrome.tabs.sendMessage(tab.id, { type: 'gv.sync.requestData' }),\n            new Promise((_, reject) => setTimeout(() => reject('Timeout'), 500)),\n          ])) as { ok?: boolean; data?: FolderData; accountScope?: SyncAccountScope } | null;\n\n          if (response?.ok && response.data) {\n            folders = response.data;\n            console.log('[CloudSyncSettings] Got fresh folder data from content script');\n            if (response.accountScope) {\n              accountScope = response.accountScope;\n              folderStorageKey = buildScopedStorageKey(\n                getBaseFolderStorageKey(platform),\n                response.accountScope.accountKey,\n              );\n            }\n          }\n        }\n      } catch (e) {\n        console.log('[CloudSyncSettings] Tab fetch failed/skipped:', e);\n      }\n\n      // 2. Fallback to storage\n      try {\n        const storageResult = await chrome.storage.local.get([\n          folderStorageKey,\n          StorageKeys.PROMPT_ITEMS,\n        ]);\n\n        // Only use storage folders if we didn't get them from tab\n        if ((!folders.folders || folders.folders.length === 0) && storageResult[folderStorageKey]) {\n          folders = storageResult[folderStorageKey];\n          console.log(`[CloudSyncSettings] Loaded folders from ${folderStorageKey} (fallback)`);\n        }\n\n        // Prompts usually sync well to storage (only for Gemini)\n        if (platform === 'gemini' && storageResult[StorageKeys.PROMPT_ITEMS]) {\n          prompts = storageResult[StorageKeys.PROMPT_ITEMS];\n        }\n      } catch (err) {\n        console.error('[CloudSyncSettings] Error loading data:', err);\n      }\n\n      console.log(\n        `[CloudSyncSettings] Uploading ${platform} folders:`,\n        folders.folders?.length || 0,\n        platform === 'gemini' ? `prompts: ${prompts.length}` : '(prompts skipped for AI Studio)',\n      );\n\n      // Upload to Google Drive with platform info\n      const response = (await chrome.runtime.sendMessage({\n        type: 'gv.sync.upload',\n        payload: { folders, prompts, platform, accountScope },\n      })) as { ok?: boolean; error?: string; state?: SyncState } | undefined;\n\n      if (response?.state) {\n        setSyncState(response.state);\n      }\n\n      if (response?.ok) {\n        setStatusMessage({ text: t('syncSuccess'), kind: 'ok' });\n      } else {\n        throw new Error(response?.error || response?.state?.error || t('syncUploadFailed'));\n      }\n    } catch (error) {\n      const errorMessage = error instanceof Error ? error.message : 'Sync failed';\n      console.error('[CloudSyncSettings] Sync failed:', error);\n      setStatusMessage({ text: t('syncError').replace('{error}', errorMessage), kind: 'err' });\n    } finally {\n      setIsUploading(false);\n    }\n  }, [getBaseFolderStorageKey, platform, resolveAccountSyncContext, t]);\n\n  // Handle download from Drive (restore data) - NOW MERGES instead of overwrite\n  const handleDownloadFromDrive = useCallback(async () => {\n    setStatusMessage(null);\n    setIsDownloading(true);\n\n    try {\n      const accountContext = await resolveAccountSyncContext();\n      let accountScope = accountContext.accountScope;\n      let folderStorageKey = accountContext.folderStorageKey;\n\n      // Download from Google Drive (platform-specific)\n      const response = (await chrome.runtime.sendMessage({\n        type: 'gv.sync.download',\n        payload: { platform, accountScope },\n      })) as\n        | {\n            ok?: boolean;\n            error?: string;\n            state?: SyncState;\n            data?: {\n              folders?: { data?: FolderData };\n              prompts?: { items?: PromptItem[] };\n              starred?: { data?: StarredMessagesData };\n            } | null;\n          }\n        | undefined;\n\n      if (response?.state) {\n        setSyncState(response.state);\n      }\n\n      if (!response?.ok) {\n        throw new Error(response?.error || response?.state?.error || t('syncDownloadFailed'));\n      }\n\n      if (!response.data) {\n        setStatusMessage({ text: t('syncNoData'), kind: 'err' });\n        setIsDownloading(false);\n        return;\n      }\n\n      // Get current local data for merging - prioritize Content Script\n      let localFolders: FolderData = { folders: [], folderContents: {} };\n      let localPrompts: PromptItem[] = [];\n\n      // 1. Try to get fresh folder data from active tab\n      try {\n        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n        console.log('[CloudSyncSettings] Active tab:', tab?.id, tab?.url);\n        if (tab?.id) {\n          const tabResponse = (await Promise.race([\n            chrome.tabs.sendMessage(tab.id, { type: 'gv.sync.requestData' }),\n            new Promise((_, reject) => setTimeout(() => reject('Timeout after 2s'), 2000)),\n          ])) as { ok?: boolean; data?: FolderData; accountScope?: SyncAccountScope } | null;\n\n          console.log('[CloudSyncSettings] Tab response:', tabResponse);\n          if (tabResponse?.ok && tabResponse.data) {\n            localFolders = tabResponse.data;\n            console.log(\n              '[CloudSyncSettings] Got fresh folder data from content script:',\n              'folders:',\n              localFolders.folders?.length,\n              'folderContents keys:',\n              Object.keys(localFolders.folderContents || {}).length,\n            );\n            if (tabResponse.accountScope) {\n              accountScope = tabResponse.accountScope;\n              folderStorageKey = buildScopedStorageKey(\n                getBaseFolderStorageKey(platform),\n                tabResponse.accountScope.accountKey,\n              );\n            }\n          }\n        }\n      } catch (e) {\n        console.warn('[CloudSyncSettings] Tab fetch failed/skipped:', e);\n      }\n\n      // 2. Fallback to storage\n      try {\n        const storageResult = await chrome.storage.local.get([\n          folderStorageKey,\n          StorageKeys.PROMPT_ITEMS,\n        ]);\n\n        // Only use storage folders if we didn't get them from tab\n        if (\n          (!localFolders.folders || localFolders.folders.length === 0) &&\n          storageResult[folderStorageKey]\n        ) {\n          localFolders = storageResult[folderStorageKey];\n          console.log(`[CloudSyncSettings] Loaded folders from ${folderStorageKey} (fallback)`);\n        }\n\n        // Prompts only for Gemini platform\n        if (platform === 'gemini' && storageResult[StorageKeys.PROMPT_ITEMS]) {\n          localPrompts = storageResult[StorageKeys.PROMPT_ITEMS];\n        }\n      } catch (err) {\n        console.error('[CloudSyncSettings] Error loading local data for merge:', err);\n      }\n\n      // SyncData contains FolderExportPayload.data, PromptExportPayload.items, and StarredExportPayload.data\n      const {\n        folders: cloudFoldersPayload,\n        prompts: cloudPromptsPayload,\n        starred: cloudStarredPayload,\n      } = response.data;\n      const cloudFolderData = cloudFoldersPayload?.data || { folders: [], folderContents: {} };\n      const cloudPromptItems = cloudPromptsPayload?.items || [];\n      const cloudStarredData: StarredMessagesData = cloudStarredPayload?.data || { messages: {} };\n\n      console.log('[CloudSyncSettings] === MERGE DEBUG ===');\n      console.log('[CloudSyncSettings] Local folders count:', localFolders.folders?.length || 0);\n      console.log(\n        '[CloudSyncSettings] Local folderContents:',\n        JSON.stringify(Object.keys(localFolders.folderContents || {})),\n      );\n      console.log('[CloudSyncSettings] Cloud folders count:', cloudFolderData.folders?.length || 0);\n      console.log(\n        '[CloudSyncSettings] Cloud folderContents:',\n        JSON.stringify(Object.keys(cloudFolderData.folderContents || {})),\n      );\n      console.log(\n        '[CloudSyncSettings] Cloud starred conversations:',\n        Object.keys(cloudStarredData.messages || {}).length,\n      );\n\n      // Get local starred messages for merge\n      let localStarred: StarredMessagesData = { messages: {} };\n      try {\n        const starredResult = await chrome.storage.local.get(['geminiTimelineStarredMessages']);\n        if (starredResult.geminiTimelineStarredMessages) {\n          localStarred = starredResult.geminiTimelineStarredMessages;\n        }\n      } catch (err) {\n        console.warn('[CloudSyncSettings] Could not get local starred messages:', err);\n      }\n\n      // Perform Merge\n      const mergedFolders = mergeFolderData(localFolders, cloudFolderData);\n      const mergedPrompts = mergePrompts(localPrompts, cloudPromptItems);\n      const mergedStarred = mergeStarredMessages(localStarred, cloudStarredData);\n\n      console.log('[CloudSyncSettings] Merged folders count:', mergedFolders.folders?.length || 0);\n      console.log(\n        '[CloudSyncSettings] Merged folderContents:',\n        JSON.stringify(Object.keys(mergedFolders.folderContents || {})),\n      );\n      console.log(\n        '[CloudSyncSettings] Merged starred conversations:',\n        Object.keys(mergedStarred.messages || {}).length,\n      );\n      console.log('[CloudSyncSettings] === END MERGE DEBUG ===');\n\n      // Save merged data to storage (platform-specific storage key for folders)\n      const storageUpdate: Record<string, unknown> = {\n        [folderStorageKey]: mergedFolders,\n      };\n\n      // Only save prompts and starred for Gemini platform\n      if (platform === 'gemini') {\n        storageUpdate[StorageKeys.PROMPT_ITEMS] = mergedPrompts;\n        storageUpdate.geminiTimelineStarredMessages = mergedStarred;\n      }\n\n      await chrome.storage.local.set(storageUpdate);\n\n      // Notify content script to reload folders\n      try {\n        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n        if (tab?.id) {\n          await chrome.tabs.sendMessage(tab.id, { type: 'gv.folders.reload' });\n          console.log('[CloudSyncSettings] Sent reload message to content script');\n        }\n      } catch (err) {\n        console.warn('[CloudSyncSettings] Could not notify content script:', err);\n      }\n\n      setStatusMessage({ text: t('syncSuccess'), kind: 'ok' });\n    } catch (error) {\n      const errorMessage = error instanceof Error ? error.message : 'Download failed';\n      console.error('[CloudSyncSettings] Download failed:', error);\n      setStatusMessage({ text: t('syncError').replace('{error}', errorMessage), kind: 'err' });\n    } finally {\n      setIsDownloading(false);\n    }\n  }, [getBaseFolderStorageKey, platform, resolveAccountSyncContext, t]);\n\n  // Clear status message after 3 seconds\n  useEffect(() => {\n    if (statusMessage) {\n      const timer = setTimeout(() => setStatusMessage(null), 3000);\n      return () => clearTimeout(timer);\n    }\n  }, [statusMessage]);\n\n  // Don't render on Safari\n  if (isSafariBrowser) return null;\n\n  return (\n    <Card className=\"p-4 transition-all hover:shadow-md\">\n      <CardTitle className=\"mb-4\">{t('cloudSync')}</CardTitle>\n      <CardContent className=\"space-y-4 p-0\">\n        {/* Description */}\n        <p className=\"text-muted-foreground text-xs\">{t('cloudSyncDescription')}</p>\n\n        {/* Sync Mode Toggle */}\n        <div>\n          <Label className=\"mb-2 block text-sm font-medium\">{t('syncMode')}</Label>\n          <div className=\"bg-secondary/60 relative grid grid-cols-2 gap-1 rounded-xl p-1\">\n            <div\n              className=\"bg-primary pointer-events-none absolute top-1 bottom-1 w-[calc(50%-4px)] rounded-lg shadow-sm transition-all duration-300 ease-out\"\n              style={{\n                left: syncState.mode === 'disabled' ? '4px' : 'calc(50% + 2px)',\n              }}\n            />\n            <button\n              className={`relative z-10 rounded-lg px-2 py-2 text-xs font-bold transition-all duration-200 ${\n                syncState.mode === 'disabled'\n                  ? 'text-primary-foreground'\n                  : 'text-muted-foreground hover:text-foreground'\n              }`}\n              onClick={() => handleModeChange('disabled')}\n            >\n              {t('syncModeDisabled')}\n            </button>\n            <button\n              className={`relative z-10 rounded-lg px-2 py-2 text-xs font-bold transition-all duration-200 ${\n                syncState.mode === 'manual'\n                  ? 'text-primary-foreground'\n                  : 'text-muted-foreground hover:text-foreground'\n              }`}\n              onClick={() => handleModeChange('manual')}\n            >\n              {t('syncModeManual')}\n            </button>\n          </div>\n        </div>\n\n        {/* Sync Actions - Only show if not disabled */}\n        {syncState.mode !== 'disabled' && (\n          <>\n            {/* Upload/Download Buttons */}\n            <div className=\"flex gap-2\">\n              {/* Upload Button (Local → Drive) */}\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                className=\"group hover:border-primary/50 flex-1\"\n                onClick={handleSyncNow}\n                disabled={isUploading || isDownloading}\n              >\n                <span className=\"flex items-center gap-1 text-xs transition-transform group-hover:scale-105\">\n                  {isUploading ? (\n                    <svg className=\"h-3 w-3 animate-spin\" viewBox=\"0 0 24 24\" fill=\"none\">\n                      <circle\n                        className=\"opacity-25\"\n                        cx=\"12\"\n                        cy=\"12\"\n                        r=\"10\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"4\"\n                      />\n                      <path\n                        className=\"opacity-75\"\n                        fill=\"currentColor\"\n                        d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"\n                      />\n                    </svg>\n                  ) : (\n                    <svg\n                      width=\"12\"\n                      height=\"12\"\n                      viewBox=\"0 0 24 24\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"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                  {t('syncUpload')}\n                </span>\n              </Button>\n\n              {/* Sync Button (Drive → Local) */}\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                className=\"group hover:border-primary/50 flex-1\"\n                onClick={handleDownloadFromDrive}\n                disabled={isUploading || isDownloading}\n              >\n                <span className=\"flex items-center gap-1 text-xs transition-transform group-hover:scale-105\">\n                  {isDownloading ? (\n                    <svg className=\"h-3 w-3 animate-spin\" viewBox=\"0 0 24 24\" fill=\"none\">\n                      <circle\n                        className=\"opacity-25\"\n                        cx=\"12\"\n                        cy=\"12\"\n                        r=\"10\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"4\"\n                      />\n                      <path\n                        className=\"opacity-75\"\n                        fill=\"currentColor\"\n                        d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"\n                      />\n                    </svg>\n                  ) : (\n                    <svg\n                      width=\"12\"\n                      height=\"12\"\n                      viewBox=\"0 0 24 24\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"2\"\n                    >\n                      <path d=\"M1 4v6h6M23 20v-6h-6\" />\n                      <path d=\"M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15\" />\n                    </svg>\n                  )}\n                  {t('syncMerge')}\n                </span>\n              </Button>\n            </div>\n\n            {/* Platform Indicator & Sync Times */}\n            <div className=\"text-muted-foreground space-y-0.5 text-center text-xs\">\n              <p className=\"text-foreground/70 font-medium\">\n                {platform === 'aistudio' ? '📊 AI Studio' : '✨ Gemini'}\n              </p>\n              <p>\n                ↑{' '}\n                {formatLastUpload(\n                  platform === 'aistudio'\n                    ? syncState.lastUploadTimeAIStudio\n                    : syncState.lastUploadTime,\n                )}\n              </p>\n              <p>\n                ↓{' '}\n                {formatLastSync(\n                  platform === 'aistudio' ? syncState.lastSyncTimeAIStudio : syncState.lastSyncTime,\n                )}\n              </p>\n            </div>\n\n            {/* Sign Out Button - Only show if authenticated */}\n            {syncState.isAuthenticated && (\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"text-muted-foreground hover:text-destructive w-full text-xs\"\n                onClick={handleSignOut}\n              >\n                {t('signOut')}\n              </Button>\n            )}\n          </>\n        )}\n\n        {/* Status Message */}\n        {statusMessage && (\n          <p\n            className={`text-center text-xs ${\n              statusMessage.kind === 'ok' ? 'text-green-600' : 'text-destructive'\n            }`}\n          >\n            {statusMessage.text}\n          </p>\n        )}\n\n        {/* Error Display */}\n        {syncState.error && !statusMessage && (\n          <p className=\"text-destructive text-center text-xs\">\n            {t('syncError').replace('{error}', syncState.error)}\n          </p>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "src/pages/popup/components/ContextSyncSettings.tsx",
    "content": "import React, { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { Button } from '../../../components/ui/button';\nimport { Card, CardContent, CardTitle } from '../../../components/ui/card';\nimport { Label } from '../../../components/ui/label';\nimport { useLanguage } from '../../../contexts/LanguageContext';\n\nconst STORAGE_KEY_ENABLED = 'contextSyncEnabled';\nconst STORAGE_KEY_PORT = 'contextSyncPort';\nconst DEFAULT_PORT = 3030;\n\nexport function ContextSyncSettings() {\n  const { t } = useLanguage();\n  const [isEnabled, setIsEnabled] = useState(false);\n  const [port, setPort] = useState(DEFAULT_PORT);\n  const [isOnline, setIsOnline] = useState(false);\n  const [isSyncing, setIsSyncing] = useState(false);\n  const [statusMessage, setStatusMessage] = useState<{\n    text: string;\n    kind: 'ok' | 'err' | 'info';\n  } | null>(null);\n\n  // Use a ref to track the latest translation function to avoid re-creating checkConnection\n  const tRef = useRef(t);\n  useEffect(() => {\n    tRef.current = t;\n  }, [t]);\n\n  useEffect(() => {\n    chrome.storage.sync.get([STORAGE_KEY_ENABLED, STORAGE_KEY_PORT], (result) => {\n      setIsEnabled(result[STORAGE_KEY_ENABLED] === true);\n      setPort(result[STORAGE_KEY_PORT] || DEFAULT_PORT);\n    });\n  }, []);\n\n  const handleModeChange = (enabled: boolean) => {\n    setIsEnabled(enabled);\n    chrome.storage.sync.set({ [STORAGE_KEY_ENABLED]: enabled });\n    if (!enabled) {\n      setIsOnline(false);\n      setStatusMessage(null);\n    }\n  };\n\n  const handlePortChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const val = e.target.value;\n    // Allow empty string to let user delete everything and type\n    if (val === '') {\n      // @ts-ignore - temporary state allow\n      setPort('');\n      return;\n    }\n    let newPort = parseInt(val, 10);\n    if (!isNaN(newPort)) {\n      // Range validation 1-65535\n      if (newPort < 1) newPort = 1;\n      if (newPort > 65535) newPort = 65535;\n\n      setPort(newPort);\n      chrome.storage.sync.set({ [STORAGE_KEY_PORT]: newPort });\n    }\n  };\n\n  const checkConnection = useCallback(async () => {\n    if (!isEnabled) return;\n\n    // If port is invalid (e.g. empty string during typing), don't check\n    if (!port) return;\n\n    const url = `http://127.0.0.1:${port}/sync`;\n\n    let timeoutId: ReturnType<typeof setTimeout> | undefined;\n    try {\n      const controller = new AbortController();\n      timeoutId = setTimeout(() => controller.abort(), 200);\n\n      await fetch(url, {\n        method: 'GET',\n        signal: controller.signal,\n      });\n\n      setIsOnline(true);\n    } catch {\n      setIsOnline(false);\n    } finally {\n      if (timeoutId) clearTimeout(timeoutId);\n    }\n  }, [isEnabled, port]);\n\n  useEffect(() => {\n    if (isEnabled) {\n      checkConnection();\n      // Poll every 5 seconds\n      const interval = setInterval(checkConnection, 5000);\n      return () => clearInterval(interval);\n    }\n  }, [checkConnection, isEnabled]);\n\n  const handleSync = async () => {\n    setIsSyncing(true);\n    setStatusMessage({ text: t('capturing'), kind: 'info' });\n\n    try {\n      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n\n      if (!tab?.id) {\n        throw new Error('No active tab found');\n      }\n\n      // Check if it's a supported page\n      if (\n        tab.url &&\n        !tab.url.includes('gemini.google.com') &&\n        !tab.url.includes('chatgpt.com') &&\n        !tab.url.includes('claude.ai')\n      ) {\n        // If it's not one of the default supported, maybe it's a custom one?\n        // For now, let's just warn but try anyway if the script is injected\n      }\n\n      const response = await chrome.tabs.sendMessage(tab.id, { action: 'sync_to_ide' });\n\n      if (response && response.status === 'success') {\n        setStatusMessage({ text: t('syncedSuccess'), kind: 'ok' });\n        // Clear success message after 3 seconds\n        setTimeout(() => setStatusMessage(null), 3000);\n      } else {\n        throw new Error(response?.message || 'Unknown error');\n      }\n    } catch (err) {\n      console.error('Sync failed', err);\n      setStatusMessage({ text: (err as Error).message, kind: 'err' });\n    } finally {\n      setIsSyncing(false);\n    }\n  };\n\n  return (\n    <Card className=\"p-4 transition-all hover:shadow-md\">\n      <CardTitle className=\"mb-4\">{t('contextSync')}</CardTitle>\n      <CardContent className=\"space-y-4 p-0\">\n        <p className=\"text-muted-foreground text-xs\">{t('contextSyncDescription')}</p>\n\n        {/* Sync Mode Toggle */}\n        <div>\n          <Label className=\"mb-2 block text-sm font-medium\">{t('syncMode')}</Label>\n          <div className=\"bg-secondary/60 relative grid grid-cols-2 gap-1 rounded-xl p-1\">\n            <div\n              className=\"bg-primary pointer-events-none absolute top-1 bottom-1 w-[calc(50%-4px)] rounded-lg shadow-sm transition-all duration-300 ease-out\"\n              style={{\n                left: !isEnabled ? '4px' : 'calc(50% + 2px)',\n              }}\n            />\n            <button\n              className={`relative z-10 rounded-lg px-2 py-2 text-xs font-bold transition-all duration-200 ${\n                !isEnabled\n                  ? 'text-primary-foreground'\n                  : 'text-muted-foreground hover:text-foreground'\n              }`}\n              onClick={() => handleModeChange(false)}\n            >\n              {t('syncModeDisabled')}\n            </button>\n            <button\n              className={`relative z-10 rounded-lg px-2 py-2 text-xs font-bold transition-all duration-200 ${\n                isEnabled\n                  ? 'text-primary-foreground'\n                  : 'text-muted-foreground hover:text-foreground'\n              }`}\n              onClick={() => handleModeChange(true)}\n            >\n              {t('syncModeManual')}\n            </button>\n          </div>\n        </div>\n\n        {isEnabled && (\n          <>\n            {/* Port Configuration */}\n            <div>\n              <Label className=\"mb-2 block text-sm font-medium\">{t('syncServerPort')}</Label>\n              <input\n                type=\"number\"\n                value={port}\n                onChange={handlePortChange}\n                min=\"1\"\n                max=\"65535\"\n                className=\"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50\"\n              />\n            </div>\n\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex items-center gap-2\">\n                <div\n                  className={`h-2 w-2 rounded-full ${isOnline ? 'bg-green-500' : 'bg-red-500'}`}\n                />\n                <span className=\"text-xs font-medium\">\n                  {isOnline ? t('ideOnline') : t('ideOffline')}\n                </span>\n              </div>\n\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                className=\"group hover:border-primary/50\"\n                onClick={handleSync}\n                disabled={!isOnline || isSyncing}\n              >\n                <span className=\"flex items-center gap-1 text-xs transition-transform group-hover:scale-105\">\n                  {isSyncing ? (\n                    <svg className=\"h-3 w-3 animate-spin\" viewBox=\"0 0 24 24\" fill=\"none\">\n                      <circle\n                        className=\"opacity-25\"\n                        cx=\"12\"\n                        cy=\"12\"\n                        r=\"10\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"4\"\n                      />\n                      <path\n                        className=\"opacity-75\"\n                        fill=\"currentColor\"\n                        d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"\n                      />\n                    </svg>\n                  ) : (\n                    <svg\n                      width=\"12\"\n                      height=\"12\"\n                      viewBox=\"0 0 24 24\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"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                  {t('syncToIDE')}\n                </span>\n              </Button>\n            </div>\n            {statusMessage && (\n              <div\n                className={`text-xs ${\n                  statusMessage.kind === 'ok'\n                    ? 'text-green-500'\n                    : statusMessage.kind === 'err'\n                      ? 'text-red-500'\n                      : 'text-blue-500'\n                }`}\n              >\n                {statusMessage.text}\n              </div>\n            )}\n          </>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "src/pages/popup/components/KeyboardShortcutSettings.tsx",
    "content": "import React, { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { Card, CardContent, CardTitle } from '@/components/ui/card';\nimport { Label } from '@/components/ui/label';\nimport { Switch } from '@/components/ui/switch';\nimport { useLanguage } from '@/contexts/LanguageContext';\nimport { keyboardShortcutService } from '@/core/services/KeyboardShortcutService';\nimport type {\n  KeyboardShortcutConfig,\n  ModifierKey,\n  ShortcutKey,\n} from '@/core/types/keyboardShortcut';\n\ninterface RecordingState {\n  action: 'previous' | 'next' | null;\n  modifiers: ModifierKey[];\n  key: ShortcutKey | null;\n}\n\nexport function KeyboardShortcutSettings() {\n  const { t } = useLanguage();\n  const [enabled, setEnabled] = useState<boolean>(true);\n  const [config, setConfig] = useState<KeyboardShortcutConfig | null>(null);\n  const [recording, setRecording] = useState<RecordingState>({\n    action: null,\n    modifiers: [],\n    key: null,\n  });\n  const [loading, setLoading] = useState<boolean>(true);\n\n  // Use refs to avoid stale closures in event handlers\n  const configRef = useRef<KeyboardShortcutConfig | null>(null);\n  const enabledRef = useRef<boolean>(true);\n  const recordingRef = useRef<RecordingState>({ action: null, modifiers: [], key: null });\n\n  // Keep refs in sync with state\n  useEffect(() => {\n    configRef.current = config;\n  }, [config]);\n\n  useEffect(() => {\n    enabledRef.current = enabled;\n  }, [enabled]);\n\n  useEffect(() => {\n    recordingRef.current = recording;\n  }, [recording]);\n\n  // Load configuration\n  useEffect(() => {\n    const loadConfig = async () => {\n      try {\n        // Initialize service first to load from storage\n        await keyboardShortcutService.init();\n\n        // Then get the loaded config\n        const { config: currentConfig, enabled: currentEnabled } =\n          keyboardShortcutService.getConfig();\n        setConfig(currentConfig);\n        setEnabled(currentEnabled);\n      } catch (error) {\n        console.error('[KeyboardShortcut] Failed to load config:', error);\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    loadConfig();\n  }, []);\n\n  // Toggle enabled state\n  const handleToggleEnabled = useCallback(async (checked: boolean) => {\n    setEnabled(checked);\n    try {\n      await keyboardShortcutService.setEnabled(checked);\n    } catch (error) {\n      console.error('[KeyboardShortcut] Failed to toggle enabled:', error);\n    }\n  }, []);\n\n  // Start recording shortcut\n  const startRecording = useCallback((action: 'previous' | 'next') => {\n    setRecording({ action, modifiers: [], key: null });\n  }, []);\n\n  // Handle key press during recording\n  const handleKeyDown = useCallback(async (e: KeyboardEvent) => {\n    const currentRecording = recordingRef.current;\n    const currentConfig = configRef.current;\n    const currentEnabled = enabledRef.current;\n\n    if (!currentRecording.action) return;\n\n    e.preventDefault();\n    e.stopPropagation();\n\n    // Escape cancels recording\n    if (e.key === 'Escape') {\n      setRecording({ action: null, modifiers: [], key: null });\n      return;\n    }\n\n    // Ignore modifier keys alone (they can't be shortcuts by themselves)\n    if (['Alt', 'Control', 'Shift', 'Meta', 'AltGraph'].includes(e.key)) {\n      return;\n    }\n\n    // Accept any key! Normalize arrow keys for consistency\n    let key: ShortcutKey;\n    if (e.key === 'Up') {\n      key = 'ArrowUp';\n    } else if (e.key === 'Down') {\n      key = 'ArrowDown';\n    } else if (e.key === 'Left') {\n      key = 'ArrowLeft';\n    } else if (e.key === 'Right') {\n      key = 'ArrowRight';\n    } else {\n      key = e.key;\n    }\n\n    // Extract modifiers\n    const modifiers: ModifierKey[] = [];\n    if (e.altKey) modifiers.push('Alt');\n    if (e.ctrlKey) modifiers.push('Ctrl');\n    if (e.shiftKey) modifiers.push('Shift');\n    if (e.metaKey) modifiers.push('Meta');\n\n    // Update config\n    if (!currentConfig) return;\n\n    const updatedConfig: KeyboardShortcutConfig = {\n      ...currentConfig,\n      [currentRecording.action]: {\n        action: `timeline:${currentRecording.action}` as const,\n        modifiers,\n        key,\n      },\n    };\n\n    try {\n      await keyboardShortcutService.saveConfig(updatedConfig, currentEnabled);\n      setConfig(updatedConfig);\n      setRecording({ action: null, modifiers: [], key: null });\n    } catch (error) {\n      console.error('[KeyboardShortcut] Failed to save shortcut:', error);\n    }\n  }, []);\n\n  // Attach/detach keydown listener\n  useEffect(() => {\n    if (recording.action) {\n      window.addEventListener('keydown', handleKeyDown, { capture: true });\n      return () => {\n        window.removeEventListener('keydown', handleKeyDown, { capture: true });\n      };\n    }\n  }, [recording.action, handleKeyDown]);\n\n  // Reset to defaults\n  const handleReset = useCallback(async () => {\n    try {\n      await keyboardShortcutService.resetToDefaults();\n      const { config: currentConfig } = keyboardShortcutService.getConfig();\n      setConfig(currentConfig);\n    } catch (error) {\n      console.error('[KeyboardShortcut] Failed to reset shortcuts:', error);\n    }\n  }, []);\n\n  if (loading || !config) {\n    return (\n      <Card className=\"p-4\">\n        <CardTitle className=\"mb-4\">{t('keyboardShortcuts')}</CardTitle>\n        <CardContent className=\"p-0\">\n          <p className=\"text-muted-foreground text-xs\">{t('loading')}</p>\n        </CardContent>\n      </Card>\n    );\n  }\n\n  return (\n    <Card className=\"p-4 transition-all hover:shadow-md\">\n      <CardTitle className=\"mb-4\">{t('keyboardShortcuts')}</CardTitle>\n      <CardContent className=\"space-y-6 p-0\">\n        {/* Enable/Disable Toggle */}\n        <div className=\"flex items-center justify-between\">\n          <Label htmlFor=\"shortcuts-enabled\" className=\"cursor-pointer text-sm font-medium\">\n            {t('enableShortcuts')}\n          </Label>\n          <Switch\n            id=\"shortcuts-enabled\"\n            checked={enabled}\n            onChange={(e) => handleToggleEnabled(e.target.checked)}\n          />\n        </div>\n\n        {/* Shortcut Configuration */}\n        {enabled && (\n          <>\n            <div className=\"grid grid-cols-2 gap-4 pt-2\">\n              {/* Previous Node */}\n              <div className=\"space-y-2\">\n                <Label className=\"text-muted-foreground text-xs font-medium\">\n                  {t('previousNode')}\n                </Label>\n                <button\n                  type=\"button\"\n                  onClick={() => startRecording('previous')}\n                  className={`flex w-full items-center justify-center rounded-lg border-2 px-4 py-3 font-mono text-sm font-semibold transition-all ${\n                    recording.action === 'previous'\n                      ? 'border-primary bg-primary/5 text-primary animate-pulse'\n                      : 'border-border hover:border-primary/50 hover:bg-accent'\n                  } `}\n                >\n                  {recording.action === 'previous'\n                    ? 'Press key...'\n                    : keyboardShortcutService.formatShortcut(config.previous)}\n                </button>\n              </div>\n\n              {/* Next Node */}\n              <div className=\"space-y-2\">\n                <Label className=\"text-muted-foreground text-xs font-medium\">{t('nextNode')}</Label>\n                <button\n                  type=\"button\"\n                  onClick={() => startRecording('next')}\n                  className={`flex w-full items-center justify-center rounded-lg border-2 px-4 py-3 font-mono text-sm font-semibold transition-all ${\n                    recording.action === 'next'\n                      ? 'border-primary bg-primary/5 text-primary animate-pulse'\n                      : 'border-border hover:border-primary/50 hover:bg-accent'\n                  } `}\n                >\n                  {recording.action === 'next'\n                    ? 'Press key...'\n                    : keyboardShortcutService.formatShortcut(config.next)}\n                </button>\n              </div>\n            </div>\n\n            {/* Hint text */}\n            {recording.action && (\n              <p className=\"text-muted-foreground animate-in fade-in text-center text-xs\">\n                Press Esc to cancel\n              </p>\n            )}\n\n            {/* Reset button */}\n            <div className=\"flex justify-center pt-2\">\n              <button\n                type=\"button\"\n                onClick={handleReset}\n                className=\"text-muted-foreground hover:text-foreground text-xs underline-offset-4 transition-colors hover:underline\"\n              >\n                {t('resetShortcuts')}\n              </button>\n            </div>\n          </>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "src/pages/popup/components/StarredHistory.tsx",
    "content": "import React, { useEffect, useState } from 'react';\n\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { useLanguage } from '@/contexts/LanguageContext';\nimport { StarredMessagesService } from '@/pages/content/timeline/StarredMessagesService';\nimport type { StarredMessage } from '@/pages/content/timeline/starredTypes';\n\ninterface StarredHistoryProps {\n  onClose: () => void;\n}\n\nexport function StarredHistory({ onClose }: StarredHistoryProps) {\n  const { t } = useLanguage();\n  const [messages, setMessages] = useState<StarredMessage[]>([]);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    loadStarredMessages();\n  }, []);\n\n  const loadStarredMessages = async () => {\n    setLoading(true);\n    try {\n      const allMessages = await StarredMessagesService.getAllStarredMessagesSorted();\n      setMessages(allMessages);\n    } catch (error) {\n      console.error('Failed to load starred messages:', error);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleMessageClick = async (message: StarredMessage) => {\n    // Check if we're already on a Gemini page\n    const [currentTab] = await chrome.tabs.query({ active: true, currentWindow: true });\n    const targetUrl = `${message.conversationUrl}#gv-turn-${message.turnId}`;\n\n    const isGeminiPage =\n      currentTab?.url?.includes('gemini.google.com') ||\n      currentTab?.url?.includes('aistudio.google.com');\n\n    if (isGeminiPage && currentTab?.id) {\n      // Navigate in the same tab (SPA navigation)\n      await chrome.tabs.update(currentTab.id, { url: targetUrl });\n      // Close the popup\n      window.close();\n    } else {\n      // Open in new tab if not on Gemini\n      await chrome.tabs.create({ url: targetUrl });\n    }\n  };\n\n  const handleDeleteMessage = async (message: StarredMessage, e: React.MouseEvent) => {\n    e.stopPropagation();\n\n    try {\n      await StarredMessagesService.removeStarredMessage(message.conversationId, message.turnId);\n      // Reload messages\n      await loadStarredMessages();\n    } catch (error) {\n      console.error('Failed to delete starred message:', error);\n    }\n  };\n\n  const formatDate = (timestamp: number): string => {\n    const d = new Date(timestamp);\n    const yyyy = d.getFullYear();\n    const mm = String(d.getMonth() + 1).padStart(2, '0');\n    const dd = String(d.getDate()).padStart(2, '0');\n    const hh = String(d.getHours()).padStart(2, '0');\n    const min = String(d.getMinutes()).padStart(2, '0');\n    const ss = String(d.getSeconds()).padStart(2, '0');\n    return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`;\n  };\n\n  const truncateText = (text: string, maxLength: number): string => {\n    if (text.length <= maxLength) return text;\n    return text.slice(0, maxLength) + '...';\n  };\n\n  return (\n    <div className=\"bg-background text-foreground flex h-[600px] w-[360px] flex-col\">\n      {/* Header */}\n      <div className=\"from-primary/10 via-accent/5 border-border/50 border-b bg-linear-to-br to-transparent px-5 py-4 backdrop-blur-sm\">\n        <div className=\"mb-2 flex items-center justify-between\">\n          <h1 className=\"from-primary to-primary/70 bg-linear-to-r bg-clip-text text-xl font-bold text-transparent\">\n            {t('starredHistory')}\n          </h1>\n          <Button\n            onClick={onClose}\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"hover:bg-primary/10 h-8 w-8 rounded-full\"\n          >\n            <svg\n              width=\"20\"\n              height=\"20\"\n              viewBox=\"0 0 20 20\"\n              fill=\"none\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n            >\n              <path\n                d=\"M15 5L5 15M5 5l10 10\"\n                stroke=\"currentColor\"\n                strokeWidth=\"2\"\n                strokeLinecap=\"round\"\n              />\n            </svg>\n          </Button>\n        </div>\n      </div>\n\n      {/* Content */}\n      <div className=\"flex-1 overflow-y-auto p-4\">\n        {loading ? (\n          <div className=\"flex h-full items-center justify-center\">\n            <div className=\"text-muted-foreground\">{t('loading')}</div>\n          </div>\n        ) : messages.length === 0 ? (\n          <div className=\"flex h-full flex-col items-center justify-center gap-2\">\n            <svg\n              width=\"48\"\n              height=\"48\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              className=\"text-muted-foreground\"\n            >\n              <path\n                d=\"M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z\"\n                fill=\"currentColor\"\n                opacity=\"0.3\"\n              />\n            </svg>\n            <p className=\"text-muted-foreground text-sm\">{t('noStarredMessages')}</p>\n          </div>\n        ) : (\n          <div className=\"space-y-2\">\n            {messages.map((message) => (\n              <Card\n                key={`${message.conversationId}-${message.turnId}`}\n                className=\"group relative cursor-pointer p-3 transition-all hover:shadow-md\"\n                onClick={() => handleMessageClick(message)}\n              >\n                {/* Delete button */}\n                <button\n                  onClick={(e) => handleDeleteMessage(message, e)}\n                  className=\"hover:bg-destructive/10 absolute top-2 right-2 rounded-full p-1 opacity-0 transition-opacity group-hover:opacity-100\"\n                  title={t('removeFromStarred')}\n                >\n                  <svg\n                    width=\"16\"\n                    height=\"16\"\n                    viewBox=\"0 0 16 16\"\n                    fill=\"none\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    className=\"text-destructive\"\n                  >\n                    <path\n                      d=\"M4 4l8 8M12 4l-8 8\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"2\"\n                      strokeLinecap=\"round\"\n                    />\n                  </svg>\n                </button>\n\n                {/* Message content */}\n                <div className=\"pr-6\">\n                  <div className=\"mb-1 flex items-start gap-2\">\n                    <svg\n                      width=\"16\"\n                      height=\"16\"\n                      viewBox=\"0 0 24 24\"\n                      fill=\"none\"\n                      xmlns=\"http://www.w3.org/2000/svg\"\n                      className=\"text-primary mt-0.5 shrink-0\"\n                    >\n                      <path\n                        d=\"M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z\"\n                        fill=\"currentColor\"\n                      />\n                    </svg>\n                    <p className=\"line-clamp-2 text-sm font-medium\">\n                      {truncateText(message.content, 100)}\n                    </p>\n                  </div>\n\n                  {/* Conversation info */}\n                  <div className=\"ml-6 space-y-1\">\n                    {message.conversationTitle && (\n                      <p className=\"text-muted-foreground truncate text-xs\">\n                        {message.conversationTitle}\n                      </p>\n                    )}\n                    <p className=\"text-muted-foreground text-xs\">{formatDate(message.starredAt)}</p>\n                  </div>\n                </div>\n              </Card>\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/pages/popup/components/WebsiteLogos.tsx",
    "content": "import React from 'react';\n\nexport const IconChatGPT = () => (\n  <svg viewBox=\"0 0 24 24\" width=\"100%\" height=\"100%\" fill=\"currentColor\">\n    <path d=\"M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364l2.0201-1.1685a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z\" />\n  </svg>\n);\n\nexport const IconClaude = () => (\n  <svg viewBox=\"0 0 24 24\" width=\"100%\" height=\"100%\" fill=\"currentColor\">\n    <path d=\"M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z\" />\n  </svg>\n);\n\nexport const IconGrok = () => (\n  <svg viewBox=\"0 0 24 24\" width=\"100%\" height=\"100%\" fill=\"currentColor\">\n    <path d=\"M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815\" />\n  </svg>\n);\n\nexport const IconDeepSeek = () => (\n  <svg viewBox=\"0 0 28 28\" width=\"100%\" height=\"100%\" fill=\"currentColor\">\n    <path d=\"M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 0 1-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 0 0-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 0 1-.465.137 9.597 9.597 0 0 0-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 0 0 1.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 0 1 1.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 0 1 .415-.287.302.302 0 0 1 .2.288.306.306 0 0 1-.31.307.303.303 0 0 1-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 0 1-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 0 1 .016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 0 1-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z\" />\n  </svg>\n);\n\nexport const IconQwen = () => (\n  <svg viewBox=\"0 0 24 24\" width=\"100%\" height=\"100%\" fill=\"currentColor\">\n    <path d=\"M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z\" />\n  </svg>\n);\n\nexport const IconKimi = () => (\n  <svg viewBox=\"0 0 24 24\" width=\"100%\" height=\"100%\" fill=\"currentColor\">\n    <path d=\"M1.052 16.916l9.539 2.552a21.007 21.007 0 00.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865l-.18-.016-.044-.004-.084-.009-.094-.01a11.605 11.605 0 01-.157-.02l-.107-.014-.11-.016a11.962 11.962 0 01-.32-.051l-.042-.008-.075-.013-.107-.02-.07-.015-.093-.019-.075-.016-.095-.02-.097-.023-.094-.022-.068-.017-.088-.022-.09-.024-.095-.025-.082-.023-.109-.03-.062-.02-.084-.025-.093-.028-.105-.034-.058-.019-.08-.026-.09-.031-.066-.024a6.293 6.293 0 01-.044-.015l-.068-.025-.101-.037-.057-.022-.08-.03-.087-.035-.088-.035-.079-.032-.095-.04-.063-.028-.063-.027a5.655 5.655 0 01-.041-.018l-.066-.03-.103-.047-.052-.024-.096-.046-.062-.03-.084-.04-.086-.044-.093-.047-.052-.027-.103-.055-.057-.03-.058-.032a6.49 6.49 0 01-.046-.026l-.094-.053-.06-.034-.051-.03-.072-.041-.082-.05-.093-.056-.052-.032-.084-.053-.061-.039-.079-.05-.07-.047-.053-.035a7.785 7.785 0 01-.054-.036l-.044-.03-.044-.03a6.066 6.066 0 01-.04-.028l-.057-.04-.076-.054-.069-.05-.074-.054-.056-.042-.076-.057-.076-.059-.086-.067-.045-.035-.064-.052-.074-.06-.089-.073-.046-.039-.046-.039a7.516 7.516 0 01-.043-.037l-.045-.04-.061-.053-.07-.062-.068-.06-.062-.058-.067-.062-.053-.05-.088-.084a13.28 13.28 0 01-.099-.097l-.029-.028-.041-.042-.069-.07-.05-.051-.05-.053a6.457 6.457 0 01-.168-.179l-.08-.088-.062-.07-.071-.08-.042-.049-.053-.062-.058-.068-.046-.056a7.175 7.175 0 01-.027-.033l-.045-.055-.066-.082-.041-.052-.05-.064-.02-.025a11.99 11.99 0 01-1.44-2.402zm-1.02-5.794l11.353 3.037a20.468 20.468 0 00-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923l-.016-.046-.035-.104a11.965 11.965 0 01-.05-.153l-.007-.023a11.896 11.896 0 01-.207-.741l-.03-.126-.018-.08-.021-.097-.018-.081-.018-.09-.017-.084-.018-.094c-.026-.141-.05-.283-.071-.426l-.017-.118-.011-.083-.013-.102a12.01 12.01 0 01-.019-.161l-.005-.047a12.12 12.12 0 01-.034-2.145zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1.008-.049.01-.067.015-.087.018-.098c.026-.148.056-.295.088-.442l.028-.124.02-.085.024-.097c.022-.09.045-.18.07-.268l.028-.102.023-.083.03-.1.025-.082.03-.096.026-.082.031-.095a11.896 11.896 0 011.01-2.232zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065.027-.04.04-.055.046-.065.055-.076.054-.072.064-.086.05-.065.057-.073.055-.07.06-.074.055-.069.065-.077.054-.066.066-.077.053-.06.072-.082.053-.06.067-.074.054-.058.073-.078.058-.06.063-.067.168-.17.1-.098.059-.056.076-.071a12.084 12.084 0 012.272-1.677zM12.017 0h.097l.082.001.069.001.054.002.068.002.046.001.076.003.047.002.06.003.054.002.087.005.105.007.144.011.088.007.044.004.077.008.082.008.047.005.102.012.05.006.108.014.081.01.042.006.065.01.207.032.07.012.065.011.14.026.092.018.11.022.046.01.075.016.041.01L14.7.3l.042.01.065.015.049.012.071.017.096.024.112.03.113.03.113.032.05.015.07.02.078.024.073.023.05.016.05.016.076.025.099.033.102.036.048.017.064.023.093.034.11.041.116.045.1.04.047.02.06.024.041.018.063.026.04.018.057.025.11.048.1.046.074.035.075.036.06.028.092.046.091.045.102.052.053.028.049.026.046.024.06.033.041.022.052.029.088.05.106.06.087.051.057.034.053.032.096.059.088.055.098.062.036.024.064.041.084.056.04.027.062.042.062.043.023.017c.054.037.108.075.161.114l.083.06.065.048.056.043.086.065.082.064.04.03.05.041.086.069.079.065.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027.065-.028.081-.034.086-.035c.113-.045.227-.09.341-.131l.096-.035.093-.033.084-.03.096-.031c.087-.03.176-.058.264-.085l.091-.027.086-.025.102-.03.085-.023.1-.026L9.04.37l.09-.023.091-.022.095-.022.09-.02.098-.021.091-.02.095-.018.092-.018.1-.018.091-.016.098-.017.092-.014.097-.015.092-.013.102-.013.091-.012.105-.012.09-.01.105-.01c.093-.01.186-.018.28-.024l.106-.008.09-.005.11-.006.093-.004.1-.004.097-.002.099-.002.197-.002z\" />\n  </svg>\n);\n\nexport const IconGemini = () => (\n  <svg viewBox=\"0 0 65 65\" width=\"100%\" height=\"100%\" fill=\"currentColor\">\n    <path d=\"M32.447 0c.68 0 1.273.465 1.439 1.125a38.904 38.904 0 001.999 5.905c2.152 5 5.105 9.376 8.854 13.125 3.751 3.75 8.126 6.703 13.125 8.855a38.98 38.98 0 005.906 1.999c.66.166 1.124.758 1.124 1.438 0 .68-.464 1.273-1.125 1.439a38.902 38.902 0 00-5.905 1.999c-5 2.152-9.375 5.105-13.125 8.854-3.749 3.751-6.702 8.126-8.854 13.125a38.973 38.973 0 00-2 5.906 1.485 1.485 0 01-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a38.913 38.913 0 00-2-5.905c-2.151-5-5.103-9.375-8.854-13.125-3.75-3.749-8.125-6.702-13.125-8.854a38.973 38.973 0 00-5.905-2A1.485 1.485 0 010 32.448c0-.68.465-1.272 1.125-1.438a38.903 38.903 0 005.905-2c5-2.151 9.376-5.104 13.125-8.854 3.75-3.749 6.703-8.125 8.855-13.125a38.972 38.972 0 001.999-5.905A1.485 1.485 0 0132.447 0z\" />\n  </svg>\n);\n\nexport const IconNotebookLM = () => (\n  <svg viewBox=\"0 0 24 24\" width=\"100%\" height=\"100%\" fill=\"currentColor\">\n    <path d=\"M11.999 3.14C5.372 3.14 0 8.588 0 15.312v5.828h2.212v-.58c0-2.728 2.178-4.938 4.866-4.938 2.688 0 4.866 2.21 4.866 4.937v.581h2.212v-.58c0-3.967-3.17-7.18-7.078-7.18a6.966 6.966 0 00-4.086 1.318C4.2 12.262 6.687 10.59 9.56 10.59c4.057 0 7.347 3.338 7.347 7.453v3.097h2.212v-3.097c0-5.355-4.28-9.698-9.56-9.698a9.438 9.438 0 00-6.217 2.332C4.984 7.528 8.244 5.383 12 5.383c5.406 0 9.788 4.446 9.788 9.93v5.827H24v-5.828C23.999 8.588 18.627 3.14 11.999 3.14z\" />\n  </svg>\n);\n\nexport const IconMidjourney = () => (\n  <svg viewBox=\"0 0 24 24\" width=\"100%\" height=\"100%\" fill=\"currentColor\">\n    <path d=\"M22.369 17.676c-1.387 1.259-3.17 2.378-5.332 3.417.044.03.086.057.13.083l.018.01.019.012c.216.123.42.184.641.184.222 0 .426-.061.642-.184l.018-.011.019-.011c.14-.084.266-.178.492-.366l.178-.148c.279-.232.426-.342.625-.456.304-.174.612-.266.949-.266.337 0 .645.092.949.266l.023.014c.188.109.334.219.602.442l.178.148c.221.184.346.278.483.36l.028.017.018.01c.21.12.407.181.62.185h.022a.31.31 0 110 .618c-.337 0-.645-.092-.95-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.02-.014a5.356 5.356 0 01-.49-.377l-.159-.132a3.836 3.836 0 00-.483-.36l-.027-.017-.019-.01a1.256 1.256 0 00-.641-.185c-.222 0-.426.061-.641.184l-.02.011-.018.011c-.14.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.51.39l-.022.014-.022.014-.09.054a1.868 1.868 0 01-.95.266c-.337 0-.644-.092-.949-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.026-.017a4.881 4.881 0 01-.425-.325.308.308 0 01-.12-.1l-.098-.081a3.836 3.836 0 00-.483-.36l-.027-.017-.019-.01a1.256 1.256 0 00-.641-.185c-.222 0-.426.061-.642.184l-.018.011-.019.011c-.14.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.51.39l-.023.014-.022.014-.09.054A1.868 1.868 0 0112 22c-.337 0-.645-.092-.949-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.021-.014a5.356 5.356 0 01-.49-.377l-.158-.132a3.836 3.836 0 00-.483-.36l-.028-.017-.018-.01a1.256 1.256 0 00-.642-.185c-.221 0-.425.061-.641.184l-.019.011-.018.011c-.141.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.511.39l-.022.014-.022.014-.09.054a1.868 1.868 0 01-.986.264c-.746-.09-1.319-.38-1.89-.866l-.035-.03c-.047-.041-.118-.106-.192-.174l-.196-.181-.107-.1-.011-.01a1.531 1.531 0 00-.336-.253.313.313 0 00-.095-.03h-.005c-.119.022-.238.059-.361.11a.308.308 0 01-.077.061l-.008.005a.309.309 0 01-.126.034 5.66 5.66 0 00-.774.518l-.416.324-.055.043a6.542 6.542 0 01-.324.236c-.305.207-.552.315-.8.315a.31.31 0 01-.01-.618h.01c.09 0 .235-.062.438-.198l.04-.027c.077-.054.163-.117.27-.199l.385-.301.06-.047c.268-.206.506-.373.73-.505l-.633-1.21a.309.309 0 01.254-.451l20.287-1.305a.309.309 0 01.228.537zm-1.118.14L2.369 19.03l.423.809c.128-.045.256-.078.388-.1a.31.31 0 01.052-.005c.132 0 .26.032.386.093.153.073.294.179.483.35l.016.015.092.086.144.134.097.089c.065.06.125.114.16.144.485.418.948.658 1.554.736h.011a1.25 1.25 0 00.6-.172l.021-.011.019-.011.018-.011c.141-.084.266-.178.492-.366l.178-.148c.279-.232.426-.342.625-.456.305-.174.612-.266.95-.266.336 0 .644.092.948.266l.023.014c.188.109.335.219.603.442l.177.148c.222.184.346.278.484.36l.027.017.019.01c.215.124.42.185.641.185.222 0 .426-.061.641-.184l.019-.011.018-.011c.141-.084.267-.178.493-.366l.177-.148c.28-.232.427-.342.626-.456.304-.174.612-.266.949-.266.337 0 .644.092.949.266l.025.015c.187.109.334.22.603.443 1.867-.878 3.448-1.811 4.73-2.832l.02-.016zM3.653 2.026C6.073 3.06 8.69 4.941 10.8 7.258c2.46 2.7 4.109 5.828 4.637 9.149a.31.31 0 01-.421.335c-2.348-.945-4.54-1.258-6.59-1.02-1.739.2-3.337.792-4.816 1.703-.294.182-.62-.182-.405-.454 1.856-2.355 2.581-4.99 2.343-7.794-.195-2.292-1.031-4.61-2.284-6.709a.31.31 0 01.388-.442zM10.04 4.45c1.778.543 3.892 2.102 5.782 4.243 1.984 2.248 3.552 4.934 4.347 7.582a.31.31 0 01-.401.38l-.022-.01-.386-.154a10.594 10.594 0 00-.291-.112l-.016-.006c-.68-.247-1.199-.291-1.944-.101a.31.31 0 01-.375-.218C15.378 11.123 13.073 7.276 9.775 5c-.291-.201-.072-.653.266-.55zM4.273 2.996l.008.015c1.028 1.94 1.708 4.031 1.885 6.113.213 2.513-.31 4.906-1.673 7.092l-.02.031.003-.001c1.198-.581 2.47-.969 3.825-1.132l.055-.006c1.981-.23 4.083.029 6.309.837l.066.025-.007-.039c-.593-2.95-2.108-5.737-4.31-8.179l-.07-.078c-1.785-1.96-3.944-3.6-6.014-4.65l-.057-.028zm7.92 3.238l.048.048c2.237 2.295 3.885 5.431 4.974 9.191l.038.132.022-.004c.71-.133 1.284-.063 1.963.18l.027.01.066.024.046.018-.025-.073c-.811-2.307-2.208-4.62-3.936-6.594l-.058-.065c-1.02-1.155-2.103-2.132-3.15-2.856l-.015-.011z\" />\n  </svg>\n);\n\nexport const IconPerplexity = () => (\n  <svg viewBox=\"0 0 24 24\" width=\"100%\" height=\"100%\" fill=\"currentColor\">\n    <path d=\"M22.398 7.09h-2.31V.068l-7.51 6.354V.158h-1.156v6.196L4.49 0v7.09H1.602v10.397H4.49V24l6.933-6.36v6.201h1.155v-6.047l6.932 6.181v-6.488h2.888zm-3.466-4.531v4.53h-5.355zm-13.286.067l4.869 4.464h-4.87zM2.758 16.332V8.245h7.847L4.49 14.36v1.972zm2.888 5.04v-6.534l5.776-5.776v7.011zm12.708.025l-5.776-5.15V9.061l5.776 5.776zm2.889-5.065H19.51V14.36l-6.115-6.115h7.848z\" />\n  </svg>\n);\n"
  },
  {
    "path": "src/pages/popup/components/WidthSlider.tsx",
    "content": "import React from 'react';\n\nimport { Card, CardContent, CardTitle } from '../../../components/ui/card';\nimport { Slider } from '../../../components/ui/slider';\nimport { Switch } from '../../../components/ui/switch';\n\ninterface WidthSliderProps {\n  label: string;\n  value: number;\n  min: number;\n  max: number;\n  step: number;\n  narrowLabel: string;\n  wideLabel: string;\n  valueFormatter?: (value: number) => string;\n  onChange: (value: number) => void;\n  onChangeComplete?: (value: number) => void;\n  /** When provided, renders a toggle switch in the title row */\n  enabled?: boolean;\n  /** Callback when the toggle is flipped */\n  onToggle?: (enabled: boolean) => void;\n}\n\n/**\n * Reusable width adjustment slider component\n * Used for chat width, edit input width, and sidebar width settings\n */\nexport default function WidthSlider({\n  label,\n  value,\n  min,\n  max,\n  step,\n  narrowLabel,\n  wideLabel,\n  valueFormatter,\n  onChange,\n  onChangeComplete,\n  enabled,\n  onToggle,\n}: WidthSliderProps) {\n  const formatValue = valueFormatter ?? ((v: number) => `${v}%`);\n  const hasToggle = enabled !== undefined && onToggle !== undefined;\n  const isExpanded = !hasToggle || enabled;\n\n  return (\n    <Card className=\"p-4 transition-all hover:shadow-md\">\n      <div className=\"mb-0 flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          {hasToggle && (\n            <Switch\n              checked={enabled}\n              onChange={(e) => onToggle(e.target.checked)}\n              className=\"scale-75\"\n            />\n          )}\n          <CardTitle>{label}</CardTitle>\n        </div>\n        <span\n          className=\"text-primary bg-primary/10 rounded-md px-2.5 py-1 text-sm font-bold shadow-sm transition-opacity duration-200\"\n          style={{ opacity: isExpanded ? 1 : 0 }}\n        >\n          {formatValue(value)}\n        </span>\n      </div>\n      <div\n        className=\"overflow-hidden transition-all duration-200 ease-in-out\"\n        style={{\n          maxHeight: isExpanded ? '120px' : '0px',\n          opacity: isExpanded ? 1 : 0,\n          marginTop: isExpanded ? '12px' : '0px',\n        }}\n      >\n        <CardContent className=\"p-0\">\n          <div className=\"px-1\">\n            <Slider\n              min={min}\n              max={max}\n              step={step}\n              value={value}\n              onValueChange={onChange}\n              onValueCommit={onChangeComplete}\n            />\n            <div className=\"text-muted-foreground mt-3 flex items-center justify-between text-xs font-medium\">\n              <span>{narrowLabel}</span>\n              <span>{wideLabel}</span>\n            </div>\n          </div>\n        </CardContent>\n      </div>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "src/pages/popup/components/__tests__/CloudSyncSettings.test.tsx",
    "content": "import React, { act } from 'react';\nimport { type Root, createRoot } from 'react-dom/client';\n\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type { SyncState } from '@/core/types/sync';\nimport { DEFAULT_SYNC_STATE } from '@/core/types/sync';\n\nimport { CloudSyncSettings } from '../CloudSyncSettings';\n\nvi.mock('@/contexts/LanguageContext', () => ({\n  useLanguage: () => ({\n    language: 'en',\n    setLanguage: vi.fn(),\n    t: (key: string) => key,\n  }),\n}));\n\nvi.mock('@/core/utils/browser', () => ({\n  isSafari: () => false,\n}));\n\ntype MockedChrome = typeof chrome;\n\nconst baseState: SyncState = {\n  ...DEFAULT_SYNC_STATE,\n  mode: 'manual',\n  isAuthenticated: false,\n};\n\nfunction createChromeMock(sendMessage: ReturnType<typeof vi.fn>): MockedChrome {\n  return {\n    runtime: {\n      sendMessage,\n      lastError: null,\n      id: 'test-extension-id',\n    },\n    tabs: {\n      query: vi.fn().mockResolvedValue([{ id: 1, url: 'https://gemini.google.com/app' }]),\n      sendMessage: vi.fn().mockResolvedValue({\n        ok: true,\n        data: { folders: [], folderContents: {} },\n      }),\n    },\n    storage: {\n      local: {\n        get: vi.fn().mockResolvedValue({\n          gvFolderData: { folders: [], folderContents: {} },\n          gvPromptItems: [],\n          geminiTimelineStarredMessages: { messages: {} },\n        }),\n        set: vi.fn().mockResolvedValue(undefined),\n        remove: vi.fn().mockResolvedValue(undefined),\n      },\n      sync: {\n        get: vi.fn().mockResolvedValue({}),\n        set: vi.fn().mockResolvedValue(undefined),\n        remove: vi.fn().mockResolvedValue(undefined),\n        clear: vi.fn().mockResolvedValue(undefined),\n      },\n      onChanged: {\n        addListener: vi.fn(),\n        removeListener: vi.fn(),\n      },\n    },\n  } as unknown as MockedChrome;\n}\n\nasync function flushMicrotasks(): Promise<void> {\n  await act(async () => {\n    await Promise.resolve();\n  });\n}\n\ndescribe('CloudSyncSettings auth flow', () => {\n  let container: HTMLDivElement;\n  let root: Root;\n\n  afterEach(() => {\n    if (root) {\n      act(() => {\n        root.unmount();\n      });\n    }\n    document.body.innerHTML = '';\n    vi.clearAllMocks();\n  });\n\n  beforeEach(() => {\n    (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;\n    container = document.createElement('div');\n    document.body.appendChild(container);\n  });\n\n  it('triggers upload directly without a separate authenticate message', async () => {\n    const sendMessageMock = vi.fn().mockImplementation((message: { type?: string }) => {\n      if (message.type === 'gv.sync.getState') {\n        return Promise.resolve({ ok: true, state: baseState });\n      }\n      if (message.type === 'gv.sync.upload') {\n        return Promise.resolve({\n          ok: true,\n          state: { ...baseState, isAuthenticated: true },\n        });\n      }\n      return Promise.resolve({ ok: true });\n    });\n\n    (globalThis as { chrome: MockedChrome }).chrome = createChromeMock(sendMessageMock);\n\n    await act(async () => {\n      root = createRoot(container);\n      root.render(<CloudSyncSettings />);\n    });\n    await flushMicrotasks();\n\n    const uploadButton = Array.from(container.querySelectorAll('button')).find((btn) =>\n      (btn.textContent || '').includes('syncUpload'),\n    );\n    expect(uploadButton).toBeTruthy();\n\n    await act(async () => {\n      uploadButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));\n    });\n    await flushMicrotasks();\n\n    expect(sendMessageMock).toHaveBeenCalledWith(\n      expect.objectContaining({\n        type: 'gv.sync.upload',\n      }),\n    );\n    expect(sendMessageMock).not.toHaveBeenCalledWith(\n      expect.objectContaining({\n        type: 'gv.sync.authenticate',\n      }),\n    );\n  });\n\n  it('triggers download directly without a separate authenticate message', async () => {\n    const sendMessageMock = vi.fn().mockImplementation((message: { type?: string }) => {\n      if (message.type === 'gv.sync.getState') {\n        return Promise.resolve({ ok: true, state: baseState });\n      }\n      if (message.type === 'gv.sync.download') {\n        return Promise.resolve({\n          ok: true,\n          state: { ...baseState, isAuthenticated: true },\n          data: {\n            folders: { data: { folders: [], folderContents: {} } },\n            prompts: { items: [] },\n            starred: { data: { messages: {} } },\n          },\n        });\n      }\n      return Promise.resolve({ ok: true });\n    });\n\n    (globalThis as { chrome: MockedChrome }).chrome = createChromeMock(sendMessageMock);\n\n    await act(async () => {\n      root = createRoot(container);\n      root.render(<CloudSyncSettings />);\n    });\n    await flushMicrotasks();\n\n    const downloadButton = Array.from(container.querySelectorAll('button')).find((btn) =>\n      (btn.textContent || '').includes('syncMerge'),\n    );\n    expect(downloadButton).toBeTruthy();\n\n    await act(async () => {\n      downloadButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));\n    });\n    await flushMicrotasks();\n\n    expect(sendMessageMock).toHaveBeenCalledWith(\n      expect.objectContaining({\n        type: 'gv.sync.download',\n      }),\n    );\n    expect(sendMessageMock).not.toHaveBeenCalledWith(\n      expect.objectContaining({\n        type: 'gv.sync.authenticate',\n      }),\n    );\n  });\n});\n"
  },
  {
    "path": "src/pages/popup/index.css",
    "content": "body {\n  margin: 0;\n  font-family:\n    -apple-system,\n    BlinkMacSystemFont,\n    Segoe UI,\n    Roboto,\n    Helvetica,\n    Arial,\n    sans-serif;\n}\n#__root {\n  display: inline-block;\n}\n"
  },
  {
    "path": "src/pages/popup/index.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\" />\n    <title>Popup</title>\n    <link\n      rel=\"stylesheet\"\n      href=\"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=check,content_copy,experiment\"\n    />\n  </head>\n\n  <body>\n    <div id=\"__root\"></div>\n    <script type=\"module\" src=\"./index.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "src/pages/popup/index.tsx",
    "content": "import { createRoot } from 'react-dom/client';\n\nimport Popup from '@pages/popup/Popup';\nimport '@pages/popup/index.css';\n\nimport '@assets/styles/tailwind.css';\n\nimport { LanguageProvider } from '../../contexts/LanguageContext';\n\nfunction init() {\n  const rootContainer = document.querySelector('#__root');\n  if (!rootContainer) throw new Error(\"Can't find Popup root element\");\n  const root = createRoot(rootContainer);\n  root.render(\n    <LanguageProvider>\n      <Popup />\n    </LanguageProvider>,\n  );\n}\n\ninit();\n"
  },
  {
    "path": "src/pages/popup/utils/latestVersion.ts",
    "content": "export interface LatestVersionCacheEntry {\n  version: string;\n  fetchedAt: number;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n  typeof value === 'object' && value !== null;\n\nexport function getManifestUpdateUrl(manifest: unknown): string | null {\n  if (!isRecord(manifest)) return null;\n  const updateUrl = manifest.update_url;\n  return typeof updateUrl === 'string' && updateUrl.trim() ? updateUrl : null;\n}\n\nexport function extractLatestReleaseVersion(data: unknown): string | null {\n  if (!isRecord(data)) return null;\n\n  const tagName = data.tag_name;\n  if (typeof tagName === 'string' && tagName.trim()) return tagName;\n\n  const name = data.name;\n  if (typeof name === 'string' && name.trim()) return name;\n\n  return null;\n}\n\nexport function extractDmgDownloadUrl(data: unknown): string | null {\n  if (!isRecord(data)) return null;\n  const assets = data.assets;\n  if (!Array.isArray(assets)) return null;\n\n  for (const asset of assets) {\n    if (!isRecord(asset)) continue;\n    const name = asset.name;\n    if (typeof name === 'string' && name.endsWith('.dmg')) {\n      const url = asset.browser_download_url;\n      if (typeof url === 'string' && url.trim()) return url;\n    }\n  }\n  return null;\n}\n\nexport function getCachedLatestVersion(\n  cachedValue: unknown,\n  now: number,\n  maxAgeMs: number,\n): string | null {\n  if (!isRecord(cachedValue)) return null;\n\n  const version = cachedValue.version;\n  const fetchedAt = cachedValue.fetchedAt;\n\n  if (typeof version !== 'string' || !version.trim()) return null;\n  if (typeof fetchedAt !== 'number' || !Number.isFinite(fetchedAt)) return null;\n  if (now - fetchedAt >= maxAgeMs) return null;\n\n  return version;\n}\n"
  },
  {
    "path": "src/tests/setup.ts",
    "content": "/**\n * Test setup file\n * Configure test environment\n */\nimport { vi } from 'vitest';\n\n// Mock chrome API\nglobalThis.chrome = {\n  storage: {\n    sync: {\n      get: vi.fn(),\n      set: vi.fn(),\n      remove: vi.fn(),\n      clear: vi.fn(),\n    },\n    onChanged: {\n      addListener: vi.fn(),\n      removeListener: vi.fn(),\n    },\n  },\n  runtime: {\n    lastError: null,\n    id: 'test-extension-id',\n  },\n} as unknown as typeof chrome;\n\n// Mock localStorage\nconst localStorageMock = (() => {\n  let store: Record<string, string> = {};\n\n  return {\n    getItem: (key: string) => store[key] || null,\n    setItem: (key: string, value: string) => {\n      store[key] = value;\n    },\n    removeItem: (key: string) => {\n      delete store[key];\n    },\n    clear: () => {\n      store = {};\n    },\n    get length() {\n      return Object.keys(store).length;\n    },\n  };\n})();\n\nObject.defineProperty(window, 'localStorage', {\n  value: localStorageMock,\n  writable: true,\n});\n\n// Also expose localStorage globally (not just on window)\nglobalThis.localStorage = localStorageMock as unknown as Storage;\n\n// Expose document globally\nglobalThis.document = window.document;\n\n// Mock DOM API\nObject.defineProperty(window, 'matchMedia', {\n  writable: true,\n  value: vi.fn().mockImplementation((query) => ({\n    matches: false,\n    media: query,\n    onchange: null,\n    addListener: vi.fn(),\n    removeListener: vi.fn(),\n    addEventListener: vi.fn(),\n    removeEventListener: vi.fn(),\n    dispatchEvent: vi.fn(),\n  })),\n});\n"
  },
  {
    "path": "src/utils/__tests__/i18n.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest';\nimport browser from 'webextension-polyfill';\n\nimport { getCurrentLanguage } from '../i18n';\n\nvi.mock('webextension-polyfill', () => {\n  const storageArea = { get: vi.fn(), set: vi.fn() };\n  return {\n    default: {\n      storage: {\n        sync: storageArea,\n        local: { get: vi.fn(), set: vi.fn() },\n        onChanged: { addListener: vi.fn(), removeListener: vi.fn() },\n      },\n      i18n: { getUILanguage: vi.fn() },\n    },\n  };\n});\n\ndescribe('getCurrentLanguage', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('uses sync storage when available', async () => {\n    const syncGet = browser.storage.sync.get as unknown as ReturnType<typeof vi.fn>;\n    const localGet = browser.storage.local.get as unknown as ReturnType<typeof vi.fn>;\n    const uiLang = browser.i18n.getUILanguage as unknown as ReturnType<typeof vi.fn>;\n    syncGet.mockResolvedValue({ language: 'zh' });\n    localGet.mockResolvedValue({});\n    uiLang.mockReturnValue('en-US');\n\n    const lang = await getCurrentLanguage();\n\n    expect(lang).toBe('zh');\n  });\n\n  it('falls back to local storage when sync is missing', async () => {\n    const syncGet = browser.storage.sync.get as unknown as ReturnType<typeof vi.fn>;\n    const localGet = browser.storage.local.get as unknown as ReturnType<typeof vi.fn>;\n    const uiLang = browser.i18n.getUILanguage as unknown as ReturnType<typeof vi.fn>;\n    syncGet.mockResolvedValue({});\n    localGet.mockResolvedValue({ language: 'es' });\n    uiLang.mockReturnValue('en-US');\n\n    const lang = await getCurrentLanguage();\n\n    expect(lang).toBe('es');\n  });\n\n  it('falls back to chrome storage when browser storage throws', async () => {\n    const syncGet = browser.storage.sync.get as unknown as ReturnType<typeof vi.fn>;\n    const localGet = browser.storage.local.get as unknown as ReturnType<typeof vi.fn>;\n    const uiLang = browser.i18n.getUILanguage as unknown as ReturnType<typeof vi.fn>;\n    syncGet.mockRejectedValue(new Error('sync failed'));\n    localGet.mockRejectedValue(new Error('local failed'));\n    uiLang.mockReturnValue('en-US');\n\n    const chromeStorageGet = chrome.storage.sync.get as unknown as {\n      mockImplementation: (fn: (key: string, callback: (result: unknown) => void) => void) => void;\n    };\n    chromeStorageGet.mockImplementation((key: string, callback: (result: unknown) => void) => {\n      callback({ language: 'ja' });\n    });\n\n    const lang = await getCurrentLanguage();\n\n    expect(lang).toBe('ja');\n  });\n\n  it('falls back to browser UI language when no storage value exists', async () => {\n    const syncGet = browser.storage.sync.get as unknown as ReturnType<typeof vi.fn>;\n    const localGet = browser.storage.local.get as unknown as ReturnType<typeof vi.fn>;\n    const uiLang = browser.i18n.getUILanguage as unknown as ReturnType<typeof vi.fn>;\n    syncGet.mockResolvedValue({});\n    localGet.mockResolvedValue({});\n    uiLang.mockReturnValue('fr-FR');\n\n    const lang = await getCurrentLanguage();\n\n    expect(lang).toBe('fr');\n  });\n});\n"
  },
  {
    "path": "src/utils/__tests__/language.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { APP_LANGUAGES, isAppLanguage, normalizeLanguage } from '@/utils/language';\nimport { extractMessageDictionary } from '@/utils/localeMessages';\n\ndescribe('normalizeLanguage', () => {\n  it('normalizes empty values to en', () => {\n    expect(normalizeLanguage(undefined)).toBe('en');\n    expect(normalizeLanguage(null)).toBe('en');\n  });\n\n  it('normalizes common language codes', () => {\n    expect(normalizeLanguage('en')).toBe('en');\n    expect(normalizeLanguage('en-US')).toBe('en');\n    expect(normalizeLanguage('zh')).toBe('zh');\n    expect(normalizeLanguage('zh-CN')).toBe('zh');\n    expect(normalizeLanguage('ja')).toBe('ja');\n    expect(normalizeLanguage('ja-JP')).toBe('ja');\n    expect(normalizeLanguage('ko')).toBe('ko');\n    expect(normalizeLanguage('ko-KR')).toBe('ko');\n  });\n\n  it('normalizes traditional chinese variants', () => {\n    expect(normalizeLanguage('zh-TW')).toBe('zh_TW');\n    expect(normalizeLanguage('zh_TW')).toBe('zh_TW');\n    expect(normalizeLanguage('zh-HK')).toBe('zh_TW');\n    expect(normalizeLanguage('zh-Hant')).toBe('zh_TW');\n  });\n\n  it('falls back to en for unknown languages', () => {\n    expect(normalizeLanguage('de-DE')).toBe('en');\n    expect(normalizeLanguage('it-IT')).toBe('en');\n  });\n});\n\ndescribe('isAppLanguage', () => {\n  it('accepts exactly supported language tags', () => {\n    for (const lang of APP_LANGUAGES) {\n      expect(isAppLanguage(lang)).toBe(true);\n    }\n  });\n\n  it('rejects non-canonical tags', () => {\n    expect(isAppLanguage('en-US')).toBe(false);\n    expect(isAppLanguage('zh-CN')).toBe(false);\n    expect(isAppLanguage('ja-JP')).toBe(false);\n    expect(isAppLanguage('')).toBe(false);\n  });\n});\n\ndescribe('extractMessageDictionary', () => {\n  it('extracts {key: {message}} into a flat dictionary', () => {\n    const dict = extractMessageDictionary({\n      hello: { message: 'Hello' },\n      broken: { message: 123 },\n      empty: {},\n    });\n    expect(dict).toEqual({ hello: 'Hello' });\n  });\n\n  it('handles Vite JSON dynamic import module shape', () => {\n    const dict = extractMessageDictionary({\n      default: {\n        hello: { message: 'Hello' },\n      },\n    });\n    expect(dict).toEqual({ hello: 'Hello' });\n  });\n});\n"
  },
  {
    "path": "src/utils/__tests__/mergeForkNodes.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport type { ForkNode, ForkNodesData } from '@/pages/content/fork/forkTypes';\n\nimport { mergeForkNodes } from '../merge';\n\nfunction createForkNode(overrides: Partial<ForkNode> = {}): ForkNode {\n  return {\n    turnId: 'u-0',\n    conversationId: 'conv1',\n    conversationUrl: 'https://gemini.google.com/app/conv1',\n    conversationTitle: 'Test Conversation',\n    forkGroupId: 'fork-group-1',\n    forkIndex: 0,\n    createdAt: 1000,\n    ...overrides,\n  };\n}\n\nfunction createForkData(\n  nodes: Record<string, ForkNode[]>,\n  groups?: Record<string, string[]>,\n): ForkNodesData {\n  // Auto-build groups from nodes if not provided\n  if (!groups) {\n    groups = {};\n    for (const [convId, nodeArr] of Object.entries(nodes)) {\n      for (const node of nodeArr) {\n        if (!groups[node.forkGroupId]) groups[node.forkGroupId] = [];\n        const key = `${convId}:${node.turnId}`;\n        if (!groups[node.forkGroupId].includes(key)) {\n          groups[node.forkGroupId].push(key);\n        }\n      }\n    }\n  }\n  return { nodes, groups };\n}\n\ndescribe('mergeForkNodes', () => {\n  it('should return empty data when both inputs are empty', () => {\n    const local = createForkData({});\n    const cloud = createForkData({});\n    const result = mergeForkNodes(local, cloud);\n    expect(result).toEqual({ nodes: {}, groups: {} });\n  });\n\n  it('should return local nodes when cloud is empty', () => {\n    const node = createForkNode();\n    const local = createForkData({ conv1: [node] });\n    const cloud = createForkData({});\n    const result = mergeForkNodes(local, cloud);\n    expect(result.nodes.conv1).toHaveLength(1);\n    expect(result.nodes.conv1[0].turnId).toBe('u-0');\n  });\n\n  it('should return cloud nodes when local is empty', () => {\n    const node = createForkNode();\n    const local = createForkData({});\n    const cloud = createForkData({ conv1: [node] });\n    const result = mergeForkNodes(local, cloud);\n    expect(result.nodes.conv1).toHaveLength(1);\n    expect(result.nodes.conv1[0].turnId).toBe('u-0');\n  });\n\n  it('should merge nodes from different conversations', () => {\n    const node1 = createForkNode({ conversationId: 'conv1' });\n    const node2 = createForkNode({ conversationId: 'conv2', forkGroupId: 'fork-group-2' });\n    const local = createForkData({ conv1: [node1] });\n    const cloud = createForkData({ conv2: [node2] });\n    const result = mergeForkNodes(local, cloud);\n    expect(Object.keys(result.nodes)).toHaveLength(2);\n    expect(result.nodes.conv1).toHaveLength(1);\n    expect(result.nodes.conv2).toHaveLength(1);\n  });\n\n  it('should prefer local node when createdAt is newer', () => {\n    const localNode = createForkNode({ createdAt: 2000, conversationTitle: 'Local Title' });\n    const cloudNode = createForkNode({ createdAt: 1000, conversationTitle: 'Cloud Title' });\n    const local = createForkData({ conv1: [localNode] });\n    const cloud = createForkData({ conv1: [cloudNode] });\n    const result = mergeForkNodes(local, cloud);\n    expect(result.nodes.conv1).toHaveLength(1);\n    expect(result.nodes.conv1[0].conversationTitle).toBe('Local Title');\n  });\n\n  it('should prefer cloud node when cloud createdAt is newer', () => {\n    const localNode = createForkNode({ createdAt: 1000, conversationTitle: 'Local Title' });\n    const cloudNode = createForkNode({ createdAt: 2000, conversationTitle: 'Cloud Title' });\n    const local = createForkData({ conv1: [localNode] });\n    const cloud = createForkData({ conv1: [cloudNode] });\n    const result = mergeForkNodes(local, cloud);\n    expect(result.nodes.conv1).toHaveLength(1);\n    expect(result.nodes.conv1[0].conversationTitle).toBe('Cloud Title');\n  });\n\n  it('should prefer local when timestamps are equal', () => {\n    const localNode = createForkNode({ createdAt: 1000, conversationTitle: 'Local Title' });\n    const cloudNode = createForkNode({ createdAt: 1000, conversationTitle: 'Cloud Title' });\n    const local = createForkData({ conv1: [localNode] });\n    const cloud = createForkData({ conv1: [cloudNode] });\n    const result = mergeForkNodes(local, cloud);\n    expect(result.nodes.conv1[0].conversationTitle).toBe('Local Title');\n  });\n\n  it('should merge different fork groups within the same conversation', () => {\n    const node1 = createForkNode({ forkGroupId: 'fork-1', turnId: 'u-0' });\n    const node2 = createForkNode({ forkGroupId: 'fork-2', turnId: 'u-1' });\n    const local = createForkData({ conv1: [node1] });\n    const cloud = createForkData({ conv1: [node2] });\n    const result = mergeForkNodes(local, cloud);\n    expect(result.nodes.conv1).toHaveLength(2);\n  });\n\n  it('should rebuild groups index from merged nodes', () => {\n    const sourceNode = createForkNode({\n      conversationId: 'conv1',\n      forkGroupId: 'fork-1',\n      turnId: 'u-0',\n      forkIndex: 0,\n    });\n    const forkNode = createForkNode({\n      conversationId: 'conv2',\n      forkGroupId: 'fork-1',\n      turnId: 'u-0',\n      forkIndex: 1,\n    });\n    const local = createForkData({ conv1: [sourceNode] });\n    const cloud = createForkData({ conv2: [forkNode] });\n    const result = mergeForkNodes(local, cloud);\n\n    expect(result.groups['fork-1']).toBeDefined();\n    expect(result.groups['fork-1']).toHaveLength(2);\n    expect(result.groups['fork-1']).toContain('conv1:u-0');\n    expect(result.groups['fork-1']).toContain('conv2:u-0');\n  });\n\n  it('should handle undefined/null inputs gracefully', () => {\n    // @ts-expect-error Testing null input\n    const result1 = mergeForkNodes(null, createForkData({}));\n    expect(result1).toEqual({ nodes: {}, groups: {} });\n\n    // @ts-expect-error Testing undefined input\n    const result2 = mergeForkNodes(undefined, createForkData({}));\n    expect(result2).toEqual({ nodes: {}, groups: {} });\n\n    // @ts-expect-error Testing both undefined\n    const result3 = mergeForkNodes(undefined, undefined);\n    expect(result3).toEqual({ nodes: {}, groups: {} });\n  });\n\n  it('should not create duplicate group entries', () => {\n    const node = createForkNode({ forkGroupId: 'fork-1', turnId: 'u-0' });\n    const local = createForkData({ conv1: [node] });\n    const cloud = createForkData({ conv1: [node] });\n    const result = mergeForkNodes(local, cloud);\n\n    expect(result.groups['fork-1']).toHaveLength(1);\n    expect(result.groups['fork-1'][0]).toBe('conv1:u-0');\n  });\n});\n"
  },
  {
    "path": "src/utils/__tests__/mergeStarredMessages.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { mergeStarredMessages } from '../merge';\n\ndescribe('mergeStarredMessages', () => {\n  it('should return empty messages when both inputs are empty', () => {\n    const local = { messages: {} };\n    const cloud = { messages: {} };\n    const result = mergeStarredMessages(local, cloud);\n    expect(result).toEqual({ messages: {} });\n  });\n\n  it('should return local messages when cloud is empty', () => {\n    const local = {\n      messages: {\n        conv1: [\n          {\n            turnId: 'turn1',\n            content: 'Hello',\n            conversationId: 'conv1',\n            conversationUrl: 'https://gemini.google.com/app/conv1',\n            starredAt: 1000,\n          },\n        ],\n      },\n    };\n    const cloud = { messages: {} };\n    const result = mergeStarredMessages(local, cloud);\n    expect(result.messages.conv1).toHaveLength(1);\n    expect(result.messages.conv1[0].turnId).toBe('turn1');\n  });\n\n  it('should return cloud messages when local is empty', () => {\n    const local = { messages: {} };\n    const cloud = {\n      messages: {\n        conv1: [\n          {\n            turnId: 'turn1',\n            content: 'Hello',\n            conversationId: 'conv1',\n            conversationUrl: 'https://gemini.google.com/app/conv1',\n            starredAt: 1000,\n          },\n        ],\n      },\n    };\n    const result = mergeStarredMessages(local, cloud);\n    expect(result.messages.conv1).toHaveLength(1);\n    expect(result.messages.conv1[0].turnId).toBe('turn1');\n  });\n\n  it('should merge messages from different conversations', () => {\n    const local = {\n      messages: {\n        conv1: [\n          {\n            turnId: 'turn1',\n            content: 'Local message',\n            conversationId: 'conv1',\n            conversationUrl: 'https://gemini.google.com/app/conv1',\n            starredAt: 1000,\n          },\n        ],\n      },\n    };\n    const cloud = {\n      messages: {\n        conv2: [\n          {\n            turnId: 'turn2',\n            content: 'Cloud message',\n            conversationId: 'conv2',\n            conversationUrl: 'https://gemini.google.com/app/conv2',\n            starredAt: 2000,\n          },\n        ],\n      },\n    };\n    const result = mergeStarredMessages(local, cloud);\n    expect(Object.keys(result.messages)).toHaveLength(2);\n    expect(result.messages.conv1).toHaveLength(1);\n    expect(result.messages.conv2).toHaveLength(1);\n  });\n\n  it('should prefer local message when same turnId and local starredAt is newer', () => {\n    const local = {\n      messages: {\n        conv1: [\n          {\n            turnId: 'turn1',\n            content: 'Updated local message',\n            conversationId: 'conv1',\n            conversationUrl: 'https://gemini.google.com/app/conv1',\n            starredAt: 2000, // newer\n          },\n        ],\n      },\n    };\n    const cloud = {\n      messages: {\n        conv1: [\n          {\n            turnId: 'turn1',\n            content: 'Old cloud message',\n            conversationId: 'conv1',\n            conversationUrl: 'https://gemini.google.com/app/conv1',\n            starredAt: 1000, // older\n          },\n        ],\n      },\n    };\n    const result = mergeStarredMessages(local, cloud);\n    expect(result.messages.conv1).toHaveLength(1);\n    expect(result.messages.conv1[0].content).toBe('Updated local message');\n  });\n\n  it('should prefer cloud message when cloud starredAt is newer', () => {\n    const local = {\n      messages: {\n        conv1: [\n          {\n            turnId: 'turn1',\n            content: 'Old local message',\n            conversationId: 'conv1',\n            conversationUrl: 'https://gemini.google.com/app/conv1',\n            starredAt: 1000, // older\n          },\n        ],\n      },\n    };\n    const cloud = {\n      messages: {\n        conv1: [\n          {\n            turnId: 'turn1',\n            content: 'Updated cloud message',\n            conversationId: 'conv1',\n            conversationUrl: 'https://gemini.google.com/app/conv1',\n            starredAt: 2000, // newer\n          },\n        ],\n      },\n    };\n    const result = mergeStarredMessages(local, cloud);\n    expect(result.messages.conv1).toHaveLength(1);\n    expect(result.messages.conv1[0].content).toBe('Updated cloud message');\n  });\n\n  it('should merge different turnIds within same conversation', () => {\n    const local = {\n      messages: {\n        conv1: [\n          {\n            turnId: 'turn1',\n            content: 'Local turn 1',\n            conversationId: 'conv1',\n            conversationUrl: 'https://gemini.google.com/app/conv1',\n            starredAt: 1000,\n          },\n        ],\n      },\n    };\n    const cloud = {\n      messages: {\n        conv1: [\n          {\n            turnId: 'turn2',\n            content: 'Cloud turn 2',\n            conversationId: 'conv1',\n            conversationUrl: 'https://gemini.google.com/app/conv1',\n            starredAt: 2000,\n          },\n        ],\n      },\n    };\n    const result = mergeStarredMessages(local, cloud);\n    expect(result.messages.conv1).toHaveLength(2);\n    const turnIds = result.messages.conv1.map((m) => m.turnId);\n    expect(turnIds).toContain('turn1');\n    expect(turnIds).toContain('turn2');\n  });\n\n  it('should handle undefined/null inputs gracefully', () => {\n    // @ts-expect-error Testing null input\n    const result1 = mergeStarredMessages(null, { messages: {} });\n    expect(result1).toEqual({ messages: {} });\n\n    // @ts-expect-error Testing undefined input\n    const result2 = mergeStarredMessages(undefined, { messages: {} });\n    expect(result2).toEqual({ messages: {} });\n\n    // @ts-expect-error Testing both undefined\n    const result3 = mergeStarredMessages(undefined, undefined);\n    expect(result3).toEqual({ messages: {} });\n  });\n\n  it('should prefer local when timestamps are equal', () => {\n    const local = {\n      messages: {\n        conv1: [\n          {\n            turnId: 'turn1',\n            content: 'Local message',\n            conversationId: 'conv1',\n            conversationUrl: 'https://gemini.google.com/app/conv1',\n            starredAt: 1000, // same timestamp\n          },\n        ],\n      },\n    };\n    const cloud = {\n      messages: {\n        conv1: [\n          {\n            turnId: 'turn1',\n            content: 'Cloud message',\n            conversationId: 'conv1',\n            conversationUrl: 'https://gemini.google.com/app/conv1',\n            starredAt: 1000, // same timestamp\n          },\n        ],\n      },\n    };\n    const result = mergeStarredMessages(local, cloud);\n    // Local should win when timestamps are equal\n    expect(result.messages.conv1[0].content).toBe('Local message');\n  });\n});\n"
  },
  {
    "path": "src/utils/__tests__/translations.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { APP_LANGUAGES } from '@/utils/language';\nimport { TRANSLATIONS, isTranslationKey } from '@/utils/translations';\n\ndescribe('TRANSLATIONS', () => {\n  it('contains the same keys for all languages at runtime', () => {\n    const enKeys = Object.keys(TRANSLATIONS.en).sort();\n    for (const lang of APP_LANGUAGES) {\n      const keys = Object.keys(TRANSLATIONS[lang]).sort();\n      expect(keys).toEqual(enKeys);\n    }\n  });\n\n  it('recognizes valid keys via isTranslationKey', () => {\n    expect(isTranslationKey('extName')).toBe(true);\n    expect(isTranslationKey('__not_a_real_key__')).toBe(false);\n  });\n});\n"
  },
  {
    "path": "src/utils/i18n.ts",
    "content": "import browser from 'webextension-polyfill';\n\nimport { StorageKeys } from '@/core/types/common';\n\nimport { type AppLanguage, normalizeLanguage } from './language';\nimport { TRANSLATIONS, type TranslationKey, isTranslationKey } from './translations';\n\ntype StorageAreaName = 'sync' | 'local';\n\nconst readStorageValue = async (area: StorageAreaName): Promise<unknown> => {\n  try {\n    const storageArea = browser.storage?.[area];\n    if (storageArea?.get) {\n      const result = await storageArea.get(StorageKeys.LANGUAGE);\n      if (result && typeof result === 'object') {\n        return (result as Record<string, unknown>)[StorageKeys.LANGUAGE];\n      }\n    }\n  } catch {\n    // Fall through to chrome.* fallback below.\n  }\n\n  try {\n    const chromeStorage = chrome?.storage?.[area];\n    if (!chromeStorage?.get) return null;\n    return await new Promise<unknown>((resolve) => {\n      chromeStorage.get(StorageKeys.LANGUAGE, (result) => {\n        if (result && typeof result === 'object') {\n          resolve((result as Record<string, unknown>)[StorageKeys.LANGUAGE]);\n        } else {\n          resolve(null);\n        }\n      });\n    });\n  } catch {\n    return null;\n  }\n};\n\nconst getStoredLanguage = async (): Promise<string | null> => {\n  const syncValue = await readStorageValue('sync');\n  if (typeof syncValue === 'string') return syncValue;\n  const localValue = await readStorageValue('local');\n  if (typeof localValue === 'string') return localValue;\n  return null;\n};\n\n/**\n * Get the current language preference\n * 1. First check user's saved preference in storage\n * 2. Fall back to browser UI language\n * 3. Default to English\n */\nexport async function getCurrentLanguage(): Promise<AppLanguage> {\n  const stored = await getStoredLanguage();\n  if (typeof stored === 'string') {\n    return normalizeLanguage(stored);\n  }\n\n  // Fall back to browser UI language\n  try {\n    const browserLang = browser.i18n.getUILanguage();\n    return normalizeLanguage(browserLang);\n  } catch {\n    return 'en';\n  }\n}\n\n/**\n * Get translation for a key using the current language preference\n * This function works in both React and non-React contexts (e.g., content scripts)\n */\nexport async function getTranslation(key: TranslationKey): Promise<string> {\n  const language = await getCurrentLanguage();\n  return TRANSLATIONS[language][key] ?? TRANSLATIONS.en[key] ?? key;\n}\n\n/**\n * Get translation synchronously using cached language\n * This is less accurate but faster for scenarios where async is not possible\n */\nlet cachedLanguage: AppLanguage | null = null;\n\nexport function getTranslationSync(key: TranslationKey): string {\n  const language = cachedLanguage || 'en';\n  return TRANSLATIONS[language][key] ?? TRANSLATIONS.en[key] ?? key;\n}\n\nexport function getTranslationSyncUnsafe(key: string): string {\n  if (!isTranslationKey(key)) return key;\n  return getTranslationSync(key);\n}\n\n/**\n * Initialize the i18n system and cache the current language\n * Should be called early in the application lifecycle\n */\nexport async function initI18n(): Promise<void> {\n  cachedLanguage = await getCurrentLanguage();\n\n  // Listen for language changes\n  browser.storage.onChanged.addListener((changes, areaName) => {\n    const next = changes[StorageKeys.LANGUAGE]?.newValue;\n    if ((areaName === 'sync' || areaName === 'local') && typeof next === 'string') {\n      cachedLanguage = normalizeLanguage(next);\n    }\n  });\n}\n\n/**\n * Immediately update the cached language value.\n * This should be called after setting the language in storage to ensure\n * synchronous translation calls use the new language immediately,\n * avoiding race conditions with the async storage.onChanged listener.\n */\nexport function setCachedLanguage(lang: AppLanguage): void {\n  cachedLanguage = lang;\n}\n\n/**\n * Create a translator function that uses cached language\n * This is useful for classes that need a simple t() function\n */\nexport function createTranslator(): (key: string) => string {\n  return (key: string) => getTranslationSyncUnsafe(key);\n}\n"
  },
  {
    "path": "src/utils/language.ts",
    "content": "export const APP_LANGUAGES = [\n  'en',\n  'zh',\n  'zh_TW',\n  'ja',\n  'fr',\n  'es',\n  'pt',\n  'ar',\n  'ru',\n  'ko',\n] as const;\n\nexport type AppLanguage = (typeof APP_LANGUAGES)[number];\n\nexport const APP_LANGUAGE_LABELS: Record<AppLanguage, string> = {\n  en: 'English',\n  zh: '简体中文',\n  zh_TW: '繁體中文',\n  ja: '日本語',\n  fr: 'Français',\n  es: 'Español',\n  pt: 'Português',\n  ar: 'العربية',\n  ru: 'Русский',\n  ko: '한국어',\n};\n\nexport function isAppLanguage(value: unknown): value is AppLanguage {\n  return typeof value === 'string' && (APP_LANGUAGES as readonly string[]).includes(value);\n}\n\nexport function normalizeLanguage(lang: string | undefined | null): AppLanguage {\n  if (!lang) return 'en';\n  const lower = lang.toLowerCase();\n  // Check for Traditional Chinese first (zh-TW, zh-HK, zh-Hant)\n  if (\n    lower.startsWith('zh-tw') ||\n    lower.startsWith('zh_tw') ||\n    lower.startsWith('zh-hk') ||\n    lower.includes('hant')\n  )\n    return 'zh_TW';\n  // Then check for Simplified Chinese\n  if (lower.startsWith('zh')) return 'zh';\n  if (lower.startsWith('ja')) return 'ja';\n  if (lower.startsWith('fr')) return 'fr';\n  if (lower.startsWith('es')) return 'es';\n  if (lower.startsWith('pt')) return 'pt';\n  if (lower.startsWith('ar')) return 'ar';\n  if (lower.startsWith('ru')) return 'ru';\n  if (lower.startsWith('ko')) return 'ko';\n  return 'en';\n}\n\nexport function getNextLanguage(current: AppLanguage): AppLanguage {\n  const idx = APP_LANGUAGES.indexOf(current);\n  if (idx < 0) return 'en';\n  return APP_LANGUAGES[(idx + 1) % APP_LANGUAGES.length];\n}\n"
  },
  {
    "path": "src/utils/localeMessages.ts",
    "content": "export type LocaleMessageEntry = {\n  message: string;\n  description?: string;\n};\n\nexport type LocaleMessagesJson = Record<string, LocaleMessageEntry>;\n\nexport function extractMessageDictionary(raw: unknown): Record<string, string> {\n  if (!raw || typeof raw !== 'object') return {};\n\n  const maybeModule = raw as Record<string, unknown>;\n  const resolvedRaw =\n    maybeModule.default && typeof maybeModule.default === 'object' ? maybeModule.default : raw;\n\n  const out: Record<string, string> = {};\n  for (const [key, value] of Object.entries(resolvedRaw as Record<string, unknown>)) {\n    if (!value || typeof value !== 'object') continue;\n    const message = (value as Record<string, unknown>).message;\n    if (typeof message === 'string') out[key] = message;\n  }\n  return out;\n}\n"
  },
  {
    "path": "src/utils/merge.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport type { ConversationId, FolderId } from '@/core/types/common';\nimport type { ConversationReference, Folder, FolderData } from '@/core/types/folder';\n\nimport { mergeFolderData } from './merge';\n\n// Helper to create test folder\nfunction createFolder(id: string, name: string, updatedAt: number): Folder {\n  return {\n    id: id as FolderId,\n    name,\n    parentId: null,\n    isExpanded: true,\n    createdAt: 1000,\n    updatedAt,\n  };\n}\n\n// Helper to create test conversation reference\nfunction createConvo(\n  conversationId: string,\n  title: string,\n  addedAt: number,\n  extras?: Partial<ConversationReference>,\n): ConversationReference {\n  return {\n    conversationId: conversationId as ConversationId,\n    title,\n    url: `/app/${conversationId}`,\n    addedAt,\n    ...extras,\n  };\n}\n\n// Helper to create test folder data\nfunction createFolderData(\n  folders: Folder[],\n  folderContents: Record<string, ConversationReference[]>,\n): FolderData {\n  return { folders, folderContents };\n}\n\ndescribe('mergeFolderData', () => {\n  it('should merge folders from local and cloud', () => {\n    const local = createFolderData([createFolder('f1', 'Local Folder', 1000)], {});\n    const cloud = createFolderData([createFolder('f2', 'Cloud Folder', 2000)], {});\n\n    const result = mergeFolderData(local, cloud);\n\n    expect(result.folders).toHaveLength(2);\n    expect(result.folders.map((f) => f.id).sort()).toEqual(['f1', 'f2']);\n  });\n\n  it('should prefer newer folder when same id exists', () => {\n    const local = createFolderData([createFolder('f1', 'Old Name', 1000)], {});\n    const cloud = createFolderData([createFolder('f1', 'New Name', 2000)], {});\n\n    const result = mergeFolderData(local, cloud);\n\n    expect(result.folders).toHaveLength(1);\n    expect(result.folders[0].name).toBe('New Name');\n    expect(result.folders[0].updatedAt).toBe(2000);\n  });\n\n  describe('conversation reference merging - cloud-first strategy', () => {\n    it('should use cloud title to override local (renamed sync scenario)', () => {\n      const localConvo = createConvo('c1', 'Old Title', 1000);\n      const cloudConvo = createConvo('c1', 'Renamed Title', 1000, { customTitle: true });\n\n      const local = createFolderData([createFolder('f1', 'Folder', 1000)], { f1: [localConvo] });\n      const cloud = createFolderData([createFolder('f1', 'Folder', 1000)], { f1: [cloudConvo] });\n\n      const result = mergeFolderData(local, cloud);\n\n      expect(result.folderContents.f1).toHaveLength(1);\n      expect(result.folderContents.f1[0].title).toBe('Renamed Title');\n      expect(result.folderContents.f1[0].customTitle).toBe(true);\n    });\n\n    it('should preserve local starred when cloud has no starred property', () => {\n      const localConvo = createConvo('c1', 'Title', 1000, { starred: true });\n      const cloudConvo = createConvo('c1', 'Title', 1000);\n\n      const local = createFolderData([createFolder('f1', 'Folder', 1000)], { f1: [localConvo] });\n      const cloud = createFolderData([createFolder('f1', 'Folder', 1000)], { f1: [cloudConvo] });\n\n      const result = mergeFolderData(local, cloud);\n\n      expect(result.folderContents.f1).toHaveLength(1);\n      expect(result.folderContents.f1[0].starred).toBe(true);\n    });\n\n    it('should use cloud starred when cloud has starred property', () => {\n      const localConvo = createConvo('c1', 'Title', 1000, { starred: true });\n      const cloudConvo = createConvo('c1', 'Title', 1000, { starred: false });\n\n      const local = createFolderData([createFolder('f1', 'Folder', 1000)], { f1: [localConvo] });\n      const cloud = createFolderData([createFolder('f1', 'Folder', 1000)], { f1: [cloudConvo] });\n\n      const result = mergeFolderData(local, cloud);\n\n      expect(result.folderContents.f1).toHaveLength(1);\n      expect(result.folderContents.f1[0].starred).toBe(false);\n    });\n\n    it('should keep local-only conversations', () => {\n      const localConvo = createConvo('c1', 'Local Only', 1000);\n\n      const local = createFolderData([createFolder('f1', 'Folder', 1000)], { f1: [localConvo] });\n      const cloud = createFolderData([createFolder('f1', 'Folder', 1000)], { f1: [] });\n\n      const result = mergeFolderData(local, cloud);\n\n      expect(result.folderContents.f1).toHaveLength(1);\n      expect(result.folderContents.f1[0].title).toBe('Local Only');\n    });\n\n    it('should add cloud-only conversations', () => {\n      const cloudConvo = createConvo('c1', 'Cloud Only', 2000);\n\n      const local = createFolderData([createFolder('f1', 'Folder', 1000)], { f1: [] });\n      const cloud = createFolderData([createFolder('f1', 'Folder', 1000)], { f1: [cloudConvo] });\n\n      const result = mergeFolderData(local, cloud);\n\n      expect(result.folderContents.f1).toHaveLength(1);\n      expect(result.folderContents.f1[0].title).toBe('Cloud Only');\n    });\n\n    it('should include conversations from both local and cloud folders', () => {\n      const localConvo = createConvo('c1', 'Local Conv', 1000);\n      const cloudConvo = createConvo('c2', 'Cloud Conv', 2000);\n\n      const local = createFolderData([createFolder('f1', 'Folder', 1000)], { f1: [localConvo] });\n      const cloud = createFolderData([createFolder('f1', 'Folder', 1000)], { f1: [cloudConvo] });\n\n      const result = mergeFolderData(local, cloud);\n\n      expect(result.folderContents.f1).toHaveLength(2);\n      expect(result.folderContents.f1.map((c) => c.conversationId).sort()).toEqual(['c1', 'c2']);\n    });\n  });\n});\n"
  },
  {
    "path": "src/utils/merge.ts",
    "content": "import type { ConversationReference, FolderData } from '@/core/types/folder';\nimport type { PromptItem } from '@/core/types/sync';\nimport type { ForkNode, ForkNodesData } from '@/pages/content/fork/forkTypes';\nimport type { StarredMessage, StarredMessagesData } from '@/pages/content/timeline/starredTypes';\n\n/**\n * Merges two lists of items based on ID and updatedAt timestamp.\n * Prefers the item with the later updatedAt timestamp.\n */\nfunction mergeItems<T extends { id: string; updatedAt?: number; createdAt?: number }>(\n  localItems: T[],\n  cloudItems: T[],\n): T[] {\n  const itemMap = new Map<string, T>();\n\n  // Add all local items first\n  localItems.forEach((item) => {\n    itemMap.set(item.id, item);\n  });\n\n  // Merge cloud items\n  cloudItems.forEach((cloudItem) => {\n    const localItem = itemMap.get(cloudItem.id);\n    if (!localItem) {\n      // New item from cloud\n      itemMap.set(cloudItem.id, cloudItem);\n    } else {\n      // Conflict: compare timestamps\n      // Use createdAt as fallback for updatedAt\n      const cloudTime = cloudItem.updatedAt || cloudItem.createdAt || 0;\n      const localTime = localItem.updatedAt || localItem.createdAt || 0;\n\n      if (cloudTime > localTime) {\n        itemMap.set(cloudItem.id, cloudItem);\n      }\n      // If local is newer or equal, keep local\n    }\n  });\n\n  return Array.from(itemMap.values());\n}\n\n/**\n * Merges local and cloud folder data.\n */\nexport function mergeFolderData(local: FolderData, cloud: FolderData): FolderData {\n  // 1. Merge Folders list\n  const mergedFolders = mergeItems(local.folders, cloud.folders);\n\n  // 2. Merge Folder Contents\n  const mergedContents: Record<string, ConversationReference[]> = { ...local.folderContents };\n\n  // Iterate over cloud folders to ensure we capture all content\n  // (Even for folders we might have just added)\n  const allFolderIds = new Set([\n    ...Object.keys(local.folderContents),\n    ...Object.keys(cloud.folderContents),\n  ]);\n\n  allFolderIds.forEach((folderId) => {\n    const localConvos = local.folderContents[folderId] || [];\n    const cloudConvos = cloud.folderContents[folderId] || [];\n\n    // Merge conversation references: Cloud-first strategy\n    // This ensures renamed titles from cloud sync are applied to local\n    // - If user renamed title locally and uploaded, cloud has the new title\n    // - If user downloads on another device, cloud title should override local\n    // - If conversation only exists locally, keep it (new local addition)\n\n    const convoMap = new Map<string, ConversationReference>();\n\n    // Add local conversations first\n    localConvos.forEach((c) => convoMap.set(c.conversationId, c));\n\n    // Cloud conversations override local (this is the key change)\n    cloudConvos.forEach((c) => {\n      const existing = convoMap.get(c.conversationId);\n      if (!existing) {\n        // New from cloud\n        convoMap.set(c.conversationId, c);\n      } else {\n        // Merge: cloud properties override, but keep local-only properties\n        convoMap.set(c.conversationId, {\n          ...existing, // Keep any local-only properties\n          ...c, // Cloud overrides (title, customTitle, etc.)\n          // Preserve starred if set locally but not in cloud\n          starred: c.starred ?? existing.starred,\n        });\n      }\n    });\n\n    mergedContents[folderId] = Array.from(convoMap.values());\n  });\n\n  return {\n    folders: mergedFolders,\n    folderContents: mergedContents,\n  };\n}\n\n/**\n * Merges local and cloud prompts.\n */\nexport function mergePrompts(local: PromptItem[], cloud: PromptItem[]): PromptItem[] {\n  return mergeItems(local, cloud);\n}\n\n/**\n * Merges local and cloud starred messages.\n * Uses turnId as the unique key within each conversation.\n * Prefers the message with the newer starredAt timestamp when duplicates exist.\n */\nexport function mergeStarredMessages(\n  local: StarredMessagesData,\n  cloud: StarredMessagesData,\n): StarredMessagesData {\n  // Ensure we have valid input structures\n  const localMessages = local?.messages || {};\n  const cloudMessages = cloud?.messages || {};\n\n  // Get all conversation IDs from both sources\n  const allConversationIds = new Set([\n    ...Object.keys(localMessages),\n    ...Object.keys(cloudMessages),\n  ]);\n\n  const mergedMessages: Record<string, StarredMessage[]> = {};\n\n  allConversationIds.forEach((conversationId) => {\n    const localConvoMessages = localMessages[conversationId] || [];\n    const cloudConvoMessages = cloudMessages[conversationId] || [];\n\n    // Use Map with turnId as key for deduplication\n    const messageMap = new Map<string, StarredMessage>();\n\n    // Add cloud messages first (so local can overwrite if newer)\n    cloudConvoMessages.forEach((msg) => {\n      messageMap.set(msg.turnId, msg);\n    });\n\n    // Merge local messages - prefer newer starredAt\n    localConvoMessages.forEach((localMsg) => {\n      const existingMsg = messageMap.get(localMsg.turnId);\n      if (!existingMsg) {\n        // New message from local\n        messageMap.set(localMsg.turnId, localMsg);\n      } else {\n        // Conflict: compare starredAt timestamps\n        if (localMsg.starredAt >= existingMsg.starredAt) {\n          messageMap.set(localMsg.turnId, localMsg);\n        }\n        // If cloud is newer, keep cloud (already in map)\n      }\n    });\n\n    // Only add non-empty arrays\n    const mergedArray = Array.from(messageMap.values());\n    if (mergedArray.length > 0) {\n      mergedMessages[conversationId] = mergedArray;\n    }\n  });\n\n  return { messages: mergedMessages };\n}\n\n/**\n * Merges local and cloud fork nodes.\n * Uses forkGroupId + turnId as the unique key within each conversation.\n * Prefers the node with the newer createdAt timestamp when duplicates exist.\n */\nexport function mergeForkNodes(local: ForkNodesData, cloud: ForkNodesData): ForkNodesData {\n  const localNodes = local?.nodes || {};\n  const cloudNodes = cloud?.nodes || {};\n\n  const allConversationIds = new Set([...Object.keys(localNodes), ...Object.keys(cloudNodes)]);\n\n  const mergedNodes: Record<string, ForkNode[]> = {};\n\n  allConversationIds.forEach((conversationId) => {\n    const localConvoNodes = localNodes[conversationId] || [];\n    const cloudConvoNodes = cloudNodes[conversationId] || [];\n\n    // Use \"forkGroupId:turnId\" as unique key\n    const nodeMap = new Map<string, ForkNode>();\n\n    // Add cloud nodes first\n    cloudConvoNodes.forEach((node) => {\n      const key = `${node.forkGroupId}:${node.turnId}`;\n      nodeMap.set(key, node);\n    });\n\n    // Merge local nodes - prefer newer createdAt\n    localConvoNodes.forEach((localNode) => {\n      const key = `${localNode.forkGroupId}:${localNode.turnId}`;\n      const existing = nodeMap.get(key);\n      if (!existing) {\n        nodeMap.set(key, localNode);\n      } else if (localNode.createdAt >= existing.createdAt) {\n        nodeMap.set(key, localNode);\n      }\n    });\n\n    const mergedArray = Array.from(nodeMap.values());\n    if (mergedArray.length > 0) {\n      mergedNodes[conversationId] = mergedArray;\n    }\n  });\n\n  // Rebuild groups index from merged nodes\n  const mergedGroups: Record<string, string[]> = {};\n  for (const [conversationId, nodes] of Object.entries(mergedNodes)) {\n    for (const node of nodes) {\n      if (!mergedGroups[node.forkGroupId]) {\n        mergedGroups[node.forkGroupId] = [];\n      }\n      const groupKey = `${conversationId}:${node.turnId}`;\n      if (!mergedGroups[node.forkGroupId].includes(groupKey)) {\n        mergedGroups[node.forkGroupId].push(groupKey);\n      }\n    }\n  }\n\n  return { nodes: mergedNodes, groups: mergedGroups };\n}\n"
  },
  {
    "path": "src/utils/translations.ts",
    "content": "import arMessages from '@locales/ar/messages.json';\nimport enMessages from '@locales/en/messages.json';\nimport esMessages from '@locales/es/messages.json';\nimport frMessages from '@locales/fr/messages.json';\nimport jaMessages from '@locales/ja/messages.json';\nimport koMessages from '@locales/ko/messages.json';\nimport ptMessages from '@locales/pt/messages.json';\nimport ruMessages from '@locales/ru/messages.json';\nimport zhMessages from '@locales/zh/messages.json';\nimport zhTWMessages from '@locales/zh_TW/messages.json';\n\nimport type { AppLanguage } from './language';\n\ntype RawLocaleMessages = typeof enMessages;\n\n// Compile-time guarantee: every supported language must provide at least the same keys as English.\nconst rawMessagesByLanguage = {\n  en: enMessages,\n  zh: zhMessages,\n  zh_TW: zhTWMessages,\n  ja: jaMessages,\n  fr: frMessages,\n  es: esMessages,\n  pt: ptMessages,\n  ar: arMessages,\n  ru: ruMessages,\n  ko: koMessages,\n} satisfies Record<AppLanguage, RawLocaleMessages>;\n\nexport type TranslationKey = keyof RawLocaleMessages;\nexport type Translation = Record<TranslationKey, string>;\n\nfunction extractTranslations<M extends Record<string, { message: string }>>(\n  raw: M,\n): Record<keyof M, string> {\n  const out = {} as Record<keyof M, string>;\n  for (const key of Object.keys(raw) as Array<keyof M>) {\n    out[key] = raw[key].message;\n  }\n  return out;\n}\n\nexport const TRANSLATIONS: Record<AppLanguage, Translation> = {\n  en: extractTranslations(rawMessagesByLanguage.en),\n  zh: extractTranslations(rawMessagesByLanguage.zh),\n  zh_TW: extractTranslations(rawMessagesByLanguage.zh_TW),\n  ja: extractTranslations(rawMessagesByLanguage.ja),\n  fr: extractTranslations(rawMessagesByLanguage.fr),\n  es: extractTranslations(rawMessagesByLanguage.es),\n  pt: extractTranslations(rawMessagesByLanguage.pt),\n  ar: extractTranslations(rawMessagesByLanguage.ar),\n  ru: extractTranslations(rawMessagesByLanguage.ru),\n  ko: extractTranslations(rawMessagesByLanguage.ko),\n};\n\nexport function isTranslationKey(value: string): value is TranslationKey {\n  return value in rawMessagesByLanguage.en;\n}\n"
  },
  {
    "path": "src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"esnext\",\n    \"types\": [\"vite/client\", \"node\", \"chrome\"],\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": false,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@src/*\": [\"src/*\"],\n      \"@assets/*\": [\"src/assets/*\"],\n      \"@locales/*\": [\"src/locales/*\"],\n      \"@pages/*\": [\"src/pages/*\"],\n      \"@/*\": [\"src/*\"],\n      \"@/core\": [\"src/core\"],\n      \"@/core/*\": [\"src/core/*\"],\n      \"@/features/*\": [\"src/features/*\"],\n      \"@/components/*\": [\"src/components/*\"],\n      \"@/lib/*\": [\"src/lib/*\"]\n    }\n  },\n  \"include\": [\n    \"src\",\n    \"utils\",\n    \"vite.config.base.ts\",\n    \"vite.config.chrome.ts\",\n    \"vite.config.firefox.ts\",\n    \"vite.config.safari.ts\"\n  ]\n}\n"
  },
  {
    "path": "vite.config.base.ts",
    "content": "import { ManifestV3Export } from '@crxjs/vite-plugin';\nimport tailwindcss from '@tailwindcss/vite';\nimport react from '@vitejs/plugin-react';\nimport { resolve } from 'path';\nimport { BuildOptions, defineConfig } from 'vite';\nimport tsconfigPaths from 'vite-tsconfig-paths';\n\nimport { crxI18n, stripDevIcons, stripI18nDescriptions } from './custom-vite-plugins';\nimport devManifest from './manifest.dev.json';\nimport manifest from './manifest.json';\nimport pkg from './package.json';\n\nconst isDev = process.env.__DEV__ === 'true';\n// set this flag to true, if you want localization support\nconst localize = true;\n\nexport const baseManifest = {\n  ...manifest,\n  version: pkg.version,\n  ...(isDev ? devManifest : ({} as ManifestV3Export)),\n  ...(localize\n    ? {\n        name: '__MSG_extName__',\n        description: '__MSG_extDescription__',\n        default_locale: 'en',\n      }\n    : {}),\n} as ManifestV3Export;\n\nexport const baseBuildOptions: BuildOptions = {\n  sourcemap: isDev,\n  emptyOutDir: !isDev,\n  // Content scripts run under page CSP context for DOM-injected preload links.\n  // Disable Vite modulepreload hints to avoid generating \"/assets/*\" requests\n  // on the host page origin (e.g. aistudio.google.com), which are blocked by CSP.\n  modulePreload: false,\n};\n\nexport default defineConfig({\n  plugins: [\n    tailwindcss(),\n    tsconfigPaths(),\n    react(),\n    stripDevIcons(isDev),\n    stripI18nDescriptions(isDev),\n    crxI18n({ localize, src: './src/locales', stripDescriptions: !isDev }),\n  ],\n  publicDir: resolve(__dirname, 'public'),\n  esbuild: {\n    pure: isDev ? [] : ['console.log', 'console.debug'],\n  },\n});\n"
  },
  {
    "path": "vite.config.chrome.ts",
    "content": "import { ManifestV3Export, crx } from '@crxjs/vite-plugin';\nimport { resolve } from 'path';\nimport { defineConfig, mergeConfig } from 'vite';\n\nimport baseConfig, { baseBuildOptions, baseManifest } from './vite.config.base';\n\nconst outDir = resolve(__dirname, 'dist_chrome');\n\nexport default mergeConfig(\n  baseConfig,\n  defineConfig({\n    plugins: [\n      crx({\n        manifest: {\n          ...baseManifest,\n        } as ManifestV3Export,\n        browser: 'chrome',\n        contentScripts: {\n          injectCss: true,\n        },\n      }),\n    ],\n    build: {\n      ...baseBuildOptions,\n      outDir,\n    },\n  }),\n);\n"
  },
  {
    "path": "vite.config.firefox.ts",
    "content": "import { ManifestV3Export, crx } from '@crxjs/vite-plugin';\nimport { resolve } from 'path';\nimport { defineConfig, mergeConfig } from 'vite';\n\nimport baseConfig, { baseBuildOptions, baseManifest } from './vite.config.base';\n\nconst outDir = resolve(__dirname, 'dist_firefox');\nconst FIREFOX_CHANGELOG_BANNER_RESOURCES = [\n  'changelog-promo-banner.png',\n  'changelog-promo-banner-cn.png',\n  'changelog-promo-banner-jp.png',\n];\n\ntype WebAccessibleResourceLike = {\n  resources?: string[];\n};\n\ntype FirefoxManifestLike<TResource extends WebAccessibleResourceLike = WebAccessibleResourceLike> =\n  {\n    web_accessible_resources?: TResource[];\n  };\n\nfunction appendFirefoxChangelogResources<\n  TManifest extends FirefoxManifestLike<TResource>,\n  TResource extends WebAccessibleResourceLike,\n>(manifest: TManifest): TManifest {\n  const existingEntries = manifest.web_accessible_resources ?? [];\n  if (existingEntries.length === 0) return manifest;\n\n  const [first, ...rest] = existingEntries;\n  const existingResources = first.resources ?? [];\n  const mergedResources = Array.from(\n    new Set([...existingResources, ...FIREFOX_CHANGELOG_BANNER_RESOURCES]),\n  );\n\n  return {\n    ...manifest,\n    web_accessible_resources: [\n      {\n        ...first,\n        resources: mergedResources,\n      },\n      ...rest,\n    ],\n  } as TManifest;\n}\n\nexport default mergeConfig(\n  baseConfig,\n  defineConfig({\n    plugins: [\n      crx({\n        manifest: appendFirefoxChangelogResources({\n          ...baseManifest,\n          browser_specific_settings: {\n            gecko: {\n              id: 'gemini-voyager@nagi-ovo',\n              strict_min_version: '115.0',\n              data_collection_permissions: {\n                required: ['none'],\n              },\n            },\n          },\n          background: {\n            scripts: ['src/pages/background/index.ts'],\n            type: 'module',\n          },\n        } as unknown as FirefoxManifestLike) as unknown as ManifestV3Export,\n        browser: 'firefox',\n        contentScripts: {\n          injectCss: true,\n        },\n      }),\n    ],\n    resolve: {\n      alias: {\n        // Firefox uses mermaid v9.2.2 (max compatible version)\n        // Chrome/Safari use mermaid v11.x (latest) by default\n        mermaid: 'mermaid-legacy',\n      },\n    },\n    build: {\n      ...baseBuildOptions,\n      outDir,\n    },\n    publicDir: resolve(__dirname, 'public'),\n  }),\n);\n"
  },
  {
    "path": "vite.config.safari.ts",
    "content": "import { ManifestV3Export, crx } from '@crxjs/vite-plugin';\nimport { resolve } from 'path';\nimport { defineConfig, mergeConfig } from 'vite';\n\nimport manifest from './manifest.json';\nimport baseConfig, { baseBuildOptions, baseManifest } from './vite.config.base';\n\nconst outDir = resolve(__dirname, 'dist_safari');\n\n// Environment variable to control Safari update check\n// Set to 'true' to enable update reminders for Safari builds\n// Default: 'false' (disabled)\nconst enableSafariUpdateCheck = process.env.ENABLE_SAFARI_UPDATE_CHECK === 'true';\n\nexport default mergeConfig(\n  baseConfig,\n  defineConfig({\n    define: {\n      // Inject flag into the build\n      'import.meta.env.ENABLE_SAFARI_UPDATE_CHECK': JSON.stringify(enableSafariUpdateCheck),\n    },\n    plugins: [\n      crx({\n        manifest: {\n          ...baseManifest,\n          // Safari renders toolbar icons as template images (monochrome).\n          // Use a transparent-background version so it doesn't appear as a solid square.\n          action: {\n            ...manifest.action,\n            default_icon: {\n              '32': 'icon-32-template.png',\n            },\n          },\n          // Safari-specific adjustments\n          background: {\n            // Safari supports both service_worker and scripts\n            // Using scripts for better compatibility\n            scripts: ['src/pages/background/index.ts'],\n          },\n        } as ManifestV3Export,\n        browser: 'chrome', // Use 'chrome' mode as Safari uses WebKit\n        contentScripts: {\n          injectCss: true,\n        },\n      }),\n    ],\n    build: {\n      ...baseBuildOptions,\n      outDir,\n      // Safari-specific build optimizations\n      target: 'safari14', // Safari 14+ for better extension support\n    },\n  }),\n);\n"
  },
  {
    "path": "vitest.config.ts",
    "content": "import path from 'path';\nimport { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n  test: {\n    globals: true,\n    environment: 'jsdom',\n    setupFiles: ['./src/tests/setup.ts'],\n    coverage: {\n      provider: 'v8',\n      reporter: ['text', 'json', 'html'],\n      exclude: ['node_modules/', 'src/tests/', '**/*.d.ts', '**/*.config.*', '**/mockData.ts'],\n    },\n  },\n  resolve: {\n    alias: {\n      '@': path.resolve(__dirname, './src'),\n      '@locales': path.resolve(__dirname, './src/locales'),\n      '@/core': path.resolve(__dirname, './src/core'),\n      '@/features': path.resolve(__dirname, './src/features'),\n    },\n  },\n});\n"
  }
]