diff --git a/.changeset/tiny-pumas-battle.md b/.changeset/tiny-pumas-battle.md new file mode 100644 index 000000000..0d6821478 --- /dev/null +++ b/.changeset/tiny-pumas-battle.md @@ -0,0 +1,5 @@ +--- +"@spencer-kit/coder-studio": patch +--- + +Fix the shared settings switch thumb alignment so the knob stays vertically centered and reaches the correct checked position. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 733e453f3..db6999057 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -100,7 +100,16 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release create "${{ steps.release.outputs.tag }}" \ - --title "@spencer-kit/coder-studio ${{ steps.release.outputs.version }}" \ - --generate-notes \ - --verify-tag + release_notes_file="docs/promotion/releases/${{ steps.release.outputs.tag }}.md" + + if [ -f "${release_notes_file}" ]; then + gh release create "${{ steps.release.outputs.tag }}" \ + --title "@spencer-kit/coder-studio ${{ steps.release.outputs.version }}" \ + --notes-file "${release_notes_file}" \ + --verify-tag + else + gh release create "${{ steps.release.outputs.tag }}" \ + --title "@spencer-kit/coder-studio ${{ steps.release.outputs.version }}" \ + --generate-notes \ + --verify-tag + fi diff --git a/README.md b/README.md index 8cd6f12e6..a6670cfe2 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,35 @@ +
+ +Coder Studio Logo + # Coder Studio -
+**A browser-based AI coding workspace for developers who move between desktop, tablet, and phone.** -**AI Coding Workspace · Browser-Based · Cross-Device · Self-Hosted** +Run Claude Code and Codex in one workspace. Keep your terminal, files, Git view, and AI sessions available from any device. [![npm version](https://img.shields.io/npm/v/@spencer-kit/coder-studio.svg)](https://www.npmjs.com/package/@spencer-kit/coder-studio) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D24.0.0-green.svg)](https://nodejs.org/) +[![GitHub Stars](https://img.shields.io/github/stars/spencerkit/coder-studio?style=social)](https://github.com/spencerkit/coder-studio/stargazers) -
- ---- - -> Deploy once, code everywhere. -> -> Deploy your coding workspace once, then keep working anywhere. - -[中文说明](README.zh-CN.md) | [Documentation](docs/help/quick-start.md) | [GitHub](https://github.com/spencerkit/coder-studio) - -**Coder Studio** is a browser-based AI coding workspace that runs on your local machine. Access your development environment from any device with a web browser — continue coding on your phone during commute, review changes on a tablet, or switch between computers seamlessly. - -## ✨ Key Features +[View Workspace](docs/help/assets/screenshot-desktop-workspace-full.png) · [Quick Start](#quick-start) · [Star on GitHub](https://github.com/spencerkit/coder-studio) -- 🌐 **Browser-Based Workspace** — No desktop app needed, works in any modern browser -- 📱 **Cross-Device Continuity** — Start on desktop, continue on mobile, switch devices freely -- 🤖 **Multi-Agent Support** — Run Claude Code and OpenAI Codex sessions side by side -- 🔧 **Integrated Environment** — Terminal, file editor, Git viewer, and AI agents in one interface -- 🔒 **Self-Hosted & Private** — Code stays on your machine, no cloud IDE dependency -- ⚡ **Real-Time Sync** — Live workspace state across all connected devices +[中文说明](README.zh-CN.md) | [Documentation](docs/help/quick-start.md) -![Workspace Overview](docs/help/assets/screenshot-workspace-overview.png) +
---- +[![Workspace Preview](docs/help/assets/screenshot-desktop-workspace-full.png)](docs/help/assets/screenshot-desktop-workspace-full.png) -## 🎯 Why Coder Studio +
Preview the full workspace layout built for AI coding, supervision, and device switching.
-| Feature | Benefit | -|---------|---------| -| **Deploy once, continue anywhere** | Start the service once, move between devices without breaking flow | -| **Unified workspace** | Agent, code, Git, and terminal in one place — no more app switching | -| **Multi-Agent support** | Run Claude Code and Codex sessions side by side | -| **Local-first & private** | Runs on your machine, code never leaves your device | -| **Zero-config browser access** | No desktop client, just open a URL from any device | +## Why It Feels Different ---- +- **One browser workspace for AI coding** — Keep terminal, files, Git, and AI sessions in one place. +- **Built for device switching** — Start on desktop, continue on tablet, and check progress from your phone. +- **Objective-driven multi-step orchestration** — Let Supervisor steer long-running AI tasks so you do not have to babysit every turn, reduce repetitive manual prompting, and get more consistent outcomes. -## 🚀 Quick Start +## Quick Start ```bash # Install globally @@ -63,23 +47,23 @@ Your browser opens automatically. Select your project folder and start working w ## 💡 Use Cases -### Remote-Friendly Development +### Cross-Device Development - Start an Agent task at the office, check progress on your phone during commute - Review code changes on a tablet without opening your laptop - Continue work from a home computer with zero setup -### Team Collaboration +### Long-Running AI Workflows -- Share workspace URL with teammates on the same network -- Code review without screen sharing — just open the link -- Real-time visibility into Agent progress for stakeholders +- Let Supervisor push multi-step tasks toward an objective without constant babysitting +- Check evaluation cycles and follow-up actions from your phone instead of watching terminal output +- Reduce repetitive prompting and manual coordination during long agent runs ### AI-Assisted Coding -- Run multiple Claude Code or Codex sessions in parallel -- Monitor Agent progress from mobile while away from desk -- Keep terminal, editor, and Git view in one unified interface +- Run Claude Code and Codex sessions side by side +- Keep terminal, editor, Git, and supervisor state in one unified interface +- Resume active AI work from another device without rebuilding context --- @@ -107,13 +91,13 @@ The same workspace URL works across all devices — interface adapts automatical | Feature | Description | |---------|-------------| -| **Multi-Agent Sessions** | Run Claude Code and Codex sessions side by side | -| **Integrated Editor** | Monaco-powered code editor with syntax highlighting | -| **Git Integration** | View branches, diffs, and changed files without leaving workspace | -| **Shell Terminal** | Full PTY terminal for commands and validating Agent output | -| **Responsive UI** | Desktop, tablet, and phone layouts that adapt automatically | -| **Session History** | Resume previous sessions with full context preserved | -| **Dark/Light Theme** | Customizable appearance and keyboard shortcuts | +| **Cross-Device Workspace** | Reopen the same coding environment from desktop, tablet, or phone without rebuilding context | +| **Supervisor Loops** | Run objective-driven evaluation and follow-up cycles for long AI tasks with less manual babysitting | +| **Claude Code + Codex** | Use both agent CLIs inside one workspace instead of splitting your workflow across separate tools | +| **Unified Terminal, Editor, and Git** | Keep PTY terminals, Monaco editing, diffs, and changed files in one browser UI | +| **Responsive Workspace UI** | Use layouts tuned for desktop, tablet, and mobile instead of a desktop-only interface squeezed onto small screens | +| **Session Continuity** | Resume active sessions and keep AI work visible across device switches | +| **Local-First Runtime** | Keep code and runtime on your machine instead of relying on a cloud IDE | --- @@ -146,7 +130,7 @@ The same workspace URL works across all devices — interface adapts automatical - **AI Coding Power Users** — Daily Claude Code / Codex users who want better session management - **Multi-Device Developers** — Switch between office, home, and mobile devices frequently -- **Remote-First Teams** — Need to share work progress without screen sharing +- **Developers Running Long AI Tasks** — Want Supervisor to keep multi-step work moving without constant babysitting - **Privacy-Conscious Developers** — Want code to stay on local machine, not cloud IDE --- diff --git a/README.zh-CN.md b/README.zh-CN.md index e37308f93..74cf9bd12 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,51 +1,35 @@ +
+ +Coder Studio Logo + # Coder Studio -
+**一个浏览器里的 AI 编程工作台,适合在桌面、平板和手机之间切换的开发者。** -**AI 编程工作台 · 浏览器访问 · 跨设备协作 · 本地运行** +在同一个工作台里运行 Claude Code 和 Codex,让终端、文件、Git 视图和 AI 会话跟着你在不同设备间延续。 [![npm version](https://img.shields.io/npm/v/@spencer-kit/coder-studio.svg)](https://www.npmjs.com/package/@spencer-kit/coder-studio) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D24.0.0-green.svg)](https://nodejs.org/) +[![GitHub Stars](https://img.shields.io/github/stars/spencerkit/coder-studio?style=social)](https://github.com/spencerkit/coder-studio/stargazers) -
- ---- - -> Deploy once, code everywhere. -> -> 一次部署,随处编码。 +[查看工作区](docs/help/assets/screenshot-desktop-workspace-full.png) · [快速开始](#快速开始) · [GitHub Star](https://github.com/spencerkit/coder-studio) -[English](README.md) | [文档](docs/help/quick-start.md) | [GitHub](https://github.com/spencerkit/coder-studio) +[English](README.md) | [文档](docs/help/quick-start.md) -**Coder Studio** 是一个浏览器端的 AI 编程工作台,运行在你本地的机器上。从任何设备的浏览器访问你的开发环境 —— 在通勤路上用手机查看进度,在平板上审阅代码改动,或在多台电脑之间无缝切换。 - -## ✨ 核心特性 - -- 🌐 **浏览器工作台** — 无需桌面应用,任何现代浏览器都能使用 -- 📱 **跨设备连续性** — 在桌面端开始,移动端继续,自由切换设备 -- 🤖 **多 Agent 支持** — 同时运行 Claude Code 和 OpenAI Codex 会话 -- 🔧 **一体化环境** — 终端、文件编辑器、Git 查看器和 AI Agent 统一界面 -- 🔒 **本地运行隐私保护** — 代码保留在你自己的机器上,不依赖云 IDE -- ⚡ **实时同步** — 所有连接设备的实时工作区状态 +
-![工作区界面](docs/help/assets/screenshot-workspace-overview.png) +[![工作区预览](docs/help/assets/screenshot-desktop-workspace-full.png)](docs/help/assets/screenshot-desktop-workspace-full.png) ---- +
预览这个为 AI 编程、Supervisor 监督和跨设备切换而设计的完整工作区布局。
-## 🎯 为什么选择 Coder Studio +## 为什么它不一样 -| 特性 | 优势 | -|------|------| -| **一次部署,随处继续** | 启动服务一次,在设备间切换不中断工作流 | -| **统一工作台** | Agent、代码、Git、终端在一处 —— 不再频繁切换应用 | -| **多 Agent 支持** | 同时运行 Claude Code 和 Codex 会话 | -| **本地优先 & 隐私** | 运行在你自己的机器上,代码永不离开设备 | -| **零配置浏览器访问** | 无需桌面客户端,任何设备打开 URL 即可 | - ---- +- **一个浏览器里完成 AI 编程工作流** — 把终端、文件、Git 和 AI 会话放到同一个工作台。 +- **真正为设备切换而设计** — 在桌面端开始,在平板继续,用手机随时查看 Agent 进度。 +- **目标驱动的多轮调度** — 让 Supervisor 接管长任务推进,你不必全程盯守每一轮输出,减少机械重复的人工催促,并获得更稳定的执行效果。 -## 🚀 快速开始 +## 快速开始 ```bash # 全局安装 @@ -63,23 +47,23 @@ coder-studio open ## 💡 使用场景 -### 远程友好开发 +### 跨设备开发 - 在办公室启动 Agent 任务,通勤路上用手机查看进度 - 在平板上审阅代码改动,无需打开笔记本电脑 - 在家用电脑继续工作,零配置切换 -### 团队协作 +### 长任务监督与调度 -- 与同一网络的队友分享工作区 URL -- 无需屏幕共享进行代码审阅 —— 直接打开链接 -- 实时向相关人员展示 Agent 进度 +- 让 Supervisor 围绕目标持续推进多轮任务,不必一直盯着终端 +- 用手机查看评估循环和后续动作,而不是守着每轮输出 +- 减少机械重复的人工催促,让长任务执行更稳 ### AI 辅助编程 -- 并行运行多个 Claude Code 或 Codex 会话 -- 在移动端远程监控 Agent 进度 -- 终端、编辑器和 Git 视图统一在一个界面 +- 并行运行 Claude Code 和 Codex 会话 +- 终端、编辑器、Git 和 Supervisor 状态统一在一个界面 +- 切换设备后继续当前 AI 工作,不必重新建立上下文 --- @@ -107,13 +91,13 @@ coder-studio open | 功能 | 描述 | |------|------| -| **多 Agent 会话** | 同时运行 Claude Code 和 Codex 会话 | -| **集成编辑器** | Monaco 驱动的代码编辑器,支持语法高亮 | -| **Git 集成** | 在工作区内查看分支、diff 和变更文件 | -| **Shell 终端** | 完整 PTY 终端,运行命令和验证 Agent 输出 | -| **响应式 UI** | 桌面端、平板端和手机端布局自动适配 | -| **会话历史** | 恢复之前的会话,完整上下文保留 | -| **深色/浅色主题** | 可自定义外观和快捷键 | +| **跨设备工作区** | 在桌面、平板和手机之间重新打开同一个编码环境,不必重新建立上下文 | +| **Supervisor 监督循环** | 围绕目标运行评估与续推循环,减少长任务中的人工盯守 | +| **Claude Code + Codex** | 在同一个工作区里使用两套 Agent CLI,而不是把工作流拆散到多个工具中 | +| **终端、编辑器和 Git 一体化** | 在同一个浏览器界面里完成 PTY 终端、Monaco 编辑、diff 和变更查看 | +| **响应式工作区界面** | 提供面向桌面、平板和手机的布局,而不是把桌面界面硬塞进小屏幕 | +| **会话连续性** | 切换设备后继续当前活跃会话,让 AI 工作保持可见 | +| **本地优先运行时** | 代码和运行时都留在你的机器上,不依赖云 IDE | --- @@ -146,7 +130,7 @@ coder-studio open - **AI 编程深度用户** — 每天使用 Claude Code / Codex,想要更好的会话管理 - **多设备开发者** — 频繁在办公室、家和移动设备之间切换 -- **远程优先团队** — 需要无需屏幕共享即可分享工作进度 +- **运行长任务的开发者** — 希望由 Supervisor 持续推进多轮任务,而不是全程人工盯守 - **注重隐私的开发者** — 希望代码留在本地机器,不依赖云 IDE --- diff --git a/docs/assets/demo-poster.png b/docs/assets/demo-poster.png new file mode 100644 index 000000000..9a76c2075 Binary files /dev/null and b/docs/assets/demo-poster.png differ diff --git a/docs/assets/demo-preview.gif b/docs/assets/demo-preview.gif new file mode 100644 index 000000000..531493b2a Binary files /dev/null and b/docs/assets/demo-preview.gif differ diff --git a/docs/assets/demo.mp4 b/docs/assets/demo.mp4 new file mode 100644 index 000000000..e8f0cd420 Binary files /dev/null and b/docs/assets/demo.mp4 differ diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 000000000..6ce482bbf Binary files /dev/null and b/docs/assets/logo.png differ diff --git a/docs/help/assets/screenshot-desktop-workspace-full.png b/docs/help/assets/screenshot-desktop-workspace-full.png index 1ea22cf9a..34e14af7c 100644 Binary files a/docs/help/assets/screenshot-desktop-workspace-full.png and b/docs/help/assets/screenshot-desktop-workspace-full.png differ diff --git a/docs/help/assets/screenshot-workspace-overview.png b/docs/help/assets/screenshot-workspace-overview.png index 1ea22cf9a..34e14af7c 100644 Binary files a/docs/help/assets/screenshot-workspace-overview.png and b/docs/help/assets/screenshot-workspace-overview.png differ diff --git a/docs/promotion/releases/README.md b/docs/promotion/releases/README.md new file mode 100644 index 000000000..c3ab46440 --- /dev/null +++ b/docs/promotion/releases/README.md @@ -0,0 +1,21 @@ +# Release Narratives + +These files are the human-facing release stories for Coder Studio. + +They are not a replacement for `packages/cli/CHANGELOG.md`. The changelog remains the canonical package history, while release narratives explain why a release matters, who benefits, and what changed in product terms. + +## When to add one + +Add a release narrative when a version is published to GitHub and npm, especially when the raw changeset summary is too terse for first-time evaluators. + +## Suggested structure + +1. Why this release matters +2. What changed in product language +3. Who benefits most +4. Install or upgrade command +5. Full changelog comparison link + +## Current releases + +- [v0.3.5](v0.3.5.md) diff --git a/docs/promotion/releases/v0.3.5.md b/docs/promotion/releases/v0.3.5.md new file mode 100644 index 000000000..7d9a1bcb5 --- /dev/null +++ b/docs/promotion/releases/v0.3.5.md @@ -0,0 +1,35 @@ +# Coder Studio v0.3.5 + +## Why this patch matters + +Coder Studio is most valuable when you let Claude Code or Codex run for a while, step away, and return later from another device. That workflow breaks down fast if a silent websocket disconnect causes terminal output to disappear. + +`v0.3.5` is a reliability patch focused on that gap. It strengthens terminal recovery so buffered PTY output is replayed after silent disconnects, with better probe-based recovery and keepalive handling. + +In practice, this release means: + +- less missing terminal output after short network interruptions +- more confidence when leaving long-running AI tasks unattended +- better continuity when checking progress from desktop, tablet, or phone + +## Included in v0.3.5 + +- fix terminal websocket recovery so buffered PTY output is replayed after silent disconnects +- improve probe-based recovery and keepalive handling for long-running terminal sessions + +## Who benefits most + +- developers running long Claude Code or Codex tasks +- users checking workspace progress from another device +- anyone relying on Coder Studio as a persistent browser-based AI coding workspace + +## Install or upgrade + +```bash +npm install -g @spencer-kit/coder-studio +coder-studio open +``` + +## Full changelog + +- [Compare `v0.3.4...v0.3.5`](https://github.com/spencerkit/coder-studio/compare/v0.3.4...v0.3.5) diff --git a/docs/superpowers/plans/2026-05-11-theme-skins-system.md b/docs/superpowers/plans/2026-05-11-theme-skins-system.md new file mode 100644 index 000000000..525ccddc5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-theme-skins-system.md @@ -0,0 +1,733 @@ +# Theme Skins System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade the current dark/light appearance toggle into a multi-skin theme system driven by a single persisted `themeId`, with shared theme metadata powering Web UI tokens, xterm terminal themes, Monaco editor themes, and preview/e2e infrastructure. + +**Architecture:** Keep the user-facing settings model simple by persisting only `appearance.themeId`, then centralize all actual theme behavior behind a shared web theme registry. Each theme definition carries metadata (`family`, `kind`, high-contrast flags, paired theme ID) plus explicit UI/xterm/Monaco definitions. Web bootstrap, settings UI, xterm, Monaco, and preview tools all resolve the active theme through the same registry instead of branching directly on `dark | light`. + +**Tech Stack:** React 19, TypeScript 6, Jotai, Vite, Vitest + Testing Library, Playwright, xterm.js, Monaco editor, CSS custom properties, Zod. + +**Spec reference:** `docs/superpowers/specs/2026-05-11-theme-skins-design.md` + +--- + +## File Structure + +**New files:** +- `packages/web/src/theme/index.ts` +- `packages/web/src/theme/registry.ts` +- `packages/web/src/theme/resolve.ts` +- `packages/web/src/theme/registry.test.ts` +- `packages/web/src/theme/resolve.test.ts` +- `docs/superpowers/specs/2026-05-11-theme-skins-design.md` already exists; do not modify unless plan execution exposes a spec bug + +**Modified files:** +- `packages/core/src/domain/types.ts` +- `packages/server/src/commands/settings.ts` +- `packages/server/src/commands/settings.test.ts` +- `packages/web/src/atoms/app-ui.ts` +- `packages/web/src/app/providers.tsx` +- `packages/web/src/app/providers.lifecycle.test.tsx` +- `packages/web/src/features/settings/components/settings-page.tsx` +- `packages/web/src/features/settings/components/settings-page.test.tsx` +- `packages/web/src/features/code-editor/components/monaco-host.tsx` +- `packages/web/src/features/code-editor/components/monaco-host.test.tsx` +- `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` +- `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` +- `packages/web/src/styles/tokens.css` +- `packages/web/src/styles/tokens-touch.test.ts` +- `packages/web/src/styles/components.theme.test.ts` +- `packages/web/src/locales/zh.json` +- `packages/web/src/locales/en.json` +- `packages/web/src/ui-preview/app.tsx` +- `packages/web/src/ui-preview/app.test.tsx` +- `packages/web/src/ui-preview/catalog.ts` +- `packages/web/src/ui-preview/preview-store.ts` +- `packages/web/src/ui-preview/scene-metadata.ts` +- `packages/web/src/ui-preview/scenes/page-scenes.tsx` +- `e2e-ui/scenes/index.ts` +- `e2e-ui/fixtures/prefs.ts` +- `e2e-ui/fixtures/scene-runner.ts` +- `e2e-ui/report/build-report.ts` +- `e2e-ui/report/build-report.test.ts` +- `e2e/specs/settings/general.spec.ts` +- `e2e/specs/quality/general.spec.ts` + +**Likely no changes in this plan:** +- `packages/web/src/styles/components.css` beyond token consumption behavior that already exists +- server database schema or migrations +- provider/runtime code +- auth flows +- route structure + +## Task 1: Establish Shared Theme Types and Registry + +**Files:** +- Create: `packages/web/src/theme/index.ts` +- Create: `packages/web/src/theme/registry.ts` +- Create: `packages/web/src/theme/resolve.ts` +- Create: `packages/web/src/theme/registry.test.ts` +- Create: `packages/web/src/theme/resolve.test.ts` +- Modify: `packages/core/src/domain/types.ts` + +- [ ] **Step 1: Write failing registry and resolver tests** + +Add tests that codify the first-phase contract: + +```ts +expect(THEME_IDS).toEqual( + expect.arrayContaining([ + "mint-dark", + "mint-light", + "graphite-dark", + "graphite-light", + "nord-dark", + "nord-light", + "hc-dark", + "hc-light", + ]) +); + +expect(resolveStoredThemeId("dark")).toBe("mint-dark"); +expect(resolveStoredThemeId("light")).toBe("mint-light"); +expect(resolveStoredThemeId("mint-dark")).toBe("mint-dark"); +expect(resolveStoredThemeId("missing-theme")).toBe("mint-dark"); +``` + +Also assert: + +- all IDs are unique +- every theme has `family`, `kind`, `documentThemeAttr`, `terminalTheme`, `monaco` +- all `pairedThemeId` values point to real themes +- high contrast themes are flagged with `isHighContrast: true` + +- [ ] **Step 2: Introduce shared theme types** + +Update `packages/core/src/domain/types.ts` to export the web-consumed settings type shape needed by the rest of the app. At minimum, stop baking `appearance.theme: "dark"` into the shared `Settings` interface. Replace it with a string-based theme ID field: + +```ts +appearance: { + themeId: string; + terminalRenderer: "standard" | "compatibility"; + locale: "zh" | "en"; +}; +``` + +Keep this change additive and pragmatic: + +- do not attempt to model all theme IDs as a cross-package literal union yet +- do not remove unrelated settings fields + +- [ ] **Step 3: Implement the central theme registry** + +In `packages/web/src/theme/registry.ts`, define: + +- `ThemeFamily` +- `ThemeKind` +- `AppThemeDefinition` +- the eight first-phase theme definitions + +Each theme definition must include: + +- `id` +- `family` +- `kind` +- `labelKey` +- `pairedThemeId` +- `isHighContrast` +- `documentThemeAttr` +- explicit xterm palette +- Monaco base theme metadata and colors + +In `packages/web/src/theme/resolve.ts`, implement focused helpers: + +- `getThemeById(themeId: string): AppThemeDefinition` +- `resolveStoredThemeId(value: unknown): string` +- `getThemeFamily(themeId: string): ThemeFamily` +- `getThemeVariant(themeId: string): "dark" | "light"` +- `getThemeIdForFamilyVariant(family: ThemeFamily, variant: "dark" | "light"): string | null` + +Default behavior: + +- unknown values resolve to `mint-dark` +- legacy `dark` resolves to `mint-dark` +- legacy `light` resolves to `mint-light` + +- [ ] **Step 4: Export the theme utilities** + +From `packages/web/src/theme/index.ts`, export the registry constants and resolver helpers. Keep consumers importing from the barrel, not deep internal file paths. + +- [ ] **Step 5: Run the focused registry verification** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/theme/registry.test.ts \ + src/theme/resolve.test.ts +``` + +Expected: all new tests fail before implementation, then pass after the registry and resolver are in place. + +## Task 2: Migrate Settings Persistence and Bootstrap to `themeId` + +**Files:** +- Modify: `packages/server/src/commands/settings.ts` +- Modify: `packages/server/src/commands/settings.test.ts` +- Modify: `packages/web/src/atoms/app-ui.ts` +- Modify: `packages/web/src/app/providers.tsx` +- Modify: `packages/web/src/app/providers.lifecycle.test.tsx` + +- [ ] **Step 1: Write failing server settings tests for `appearance.themeId`** + +Add tests that lock in: + +```ts +await dispatch({ + kind: "command", + id: "settings-update-theme-id", + op: "settings.update", + args: { settings: { appearance: { themeId: "graphite-light" } } }, +}, ctx); + +expect( + db.prepare("SELECT value FROM user_settings WHERE key = ?").get("appearance.themeId") +).toEqual({ value: '"graphite-light"' }); +``` + +Also add a `settings.get` test that: + +- inserts `appearance.themeId = "nord-dark"` into `user_settings` +- confirms `settings.get` returns `"appearance.themeId": "nord-dark"` + +- [ ] **Step 2: Extend server settings schema without breaking migration** + +In `packages/server/src/commands/settings.ts`: + +- add `appearance.themeId: z.string().optional()` +- keep `appearance.theme: z.enum(["dark"]).optional()` or widen it to accept `"dark" | "light"` only if existing tests require it +- do not remove existing `terminalRenderer`, `terminalCopyOnSelect`, or `locale` + +Requirements: + +- `settings.update` accepts new `themeId` +- flattening continues to persist `appearance.themeId` as a dot-path key +- migration remains “read old, write new” at the app layer; the server only needs to accept both + +- [ ] **Step 3: Replace the web theme atom with `themeId` storage** + +In `packages/web/src/atoms/app-ui.ts`: + +- migrate `themeAtom` from `atomWithStorage<"dark" | "light">("ui.theme", "dark")` +- to `atomWithStorage("ui.themeId", "mint-dark")` + +Also introduce a compatibility helper in the theme module if the atom bootstrap needs to normalize legacy values after load. + +- [ ] **Step 4: Apply theme resolution during app bootstrap** + +In `packages/web/src/app/providers.tsx`: + +- replace the localStorage bootstrap that reads `ui.theme` +- read `ui.themeId` first +- if absent, read legacy `ui.theme` +- normalize via `resolveStoredThemeId` +- set `document.documentElement.setAttribute("data-theme", resolvedTheme.documentThemeAttr)` + +When `settings.get` data is available: + +- prefer `settings["appearance.themeId"]` +- else fall back to `settings["appearance.theme"]` +- normalize through the resolver +- update the theme atom +- write the normalized `ui.themeId` cache back to localStorage + +Do not rewrite unrelated connection logic. + +- [ ] **Step 5: Add bootstrap lifecycle tests** + +Expand `packages/web/src/app/providers.lifecycle.test.tsx` to verify: + +- legacy `ui.theme = "light"` bootstraps the document to `mint-light` +- `settings.get` returning `appearance.themeId = "graphite-dark"` updates the document theme and atom +- server-provided `appearance.themeId` wins over local legacy storage + +Use existing mocked `sendCommand` plumbing; do not add browser-integration-only assertions. + +- [ ] **Step 6: Run the persistence/bootstrap verification set** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/commands/settings.test.ts +``` + +and: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/app/providers.lifecycle.test.tsx \ + src/theme/registry.test.ts \ + src/theme/resolve.test.ts +``` + +Expected: `appearance.themeId` persists and the app bootstraps a normalized theme ID from new or legacy settings. + +## Task 3: Refactor Settings UI to Family + Variant While Saving One `themeId` + +**Files:** +- Modify: `packages/web/src/features/settings/components/settings-page.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` +- Modify: `packages/web/src/locales/zh.json` +- Modify: `packages/web/src/locales/en.json` + +- [ ] **Step 1: Write failing appearance-settings tests for theme families** + +Replace current two-pill assumptions with tests like: + +```tsx +expect(await screen.findByRole("button", { name: "Mint" })).toBeInTheDocument(); +expect(screen.getByRole("button", { name: "Graphite" })).toBeInTheDocument(); +expect(screen.getByRole("button", { name: "Nord" })).toBeInTheDocument(); +expect(screen.getByRole("button", { name: "High Contrast" })).toBeInTheDocument(); +expect(screen.getByRole("button", { name: "Dark" })).toBeInTheDocument(); +expect(screen.getByRole("button", { name: "Light" })).toBeInTheDocument(); +``` + +Add interaction assertions that: + +- starting from `mint-dark`, clicking `Graphite` triggers `settings.update({ appearance: { themeId: "graphite-dark" } })` +- clicking `Light` afterwards triggers `settings.update({ appearance: { themeId: "graphite-light" } })` +- `document.documentElement` updates to the resolved `data-theme` + +- [ ] **Step 2: Add localization keys for theme families and variants** + +Update `packages/web/src/locales/zh.json` and `packages/web/src/locales/en.json` with keys such as: + +- `settings.theme.title` +- `settings.theme.hint` +- `settings.theme.family` +- `settings.theme.variant` +- `settings.theme.family_mint` +- `settings.theme.family_graphite` +- `settings.theme.family_nord` +- `settings.theme.family_hc` +- `settings.theme.variant_dark` +- `settings.theme.variant_light` + +Keep the existing translation key namespace instead of inventing a parallel one. + +- [ ] **Step 3: Rework the settings page appearance section** + +In `packages/web/src/features/settings/components/settings-page.tsx`: + +- treat the theme atom as `themeId` +- derive `family` and `variant` through the resolver +- replace the current two-pill dark/light group with: + - one group for family + - one group for variant +- keep the language controls unchanged + +Implementation requirements: + +- `handleThemeChange` should accept a final `themeId`, not `dark | light` +- setting family should preserve the current variant when possible +- setting variant should preserve the current family +- all saves still call `settings.update({ appearance: { themeId } })` +- update `document.documentElement` with the resolved `documentThemeAttr` + +- [ ] **Step 4: Update settings load behavior** + +In the same file’s `settings.get` hydration logic: + +- read `appearance.themeId` first +- else read legacy `appearance.theme` +- normalize before calling `setTheme` + +Do not entangle this with locale or terminal renderer version counters unless needed to avoid stale writes. + +- [ ] **Step 5: Run focused settings tests** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/settings-page.test.tsx +``` + +Expected: the appearance section uses family/variant controls, still persists one `themeId`, and no locale/terminal preference tests regress. + +## Task 4: Upgrade CSS Tokens to Named Themes + +**Files:** +- Modify: `packages/web/src/styles/tokens.css` +- Modify: `packages/web/src/styles/tokens-touch.test.ts` +- Modify: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Add failing token tests for multiple `data-theme` blocks** + +Extend the node-based stylesheet tests to assert: + +```ts +expect(stylesheet).toContain('[data-theme="mint-light"]'); +expect(stylesheet).toContain('[data-theme="graphite-dark"]'); +expect(stylesheet).toContain('[data-theme="graphite-light"]'); +expect(stylesheet).toContain('[data-theme="nord-dark"]'); +expect(stylesheet).toContain('[data-theme="nord-light"]'); +expect(stylesheet).toContain('[data-theme="hc-dark"]'); +expect(stylesheet).toContain('[data-theme="hc-light"]'); +``` + +Also preserve current touch-token assertions unchanged. + +- [ ] **Step 2: Restructure `tokens.css` around named themes** + +In `packages/web/src/styles/tokens.css`: + +- keep base non-color tokens on `:root` +- move current dark-theme colors into `:root, [data-theme="mint-dark"]` +- convert current light override into `[data-theme="mint-light"]` +- add first-pass color values for: + - `graphite-dark` + - `graphite-light` + - `nord-dark` + - `nord-light` + - `hc-dark` + - `hc-light` + +Requirements: + +- maintain existing semantic token names +- do not introduce component-specific hardcoded colors here +- preserve current scrollbar/touch/sizing tokens +- ensure no theme block depends on another theme block’s presence + +- [ ] **Step 3: Verify token-aware component tests** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/styles/tokens-touch.test.ts \ + src/styles/components.theme.test.ts +``` + +Expected: current token-consuming component tests still pass and new theme block existence checks pass. + +## Task 5: Route xterm and Monaco Through the Shared Theme Resolver + +**Files:** +- Modify: `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` +- Modify: `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` +- Modify: `packages/web/src/features/code-editor/components/monaco-host.tsx` +- Modify: `packages/web/src/features/code-editor/components/monaco-host.test.tsx` + +- [ ] **Step 1: Write failing terminal/editor tests against theme IDs** + +Update Monaco tests to stop assuming `themeAtom = "light"` and instead set: + +```ts +store.set(themeAtom, "mint-light"); +``` + +Assert that: + +- Monaco creates the editor with a named theme ID such as `coder-studio-mint-light` +- `monaco.editor.setTheme()` is called with the named theme, not raw `vs` + +Update xterm tests to verify: + +- `mint-light` produces the light terminal palette +- changing from `mint-dark` to `graphite-light` updates the live terminal options theme +- a non-default theme such as `hc-dark` uses the registry-provided palette + +- [ ] **Step 2: Refactor `MonacoHost` to named Monaco themes** + +In `packages/web/src/features/code-editor/components/monaco-host.tsx`: + +- resolve the current theme via the shared theme module +- ensure the Monaco theme definition is registered once with `monaco.editor.defineTheme` +- create the editor with `theme: resolvedTheme.monaco.id` +- on changes, call `monaco.editor.setTheme(resolvedTheme.monaco.id)` + +Keep language detection and save-command behavior unchanged. + +- [ ] **Step 3: Refactor `XtermHost` to registry-provided palettes** + +In `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx`: + +- remove the hardcoded `AURORA_MINT_THEMES.dark/light` branching as the active source of truth +- use `getThemeById(themeId).terminalTheme` +- initialize the terminal with the resolved palette +- update live instances on theme changes through the same resolver + +Do not disturb unrelated hydration, replay, mobile input, or copy-on-select logic. + +- [ ] **Step 4: Run focused terminal/editor verification** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/code-editor/components/monaco-host.test.tsx \ + src/features/terminal-panel/__tests__/xterm-host.test.tsx +``` + +Expected: Monaco and xterm both consume theme IDs through the registry and still react to runtime theme changes. + +## Task 6: Upgrade UI Preview and E2E-UI Theme Dimensions + +**Files:** +- Modify: `packages/web/src/ui-preview/catalog.ts` +- Modify: `packages/web/src/ui-preview/preview-store.ts` +- Modify: `packages/web/src/ui-preview/scene-metadata.ts` +- Modify: `packages/web/src/ui-preview/scenes/page-scenes.tsx` +- Modify: `packages/web/src/ui-preview/app.tsx` +- Modify: `packages/web/src/ui-preview/app.test.tsx` +- Modify: `e2e-ui/scenes/index.ts` +- Modify: `e2e-ui/fixtures/prefs.ts` +- Modify: `e2e-ui/fixtures/scene-runner.ts` +- Modify: `e2e-ui/report/build-report.ts` +- Modify: `e2e-ui/report/build-report.test.ts` + +- [ ] **Step 1: Write failing preview and report tests for named theme IDs** + +Update `packages/web/src/ui-preview/app.test.tsx` to use: + +```tsx +renderPreview("?scene=welcome&theme=mint-light&locale=en&device=desktop"); +expect(document.documentElement).toHaveAttribute("data-theme", "mint-light"); +``` + +Update e2e-ui report tests to expect screenshot paths like: + +```ts +"screenshots/page/welcome/desktop__mint-light__zh.png" +``` + +- [ ] **Step 2: Upgrade preview-store theme typing** + +In `packages/web/src/ui-preview/preview-store.ts`: + +- replace `UiPreviewTheme = "dark" | "light"` with string-based theme IDs or a first-phase literal union +- seed `themeAtom` with the final theme ID + +In `packages/web/src/ui-preview/catalog.ts` and `app.tsx`: + +- update `UiPreviewSceneContext` and request parsing to accept named theme IDs +- normalize unknown or legacy values through the shared resolver +- set `document.documentElement.dataset.theme` from the resolved theme definition + +- [ ] **Step 3: Update preview scene metadata and seeded settings** + +In `packages/web/src/ui-preview/scene-metadata.ts`: + +- change `themes` arrays from `["dark", "light"]` +- to representative named theme lists, defaulting to: + - `mint-dark` + - `mint-light` + - `hc-dark` + +In `packages/web/src/ui-preview/scenes/page-scenes.tsx`: + +- make `settingsGet` seed `appearance.themeId = context.theme` +- stop seeding legacy `appearance.theme` for new preview scenarios unless a specific migration test needs it + +- [ ] **Step 4: Update e2e-ui fixtures and report generation** + +In `e2e-ui/fixtures/prefs.ts`: + +- persist `ui.themeId` +- optionally seed legacy `ui.theme` only if backward-compat preview coverage is desired + +In `e2e-ui/scenes/index.ts`, `scene-runner.ts`, `build-report.ts`, and `build-report.test.ts`: + +- update theme typing and screenshot naming to use the final theme ID string +- keep device/locale grouping intact + +- [ ] **Step 5: Run focused preview/e2e-ui verification** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/ui-preview/app.test.tsx +``` + +and: + +```bash +pnpm --filter e2e-ui exec vitest run report/build-report.test.ts +``` + +Expected: preview and e2e-ui now model themes as named IDs and preserve stable filtering/report behavior. + +## Task 7: Update End-to-End Theme Assertions and Backward Compatibility Coverage + +**Files:** +- Modify: `e2e/specs/settings/general.spec.ts` +- Modify: `e2e/specs/quality/general.spec.ts` +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` if needed for migration assertions + +- [ ] **Step 1: Update acceptance tests for the new appearance UI** + +In `e2e/specs/settings/general.spec.ts`, replace the “dark/light buttons visible” assumption with: + +- Theme section visible +- family controls visible +- variant controls visible + +Keep the test bounded; it does not need to click every theme. + +- [ ] **Step 2: Update localStorage persistence checks** + +In `e2e/specs/quality/general.spec.ts`, migrate checks from `ui.theme` to `ui.themeId`: + +```ts +const themeId = await page.evaluate(() => localStorage.getItem("ui.themeId")); +expect(themeId === null || themeId === '"mint-dark"' || themeId === "mint-dark").toBe(true); +``` + +Add a compatibility assertion that seeding legacy `ui.theme = "light"` still yields the expected document theme or normalized `themeId` after app startup. + +- [ ] **Step 3: Run the bounded Playwright coverage** + +Run: + +```bash +pnpm --filter e2e exec playwright test \ + specs/settings/general.spec.ts \ + specs/quality/general.spec.ts +``` + +Expected: settings and quality acceptance specs pass with the new `themeId` model. + +## Task 8: Full Verification and Cleanup + +**Files:** no new product code; touch only if verification reveals issues + +- [ ] **Step 1: Run the full focused unit/integration verification set** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/commands/settings.test.ts +``` + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/theme/registry.test.ts \ + src/theme/resolve.test.ts \ + src/app/providers.lifecycle.test.tsx \ + src/features/settings/components/settings-page.test.tsx \ + src/features/code-editor/components/monaco-host.test.tsx \ + src/features/terminal-panel/__tests__/xterm-host.test.tsx \ + src/ui-preview/app.test.tsx \ + src/styles/tokens-touch.test.ts \ + src/styles/components.theme.test.ts +``` + +```bash +pnpm --filter e2e-ui exec vitest run report/build-report.test.ts +``` + +Expected: all focused tests pass. + +- [ ] **Step 2: Run formatting and static checks on touched files** + +Run: + +```bash +pnpm --filter @coder-studio/web exec biome check \ + src/theme \ + src/atoms/app-ui.ts \ + src/app/providers.tsx \ + src/app/providers.lifecycle.test.tsx \ + src/features/settings/components/settings-page.tsx \ + src/features/settings/components/settings-page.test.tsx \ + src/features/code-editor/components/monaco-host.tsx \ + src/features/code-editor/components/monaco-host.test.tsx \ + src/features/terminal-panel/views/shared/xterm-host.tsx \ + src/features/terminal-panel/__tests__/xterm-host.test.tsx \ + src/styles/tokens.css \ + src/styles/tokens-touch.test.ts \ + src/styles/components.theme.test.ts \ + src/locales/zh.json \ + src/locales/en.json \ + src/ui-preview/app.tsx \ + src/ui-preview/app.test.tsx \ + src/ui-preview/catalog.ts \ + src/ui-preview/preview-store.ts \ + src/ui-preview/scene-metadata.ts \ + src/ui-preview/scenes/page-scenes.tsx +``` + +and: + +```bash +pnpm --filter @coder-studio/server exec biome check \ + src/commands/settings.ts \ + src/commands/settings.test.ts +``` + +Expected: no Biome issues. + +- [ ] **Step 3: Re-scan for direct `dark | light` theme branching in theme consumers** + +Run: + +```bash +rg -n 'ui\\.theme|appearance\\.theme|theme === "light"|theme === "dark"|data-theme="light"|data-theme="dark"' \ + packages/web \ + packages/server \ + e2e \ + e2e-ui +``` + +Expected: + +- remaining matches are either explicit migration-compatibility code paths or documented test fixtures +- no core UI/xterm/Monaco runtime path should still branch directly on `dark | light` + +- [ ] **Step 4: Commit the implementation** + +Run: + +```bash +git add \ + packages/core/src/domain/types.ts \ + packages/server/src/commands/settings.ts \ + packages/server/src/commands/settings.test.ts \ + packages/web/src/theme \ + packages/web/src/atoms/app-ui.ts \ + packages/web/src/app/providers.tsx \ + packages/web/src/app/providers.lifecycle.test.tsx \ + packages/web/src/features/settings/components/settings-page.tsx \ + packages/web/src/features/settings/components/settings-page.test.tsx \ + packages/web/src/features/code-editor/components/monaco-host.tsx \ + packages/web/src/features/code-editor/components/monaco-host.test.tsx \ + packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx \ + packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx \ + packages/web/src/styles/tokens.css \ + packages/web/src/styles/tokens-touch.test.ts \ + packages/web/src/styles/components.theme.test.ts \ + packages/web/src/locales/zh.json \ + packages/web/src/locales/en.json \ + packages/web/src/ui-preview/app.tsx \ + packages/web/src/ui-preview/app.test.tsx \ + packages/web/src/ui-preview/catalog.ts \ + packages/web/src/ui-preview/preview-store.ts \ + packages/web/src/ui-preview/scene-metadata.ts \ + packages/web/src/ui-preview/scenes/page-scenes.tsx \ + e2e-ui/scenes/index.ts \ + e2e-ui/fixtures/prefs.ts \ + e2e-ui/fixtures/scene-runner.ts \ + e2e-ui/report/build-report.ts \ + e2e-ui/report/build-report.test.ts \ + e2e/specs/settings/general.spec.ts \ + e2e/specs/quality/general.spec.ts \ + docs/superpowers/plans/2026-05-11-theme-skins-system.md +git commit -m "feat: add multi-skin theme system" +``` + +Expected: one clean feature commit containing the theme system implementation and its plan doc. diff --git a/docs/superpowers/plans/2026-05-13-mobile-terminal-long-press-line-copy.md b/docs/superpowers/plans/2026-05-13-mobile-terminal-long-press-line-copy.md new file mode 100644 index 000000000..3156a6fad --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-mobile-terminal-long-press-line-copy.md @@ -0,0 +1,1259 @@ +# Mobile Terminal Long-Press Line Copy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the mobile terminal copy-mode overlay with direct long-press line copy that copies the wrapped logical line from the frontend xterm buffer when `Copy on select` is enabled. + +**Architecture:** Keep gesture detection, clipboard side effects, toast side effects, and vibration inside [`packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx`](/home/spencer/workspace/coder-studio/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx). Extract target-to-row resolution and wrapped logical-line reconstruction into a small pure helper under `mobile/` so the tricky buffer mapping logic is unit-testable without mounting a real xterm instance. Delete the old DOM snapshot overlay path entirely, including its CSS, locale strings, tests, and helper files. + +**Tech Stack:** TypeScript, React 19, xterm.js 6, Jotai, Vitest, Testing Library, Playwright. + +--- + +## File Structure + +- Create: `packages/web/src/features/terminal-panel/mobile/long-press-copy-line.ts` - pure helper that resolves `touchstart.target` to the nearest `.xterm-rows > *` row and reconstructs the wrapped logical line from `terminal.buffer.active`. +- Create: `packages/web/src/features/terminal-panel/mobile/long-press-copy-line.test.ts` - helper unit coverage for nested targets, viewport mapping, wrapped-line expansion, and final-segment trimming. +- Modify: `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` - mobile long-press copy flow, success toast, vibration, and overlay removal. +- Modify: `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` - mobile long-press behavior tests rewritten around direct copy instead of overlay entry. +- Modify: `packages/web/src/locales/en.json` - add `terminal.copied_current_line`, remove overlay-only `copy_mode_*` strings. +- Modify: `packages/web/src/locales/zh.json` - add `terminal.copied_current_line`, remove overlay-only `copy_mode_*` strings. +- Delete: `packages/web/src/features/terminal-panel/mobile/copy-mode-snapshot.ts` - obsolete DOM snapshot helper for removed overlay workflow. +- Delete: `packages/web/src/features/terminal-panel/mobile/copy-mode-snapshot.test.ts` - obsolete overlay helper tests. +- Modify: `packages/web/src/styles/components.css` - remove `.mobile-terminal-copy-mode*` rules. +- Modify: `packages/web/src/styles/components.theme.test.ts` - assert removed overlay CSS no longer ships. +- Modify: `e2e/specs/settings/mobile-copy-on-select.spec.ts` - mobile regression coverage for direct clipboard copy and disabled-state no-op behavior. + +### Task 1: Add The Wrapped-Line Copy Helper + +**Files:** +- Create: `packages/web/src/features/terminal-panel/mobile/long-press-copy-line.ts` +- Create: `packages/web/src/features/terminal-panel/mobile/long-press-copy-line.test.ts` + +- [ ] **Step 1: Write the failing helper tests** + +Create `packages/web/src/features/terminal-panel/mobile/long-press-copy-line.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { getLogicalLineTextFromTouchTarget } from "./long-press-copy-line"; + +interface MockBufferLine { + isWrapped?: boolean; + translateToString(trimRight?: boolean): string; +} + +function createBufferLine(text: string, isWrapped = false): MockBufferLine { + return { + isWrapped, + translateToString(trimRight = false) { + return trimRight ? text.replace(/\s+$/u, "") : text; + }, + }; +} + +function createTerminal( + viewportY: number, + lines: Array<[row: number, line: MockBufferLine]> +): { + buffer: { + active: { + viewportY: number; + getLine(row: number): MockBufferLine | undefined; + }; + }; +} { + const byRow = new Map(lines); + return { + buffer: { + active: { + viewportY, + getLine(row: number) { + return byRow.get(row); + }, + }, + }, + }; +} + +function createRowsDom() { + const rows = document.createElement("div"); + rows.className = "xterm-rows"; + + const firstRow = document.createElement("div"); + firstRow.innerHTML = "first"; + + const secondRow = document.createElement("div"); + secondRow.innerHTML = "second"; + + const thirdRow = document.createElement("div"); + thirdRow.innerHTML = "third"; + + rows.append(firstRow, secondRow, thirdRow); + + return { + rows, + firstTarget: firstRow.querySelector("span") as HTMLSpanElement, + secondTarget: secondRow.querySelector("span span") as HTMLSpanElement, + thirdTarget: thirdRow.querySelector("span span") as HTMLSpanElement, + }; +} + +describe("getLogicalLineTextFromTouchTarget", () => { + it("maps the touched visual row through viewportY", () => { + const { rows, secondTarget } = createRowsDom(); + document.body.appendChild(rows); + + const terminal = createTerminal(10, [[11, createBufferLine("beta")]]); + + expect(getLogicalLineTextFromTouchTarget({ target: secondTarget, terminal })).toBe("beta"); + + rows.remove(); + }); + + it("walks upward and downward across wrapped rows and trims only the final segment", () => { + const { rows, thirdTarget } = createRowsDom(); + document.body.appendChild(rows); + + const terminal = createTerminal(20, [ + [20, createBufferLine("unrelated line")], + [21, createBufferLine("prefix ", false)], + [22, createBufferLine("middle ", true)], + [23, createBufferLine("suffix ", true)], + ]); + + expect(getLogicalLineTextFromTouchTarget({ target: thirdTarget, terminal })).toBe( + "prefix middle suffix" + ); + + rows.remove(); + }); + + it("preserves meaningful internal spaces from wrapped intermediate segments", () => { + const { rows, secondTarget } = createRowsDom(); + document.body.appendChild(rows); + + const terminal = createTerminal(30, [ + [30, createBufferLine("double ", false)], + [31, createBufferLine("space ", true)], + ]); + + expect(getLogicalLineTextFromTouchTarget({ target: secondTarget, terminal })).toBe( + "double space" + ); + + rows.remove(); + }); + + it("returns null when the touch target does not resolve to a direct xterm row", () => { + const outside = document.createElement("div"); + outside.innerHTML = "outside"; + document.body.appendChild(outside); + + const terminal = createTerminal(0, [[0, createBufferLine("ignored")]]); + + expect( + getLogicalLineTextFromTouchTarget({ + target: outside.querySelector("span"), + terminal, + }) + ).toBeNull(); + + outside.remove(); + }); +}); +``` + +- [ ] **Step 2: Run the helper tests to verify they fail** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/features/terminal-panel/mobile/long-press-copy-line.test.ts` + +Expected: FAIL with a module resolution error because `long-press-copy-line.ts` does not exist yet. + +- [ ] **Step 3: Write the minimal helper implementation** + +Create `packages/web/src/features/terminal-panel/mobile/long-press-copy-line.ts`: + +```ts +interface BufferLineLike { + isWrapped?: boolean; + translateToString(trimRight?: boolean): string; +} + +interface ActiveBufferLike { + viewportY: number; + getLine(row: number): BufferLineLike | undefined; +} + +export interface TerminalLikeForLongPressCopy { + buffer: { + active: ActiveBufferLike; + }; +} + +export interface GetLogicalLineTextFromTouchTargetArgs { + target: EventTarget | null; + terminal: TerminalLikeForLongPressCopy; +} + +function toElement(target: EventTarget | null): Element | null { + if (target instanceof Element) { + return target; + } + + if (target instanceof Node) { + return target.parentElement; + } + + return null; +} + +function findDirectXtermRow(target: EventTarget | null): HTMLElement | null { + let current: Element | null = toElement(target); + + while (current) { + const parent = current.parentElement; + if (parent?.classList.contains("xterm-rows") && current instanceof HTMLElement) { + return current; + } + current = parent; + } + + return null; +} + +export function getLogicalLineTextFromTouchTarget( + args: GetLogicalLineTextFromTouchTargetArgs +): string | null { + const rowElement = findDirectXtermRow(args.target); + if (!rowElement) { + return null; + } + + const rowsElement = rowElement.parentElement; + if (!rowsElement || !rowsElement.classList.contains("xterm-rows")) { + return null; + } + + const visualRowIndex = Array.prototype.indexOf.call(rowsElement.children, rowElement) as number; + if (visualRowIndex < 0) { + return null; + } + + const activeBuffer = args.terminal.buffer.active; + const bufferRow = activeBuffer.viewportY + visualRowIndex; + + let startRow = bufferRow; + let currentLine = activeBuffer.getLine(startRow); + if (!currentLine) { + return null; + } + + while (currentLine.isWrapped === true) { + startRow -= 1; + if (startRow < 0) { + return null; + } + + currentLine = activeBuffer.getLine(startRow); + if (!currentLine) { + return null; + } + } + + const segments: BufferLineLike[] = [currentLine]; + let scanRow = startRow; + + while (true) { + const nextLine = activeBuffer.getLine(scanRow + 1); + if (!nextLine || nextLine.isWrapped !== true) { + break; + } + + segments.push(nextLine); + scanRow += 1; + } + + return segments + .map((line, index) => line.translateToString(index === segments.length - 1)) + .join(""); +} +``` + +- [ ] **Step 4: Run the helper tests to verify they pass** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/features/terminal-panel/mobile/long-press-copy-line.test.ts` + +Expected: PASS with 4 tests green. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/terminal-panel/mobile/long-press-copy-line.ts packages/web/src/features/terminal-panel/mobile/long-press-copy-line.test.ts +git commit -m "test(web): add mobile terminal line-copy helper" +``` + +### Task 2: Rewrite Mobile Long-Press Behavior Around Direct Copy + +**Files:** +- Modify: `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` +- Modify: `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Replace the mobile overlay tests with direct-copy tests** + +Patch `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` in two places. + +First, extend the test scaffolding near the existing `mockTerminal` definition: + +```ts +interface MockBufferLine { + isWrapped?: boolean; + translateToString(trimRight?: boolean): string; +} + +const mockBufferLines = new Map(); + +function createMockBufferLine(text: string, isWrapped = false): MockBufferLine { + return { + isWrapped, + translateToString(trimRight = false) { + return trimRight ? text.replace(/\s+$/u, "") : text; + }, + }; +} + +function setMockBufferLines(entries: Array<[row: number, text: string, isWrapped?: boolean]>) { + mockBufferLines.clear(); + for (const [row, text, isWrapped = false] of entries) { + mockBufferLines.set(row, createMockBufferLine(text, isWrapped)); + } +} + +function dispatchTouchEvent( + target: Element, + type: string, + touches: Array<{ identifier: number; clientX: number; clientY: number }>, + changedTouches = touches +) { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "touches", { value: touches }); + Object.defineProperty(event, "targetTouches", { value: touches }); + Object.defineProperty(event, "changedTouches", { value: changedTouches }); + target.dispatchEvent(event); +} +``` + +Update `mockTerminal.buffer.active` so it includes `getLine`: + +```ts + buffer: { + active: { + viewportY: 0, + baseY: 0, + getLine: vi.fn((row: number) => mockBufferLines.get(row)), + }, + }, +``` + +Reset the new buffer map in `beforeEach`: + +```ts + mockBufferLines.clear(); + mockTerminal.buffer.active.getLine.mockImplementation((row: number) => mockBufferLines.get(row)); +``` + +Then replace the old overlay-only mobile copy tests in the `6895-8099` region with these direct-copy tests: + +```tsx + it("mobile line copy copies the wrapped logical line on long press when copy on select is enabled", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockResolvedValue(undefined); + const vibrate = vi.fn(); + const store = createStore(); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + Object.defineProperty(navigator, "vibrate", { + configurable: true, + value: vibrate, + }); + + mockTerminal.buffer.active.viewportY = 20; + setMockBufferLines([ + [20, "prefix ", false], + [21, "middle ", true], + [22, "suffix ", true], + ]); + + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + sendTerminalInput: vi.fn().mockResolvedValue(undefined), + } as never); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const rows = document.createElement("div"); + rows.className = "xterm-rows"; + rows.innerHTML = ` +
prefix
+
middle
+
suffix
+ `; + host!.appendChild(rows); + + const target = rows.querySelector("div:nth-child(2) span span") as HTMLSpanElement; + + dispatchTouchEvent(target, "touchstart", [{ identifier: 1, clientX: 40, clientY: 120 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith("prefix middle suffix"); + }); + expect(vibrate).toHaveBeenCalledWith(10); + expect(store.get(toastsAtom)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "success", + title: "Copied current line", + }), + ]) + ); + expect(container.querySelector(".mobile-terminal-copy-mode")).toBeNull(); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("mobile line copy does not copy on long press when copy on select is disabled", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockResolvedValue(undefined); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: false }); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + sendTerminalInput: vi.fn().mockResolvedValue(undefined), + } as never); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const rows = document.createElement("div"); + rows.className = "xterm-rows"; + rows.innerHTML = "
only line
"; + host!.appendChild(rows); + + const target = rows.querySelector("span span") as HTMLSpanElement; + dispatchTouchEvent(target, "touchstart", [{ identifier: 1, clientX: 40, clientY: 120 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + + expect(writeText).not.toHaveBeenCalled(); + expect(store.get(toastsAtom)).toEqual([]); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("mobile line copy does not copy when the gesture becomes a scroll before long press matures", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockResolvedValue(undefined); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + mockTerminal.rows = 20; + mockTerminal.buffer.active.viewportY = 6; + mockTerminal.buffer.active.baseY = 80; + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const rows = document.createElement("div"); + rows.className = "xterm-rows"; + rows.innerHTML = "
only line
"; + host!.appendChild(rows); + + const target = rows.querySelector("span span") as HTMLSpanElement; + dispatchTouchEvent(target, "touchstart", [{ identifier: 1, clientX: 40, clientY: 120 }]); + dispatchTouchEvent(target, "touchmove", [{ identifier: 1, clientX: 40, clientY: 88 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + + expect(writeText).not.toHaveBeenCalled(); + expect(mockTerminal.scrollLines).toHaveBeenCalled(); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("mobile line copy does not copy when horizontal drift exceeds the long-press tolerance", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockResolvedValue(undefined); + const vibrate = vi.fn(); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + Object.defineProperty(navigator, "vibrate", { + configurable: true, + value: vibrate, + }); + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const rows = document.createElement("div"); + rows.className = "xterm-rows"; + rows.innerHTML = "
only line
"; + host!.appendChild(rows); + + const target = rows.querySelector("span span") as HTMLSpanElement; + dispatchTouchEvent(target, "touchstart", [{ identifier: 1, clientX: 40, clientY: 120 }]); + dispatchTouchEvent(target, "touchmove", [{ identifier: 1, clientX: 56, clientY: 120 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + + expect(writeText).not.toHaveBeenCalled(); + expect(vibrate).not.toHaveBeenCalled(); + expect(mockTerminal.scrollLines).not.toHaveBeenCalled(); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("mobile line copy reuses the existing copy failure toast when clipboard write fails", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockRejectedValue(new Error("clipboard failed")); + const store = createStore(); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + mockTerminal.buffer.active.viewportY = 0; + setMockBufferLines([[0, "failed line"]]); + + store.set(localeAtom, "zh"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + sendTerminalInput: vi.fn().mockResolvedValue(undefined), + } as never); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const rows = document.createElement("div"); + rows.className = "xterm-rows"; + rows.innerHTML = "
failed line
"; + host!.appendChild(rows); + + const target = rows.querySelector("span span") as HTMLSpanElement; + dispatchTouchEvent(target, "touchstart", [{ identifier: 1, clientX: 40, clientY: 120 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + + await waitFor(() => { + expect(store.get(toastsAtom)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "error", + title: "自动复制失败", + }), + ]) + ); + }); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); +``` + +- [ ] **Step 2: Run the rewritten mobile host tests to verify they fail** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/features/terminal-panel/__tests__/xterm-host.test.tsx -t "mobile line copy"` + +Expected: FAIL because `XtermHost` still tries to enter the removed overlay path and never copies directly from `touchstart.target`. + +- [ ] **Step 3: Rewrite `XtermHost` to copy the logical line directly and add the success locale** + +Make four exact changes in `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx`. + +Replace the old copy-mode snapshot import with the new helper import and remove the now-unused React mouse-event import: + +```tsx +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { getLogicalLineTextFromTouchTarget } from "../../mobile/long-press-copy-line"; +``` + +Concretely, the React import block at the top should stop importing `type MouseEvent as ReactMouseEvent`. + +Add the direct-copy callback near `pushCopyOnSelectFailureToast`: + +```tsx +const copyMobileLongPressRef = useRef<(target: EventTarget | null) => void>(() => {}); + +const copyMobileLongPress = useCallback( + async (target: EventTarget | null) => { + if (viewport !== "mobile" || !terminalPreferences.copyOnSelect) { + resetTouchStateRef.current(); + return; + } + + const terminal = terminalRef.current; + resetTouchStateRef.current(); + + if (!terminal) { + return; + } + + const text = getLogicalLineTextFromTouchTarget({ + target, + terminal, + }); + if (text === null) { + return; + } + + try { + if (!navigator.clipboard?.writeText) { + pushCopyOnSelectFailureToast(); + return; + } + + await navigator.clipboard.writeText(text); + pushToast({ + kind: "success", + title: t("terminal.copied_current_line"), + }); + if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") { + navigator.vibrate(10); + } + } catch { + pushCopyOnSelectFailureToast(); + } + }, + [pushCopyOnSelectFailureToast, pushToast, t, terminalPreferences.copyOnSelect, viewport] +); + +useEffect(() => { + copyMobileLongPressRef.current = (target) => { + void copyMobileLongPress(target); + }; +}, [copyMobileLongPress]); +``` + +Replace the long-press timer logic inside the touch effect with target capture instead of overlay entry: + +```tsx +let longPressTarget: EventTarget | null = null; + +const clearLongPressTimer = () => { + if (longPressTimer !== null) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + + longPressTouchId = null; + longPressStartClientX = 0; + longPressStartClientY = 0; + longPressTarget = null; +}; + +const handleTouchStart = (event: TouchEvent) => { + if (event.touches.length !== 1) { + resetTouchState(); + return; + } + + const touch = getTouchAt(event.touches, 0); + if (!touch) { + resetTouchState(); + return; + } + + stopMomentumScroll(); + state.activeTouchId = touch.identifier; + state.lastClientY = touch.clientY; + state.carryPx = 0; + state.pxPerLine = terminalRef.current + ? getTouchScrollPxPerLine(terminalRef.current, container) + : MOBILE_TOUCH_SCROLL_FALLBACK_PX_PER_LINE; + state.gestureDidScroll = false; + state.samples = []; + recordTouchSample(touch.clientY, performance.now()); + + if (viewport === "mobile" && terminalPreferences.copyOnSelect) { + longPressTouchId = touch.identifier; + longPressStartClientX = touch.clientX; + longPressStartClientY = touch.clientY; + longPressTarget = event.target; + longPressTimer = setTimeout(() => { + const target = longPressTarget; + copyMobileLongPressRef.current(target); + }, MOBILE_COPY_MODE_LONG_PRESS_MS); + } +}; +``` + +Delete the entire overlay-specific path: + +```tsx +// Delete these copy-mode-only refs/state/callbacks/effects entirely: +// - enterMobileCopyModeRef +// - mobileCopyModeActiveRef +// - mobileCopyModeSnapshot state +// - pushCopyModeFailureToast +// - exitMobileCopyMode +// - handleMobileCopyModeBackgroundClick +// - enterMobileCopyMode +// - the effect that syncs enterMobileCopyModeRef +// - the effect that clears mobileCopyModeSnapshot on viewport changes +// - the `setMobileCopyModeSnapshot(null)` branch in the terminalId/workspaceId useLayoutEffect +// - the `if (mobileCopyModeActiveRef.current)` early-return guards in touchstart/touchmove/touchend +// - `getMeasuredTerminalLineHeightPx`, which becomes dead after the snapshot overlay is removed +// +// Replace the terminalId/workspaceId reset effect with: +useLayoutEffect(() => { + resetTouchStateRef.current(); +}, [terminalId, workspaceId]); +// +// Delete the entire JSX block below from the render return: +// {viewport === "mobile" && mobileCopyModeSnapshot ? ( +//
+//
+//
{t("terminal.copy_mode_title")}
+//
{t("terminal.copy_mode_hint")}
+// +//
+//
+//
+//
+//
+// ) : null} +``` + +Add the success toast key and keep the existing failure toast keys in the locale files. + +Patch `packages/web/src/locales/en.json`: + +```json + "create_unavailable_title": "No workspace selected", + "create_unavailable_body": "Open or switch to a workspace before creating a terminal.", + "copied_current_line": "Copied current line", + "hide": "Hide Terminal", +``` + +Patch `packages/web/src/locales/zh.json`: + +```json + "create_unavailable_title": "当前没有已选中的工作区", + "create_unavailable_body": "请先打开或切换到一个工作区,再新建终端。", + "copied_current_line": "已复制当前行", + "hide": "隐藏终端", +``` + +- [ ] **Step 4: Run the mobile host tests to verify they pass** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/features/terminal-panel/__tests__/xterm-host.test.tsx -t "mobile line copy"` + +Expected: PASS with the rewritten mobile long-press tests green. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx packages/web/src/locales/en.json packages/web/src/locales/zh.json +git commit -m "feat(web): copy mobile terminal lines on long press" +``` + +### Task 3: Remove The Obsolete Overlay Files, CSS, And Theme Assertions + +**Files:** +- Delete: `packages/web/src/features/terminal-panel/mobile/copy-mode-snapshot.ts` +- Delete: `packages/web/src/features/terminal-panel/mobile/copy-mode-snapshot.test.ts` +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Replace the old overlay CSS assertion with a removal assertion** + +Patch `packages/web/src/styles/components.theme.test.ts` by deleting the entire `it("mobile terminal copy mode overlay styles", ...)` block and replacing it with: + +```ts + it("does not ship removed mobile terminal copy mode overlay CSS", () => { + expect(stylesheet).not.toContain(".mobile-terminal-copy-mode"); + }); +``` + +- [ ] **Step 2: Run the theme test to verify it fails** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts -t "does not ship removed mobile terminal copy mode overlay CSS"` + +Expected: FAIL because `components.css` still contains the `.mobile-terminal-copy-mode*` rules. + +- [ ] **Step 3: Delete the obsolete helper files, remove overlay CSS, and remove overlay-only locale keys** + +Delete the old overlay helper files: + +```bash +git rm packages/web/src/features/terminal-panel/mobile/copy-mode-snapshot.ts +git rm packages/web/src/features/terminal-panel/mobile/copy-mode-snapshot.test.ts +``` + +Delete this entire CSS block from `packages/web/src/styles/components.css`: + +```css +.mobile-terminal-copy-mode { + position: absolute; + inset: 0; + z-index: 6; + display: flex; + flex-direction: column; + min-height: 0; + box-sizing: border-box; + overflow: hidden; + background: color-mix(in srgb, var(--bg-terminal) 94%, var(--bg-page) 6%); + color: var(--text-primary); + user-select: text; + -webkit-user-select: text; +} + +.mobile-terminal-copy-mode__toolbar { + display: flex; + align-items: center; + gap: var(--sp-2); + padding-inline: var(--sp-3); + padding-bottom: var(--sp-3); +} + +.mobile-terminal-copy-mode__title { + font-size: var(--text-sm); + font-weight: var(--font-semibold); + color: var(--text-primary); +} + +.mobile-terminal-copy-mode__hint { + font-size: var(--text-xs); + color: var(--text-secondary); +} + +.mobile-terminal-copy-mode__done { + margin-left: auto; + flex-shrink: 0; +} + +.mobile-terminal-copy-mode__content { + flex: 1; + min-height: 0; + min-width: 0; + max-width: 100%; + max-height: 100%; + overflow: auto; + padding-inline: var(--sp-3); + user-select: text; + -webkit-user-select: text; + -webkit-touch-callout: default; + -webkit-overflow-scrolling: touch; +} + +.mobile-terminal-copy-mode__text { + display: block; + width: max-content; + max-width: 100%; + min-width: 100%; + margin: 0; + white-space: pre; + user-select: text; + -webkit-user-select: text; +} +``` + +Remove the overlay-only locale keys from the `terminal` group in both locale files. + +Delete these lines from `packages/web/src/locales/en.json`: + +```json + "copy_mode_title": "Copy Mode", + "copy_mode_hint": "Drag to select text", + "copy_mode_done": "Done", + "copy_mode_failed_title": "Couldn't enter copy mode", + "copy_mode_failed_body": "Try again, or scroll the terminal and long press again", +``` + +Delete these lines from `packages/web/src/locales/zh.json`: + +```json + "copy_mode_title": "复制模式", + "copy_mode_hint": "拖动选择文本", + "copy_mode_done": "完成", + "copy_mode_failed_title": "无法进入复制模式", + "copy_mode_failed_body": "请重试,或先滚动终端后再长按", +``` + +- [ ] **Step 4: Run the helper, host, and theme tests together** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/features/terminal-panel/mobile/long-press-copy-line.test.ts src/features/terminal-panel/__tests__/xterm-host.test.tsx src/styles/components.theme.test.ts` + +Expected: PASS with the new helper tests, rewritten `XtermHost` tests, and the CSS removal assertion all green. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/styles/components.css packages/web/src/styles/components.theme.test.ts packages/web/src/locales/en.json packages/web/src/locales/zh.json +git add -u packages/web/src/features/terminal-panel/mobile +git commit -m "refactor(web): remove mobile terminal copy mode overlay" +``` + +### Task 4: Rewrite The Mobile E2E Around Direct Clipboard Copy + +**Files:** +- Modify: `e2e/specs/settings/mobile-copy-on-select.spec.ts` + +- [ ] **Step 1: Rewrite the Playwright spec to capture clipboard writes and dispatch the touch on a real row target** + +Patch `e2e/specs/settings/mobile-copy-on-select.spec.ts` in three places. + +First, add a tiny clipboard probe in `beforeEach`: + +```ts + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + + let copiedText = ""; + Object.defineProperty(window, "__mobileCopiedText", { + configurable: true, + get() { + return copiedText; + }, + }); + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: async (text: string) => { + copiedText = text; + }, + }, + }); + }); + }); +``` + +Second, replace `longPressTerminalRows` so it dispatches the synthetic touch on a nested row target instead of the host: + +```ts +async function longPressTerminalRows(page: Page, rowIndex = 1): Promise { + const row = page.locator(".mobile-sheet--terminal .xterm-rows > div").nth(rowIndex); + const target = row.locator("span").first(); + await expect(target).toBeVisible({ timeout: 10000 }); + + const box = await target.boundingBox(); + expect(box).toBeTruthy(); + if (!box) { + throw new Error("xterm row target bounding box missing"); + } + + const x = box.x + Math.max(4, box.width / 2); + const y = box.y + Math.max(4, box.height / 2); + + await target.evaluate( + (node, { clientX, clientY }) => { + const touches = [{ identifier: 1, clientX, clientY }]; + const buildEvent = ( + type: string, + activeTouches: typeof touches, + changedTouches = activeTouches + ) => { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "touches", { value: activeTouches }); + Object.defineProperty(event, "targetTouches", { value: activeTouches }); + Object.defineProperty(event, "changedTouches", { value: changedTouches }); + return event; + }; + + node.dispatchEvent(buildEvent("touchstart", touches)); + window.setTimeout(() => { + node.dispatchEvent(buildEvent("touchend", [], touches)); + }, 650); + }, + { clientX: x, clientY: y } + ); +} +``` + +Third, replace the two tests at the bottom of the file with direct-copy assertions: + +```ts + test("mobile long press copies the wrapped logical line without opening a copy overlay", async ({ + page, + }) => { + await setMobileCopyOnSelect(page, true); + await openMobileWorkspace(page); + await openMobileTerminalSheet(page); + await ensureTerminalExists(page); + await seedLongTerminalLine(page); + + await expect.poll(async () => { + return page.locator(".mobile-sheet--terminal .xterm-rows > div").count(); + }).toBeGreaterThan(1); + + const rowCount = await page.locator(".mobile-sheet--terminal .xterm-rows > div").count(); + await longPressTerminalRows(page, rowCount - 1); + + await expect( + page.getByText(translateForE2E("terminal.copied_current_line", "en")) + ).toBeVisible({ timeout: 5000 }); + await expect(page.locator(".mobile-terminal-copy-mode")).toHaveCount(0); + + await expect.poll(async () => { + return page.evaluate(() => { + return (window as Window & { __mobileCopiedText?: string }).__mobileCopiedText ?? ""; + }); + }).toBe(LONG_LINE_TEXT); + }); + + test("mobile long press does not copy when copy on select is disabled", async ({ page }) => { + await setMobileCopyOnSelect(page, false); + await openMobileWorkspace(page); + await openMobileTerminalSheet(page); + await ensureTerminalExists(page); + await seedLongTerminalLine(page); + + await longPressTerminalRows(page, 1); + + await expect(page.locator(".mobile-terminal-copy-mode")).toHaveCount(0); + await expect(page.getByText(translateForE2E("terminal.copied_current_line", "en"))).toHaveCount(0); + await expect.poll(async () => { + return page.evaluate(() => { + return (window as Window & { __mobileCopiedText?: string }).__mobileCopiedText ?? ""; + }); + }).toBe(""); + }); +``` + +- [ ] **Step 2: Run the Playwright spec** + +Run: `pnpm --dir e2e exec playwright test --config playwright.config.ts specs/settings/mobile-copy-on-select.spec.ts` + +Expected: PASS with both mobile copy-on-select scenarios green. + +- [ ] **Step 3: Commit** + +```bash +git add e2e/specs/settings/mobile-copy-on-select.spec.ts +git commit -m "test(e2e): cover mobile terminal long-press line copy" +``` + +### Task 5: Final Verification Sweep + +**Files:** +- No new files + +- [ ] **Step 1: Run the targeted web verification suite** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/features/terminal-panel/mobile/long-press-copy-line.test.ts src/features/terminal-panel/__tests__/xterm-host.test.tsx src/styles/components.theme.test.ts` + +Expected: PASS. + +- [ ] **Step 2: Run the targeted mobile e2e spec** + +Run: `pnpm --dir e2e exec playwright test --config playwright.config.ts specs/settings/mobile-copy-on-select.spec.ts` + +Expected: PASS. + +- [ ] **Step 3: Confirm the worktree only contains the intended feature files** + +Run: `git status --short` + +Expected: + +```txt + +``` + +or only the intentional untracked docs files that pre-existed outside this feature if commits have not been created yet. + +- [ ] **Step 4: Push the branch** + +Run: `git push` + +Expected: the current branch updates successfully on `origin`. + +## Self-Review + +- Spec coverage: Task 1 covers target-to-row resolution, viewport mapping, wrapped logical-line reconstruction, and last-segment trimming. Task 2 covers mobile long-press gating by `Copy on select`, direct clipboard copy, success toast, vibration, scroll-cancel behavior, and failure-toast reuse. Task 3 removes the overlay helper, CSS, and locale baggage required by the new design. Task 4 rewrites the mobile e2e away from overlay assertions toward direct clipboard-copy assertions. +- Placeholder scan: no `TODO`, `TBD`, `appropriate`, `similar to`, or deferred implementation placeholders remain. +- Type consistency: the helper API is consistently named `getLogicalLineTextFromTouchTarget`, the new locale key is consistently named `terminal.copied_current_line`, and the clipboard failure path consistently reuses `settings.copy_on_select_failed_*`. + +Plan complete and saved to `docs/superpowers/plans/2026-05-13-mobile-terminal-long-press-line-copy.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? diff --git a/docs/superpowers/specs/2026-05-11-datetime-picker-design.md b/docs/superpowers/specs/2026-05-11-datetime-picker-design.md new file mode 100644 index 000000000..728a7116c --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-datetime-picker-design.md @@ -0,0 +1,259 @@ +# DateTimePicker 组件设计 + +> **版本:** 1.0 +> **日期:** 2026-05-11 +> **状态:** Draft +> **作者:** 技术共同设计 + +## 0. 概述 + +为组件库新增一个共享的 `DateTimePicker` 组件,用于选择日期和时间。组件需支持桌面端和移动端,复用现有的 `Popover` 和 `Sheet` 容器,对外读写本地 `YYYY-MM-DDTHH:mm` 格式的字符串值。 + +### 0.1 目标 + +- 提供一个统一的日期时间选择器,可在多个表单场景复用 +- 桌面端使用 `Popover` 展示选择面板,移动端使用 `Sheet` +- 支持清空、快速选择(今天、明天等)、手动输入时间 +- 对外 API 与现有 `Input`/`Select` 保持一致的风格 + +### 0.2 非目标 + +- 不做完整的日期库级组件(日期范围选择、日历视图切换等) +- 不引入第三方日期库(dayjs、date-fns 等) +- 不支持时区选择(统一使用本地时区) + +## 1. 组件 API + +### 1.1 Props + +```tsx +interface DateTimePickerProps { + // 值:本地时间字符串,格式 YYYY-MM-DDTHH:mm + readonly value: string; + + // 值变化回调 + readonly onValueChange: (value: string) => void; + + // 标签(用于 aria-label 和移动端 sheet title) + readonly label: string; + + // 占位符文本 + readonly placeholder?: string; + + // 是否禁用 + readonly disabled?: boolean; + + // 是否显示清空按钮 + readonly clearable?: boolean; + + // 最小日期 + readonly minDate?: Date; + + // 最大日期 + readonly maxDate?: Date; + + // 自定义类名 + readonly className?: string; + + // 尺寸 + readonly size?: "sm" | "md" | "lg"; + + // 是否无效 + readonly invalid?: boolean; + + // aria-describedby + readonly "aria-describedby"?: string; +} +``` + +### 1.2 值格式 + +- 输入/输出值均为本地时间字符串:`YYYY-MM-DDTHH:mm` +- 例如:`2026-05-11T14:30` +- 空值用空字符串 `""` 表示 +- 组件内部处理 `Date` 对象与字符串之间的转换 + +### 1.3 使用示例 + +```tsx +function ExampleForm() { + const [scheduledAt, setScheduledAt] = useState(""); + + return ( + + ); +} +``` + +## 2. 交互设计 + +### 2.1 桌面端 + +1. 点击触发器打开 `Popover` +2. Popover 内容包含: + - 月份导航(上一月/下一月按钮) + - 日历网格(周日至周六) + - 时间选择器(小时和分钟下拉) + - 快速选择按钮(今天、明天、下周) + - 清空按钮(可选) +3. 选择日期后自动关闭,或点击外部关闭 +4. 键盘支持: + - `Escape` 关闭 + - `Tab` 在面板内导航 + - `Enter` 确认选择 + +### 2.2 移动端 + +1. 点击触发器打开全屏 `Sheet` +2. Sheet 内容包含: + - 月份导航 + - 日历网格 + - 时间选择器(滚轮或下拉) + - 快速选择按钮 + - 底部操作栏:清空 / 确认 +3. 选择后需点击"确认"按钮关闭 +4. 支持返回手势关闭 + +### 2.3 触发器样式 + +- 触发器外观与 `Select` 组件的 listbox 模式一致 +- 显示当前选中的日期时间,或占位符文本 +- 右侧显示日历图标 +- 支持 `invalid` 状态的红色边框 + +## 3. 实现细节 + +### 3.1 文件结构 + +``` +packages/web/src/components/ui/datetime-picker/ +├── index.tsx # 主组件 +├── index.test.tsx # 单元测试 +├── index.module.css # 样式 +├── calendar-grid.tsx # 日历网格子组件 +├── time-selector.tsx # 时间选择子组件 +└── README.md # 文档 +``` + +### 3.2 依赖关系 + +- 复用 `Popover` 组件(桌面端) +- 复用 `Sheet` 组件(移动端) +- 复用 `useViewport` 判断桌面/移动端 +- 复用 `Input` 样式(触发器) +- 使用现有 `formatDate` 函数显示日期 + +### 3.3 日期计算 + +使用原生 `Date` API: +- 不引入第三方日期库 +- 使用 `Date.getMonth()`、`Date.getDate()` 等 +- 使用 `new Date(year, month, day, hour, minute)` 构造 + +### 3.4 样式 + +- 日历网格使用 CSS Grid +- 使用设计系统的间距变量 +- 使用设计系统的颜色变量 +- 日历单元格高度 `44px`(移动端友好) + +## 4. 接入 Supervisor + +### 4.1 替换现有 Input + +在 `objective-dialog-content.tsx` 中: + +```tsx +// 之前 + onDraftScheduledAtChange(event.target.value)} + aria-describedby={scheduledAtHelperId} +/> + +// 之后 + +``` + +### 4.2 保持现有协议 + +- `draftScheduledAt` 仍为 `YYYY-MM-DDTHH:mm` 格式的字符串 +- `onDraftScheduledAtChange` 回调签名不变 +- `parseDraftScheduledAt` 函数无需修改 +- 提交时 `scheduledAt` 仍为时间戳或 `undefined` + +## 5. 测试策略 + +### 5.1 单元测试 + +- 组件渲染正确 +- 值变化时触发回调 +- 清空功能工作正常 +- 日期限制生效 +- 桌面端 Popover 打开/关闭 +- 移动端 Sheet 打开/关闭 +- 键盘导航 +- 无障碍属性正确 + +### 5.2 集成测试 + +- 在 Supervisor 表单中使用 +- 值正确传递到父组件 +- 提交时 payload 正确 + +## 6. 国际化 + +### 6.1 新增翻译 Key + +```json +{ + "datetime": { + "today": "Today", + "tomorrow": "Tomorrow", + "next_week": "Next Week", + "clear": "Clear", + "confirm": "Confirm", + "select_date": "Select Date", + "select_time": "Select Time", + "january": "January", + "february": "February" + } +} +``` + +### 6.2 日期显示格式 + +使用现有 `formatDate` 函数,根据当前 locale 显示日期: +- 中文:2026年5月11日 14:30 +- 英文:May 11, 2026, 2:30 PM + +## 7. 后续扩展 + +### 7.1 可能的扩展点 + +- 日期范围选择(DateRangePicker) +- 时间范围选择(TimeRangePicker) +- 年份选择器 +- 月份选择器 +- 自定义日期格式 + +### 7.2 不在当前范围 + +这些扩展点留待后续需求驱动时再设计实现。 diff --git a/docs/superpowers/specs/2026-05-11-supervisor-execution-policy-design.md b/docs/superpowers/specs/2026-05-11-supervisor-execution-policy-design.md new file mode 100644 index 000000000..f46bb31c5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-supervisor-execution-policy-design.md @@ -0,0 +1,489 @@ +# Supervisor Execution Policy Design + +## 1. Goal + +Extend the current supervisor feature so a single supervisor session can: + +- cap total supervision cycles with `maxSupervisionCount` +- optionally schedule one extra automatic run at a specific date/time +- optionally override the evaluator model per supervisor +- pause even while evaluation/injection/retry wait is in progress +- stop permanently once the evaluator declares the objective complete +- use global retry settings for timeout/evaluator failures +- preserve existing data through a lightweight `v1 -> v2` database upgrade + +This design intentionally keeps the new behavior narrow: + +- retry policy stays global in Settings +- `turn_completed` auto-trigger remains always-on and is not configurable +- scheduled execution is one-shot, not cron and not fixed interval +- unknown database versions are not auto-repaired; the app offers delete-and-rebuild instead + +## 2. Current Baseline + +Current supervisor behavior is implemented mainly in: + +- `packages/server/src/supervisor/manager.ts` +- `packages/server/src/supervisor/scheduler.ts` +- `packages/server/src/supervisor/evaluator.ts` +- `packages/server/src/storage/repositories/supervisor-repo.ts` +- `packages/server/src/storage/repositories/supervisor-cycle-repo.ts` +- `packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx` + +Today: + +- a supervisor stores `objective` and `evaluatorProviderId` +- auto-run happens only on `turn_completed` +- evaluator timeout is global +- retry does not exist +- scheduled execution does not exist +- pausing during an in-flight evaluation is not supported +- evaluator model is inherited from provider config only +- returning `"[objective complete]"` does not stop the whole supervisor lifecycle + +## 3. Requirements + +### 3.1 Per-supervisor persisted fields + +Each supervisor must persist: + +- `objective` +- `evaluatorProviderId` +- `evaluatorModel` as an optional override +- `maxSupervisionCount` +- `scheduledAt` as an optional one-shot timestamp + +### 3.2 Global settings + +Retry policy remains global and is read from Settings: + +- `supervisor.evaluationTimeoutSec` +- `supervisor.retryEnabled` +- `supervisor.retryMaxCount` +- `supervisor.retryDelaySec` +- `supervisor.retryOnTimeout` +- `supervisor.retryOnEvaluatorError` + +### 3.3 Behavior rules + +- `turn_completed` automatic supervision remains enabled by default and is not configurable. +- If `scheduledAt` is configured, that timestamp triggers one extra automatic cycle. +- `maxSupervisionCount = 0` means unlimited, preserving current behavior. +- If evaluator returns `"[objective complete]"`, the current cycle ends successfully and the whole supervisor stops permanently. +- If the evaluator has not declared completion and retry policy still allows retry, retry continues until: + - retry succeeds + - retry budget is exhausted + - user pauses + - user deletes supervisor + - session disappears +- Pausing must work while evaluating, injecting, or waiting for the next retry. + +## 4. Configuration Ownership + +### 4.1 Settings page + +Settings owns environment-wide execution defaults and safety limits: + +- evaluator timeout +- retry enablement +- retry count +- retry delay +- retry on timeout +- retry on evaluator error + +Settings does not own: + +- supervisor model override +- max supervision count +- scheduled execution time + +### 4.2 Supervisor session + +The objective dialog and the stored supervisor record own: + +- objective +- evaluator provider +- optional evaluator model override +- max supervision count +- optional scheduled execution time + +No supervisor-local retry settings are introduced in this phase. + +## 5. Trigger Model + +Supervisor cycles can start from three trigger sources: + +- `manual` +- `turn_completed` +- `scheduled` + +`turn_completed` remains the default continuous trigger. `scheduledAt` adds a one-shot trigger and does not disable `turn_completed`. + +If `scheduledAt` fires successfully, the field is consumed and cleared. The scheduled trigger does not repeat. + +## 6. Completion and Stop Semantics + +### 6.1 Objective-complete handling + +`packages/server/src/supervisor/evaluator.ts` already instructs the evaluator to return `"[objective complete]"` when the objective is done. This must become a first-class control path. + +When that sentinel is returned: + +- do not inject more work +- do not retry +- finalize the current cycle as successful +- stop the whole supervisor + +### 6.2 Max supervision count + +`maxSupervisionCount` counts completed cycle starts, not retry attempts. + +- `0` means unlimited +- positive values mean hard cap + +Before a new cycle starts, the manager checks the limit. If the cap is reached: + +- no new cycle is created +- supervisor transitions to stopped +- stop reason is recorded + +### 6.3 New supervisor stop state + +Add a terminal supervisor state: + +- `stopped` + +Add a persisted stop reason: + +- `objective_complete` +- `max_supervision_count_reached` +- `null` + +`stopped` is distinct from `paused`: + +- `paused` means user temporarily halted execution +- `stopped` means the supervisor has logically finished and should no longer auto-run + +## 7. Pause Semantics + +Current `pause()` only flips state. It must also interrupt active work. + +New pause behavior: + +- if evaluating: abort evaluator process immediately +- if injecting: abort the in-flight sequence as soon as possible and prevent further retry +- if waiting between retries: cancel the timer immediately +- final supervisor state becomes `paused` + +Cycle result for a user-initiated interruption should be: + +- `cancelled` + +This avoids conflating user intent with actual evaluator failure. + +## 8. Retry Model + +Retry is global, but execution is cycle-local. + +Each new cycle snapshots the current retry settings at cycle start. Mid-cycle Settings changes do not affect already-running retries. + +### 8.1 Retry scope + +Automatic retry applies only to: + +- evaluator timeout +- evaluator process/runtime failure + +Automatic retry does not apply to: + +- injector failure +- invalid provider configuration +- missing session/supervisor +- user pause/delete + +### 8.2 Retry loop + +One cycle contains one or more attempts: + +1. build context +2. run evaluator +3. if evaluator returns `"[objective complete]"`, stop supervisor +4. if evaluator returns actionable text, continue into injection +5. if evaluator fails and retry policy allows, wait `retryDelaySec` +6. rerun evaluator inside the same cycle + +### 8.3 Attempt recording + +Add a new table to persist attempt history per cycle: + +- `supervisor_cycle_attempts` + +Suggested columns: + +- `id TEXT PRIMARY KEY` +- `cycle_id TEXT NOT NULL` +- `attempt_index INTEGER NOT NULL` +- `status TEXT NOT NULL` +- `started_at INTEGER NOT NULL` +- `completed_at INTEGER` +- `error_reason TEXT` +- `provider_model TEXT` + +This keeps the cycle as the user-facing supervision event while preserving retry detail for debugging and later UI. + +## 9. Model Override + +Each supervisor may optionally provide an evaluator model override. + +Rules: + +- if `evaluatorModel` is empty or null, use current provider config behavior unchanged +- if `evaluatorModel` is set, it overrides the provider config model only for supervisor evaluation +- it does not affect the business agent session model + +Implementation impact: + +- `packages/server/src/supervisor/evaluator.ts` builds an effective provider config for evaluation +- provider command builders receive the overridden model if supported + +UI should not try to ship a provider-specific model catalog in this phase. A plain optional text input is enough. + +## 10. Scheduler Design + +Current scheduler only subscribes to `session.lifecycle.turn_completed`. + +It should be extended to support: + +- event-driven trigger on `turn_completed` +- one-shot timestamp trigger on `scheduledAt` + +Recommended implementation: + +- keep the existing event-bus subscription +- add an in-memory nearest-deadline timer +- on hydrate/create/update/delete/pause/resume/stop, recalculate the next due `scheduledAt` + +Do not implement: + +- cron parsing +- fixed-interval polling loops +- per-second global scan + +## 11. Data Model Changes + +### 11.1 Core domain + +Update `packages/core/src/domain/supervisor.ts`: + +- add `scheduled` to `CycleTrigger` +- add `cancelled` to `CycleStatus` +- add `stopped` to `SupervisorState` +- add `evaluatorModel?: string` +- add `maxSupervisionCount: number` +- add `completedSupervisionCount: number` +- add `scheduledAt?: number` +- add `stopReason?: "objective_complete" | "max_supervision_count_reached"` + +### 11.2 Storage schema + +Update `supervisors` table: + +- `evaluator_model TEXT` +- `max_supervision_count INTEGER NOT NULL DEFAULT 0` +- `completed_supervision_count INTEGER NOT NULL DEFAULT 0` +- `scheduled_at INTEGER` +- `stop_reason TEXT` + +Add new table `supervisor_cycle_attempts`. + +`supervisor_cycles` remains the top-level event record. It may need enum/test updates for `scheduled` and `cancelled`, but not necessarily new columns in this phase. + +## 12. Database Upgrade Strategy + +This work must support migration/upgrade. It should stay deliberately lightweight. + +### 12.1 Supported path + +Only support: + +- empty database -> latest schema +- known `v1` supervisor schema -> `v2` + +Do not restore the old `_migrations` chain and do not add generic migration scanning. + +### 12.2 Startup decision matrix + +On startup: + +1. empty database +- initialize latest schema + +2. known `v1` +- run automatic `v1 -> v2` upgrade + +3. latest `v2` +- continue normally + +4. unknown or incompatible schema +- do not guess +- surface a rebuild choice to the user + +### 12.3 Unknown schema handling + +Keep it simple: + +- backend returns a specific incompatible-schema startup error +- UI or CLI offers: + - delete and rebuild database + - cancel + +No automatic repair, no automatic backup flow, no generic downgrade path. + +### 12.4 `v1 -> v2` upgrade contents + +Upgrade should be explicit and transactional: + +- add new columns on `supervisors` +- create `supervisor_cycle_attempts` +- initialize defaults: + - `max_supervision_count = 0` + - `completed_supervision_count = 0` + - `stop_reason = NULL` +- set new schema version marker + +After upgrade, run normal latest-schema validation. + +## 13. Command and API Changes + +Update `packages/server/src/commands/supervisor.ts`: + +- `supervisor.create` + - accept optional `evaluatorModel` + - accept `maxSupervisionCount` + - accept optional `scheduledAt` +- `supervisor.update` + - same fields as mutable edits +- `supervisor.get` + - return new fields + +Validation: + +- `maxSupervisionCount` must be an integer `>= 0` +- `scheduledAt` must be a valid timestamp or omitted +- `evaluatorModel` may be empty in the UI but should persist as `null` + +## 14. Frontend Changes + +Update the shared objective dialog: + +- keep objective textarea +- keep evaluator provider selection +- add optional model input +- add max supervision count input +- add one-shot schedule input + +Settings page gains a bounded retry configuration section: + +- retry enabled +- retry count +- retry delay +- retry on timeout +- retry on evaluator error +- evaluation timeout + +Supervisor card and mobile sheet should also understand: + +- `stopped` state +- stop reason display +- cancelled latest cycle + +## 15. State Machine Summary + +### 15.1 Supervisor + +Main states after this change: + +- `idle` +- `evaluating` +- `injecting` +- `paused` +- `error` +- `stopped` + +Transitions: + +- `idle -> evaluating` +- `evaluating -> injecting` +- `evaluating -> paused` on user pause +- `injecting -> paused` on user pause +- `evaluating/injecting -> error` on unrecoverable failure +- `evaluating/injecting -> stopped` on objective completion +- `idle -> stopped` on max supervision cap reached before next cycle + +### 15.2 Cycle + +Cycle statuses after this change: + +- `evaluating` +- `completed` +- `injected` +- `failed` +- `cancelled` + +Retry attempts do not create new cycles. + +## 16. Out of Scope + +This phase explicitly does not include: + +- cron expressions +- repeating intervals +- per-supervisor retry policy +- provider-specific model discovery UI +- generic database migration framework reintroduction +- automatic repair of unknown database schemas + +## 17. Testing + +### 17.1 Storage and startup + +- empty DB initializes directly to latest schema +- known `v1` DB upgrades to `v2` +- unknown DB surfaces incompatible-schema rebuild flow +- upgraded DB passes latest schema validation + +### 17.2 Manager behavior + +- `maxSupervisionCount = 0` behaves as unlimited +- cap reached stops supervisor +- `"[objective complete]"` stops supervisor immediately +- retry obeys global Settings +- retry only applies to configured evaluator failure classes +- pause during evaluation aborts current work and lands in `paused` +- pause during retry wait cancels pending retry + +### 17.3 Scheduler + +- `turn_completed` still triggers automatically +- `scheduledAt` triggers once and is consumed +- stopped supervisors do not auto-trigger again +- paused supervisors do not auto-trigger until resumed + +### 17.4 Frontend + +- objective dialog submits and rehydrates new fields +- settings page saves retry settings +- supervisor card renders stopped state and reason +- mobile supervisor sheet mirrors the same behavior + +## 18. Recommended Implementation Order + +1. add lightweight DB version upgrade support for `v1 -> v2` +2. update core domain types +3. update storage schema and repositories +4. extend evaluator model override support +5. refactor manager into cycle + retry execution flow +6. extend scheduler with one-shot time trigger +7. wire WS commands and validation +8. update supervisor dialog and settings UI +9. add tests across startup, manager, scheduler, commands, and UI diff --git a/docs/superpowers/specs/2026-05-11-theme-skins-design.md b/docs/superpowers/specs/2026-05-11-theme-skins-design.md new file mode 100644 index 000000000..2763cd373 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-theme-skins-design.md @@ -0,0 +1,565 @@ +# Theme Skins System — Design + +Date: 2026-05-11 +Status: Draft +Owner: spencer + +## Problem + +当前项目的外观系统只正式支持 `dark` 和 `light` 两种主题,并且这两个值已经被写入多个层面: + +- Web UI 通过 [`tokens.css`](../../../packages/web/src/styles/tokens.css) 的 `[data-theme="light"]` 覆盖实现明暗切换 +- 应用状态通过 [`themeAtom`](../../../packages/web/src/atoms/app-ui.ts) 持久化 `dark | light` +- 设置页通过 [`settings.update`](../../../packages/server/src/commands/settings.ts) 写入 `appearance.theme` +- 终端在 [`XtermHost`](../../../packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx) 内维护独立的 dark/light xterm palette +- Monaco 编辑器在 [`MonacoHost`](../../../packages/web/src/features/code-editor/components/monaco-host.tsx) 内根据 `dark | light` 选择 `vs-dark` 或 `vs` +- UI preview、e2e-ui 截图和相关测试基建也把主题维度写成了 `dark | light` + +这使得“再加几套皮肤”并不是一个只改 CSS 的问题。如果继续把更多主题直接堆在 `theme: "dark" | "light"` 上,UI、终端、Monaco、预览和测试都会迅速退化为大量字符串分支判断,后续难以维护。 + +本次需要把当前的明暗主题升级为可扩展的皮肤系统,同时保持用户侧设置足够简单,避免把产品交互直接升级成复杂的主题编辑器。 + +## Goals + +- 支持多个内置官方皮肤,而不止 `dark` / `light` 两种外观。 +- 用户侧仍然只选择一个当前主题值,设置模型保持简单。 +- 每个主题都能同时驱动: + - Web UI token + - xterm 终端配色 + - Monaco 编辑器配色 +- 支持成对主题族,例如 `Mint Dark / Mint Light`。 +- 服务端设置为权威来源,本地只做启动缓存和首屏加速。 +- 第一阶段仅支持内置官方皮肤,但架构上预留后续扩展空间。 +- 至少包含一组高对比度主题,作为可访问性的一等公民。 + +## Non-Goals + +- 不实现用户自定义主题编辑器。 +- 不在第一阶段支持从外部文件、插件或远端下载主题。 +- 不让用户分别独立选择“UI 主题 / 终端主题 / 编辑器主题”。 +- 不在第一阶段实现“跟随系统自动切换 light/dark”。 +- 不重写整个组件样式体系;现有组件仍然通过共享语义 token 消费颜色。 + +## User Decisions Captured + +- 外观系统目标不是简单增加几个 `dark/light` 变体,而是支持“更多皮肤”。 +- 用户侧不暴露 `mode + skin` 两层设置,仍然保持单一当前主题选择。 +- 主题需要覆盖 UI、终端和 Monaco 三层。 +- 第一阶段只做内置官方皮肤,但架构上预留未来扩展能力。 +- 主题权威来源为服务端设置,本地只做缓存。 +- 首批官方主题采用“成对主题族”组织。 +- 第一阶段应包含高对比度主题。 + +## Approaches Considered + +### Option A: 继续扩展现有 `theme` 字段,直接使用复合字符串 + +例如: + +- `dark-mint` +- `light-mint` +- `dark-nord` +- `hc-dark` + +优点: + +- 表面改动最少。 +- 用户侧仍然只有一个当前主题值。 + +缺点: + +- 主题名本身会承载过多语义。 +- UI、终端、Monaco、测试都容易演化为直接对字符串做分支判断。 +- 后续加入高对比度、系统跟随、主题族切换时,代码耦合会迅速增加。 + +### Option B: 用户侧单 `themeId`,内部建立中央主题注册表(推荐) + +优点: + +- 用户体验接近 VS Code,设置简单。 +- 内部仍然可以显式声明 `kind`、`family`、可访问性标签、xterm palette、Monaco theme 等元数据。 +- 所有消费方共享同一份主题解析结果,避免字符串散落。 +- 与当前代码结构兼容,不需要把产品交互升级成双字段模型。 + +缺点: + +- 需要一次性改造类型、设置、预览和测试基建。 +- 需要为每个主题显式维护 UI/xterm/Monaco 三套定义。 + +### Option C: 用户分别选择 UI / Terminal / Editor 三套主题 + +优点: + +- 灵活度最高。 +- 可覆盖少数用户对 UI 与终端风格不一致的偏好。 + +缺点: + +- 交互复杂度明显上升。 +- 设置模型和测试矩阵膨胀。 +- 超出当前“增加皮肤支持”的范围。 + +## Final Choice + +采用 Option B。 + +最终模型为: + +- 用户侧和持久化层只保留单一 `themeId` +- 主题的 `dark/light/high-contrast` 等语义不作为独立用户设置暴露,而是作为每个主题定义的元数据 +- 所有 UI、xterm、Monaco、预览和测试都通过中央主题注册表解析主题,而不是自行判断字符串 + +这能同时满足以下目标: + +- 对用户简单 +- 对实现可扩展 +- 对未来增加主题族、高对比度主题、系统跟随和外部扩展都留有空间 + +## Final Design + +### 1. Settings Model + +新增权威设置键: + +- `appearance.themeId: string` + +本地缓存键: + +- `ui.themeId: string` + +兼容旧值: + +- `appearance.theme: "dark" | "light"` +- `ui.theme: "dark" | "light"` + +兼容映射规则: + +- `dark -> mint-dark` +- `light -> mint-light` + +说明: + +- 服务端 `appearance.themeId` 是长期权威来源 +- 浏览器本地 `ui.themeId` 只用于应用启动时的快速恢复和减少首屏闪烁 +- 一旦新设置链路完成保存,前端与服务端都应优先写入和读取 `themeId` +- 旧字段在迁移期继续容忍读取,但不再作为新的主写入路径 + +### 2. Theme Registry + +新增中央主题注册表模块,负责声明和导出所有内置主题定义。 + +每个主题定义至少包含: + +- `id` +- `family` +- `kind` +- `labelKey` +- `pairedThemeId` +- `isHighContrast` +- `documentThemeAttr` +- `terminalTheme` +- `monaco` + +建议类型形状: + +```ts +type ThemeKind = "dark" | "light" | "hc-dark" | "hc-light"; + +interface MonacoThemeDefinition { + id: string; + base: "vs" | "vs-dark"; + inherit: boolean; + colors: Record; + rules: monaco.editor.ITokenThemeRule[]; +} + +interface AppThemeDefinition { + id: string; + family: "mint" | "graphite" | "nord" | "hc"; + kind: ThemeKind; + labelKey: string; + pairedThemeId?: string; + isHighContrast: boolean; + documentThemeAttr: string; + terminalTheme: ITheme; + monaco: MonacoThemeDefinition; +} +``` + +关键原则: + +- 用户设置只保存 `id` +- 所有 `dark/light/high-contrast` 语义都从 registry 元数据推导 +- 不允许 UI、终端、Monaco 自己重新发明主题判定逻辑 + +### 3. Theme Families + +第一阶段内置 4 个 family: + +- `Mint` +- `Graphite` +- `Nord` +- `High Contrast` + +第一阶段主题列表: + +- `mint-dark` +- `mint-light` +- `graphite-dark` +- `graphite-light` +- `nord-dark` +- `nord-light` +- `hc-dark` +- `hc-light` + +主题族要求: + +- 常规 family 默认成对提供 dark/light 两个主题 +- 高对比度 family 也提供 dark/light 两个主题 +- registry 中为每个主题声明 `pairedThemeId` +- 设置页通过 `family + variant` 交互帮助用户切换,但最终持久化仍写单一 `themeId` + +### 4. Web UI Token Strategy + +现有 [`tokens.css`](../../../packages/web/src/styles/tokens.css) 架构保留,但从“仅 light 覆盖 dark 默认”升级为“按 `data-theme` 定义主题”。 + +组织方式: + +- `:root` + - 只保留不随皮肤变化的基础 token + - 例如 spacing、radius、font、z-index、touch、尺寸 +- `:root, [data-theme="mint-dark"]` + - 作为默认主题的完整颜色 token +- 其他主题分别使用独立 block 覆盖: + - `[data-theme="mint-light"]` + - `[data-theme="graphite-dark"]` + - `[data-theme="graphite-light"]` + - `[data-theme="nord-dark"]` + - `[data-theme="nord-light"]` + - `[data-theme="hc-dark"]` + - `[data-theme="hc-light"]` + +组件层继续只使用语义 token,例如: + +- `--bg-surface` +- `--text-primary` +- `--border-focus` +- `--color-success` + +组件层不感知具体主题 ID。 + +### 5. Terminal Theme Strategy + +终端配色不能依赖 CSS token 自动推导。 + +原因: + +- xterm 需要独立定义 ANSI palette +- 可读性约束比普通 UI 更严格 +- 高对比度主题往往需要单独人工校准 + +因此每个主题定义必须显式提供一套 `terminalTheme`,由 registry 统一管理。 + +[`XtermHost`](../../../packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx) 只消费解析后的结果: + +- 不再直接维护 `dark` / `light` 两套主题常量 +- 不再以 `uiTheme === "light"` 之类的条件决定终端颜色 +- 统一改为 `resolveAppTheme(themeId).terminalTheme` + +### 6. Monaco Theme Strategy + +Monaco 也不应只做 `vs` / `vs-dark` 二选一。 + +推荐策略: + +- 每个主题定义提供: + - `base: "vs" | "vs-dark"` + - `colors` + - `rules` +- 运行时通过 registry 注册和切换 Monaco 主题 + +[`MonacoHost`](../../../packages/web/src/features/code-editor/components/monaco-host.tsx) 改为: + +- 不再通过 `uiTheme === "light" ? "vs" : "vs-dark"` 决定主题 +- 启动时确保对应主题已通过 `monaco.editor.defineTheme()` 注册 +- 切换时调用 `monaco.editor.setTheme(resolvedTheme.monaco.id)` + +说明: + +- `kind` 仍然有价值,因为它决定 Monaco 主题应继承 `vs` 还是 `vs-dark` +- 但最终是否高可读、是否与 UI 一致,仍需每个主题独立人工校准 + +### 7. Runtime Resolution Flow + +新增统一主题解析模块,职责只做“加载、解析、应用”,不与设置页具体 UI 耦合。 + +启动阶段: + +1. 读取本地 `ui.themeId` +2. 若不存在,则读取旧 `ui.theme` +3. 映射得到当前主题 +4. 立即写入 `document.documentElement.dataset.theme` + +连接后同步阶段: + +1. 调用 `settings.get` +2. 优先读取 `appearance.themeId` +3. 若不存在,则读取旧 `appearance.theme` +4. 映射得到当前主题 +5. 更新运行态 atom +6. 回写本地 `ui.themeId` + +设置变更阶段: + +1. 设置页本地立即切换当前主题 +2. 更新 `document.documentElement.dataset.theme` +3. 调用 `settings.update({ appearance: { themeId } })` +4. 保存成功后维持现状 +5. 保存失败时保留即时切换行为,后续如有需要可补充 toast 或回滚策略 + +### 8. Settings Page Interaction + +用户侧仍然只持久化单一 `themeId`,但设置页交互采用“两段式选择”以适配成对主题族。 + +分组 1: + +- Theme Family + - Mint + - Graphite + - Nord + - High Contrast + +分组 2: + +- Variant + - Dark + - Light + +交互规则: + +- 当前 `themeId` 先解析出 `family` 与 `kind` +- 切换 family 时: + - 若目标 family 存在当前 kind 对应主题,则切到该主题 + - 例如 `mint-dark -> nord-dark` +- 切换 variant 时: + - 在当前 family 内切到对应主题 + - 例如 `graphite-dark -> graphite-light` +- 若未来某个 family 只提供一个 variant,则设置页需要对不可用 variant 做禁用或隐藏处理 + +持久化规则保持不变: + +- 最终写入的始终是 `appearance.themeId` + +### 9. Backward Compatibility + +本次不要求数据库迁移脚本强制清洗旧数据。 + +采用“读兼容、写新值”的渐进式迁移: + +- `settings.get` + - 返回新旧字段时,前端优先消费 `appearance.themeId` +- `settings.update` + - 接受新字段 `appearance.themeId` + - 迁移期内可继续接受旧 `appearance.theme` +- 前端 atom 和本地缓存 + - 优先使用 `ui.themeId` + - 缺省时回退到旧 `ui.theme` + +迁移结果: + +- 老用户升级后自动落到默认主题族对应值 +- 无需一次性变更 `user_settings` 中所有历史记录 +- 系统可以在后续版本再决定是否完全移除旧字段读取逻辑 + +### 10. Preview and Screenshot Model + +当前 UI preview 与 e2e-ui 基建都把主题维度定义为 `dark | light`,需要同步升级。 + +涉及模块包括: + +- [`packages/web/src/ui-preview/scene-metadata.ts`](../../../packages/web/src/ui-preview/scene-metadata.ts) +- [`e2e-ui/scenes/index.ts`](../../../e2e-ui/scenes/index.ts) +- [`e2e-ui/fixtures/prefs.ts`](../../../e2e-ui/fixtures/prefs.ts) +- [`e2e-ui/fixtures/scene-runner.ts`](../../../e2e-ui/fixtures/scene-runner.ts) + +升级策略: + +- 把 scene 维度从 `theme: "dark" | "light"` 升级为 `themeId` +- 但不要求每个 scene 跑所有主题 +- 每个 scene 可以显式声明需要覆盖的主题集 + +默认建议: + +- 常规场景只跑少量代表主题: + - `mint-dark` + - `mint-light` + - `hc-dark` +- 外观设置页、welcome、workspace 主界面等重点视觉场景可额外覆盖 `graphite-*` 与 `nord-*` + +目标: + +- 保持截图基建可扩展 +- 避免主题增多后测试矩阵出现全量笛卡尔积爆炸 + +### 11. Testing Strategy + +测试分三层控制。 + +#### 11.1 Registry and Token Tests + +增加单元测试验证: + +- 所有主题 ID 唯一 +- 每个主题都声明了 `kind` +- 每个主题都具备 `terminalTheme` +- 每个主题都具备 `monaco` 定义 +- 每个主题的 `pairedThemeId` 指向有效主题 +- `tokens.css` 中存在对应的 `[data-theme="..."]` block + +#### 11.2 App Behavior Tests + +关键功能测试覆盖: + +- 设置页能够展示 theme family 和 variant 选择 +- 切换后立即更新 `document.documentElement.dataset.theme` +- 刷新后能恢复服务端保存的 `themeId` +- 终端在主题切换后更新 palette +- Monaco 在主题切换后更新 editor theme +- 旧值 `dark` / `light` 能正确映射到默认主题 + +#### 11.3 E2E / Visual Coverage + +不要求所有业务流程跑全量主题。 + +最小覆盖建议: + +- 默认主题:`mint-dark` +- 非默认常规主题:例如 `graphite-light` +- 高对比度主题:`hc-dark` + +这样既能验证核心能力,也不会让 e2e 成本失控。 + +## Architecture + +```text +server settings (appearance.themeId) + | + v +theme state / local startup cache (ui.themeId) + | + v +central theme registry + resolver + | | | + | | | + v v v + document xterm.js Monaco + data-theme palette theme +``` + +## Implementation Notes + +### Server + +修改: + +- [`packages/server/src/commands/settings.ts`](../../../packages/server/src/commands/settings.ts) +- 如有需要,补充 `@coder-studio/core` 中 settings 类型定义 + +工作内容: + +- 扩展 `appearance` schema,支持 `themeId?: string` +- 保留迁移期对旧 `appearance.theme` 的兼容读取能力 +- 更新服务端设置相关测试,覆盖新旧值兼容场景 + +### Web State and Bootstrap + +修改: + +- [`packages/web/src/atoms/app-ui.ts`](../../../packages/web/src/atoms/app-ui.ts) +- [`packages/web/src/app/providers.tsx`](../../../packages/web/src/app/providers.tsx) + +工作内容: + +- 将当前 `themeAtom` 从 `dark | light` 升级为 `themeId` +- 增加旧值映射逻辑 +- 在启动和 `settings.get` 回填时统一应用主题 + +### Theme Registry + +新增建议: + +- `packages/web/src/theme/index.ts` +- `packages/web/src/theme/registry.ts` +- `packages/web/src/theme/resolve.ts` + +工作内容: + +- 定义主题类型 +- 注册首批内置主题 +- 提供 `resolveAppTheme(themeId)`、`getThemeFamily(themeId)` 等辅助函数 + +### UI Tokens + +修改: + +- [`packages/web/src/styles/tokens.css`](../../../packages/web/src/styles/tokens.css) +- 相关 token 测试文件 + +工作内容: + +- 将颜色 token 从现有 `dark + light` 覆盖升级为多主题 block +- 保持组件层 token 使用方式不变 + +### Settings UI + +修改: + +- [`packages/web/src/features/settings/components/settings-page.tsx`](../../../packages/web/src/features/settings/components/settings-page.tsx) +- [`packages/web/src/locales/zh.json`](../../../packages/web/src/locales/zh.json) +- [`packages/web/src/locales/en.json`](../../../packages/web/src/locales/en.json) + +工作内容: + +- 将当前“深色 / 浅色”按钮升级为 `family + variant` 两组选择 +- 仍然只保存 `themeId` +- 补充高对比度相关文案 + +### Terminal and Monaco + +修改: + +- [`packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx`](../../../packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx) +- [`packages/web/src/features/code-editor/components/monaco-host.tsx`](../../../packages/web/src/features/code-editor/components/monaco-host.tsx) + +工作内容: + +- 移除直接依赖 `dark | light` 的逻辑 +- 统一改为消费解析后的主题定义 +- 为 Monaco 注册并切换命名主题 + +### Preview and E2E UI + +修改: + +- [`packages/web/src/ui-preview/scene-metadata.ts`](../../../packages/web/src/ui-preview/scene-metadata.ts) +- [`e2e-ui/scenes/index.ts`](../../../e2e-ui/scenes/index.ts) +- [`e2e-ui/fixtures/prefs.ts`](../../../e2e-ui/fixtures/prefs.ts) +- [`e2e-ui/fixtures/scene-runner.ts`](../../../e2e-ui/fixtures/scene-runner.ts) + +工作内容: + +- 主题维度从 `dark | light` 升级为 `themeId` +- 按 scene 控制截图主题集合 + +## Rollout Plan + +推荐按以下顺序落地: + +1. 引入 `themeId` 模型和中央 registry,但先只注册 `mint-dark / mint-light` +2. 打通服务端设置、本地缓存和前端启动兼容迁移 +3. 接入 xterm 和 Monaco 主题解析 +4. 改造设置页交互为 `family + variant` +5. 扩充 `graphite / nord / high contrast` +6. 升级 preview、e2e-ui 和相关测试矩阵 + +这样可以先把主题架构站稳,再逐步增加实际皮肤数量,避免在第一步就把改动面拉到最大。 diff --git a/docs/superpowers/specs/2026-05-12-icon-theme-design.md b/docs/superpowers/specs/2026-05-12-icon-theme-design.md new file mode 100644 index 000000000..60fd36d62 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-icon-theme-design.md @@ -0,0 +1,448 @@ +# Icon Theme System — Design + +Date: 2026-05-12 +Status: Draft +Owner: spencer + +## Problem + +当前项目已经完成多主题皮肤基础设施,主题通过 [`tokens.css`](../../../packages/web/src/styles/tokens.css) 和 `themeId` 驱动 UI、终端与 Monaco。 + +但 icon 仍然存在两个明显问题: + +- 一部分 icon 继续自然继承 `currentColor`,这是合理的。 +- 另一部分 icon 的颜色和容器样式散落在 [`components.css`](../../../packages/web/src/styles/components.css) 中,直接绑定 `--text-*`、`--accent-*`、`--color-*` 等 token。 + +这导致 icon 在主题系统里还不是一等能力,具体表现为: + +- 文件树、git 状态、toast、空态、设置入口等 icon 的视觉语义分散在业务类里。 +- 浅色主题容易残留“深色时代”的 icon 观感,例如颜色过闷、容器偏重、和文本层级关系不自然。 +- 新增主题时,icon 往往只能机械复用文本色或 accent 色,难以形成真正有区分度的皮肤感。 +- e2e-ui 虽然已经支持多主题截图,但 icon 差异还没有被系统性建模和验证。 + +本次设计需要把 icon 样式纳入现有主题系统,但要保持范围克制,避免把问题升级成整套 icon 组件体系重写。 + +## Goals + +- 让 icon 颜色与轻量 surface 成为主题系统的一等能力。 +- 保持大多数普通 icon 继续通过 `currentColor` 工作,不打破现有简单路径。 +- 为“故意不等于文本色”的 icon 引入统一 token 分层,而不是继续在业务 CSS 中散落直连颜色。 +- 让不同主题 family 在文件类型、git 状态、状态提示、空态 icon 等场景上具备稳定且可区分的视觉结果。 +- 为浅色主题补齐更合理的 icon 与 icon 容器配色,减少违和的深色遗留感。 +- 与现有 `themeId`、`tokens.css`、`base.css`、`components.css`、e2e-ui 架构保持一致。 + +## Non-Goals + +- 不在本期引入用户自定义 icon 主题编辑器。 +- 不在本期支持按主题切换不同 icon pack。 +- 不在本期把 `outline / filled`、`stroke width`、圆角风格等 icon 形态差异纳入主题。 +- 不在本期引入全局 `ThemedIcon` React 包装器或 icon registry。 +- 不要求把所有 icon 都从 `currentColor` 改成主题 token。 + +## User Decisions Captured + +- icon 应该纳入现有主题系统,而不是继续作为零散 CSS 例外存在。 +- 第一阶段优先覆盖“语义层 + 业务层”两级 icon 主题化。 +- 纯按钮 icon、纯文本附属 icon 等继续沿用 `currentColor`。 +- 需要同时考虑带小底片或容器感的 icon 区块,而不只是 SVG 前景色。 +- 方案应优先复用现有 token 架构,不先引入新的 React 抽象。 + +## Approaches Considered + +### Option A: 只补 token,不增加任何复用层 + +做法: + +- 只在 [`tokens.css`](../../../packages/web/src/styles/tokens.css) 中补充 `--icon-*` 和 `--icon-surface-*` +- 所有业务类各自直接消费 token + +优点: + +- 改造最小。 +- 完全贴合现有主题机制。 + +缺点: + +- 复用约束较弱。 +- icon 语义仍然容易继续散落在业务 CSS 中。 + +### Option B: token-first,再加一层很薄的 utility 或 slot(推荐) + +做法: + +- 在 `tokens.css` 中定义 icon token 能力 +- 在 [`base.css`](../../../packages/web/src/styles/base.css) 中增加少量通用 icon utility +- 在 `components.css` 中把现有业务类接入这层语义能力 + +优点: + +- 仍然以 token 为中心,不改变现有架构。 +- 比纯 token 方案更容易复用与约束。 +- 不需要引入新的 JSX 包装层。 + +缺点: + +- 比完全不抽象多一层样式约定。 +- 需要对一批现有 icon 场景做一次迁移整理。 + +### Option C: 建立完整的 ThemedIcon 组件与 icon registry + +做法: + +- 所有 icon 统一改用包装组件 +- 颜色、size、形态、pack 映射都通过组件层管理 + +优点: + +- 中长期控制力最强。 + +缺点: + +- 对当前项目过重。 +- 当前问题主要是 CSS token 缺位,而不是 JSX API 缺位。 +- 会把一个主题扩展问题升级成一次大规模组件迁移。 + +## Final Choice + +采用 Option B。 + +最终策略为: + +- icon 主题化以 token 为核心。 +- 默认路径保持 `currentColor`。 +- 只为“应该故意不同于文本色”的 icon 建立 icon token。 +- 在 `base.css` 中增加一层很薄的 icon utility,帮助跨页面复用语义。 +- 不引入 React 包装器,不把 icon 主题化升级为 icon 架构重写。 + +这样可以在不改变整体开发范式的前提下,让 icon 真正成为皮肤系统的一部分。 + +## Final Design + +### 1. Token Layering + +icon token 分为三层: + +#### 1.1 Semantic icon tokens + +用于通用 UI icon 语义,不绑定具体业务模块。 + +- `--icon-primary` +- `--icon-secondary` +- `--icon-muted` +- `--icon-accent` +- `--icon-success` +- `--icon-warning` +- `--icon-error` +- `--icon-info` + +使用原则: + +- 适用于设置入口、空态提示、通用状态提示、危险提示等 icon。 +- 可以映射到现有 `--text-*`、`--accent-*`、`--color-*`,但消费方只认 `--icon-*`。 + +#### 1.2 Domain icon tokens + +用于颜色本身承载业务分类语义的 icon。 + +- `--icon-file-folder` +- `--icon-file-code` +- `--icon-file-data` +- `--icon-file-doc` +- `--icon-file-media` +- `--icon-file-default` +- `--icon-git-staged` +- `--icon-git-modified` +- `--icon-git-deleted` +- `--icon-git-untracked` + +使用原则: + +- 适用于文件树、git 状态等“颜色即类别”的 icon。 +- 允许不同主题 family 在这一层体现更明显的个性差异。 + +#### 1.3 Icon surface tokens + +用于 icon 的轻量背景块、底片、badge 容器,不直接用于普通文本色。 + +- `--icon-surface-subtle` +- `--icon-surface-accent` +- `--icon-surface-success` +- `--icon-surface-warning` +- `--icon-surface-error` +- `--icon-surface-info` + +使用原则: + +- 适用于 welcome feature、empty state、toast、badge 等带小容器的 icon。 +- 前景与背景都通过主题控制,避免浅色主题继续沿用过重容器感。 + +### 2. Naming Rules + +命名规则固定如下: + +- token 只表达语义,不表达组件名。 +- 不新增 `--settings-nav-icon`、`--toast-warning-icon` 这类组件私有 token。 +- 组件和业务类只能消费 semantic、domain、surface 三类 icon token。 +- 如果某个 icon 没有独立视觉语义需求,应继续继承父级 `color`。 + +目标是让 theme author 看到 token 名称时,能理解“这个颜色表达什么”,而不是“这个颜色给哪个类用”。 + +### 3. CurrentColor Boundary + +不是所有 icon 都应该主题化。 + +继续保留 `currentColor` 的场景: + +- `IconButton` 内的普通 icon +- `Select` trigger / arrow / 输入框附属 icon +- `Spinner` +- 与标题、label、按钮文案共同表达一个前景层级的 icon +- 已经由父级 `color` 统一管理且没有独立视觉语义的 icon + +判断原则: + +- 如果 icon 在换主题时应该和旁边文字同进同退,就继续使用 `currentColor`。 +- 如果 icon 在换主题时应该比文字更弱、更强、或承担独立状态语义,就切到 `--icon-*`。 + +### 4. Utility Layer + +在 [`base.css`](../../../packages/web/src/styles/base.css) 中新增很薄的一层 utility。 + +#### 4.1 Tone utilities + +- `.icon-tone-primary` +- `.icon-tone-secondary` +- `.icon-tone-muted` +- `.icon-tone-accent` +- `.icon-tone-success` +- `.icon-tone-warning` +- `.icon-tone-error` +- `.icon-tone-info` + +#### 4.2 Surface utilities + +- `.icon-surface-subtle` +- `.icon-surface-accent` +- `.icon-surface-success` +- `.icon-surface-warning` +- `.icon-surface-error` +- `.icon-surface-info` + +#### 4.3 Optional structural utility + +如确有复用价值,可增加一个轻量结构类: + +- `.icon-chip` + +职责仅限于: + +- `inline-flex` +- 居中对齐 +- 圆角 +- 紧凑尺寸约束 + +不负责表达业务样式,也不替代组件本身的布局逻辑。 + +### 5. Scope of First-Phase Migration + +第一阶段只迁移已经明显存在独立 icon 配色需求的场景。 + +#### 5.1 Semantic icon candidates + +首批纳管: + +- `.settings-mobile-item__icon` +- `.settings-nav-icon` +- `.config-empty-icon` +- `.bottom-terminal-empty-icon` +- `.welcome-feature-icon` +- `.terminal-panel-empty-icon` +- `.mobile-dock__icon` +- `.mobile-supervisor-badge__icon` + +#### 5.2 Domain icon candidates + +首批纳管: + +- `.tree-icon.folder` +- `.tree-icon.code` +- `.tree-icon.data` +- `.tree-icon.doc` +- `.tree-icon.media` +- `.tree-icon.file` +- `.git-row-icon-staged` +- `.git-row-icon-modified` +- `.git-row-icon-deleted` +- `.git-row-icon-untracked` + +#### 5.3 Status icon candidates + +首批纳管: + +- `.toast__icon` +- `.toast--success .toast__icon` +- `.toast--warning .toast__icon` +- `.toast--error .toast__icon` +- `.toast--info .toast__icon` +- 配置编辑器保存状态 icon +- confirm / warning / danger callout icon + +#### 5.4 Surface candidates + +首批纳管: + +- welcome feature icon 容器 +- empty state icon 容器 +- toast icon 容器 +- badge / chip 式 icon 容器 + +### 6. Explicit Out-of-Scope Cases + +第一阶段明确不纳入: + +- 纯按钮 icon +- 纯 hover / active 的瞬时交互前景色 +- icon 尺寸 token +- `stroke width`、`filled / outline`、圆角风格等形态主题化 +- 第三方 icon pack 替换 + +这样可以防止实现边界从“主题扩展”滑向“icon 系统重构”。 + +### 7. Theme Authoring Rules + +每个主题都必须显式声明 icon token,而不是依赖默认推导。 + +规则如下: + +- 每个 `[data-theme="..."]` block 都定义完整 icon token 集。 +- semantic icon token 可以映射到现有文本或状态色,但必须显式写出。 +- domain icon token 允许在不同 family 中体现更明显差异。 +- surface token 必须对浅色主题单独校准,避免仅通过 opacity 复制深色方案。 +- 高对比度主题的 icon token 优先保证辨识度与对比度,而不是追求常规主题的柔和层级。 + +### 8. CSS Integration Strategy + +样式文件分工保持明确: + +- [`tokens.css`](../../../packages/web/src/styles/tokens.css) + - 提供 theme-level icon 能力 +- [`base.css`](../../../packages/web/src/styles/base.css) + - 提供少量复用型 icon utility +- [`components.css`](../../../packages/web/src/styles/components.css) + - 把具体业务类接到 icon token / utility 上 + +不新增专门的 `icons.css`。 + +原因: + +- 当前全局样式入口已经稳定。 +- icon utility 的体量不足以值得单独建文件。 +- 继续把通用 token 和通用 utility 维持在既有分层内,迁移成本最低。 + +### 9. Validation Strategy + +验证分三层。 + +#### 9.1 Token and CSS tests + +在现有样式测试基础上扩充: + +- [`tokens-touch.test.ts`](../../../packages/web/src/styles/tokens-touch.test.ts) +- [`base.theme.test.ts`](../../../packages/web/src/styles/base.theme.test.ts) +- [`components.theme.test.ts`](../../../packages/web/src/styles/components.theme.test.ts) + +重点断言: + +- 所有内置主题都定义了完整的 icon token。 +- icon utility 存在且语义稳定。 +- 首批纳管类不再直接绑定裸 `--text-*`、`--accent-*`、`--color-*`,而是转向 `--icon-*`。 + +#### 9.2 e2e-ui scenes + +补强或新增以下 scene: + +- 文件树 scene:覆盖 folder / code / data / doc / media / file +- git 状态 scene:覆盖 staged / modified / deleted / untracked +- settings scene:覆盖导航与移动端入口 icon +- empty state scene:覆盖配置空态、终端空态、welcome feature +- toast scene:覆盖 success / warning / error / info + +目标不是做像素基线,而是保证不同主题下 icon 视觉差异可稳定产出并可对比查看。 + +#### 9.3 Manual review checklist + +人工 review 时重点检查: + +- 浅色主题是否仍出现偏深、偏脏、过闷的 icon 颜色。 +- icon 容器背景是否与所在 surface 协调。 +- 文件树和 git 状态在不同 theme family 下是否既保持语义一致,又具备 family 差异。 +- 高对比度主题下状态 icon 是否足够清晰。 + +### 10. Success Criteria + +完成后应满足: + +- icon 颜色和轻量 surface 成为主题系统的一等能力。 +- 默认 `currentColor` 路径继续成立,没有把普通 icon 复杂化。 +- 首批高价值业务 icon 不再散落直连文本色或 accent 色。 +- mint / graphite / nord / hc 各 family 在 icon 上既保持语义一致,又能体现皮肤差异。 +- 浅色主题的 icon 和 icon 容器不再显著带有深色皮肤遗留感。 + +## Architecture + +```text +theme registry / tokens.css + | + v +icon token layers + ├─ semantic + ├─ domain + └─ surface + | + v +base.css icon utilities + | + v +components.css feature mappings + | + v +runtime scenes + e2e-ui review +``` + +## Rollout Plan + +推荐按以下顺序落地: + +1. 在 `tokens.css` 中为全部主题补齐 icon token。 +2. 在 `base.css` 中增加薄 utility。 +3. 先迁移 domain icon:文件树与 git 状态。 +4. 再迁移 semantic / status icon:settings、toast、empty state、badge。 +5. 最后补 e2e-ui scene 与样式测试,形成视觉验证闭环。 + +## Risks and Mitigations + +### Risk 1: icon token 过度碎片化 + +如果直接按组件名建 token,会快速退化为样式别名堆积。 + +Mitigation: + +- 严格限制 token 只表达语义或业务域,不表达具体组件名。 + +### Risk 2: utility 过厚,反向演化成样式框架 + +如果 utility 层开始承载布局和业务表达,会让全局样式边界变糊。 + +Mitigation: + +- utility 只提供颜色与轻量 surface 语义。 +- 布局和场景结构仍由组件或业务类负责。 + +### Risk 3: 浅色主题仍然只是深色方案的机械翻转 + +如果 icon token 只是直接照搬 dark 的映射关系,浅色主题仍会缺乏独立视觉质量。 + +Mitigation: + +- 明确要求对浅色 theme 的 icon 和 surface token 做单独校准。 +- 通过 e2e-ui 与人工 review 把浅色主题作为重点检查对象。 diff --git a/docs/superpowers/specs/2026-05-12-mobile-terminal-copy-mode-design.md b/docs/superpowers/specs/2026-05-12-mobile-terminal-copy-mode-design.md new file mode 100644 index 000000000..569e9d128 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-mobile-terminal-copy-mode-design.md @@ -0,0 +1,275 @@ +# Mobile Terminal Copy Mode — Design + +Date: 2026-05-12 +Status: Draft +Owner: spencer + +## Problem + +移动端 terminal 当前以触摸滚动和软键输入为主,无法像普通网页文本那样通过长按进入原生选区,因此用户不能方便地复制终端输出。 + +仓库现状决定了这不是一个“打开浏览器默认选区”就能解决的问题: + +- 移动端 terminal 由 [`XtermHost`](../../../packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx) 承载。 +- 该组件在移动端显式接管了 `touchstart/touchmove/touchend/touchcancel`,用于终端滚动与惯性滚动。 +- 当前的 terminal 交互目标是“可滚动、可输入”,不是“原生可选中文本”。 + +用户希望增加一个移动端可用的复制路径,优先满足“长按后选中当前看到的终端输出并复制”。 + +## Goals + +- 为移动端 terminal 增加长按进入复制模式的能力。 +- 复制模式中允许使用浏览器原生文本选区,支持多行选中。 +- v1 仅覆盖当前可见 viewport 的终端内容。 +- 复制模式退出后恢复到原本的 live terminal 交互。 +- 不改变桌面端现有 copy-on-select 行为。 + +## Non-Goals + +- 不在 live xterm 上直接实现移动端原生长按选区。 +- 不支持跨出当前 viewport 的历史 scrollback 选中。 +- 不实现自定义选区手柄、放大镜或自定义复制菜单。 +- 不新增服务端专用“移动端复制快照”接口。 +- 不改变现有移动端软键条的键位语义。 + +## User Decisions Captured + +- 采用“长按进入 overlay 复制模式”而不是直接改 xterm 原生选区。 +- v1 的 overlay 内容来源于前端当前可见 DOM,而不是服务端快照。 +- v1 支持多行选中。 +- v1 范围只覆盖当前可见 viewport。 + +## Approaches Considered + +### Option A: 直接使用当前前端可见 DOM 生成复制 overlay(推荐) + +优点: + +- 所见即所得,复制内容与用户此刻看到的终端一致。 +- 不依赖现有服务端 snapshot 格式转换。 +- 不需要新增协议或服务端接口。 +- 与公开可验证的同类 workaround 一致,风险更低。 + +缺点: + +- v1 只能稳定覆盖当前 viewport。 +- 需要处理 xterm DOM 的空格、行宽、样式拷贝与定位。 + +### Option B: 复用服务端 `terminal.snapshot` 生成 overlay + +优点: + +- 理论上可以脱离前端渲染层,未来更容易扩展到更大范围。 + +缺点: + +- 当前 `terminal.snapshot` 是 headless xterm 的序列化 ANSI/VT 数据,不是可直接选中的文本结构。 +- 服务端现有 `renderSnapshotToText()` 只适合摘要用途,会丢失可视布局信息。 +- 为 v1 引入服务端转换逻辑,复杂度不划算。 + +### Option C: 直接在 live xterm 上实现移动端原生选区 + +优点: + +- 交互最接近桌面或原生文本组件。 + +缺点: + +- 与 xterm.js 当前移动端支持现状冲突明显。 +- 会直接撞上现有 touch scroll、焦点、软键盘与渲染层限制。 +- 超出本次范围。 + +## Final Choice + +采用 Option A。 + +v1 把复制能力定义为一个独立的“复制模式”:用户长按 terminal 输出区域后,前端读取当前可见 viewport 的 xterm DOM,生成一个位于 terminal 上层的可选中文本 overlay。用户在 overlay 上使用浏览器原生选区完成复制,退出后再回到 live terminal。 + +## Interaction Model + +### 1. 默认状态 + +在未进入复制模式时,移动端 terminal 保持当前行为: + +- 单指纵向拖动:滚动 terminal。 +- 点击 terminal:聚焦输入。 +- 软键条正常可用。 +- 不暴露原生文本选区。 + +### 2. 进入复制模式 + +用户在 terminal 输出区域单指长按约 450-500ms 后触发复制模式。 + +长按识别条件: + +- 仅单指触摸有效。 +- 触点位移超过小阈值(建议 5-8px)则取消长按。 +- 明显纵向滚动手势优先判定为滚动,不进入复制模式。 +- 若触点位于当前输入行附近,则不进入复制模式,保留输入/粘贴优先级。 + +进入复制模式时: + +- 触发轻微震动(如果浏览器支持 `navigator.vibrate`)。 +- 冻结一份“当前可见 viewport”的文本快照。 +- 在 terminal 上方显示全覆盖 overlay。 +- overlay 内文本可使用浏览器原生选区。 +- live terminal 继续存在于底层,但不响应触摸交互。 + +### 3. 复制模式中的交互 + +复制模式内的用户心智是“正在复制这一屏文本”,而不是“仍在操作 live terminal”。 + +具体行为: + +- 用户可原生长按/拖动选择多行文本。 +- overlay 自身允许滚动,但滚动范围仅限当前快照容器,不会拉取更多历史 terminal 内容。 +- 底部软键条建议隐藏或禁用,避免误触发送输入。 +- terminal 输出即使后台继续到达,也不会实时更新 overlay。 + +### 4. 退出复制模式 + +主退出方式: + +- 点击显式的“完成”按钮。 + +辅助退出方式: + +- 当没有激活选区时,点击 overlay 空白区域退出。 + +v1 不依赖“复制成功自动退出”作为核心流程,因为浏览器复制菜单与系统选区的完成信号并不总是稳定可观察。若浏览器确实触发了 `copy` 事件,可作为增强项做延迟自动退出,但不是必需条件。 + +## Overlay Content Source + +### Why Not Server Snapshot + +当前服务端 `terminal.snapshot` 实现来自 headless xterm 的 `SerializeAddon.serialize()`,返回的是可重建终端状态的序列化数据,不是用于浏览器原生选区的文本结构。 + +参考: + +- [`packages/server/src/terminal/terminal-snapshot-buffer.ts`](../../../packages/server/src/terminal/terminal-snapshot-buffer.ts) +- [`packages/server/src/commands/terminal.ts`](../../../packages/server/src/commands/terminal.ts) + +当前服务端把 snapshot 渲染成纯文本的能力也仅用于摘要: + +- [`packages/server/src/terminal/snapshot-render.ts`](../../../packages/server/src/terminal/snapshot-render.ts) + +该实现会 strip ANSI 并按换行切分,无法保留 overlay 所需的可视布局、空格占位和逐行宽度一致性,因此不适合作为 v1 的复制源。 + +### DOM-Based Snapshot + +v1 以当前前端 xterm 可见 DOM 作为唯一数据源。 + +推荐来源: + +- `terminal.element` 下当前可见的 `.xterm-rows` + +生成步骤: + +1. 读取当前 `.xterm-rows` 的可见内容。 +2. clone 当前行结构。 +3. 将原始 span 的 computed style 拷贝到 clone,避免脱离 `.xterm` 容器后颜色丢失。 +4. 将普通空格替换为 `NBSP`,保证原生选区时空白可见且可点中。 +5. 按当前 terminal `cols` 为每一行补齐尾部空白,避免短行选区断裂。 +6. 按当前 viewport 中 `.xterm-rows` 的实际偏移量,把 clone 后的内容对齐到 overlay。 + +结果是:overlay 展示的是“用户眼前这一屏的静态文本版”,不是一份重新解释的服务端文本。 + +## Viewport Scope + +v1 的复制范围严格限定为“进入复制模式那一刻,terminal 当前可见 viewport 中的内容”。 + +这意味着: + +- 支持多行拖选。 +- 支持从当前可见区域上半部分选到下半部分。 +- 不支持继续向上滚到更早的 scrollback 后扩展当前选区。 + +若用户想复制更早输出,操作方式是: + +1. 先退出复制模式。 +2. 在 live terminal 中滚到目标位置。 +3. 再次长按进入复制模式。 + +## UI Surface + +### Overlay Shell + +overlay 覆盖在 `.xterm-host` 之上,建议包含: + +- 一个顶部轻量工具条。 +- 一个主文本区。 + +工具条内容: + +- 左侧标题:`复制模式` +- 右侧主操作:`完成` + +可选的辅助提示文案: + +- `拖动选择文本` + +### Visual Rules + +- overlay 背景延续 terminal 深色底,但比 live terminal 稍亮或稍实,以明确“当前是冻结层”。 +- overlay 不需要复杂动画,最多做一个轻微淡入。 +- 文本区使用与 terminal 一致的字体、字号和行高,避免视觉跳变。 + +## Input, Scroll, And State Coordination + +进入复制模式后: + +- 终端输入事件不应再透传给 PTY。 +- 终端 touch scroll 逻辑暂停。 +- 软键条交互暂停或隐藏。 + +退出复制模式后: + +- 恢复原有 touch scroll。 +- 恢复输入与软键条。 +- 丢弃 overlay 快照。 +- live terminal 继续显示最新内容;复制模式期间积累的输出在退出后自然可见。 + +本次不要求暂停服务端输出流,也不要求阻塞 terminal 底层渲染。复制模式只阻断用户交互层,不改变现有输出链路。 + +## Error Handling + +若在进入复制模式时无法读取 `.xterm-rows` 或生成 overlay 内容失败: + +- 不进入复制模式。 +- 保持现有 terminal 交互不变。 +- 给出轻量 toast: + - 中文标题:`无法进入复制模式` + - 中文正文:`请重试,或先滚动终端后再长按` + - 英文标题:`Couldn't enter copy mode` + - 英文正文:`Try again, or scroll the terminal and long press again` + +复制模式本身不需要“复制成功 toast”。成功应保持静默。 + +## Testing + +- `XtermHost` 长按识别: + - 单指长按进入复制模式。 + - 位移超过阈值后取消长按。 + - 纵向滚动手势不进入复制模式。 + - 输入行附近长按不进入复制模式。 +- overlay 内容: + - 基于当前可见 viewport 生成。 + - 支持多行文本。 + - 行尾空白补齐后可连续选中。 + - 样式拷贝后颜色与基础排版保持稳定。 +- 状态切换: + - 进入复制模式后暂停软键条交互。 + - 退出后恢复 live terminal。 + - 复制模式期间新输出不会刷新 overlay。 +- 异常路径: + - DOM 取样失败时显示 toast,且不破坏 terminal 可用性。 + +## Rollout + +- 一次合入,无设置开关。 +- 范围仅限移动端 viewport。 +- 桌面端逻辑不受影响。 + +## Open Questions + +无。 diff --git a/docs/superpowers/specs/2026-05-12-supervisor-target-scoped-memory-design.md b/docs/superpowers/specs/2026-05-12-supervisor-target-scoped-memory-design.md new file mode 100644 index 000000000..7d9f75d71 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-supervisor-target-scoped-memory-design.md @@ -0,0 +1,492 @@ +# Supervisor Target-Scoped Memory Design + +## Goal + +Improve supervisor quality and observability by replacing the current effectively stateless, terminal-snapshot-only supervision loop with a lightweight target-scoped memory model stored in the workspace directory. + +This design intentionally does **not** use git state as a primary supervision signal. It also avoids a heavyweight planner / workflow engine. The target is a simpler supervisor that can remember what it is supervising, where it believes the work currently stands, and why it decided to continue or stop. + +## Why Change + +The current supervisor pipeline already has a solid runtime skeleton: + +- session-bound supervisor lifecycle and commands +- cycle persistence +- retry attempts +- evaluator subprocess execution +- guidance injection into the running session + +However, the evaluation logic still behaves like a mostly stateless reviewer: + +- each cycle primarily inspects the latest terminal snapshot +- only the latest submitted user input is carried forward +- prior supervision conclusions are not represented as durable working memory +- the UI shows very little of how a cycle conclusion was reached + +This causes two practical problems: + +1. Supervision quality is unstable because each cycle is close to a fresh read of terminal output. +2. Observability is weak because users cannot easily see what the supervisor thinks the current plan is, what progress it has observed, or why it chose to continue or stop. + +## Scope + +This design covers: + +- target-scoped supervisor memory +- workspace-directory persistence under `.coder-studio/supervisor/targets/` +- first-trigger plan bootstrap +- simplified evaluator result model: `continue` or `stop` +- cycle history format for observability +- command semantics when the supervised objective changes + +This design does **not** cover: + +- multi-target concurrent UX in a single session +- syncing supervisor memory across machines +- long-term knowledge retrieval or semantic memory +- using git state as a required supervision input + +## Core Model + +The design separates four concepts that are currently too blurred together. + +### Session + +The execution host. A session is where the agent runs and where supervisor guidance is injected. + +### Supervisor + +The runtime controller attached to a session. It owns lifecycle and operational state such as: + +- enabled / disabled +- paused / resumed +- evaluating / idle / error + +The supervisor remains session-attached. + +### Target + +A single supervised objective instance. This is the true owner of supervision memory. + +Targets are: + +- created when the user enables supervisor for an objective +- identified by a generated `targetId` +- invalidated and replaced when the objective materially changes + +### Memory + +The durable working state for a single target. Memory tracks the current lightweight plan, active step, recent progress summary, last guidance, and stalled count. + +The memory is **target-scoped**, not session-scoped. + +## Storage Model + +Supervisor memory and history live in the workspace directory, not in the database. + +### Directory Layout + +```text +.coder-studio/ + supervisor/ + targets/ + / + meta.json + memory.json + cycles.jsonl +``` + +### Why Workspace Storage + +This fits the nature of supervision memory better than database-only storage: + +- it follows the project +- it is easy to inspect manually +- it survives session replacement +- it is naturally target-scoped +- it avoids treating project supervision state as server-only runtime metadata + +The database may still retain runtime supervisor state, but target memory and supervision history should be file-backed. + +## File Responsibilities + +### `meta.json` + +Identity and lifecycle metadata for the target. + +Example: + +```json +{ + "targetId": "tgt_20260512_abcd123", + "sessionId": "sess_123", + "workspaceId": "ws_123", + "objective": "Optimize supervisor quality and observability", + "status": "active", + "createdAt": 1747000000000, + "updatedAt": 1747000000000, + "supersededBy": null, + "completedAt": null +} +``` + +Recommended status values: + +- `active` +- `completed` +- `cancelled` +- `superseded` + +### `memory.json` + +The minimal durable working memory for the target. + +Example: + +```json +{ + "targetId": "tgt_20260512_abcd123", + "planGenerated": true, + "plan": [ + { "id": "step-1", "title": "Review current supervisor behavior", "status": "done" }, + { "id": "step-2", "title": "Design target-scoped memory", "status": "in_progress" }, + { "id": "step-3", "title": "Define cycle observability", "status": "pending" } + ], + "activeStepId": "step-2", + "progressSummary": "Current behavior review is complete; memory and lifecycle design is in progress", + "lastGuidance": "Define target lifecycle and stop semantics", + "stalledCount": 0, + "updatedAt": 1747000000000 +} +``` + +Recommended fields: + +- `targetId` +- `planGenerated` +- `plan` +- `activeStepId` +- `progressSummary` +- `lastGuidance` +- `stalledCount` +- `updatedAt` + +Fields intentionally excluded: + +- raw terminal snapshots +- git summaries +- retry history +- long reasoning logs +- transient runtime state such as `evaluating` or `paused` + +### `cycles.jsonl` + +Append-only supervision history for observability. One JSON object per line. + +Example continue result: + +```json +{ + "cycleId": "cycle_xxx", + "targetId": "tgt_20260512_abcd123", + "startedAt": 1747000000000, + "completedAt": 1747000005000, + "result": "continue", + "stopReason": null, + "reason": "The target design is partially complete but command semantics are not yet defined", + "guidance": "Define how supervisor.update should behave when the objective changes", + "progressSummary": "Target-scoped memory and first-trigger plan bootstrap are now defined", + "activeStepId": "step-2", + "stepUpdates": [ + { "id": "step-1", "status": "done" }, + { "id": "step-2", "status": "in_progress" } + ], + "injected": true, + "attemptCount": 1 +} +``` + +Example stop result: + +```json +{ + "cycleId": "cycle_yyy", + "targetId": "tgt_20260512_abcd123", + "startedAt": 1747000010000, + "completedAt": 1747000013000, + "result": "stop", + "stopReason": "objective_complete", + "reason": "The target has been completed and no further supervision is needed", + "guidance": null, + "progressSummary": "The design is complete", + "activeStepId": "step-3", + "stepUpdates": [ + { "id": "step-3", "status": "done" } + ], + "injected": false, + "attemptCount": 1 +} +``` + +Example runtime failure record: + +```json +{ + "cycleId": "cycle_err", + "targetId": "tgt_20260512_abcd123", + "startedAt": 1747000020000, + "completedAt": 1747000029000, + "result": "error", + "errorReason": "Supervisor evaluator timed out after 600000ms", + "attemptCount": 3 +} +``` + +## Evaluation Inputs + +The new supervisor should only use four primary inputs: + +- current `objective` +- latest submitted user input +- current terminal snapshot +- target memory + +Git state should not be part of the primary supervision signal. + +Git information may still exist as optional debug-only context, but it should not participate in the normal decision path because workspace changes may not belong to the supervised agent or current target. + +## Plan Bootstrap + +The target plan should **not** be generated when supervisor is enabled. + +Instead, plan generation happens on the **first trigger**. + +### Why First Trigger + +- enabling supervisor should stay lightweight +- the first real supervision pass has fresher terminal evidence +- this avoids generating plans that are stale before supervision even begins + +### First Trigger Flow + +When `supervisor.trigger` runs against a target whose `memory.planGenerated` is `false`: + +1. load target metadata +2. gather objective, latest user input, and terminal snapshot +3. generate a lightweight plan with 3 to 7 steps +4. write `memory.json` +5. continue directly into the same supervision cycle + +The generated plan should be milestone-oriented, not overly granular. + +## Ongoing Memory Update Rules + +After bootstrap, normal cycles should update memory incrementally rather than regenerating the full plan. + +### Allowed updates + +- step status changes +- `activeStepId` +- `progressSummary` +- `lastGuidance` +- `stalledCount` + +### Disallowed behavior + +- rewriting the entire plan every cycle +- drifting the target objective +- using cycle history as memory instead of a separate working state + +### `stalledCount` + +Increment `stalledCount` when the cycle finds no meaningful visible progress, for example: + +- active step stays unchanged +- no step advances to `done` +- progress summary does not reflect a concrete new outcome + +Reset `stalledCount` to `0` when a cycle observes clear progress. + +## Simplified Evaluator Result Model + +The evaluator result model should stay narrow. + +The supervisor only needs two outcomes: + +- `continue` +- `stop` + +`stop` must include a reason. + +### Recommended stop reasons + +- `objective_complete` +- `supervisor_uncertain` +- `needs_user_input` + +### Continue result example + +```json +{ + "status": "continue", + "reason": "The lifecycle design is complete but the command semantics are still missing", + "guidance": "Define how objective edits supersede an existing target" +} +``` + +### Stop result example + +```json +{ + "status": "stop", + "stopReason": "objective_complete", + "reason": "The current supervision target is complete" +} +``` + +Runtime failures such as timeout, abort, or evaluator errors are not business decisions and should remain runtime/cycle concerns rather than part of the evaluator decision vocabulary. + +## Target Lifecycle + +### Create + +`supervisor.create`: + +- creates a new runtime supervisor +- generates a `targetId` +- creates the target directory +- writes `meta.json` +- either omits `memory.json` or writes a minimal file with `planGenerated: false` + +### First Trigger + +`supervisor.trigger`: + +- bootstraps the plan if needed +- runs the normal cycle +- updates `memory.json` +- appends a line to `cycles.jsonl` + +### Subsequent Trigger + +Later triggers: + +- reuse existing plan +- update step state and progress +- either continue with guidance or stop the target + +### Objective Change + +An objective change should **not** reuse the old memory. + +When the objective materially changes: + +1. mark the old target as `superseded` +2. create a new target with a new `targetId` +3. initialize new target metadata +4. leave plan generation deferred until first trigger on the new target + +This is the key reason the design is target-scoped: once the objective changes, prior memory becomes untrustworthy. + +### Delete / Disable + +`supervisor.delete`: + +- disables the runtime supervisor +- marks the active target as `cancelled` or archives it + +The target directory does not need to be physically deleted immediately. + +### Evaluator Stop + +When the evaluator returns `stop`: + +- the target is marked completed or ended for the given stop reason +- the runtime supervisor transitions accordingly + +## Relationship to Existing Runtime State + +Target memory and target history move to the workspace directory. + +Database-backed runtime state may still exist for: + +- whether a supervisor is enabled for the session +- current runtime state such as `idle`, `paused`, `evaluating`, `error` +- current evaluator provider and model +- the currently active `targetId` + +This preserves the existing supervisor runtime control flow while shifting long-lived target cognition out of the database. + +## UI Design + +The UI should stop presenting only the latest one-line cycle result. + +Recommended minimum surface: + +### Current Target + +- objective +- active step +- `progressSummary` +- `stalledCount` + +### Plan + +- list of current steps +- clear status display +- active step highlighted + +### Recent Cycles + +- recent `continue`, `stop`, or `error` results +- per-cycle `reason` +- per-cycle `guidance` when continuing +- `stopReason` when stopping +- `errorReason` when runtime failure occurs + +This gives users visibility into: + +- what the supervisor believes the current target is +- where it thinks work stands +- why it decided to continue or stop +- whether supervision has been stalled across multiple cycles + +## Implementation Notes + +This design can be introduced incrementally. + +Recommended order: + +1. add `targetId` ownership and target directory layout +2. add file-backed `meta.json` and `memory.json` +3. move first-plan generation to first trigger +4. change evaluator output from plain text to structured `continue` / `stop` +5. append `cycles.jsonl` +6. update UI to read and render current plan and recent cycle history + +## Risks and Tradeoffs + +### Benefits + +- better supervision continuity across cycles +- clearer project-local ownership of memory +- easier manual inspection and debugging +- simpler mental model than a fully general planner + +### Risks + +- file format evolution must be handled carefully +- concurrent writes need serialization if multiple runtime paths touch the same target files +- target lifecycle and runtime supervisor lifecycle are now distinct and must stay synchronized + +## Open Constraints Chosen Deliberately + +The design intentionally chooses: + +- target-scoped memory over session-scoped memory +- workspace files over database-backed cognition +- first-trigger plan bootstrap over enable-time planning +- `continue` / `stop` over a larger decision taxonomy +- no git signal in normal supervision + +These constraints keep the design narrow, inspectable, and aligned with the actual job of supervisor: guide the current target forward until it should stop. diff --git a/docs/superpowers/specs/2026-05-13-mobile-terminal-long-press-line-copy-design.md b/docs/superpowers/specs/2026-05-13-mobile-terminal-long-press-line-copy-design.md new file mode 100644 index 000000000..9e8842cf4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-mobile-terminal-long-press-line-copy-design.md @@ -0,0 +1,381 @@ +# Mobile Terminal Long-Press Line Copy Design + +## 1. Goal + +Replace the current mobile terminal copy-mode overlay with a simpler interaction: + +- when `Copy on select` is enabled on mobile, long-pressing terminal output copies the current wrapped logical line directly +- the copied text comes from the frontend xterm active buffer, not a server request +- wrapped terminal rows are merged into one logical line before copying +- right-side terminal padding whitespace is trimmed from the logical line ending, while meaningful internal spaces are preserved +- success feedback is a toast plus a short vibration + +This change intentionally removes the current mobile copy-mode overlay flow instead of refining it. + +## 2. Current Baseline + +Relevant current implementation: + +- `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` +- `packages/web/src/features/terminal-panel/mobile/copy-mode-snapshot.ts` +- `packages/web/src/styles/components.css` +- `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` +- `e2e/specs/settings/mobile-copy-on-select.spec.ts` +- `packages/web/src/locales/en.json` +- `packages/web/src/locales/zh.json` + +Today: + +- mobile long press enters a full copy-mode overlay +- the overlay is built from cloned `.xterm-rows` DOM +- once copy mode is active, our custom terminal touch handling exits and the browser takes over native text-selection behavior +- the overlay is rendered inside the fullscreen mobile terminal sheet + +This baseline caused a UX problem on mobile: + +- after entering copy mode, long-pressing and dragging overlay text can trigger browser-native selection and viewport/layer movement that feels like the whole DOM is being dragged + +## 3. User Decisions Captured + +Confirmed product decisions: + +- completely replace the current mobile copy-mode overlay +- keep the new behavior gated by the existing `Copy on select` setting +- copy the wrapped logical line, not a single visual row +- merge wrapped rows into one logical line +- preserve meaningful internal spaces +- trim only trailing padding whitespace from the logical line ending +- show success feedback as `copied current line` toast plus short vibration +- use a simple phase-one targeting model: + - start from `touchstart.target` + - walk upward to find the nearest xterm row element + - if no row element is found, do nothing + - do not add coordinate-based fallback in this phase + +## 4. Requirements + +### 4.1 Functional rules + +- On mobile, `Copy on select = true` enables long-press copy. +- On mobile, `Copy on select = false` keeps the current non-copy long-press behavior. +- A stable long press on terminal output must copy the current wrapped logical line directly to the clipboard. +- The copied line must come from the frontend xterm active buffer. +- No server request is allowed for this copy path. +- If the long-press target does not resolve to a rendered xterm row, nothing should be copied. +- If the clipboard write fails, existing failure toast behavior must be reused. +- Desktop behavior must remain unchanged. + +### 4.2 Non-functional rules + +- The new behavior must remove the mobile copy overlay entirely. +- The implementation should stay small and phase-one scoped. +- The targeting logic should avoid speculative heuristics for empty areas in this phase. +- The line-resolution logic should be extracted from `xterm-host.tsx` into a focused helper. + +## 5. Why The Old Overlay Is Being Removed + +The old design intentionally opened browser-native text selection inside a fullscreen fixed mobile sheet: + +- `user-select: text` +- `-webkit-touch-callout: default` +- native scrolling inside the overlay +- custom terminal touch handling disabled while copy mode is active + +That combination makes the mobile browser the primary gesture handler after copy mode opens. In practice, this can produce viewport or composited-layer movement that feels like dragging the whole page instead of just selecting text. + +The new design avoids that class of problem by eliminating the text-selection overlay entirely. + +## 6. High-Level Approach + +Recommended approach: + +- keep the existing long-press detection and movement tolerance in `XtermHost` +- when the long press matures on mobile and `Copy on select` is enabled: + - resolve the pressed xterm row from `touchstart.target` + - convert that row to a visual row index within `.xterm-rows` + - map the visual row index into `terminal.buffer.active` + - walk upward and downward across wrapped rows to collect one logical line + - copy the final logical line to the clipboard + - show success feedback +- remove all mobile copy-mode overlay state, rendering, styles, and tests + +## 7. Interaction Model + +### 7.1 Normal mobile terminal behavior + +When the feature setting is disabled: + +- long press does not copy +- drag continues to behave as terminal scrolling + +When the feature setting is enabled: + +- a stable long press on rendered terminal text copies one logical line +- if the gesture turns into a scroll before the long-press timer fires, copying is cancelled + +### 7.2 Long-press trigger + +The current long-press model already has the right shape and should remain: + +- single-touch only +- movement over the tolerance cancels the long press +- scroll gestures win over copy +- long-press maturity is still driven by the existing timer + +The copied target is locked to the original `touchstart.target`. + +### 7.3 Success and failure feedback + +On success: + +- push a lightweight success toast: `Copied current line` +- vibrate briefly if `navigator.vibrate` is available + +On failure: + +- reuse the existing copy failure toast path +- do not open any fallback overlay + +## 8. Target Resolution Rules + +Phase one deliberately uses a minimal targeting rule. + +### 8.1 Source of truth for hit testing + +Use `touchstart.target` captured when the long-press gesture begins. + +Do not use the release target. +Do not use a `clientY` fallback in this phase. + +### 8.2 Row resolution + +Resolve the nearest xterm row by walking upward from `touchstart.target`. + +Accepted row shape: + +- an element that is a direct child of `.xterm-rows` + +If upward traversal finds no such row: + +- treat the gesture as non-copyable +- do nothing + +This intentionally excludes: + +- empty space outside rendered rows +- unrelated xterm layers +- ambiguous targets + +Those cases are accepted phase-one limitations. + +### 8.3 Visual row index + +Once the row element is found: + +- use its position within `.xterm-rows.children` +- that zero-based child position is the visual row index + +If the resolved row is missing from the current children list: + +- do nothing + +## 9. Buffer Mapping Rules + +The copied text comes from the frontend xterm buffer, not the DOM text. + +### 9.1 Buffer source + +Use: + +- `terminal.buffer.active.viewportY` +- `terminal.buffer.active.getLine(y)` +- `IBufferLine.isWrapped` +- `IBufferLine.translateToString(...)` + +This is the active frontend xterm buffer only. +No backend lookup is needed. + +### 9.2 Mapping visual row to buffer row + +Compute: + +- `bufferRow = terminal.buffer.active.viewportY + visualRowIndex` + +If `getLine(bufferRow)` returns `undefined`: + +- do nothing + +### 9.3 Finding the logical line start + +The visual row may be the first segment of a logical line or a wrapped continuation. + +Starting from `bufferRow`: + +- walk upward while the current line exists and `currentLine.isWrapped === true` +- the first row where `isWrapped !== true` is the logical line start + +### 9.4 Collecting wrapped continuation rows + +From the logical line start: + +- include that row +- walk downward while the next row exists and `nextLine.isWrapped === true` +- include each wrapped continuation row + +The resulting ordered row list is the wrapped logical line. + +## 10. Text Assembly Rules + +The copied text must preserve meaningful spaces while removing terminal padding noise at the end. + +### 10.1 Per-row conversion + +For a collected logical line with multiple buffer rows: + +- every row except the last uses `translateToString(false)` +- the last row uses `translateToString(true)` + +Why: + +- wrapped intermediate rows may legitimately end in a space that is part of the logical line continuation +- trimming every segment would risk deleting meaningful spaces in the middle of the reconstructed line +- trimming only the last segment removes right-side terminal padding from the final logical line ending + +### 10.2 Final join behavior + +Join all collected row strings with no separator. + +This produces one single logical line string. + +### 10.3 Phase-one scope + +This feature copies one logical line only. + +It does not: + +- copy multiple logical lines +- preserve per-cell styling +- expose browser-native text selection + +## 11. File-Level Design + +### 11.1 `xterm-host.tsx` + +Keep ownership of: + +- touch gesture state +- long-press timer +- movement tolerance cancellation +- clipboard side effects +- toast side effects +- success vibration + +Change behavior so that mobile long-press success runs direct logical-line copy instead of entering copy mode. + +Remove: + +- mobile copy-mode snapshot state +- mobile copy-mode overlay rendering +- mobile copy-mode background click handling +- copy-mode entry failure path tied to snapshot generation + +### 11.2 New mobile helper + +Add a small helper under `packages/web/src/features/terminal-panel/mobile/` that owns: + +- resolving the nearest xterm row from a target node +- computing the visual row index +- mapping that index into `terminal.buffer.active` +- expanding the wrapped logical line +- building the final copy string + +Recommended shape: + +- accept the target node +- accept the terminal instance +- return `string | null` + +The helper should not touch clipboard APIs or UI toasts. + +### 11.3 Remove `copy-mode-snapshot.ts` + +The DOM snapshot builder is specific to the removed overlay workflow. + +Once the overlay is removed: + +- delete `packages/web/src/features/terminal-panel/mobile/copy-mode-snapshot.ts` +- delete or replace tests that only exist for overlay DOM snapshot behavior + +### 11.4 Styles and locales + +Remove overlay-only CSS and copy-mode-only locale strings. + +Add new success-toast locale strings for: + +- English +- Chinese + +The `Copy on select` setting stays in place but mobile semantics change. + +## 12. Testing Strategy + +### 12.1 Helper unit tests + +Add focused tests for: + +- resolves the nearest row from a nested span target +- returns `null` when no xterm row is found +- maps visual row index through `viewportY` +- expands upward to the logical line start when the hit row is wrapped +- expands downward through wrapped continuation rows +- trims only the final segment right side +- preserves meaningful internal spaces + +### 12.2 `xterm-host` behavior tests + +Rewrite mobile-copy tests around the new flow: + +- long press copies the current logical line when the setting is enabled +- wrapped logical line copies as one merged string +- long press does not copy when the gesture becomes a scroll +- long press does not copy when horizontal drift exceeds tolerance +- long press does not copy when the setting is disabled +- long press does not scroll the live terminal while the copy action is executing +- clipboard failure still shows the failure toast +- success path triggers toast and vibration + +Remove tests that only verify overlay behaviors, such as: + +- rendering `Copy Mode` +- exiting via Done +- background click dismissal +- overlay content DOM sizing +- overlay text tap selection guards + +### 12.3 E2E regression coverage + +Replace the existing mobile copy-mode e2e assertions with: + +- enable `Copy on select` +- seed a wrapped line in the mobile terminal +- long press rendered text +- verify clipboard content matches the merged logical line +- verify success feedback appears +- verify no copy-mode overlay is shown + +Keep the disabled-setting e2e: + +- when `Copy on select` is off, long press should not copy + +## 13. Accepted Phase-One Limitations + +The following are intentional and should not be treated as bugs in this phase: + +- pressing empty terminal space does nothing +- pressing ambiguous non-row xterm layers does nothing +- no coordinate fallback when target traversal cannot resolve a row +- only one logical line is copied per long press +- no native selection UI +- no recovery path to copy from older scrollback once it has fallen out of frontend xterm buffer retention + +If real-world usage shows the target-only resolution is too strict, a future iteration may add a `clientY` fallback. That is explicitly out of scope for this phase. diff --git a/e2e-ui/fixtures/prefs.ts b/e2e-ui/fixtures/prefs.ts index cf9680091..e8c84f0ed 100644 --- a/e2e-ui/fixtures/prefs.ts +++ b/e2e-ui/fixtures/prefs.ts @@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"; export interface OpenPreviewSceneArgs { sceneId: string; device: "desktop" | "mobile"; - theme: "dark" | "light"; + theme: string; locale: "zh" | "en"; } @@ -16,7 +16,7 @@ async function seedPreviewPreferences( }); await page.evaluate(({ theme, locale }) => { - window.localStorage.setItem("ui.theme", JSON.stringify(theme)); + window.localStorage.setItem("ui.themeId", JSON.stringify(theme)); window.localStorage.setItem("ui.locale", JSON.stringify(locale)); }, args); } diff --git a/e2e-ui/report/build-report.test.ts b/e2e-ui/report/build-report.test.ts index 116e8a9df..1f774db7d 100644 --- a/e2e-ui/report/build-report.test.ts +++ b/e2e-ui/report/build-report.test.ts @@ -11,10 +11,10 @@ describe("build-report", () => { source: "real-route", description: "Welcome page", }, - screenshotPath: "screenshots/page/welcome/desktop__dark__zh.png", + screenshotPath: "screenshots/page/welcome/desktop__mint-light__zh.png", variant: { device: "desktop", - theme: "dark", + theme: "mint-light", locale: "zh", }, }); @@ -22,13 +22,36 @@ describe("build-report", () => { expect(entry).toMatchObject({ id: "welcome", category: "page", - path: "screenshots/page/welcome/desktop__dark__zh.png", + path: "screenshots/page/welcome/desktop__mint-light__zh.png", device: "desktop", - theme: "dark", + theme: "mint-light", locale: "zh", }); }); + it("keeps icon review scene manifest entries grouped by exact scene id and theme", () => { + const entry = buildManifestEntry({ + scene: { + id: "toast-icon-review", + title: "Toast Icon Review", + category: "toast", + source: "showcase", + description: "Theme review for toast icons", + }, + screenshotPath: "screenshots/toast/toast-icon-review/mobile__graphite-light__en.png", + variant: { + device: "mobile", + theme: "graphite-light", + locale: "en", + }, + }); + + expect(entry).toMatchObject({ + id: "toast-icon-review", + theme: "graphite-light", + }); + }); + it("renders a report html shell with filters and grouped scenes", () => { const html = renderReportHtml([ { @@ -37,15 +60,15 @@ describe("build-report", () => { category: "page", source: "real-route", device: "desktop", - theme: "dark", + theme: "mint-light", locale: "zh", - path: "screenshots/page/welcome/desktop__dark__zh.png", + path: "screenshots/page/welcome/desktop__mint-light__zh.png", description: "Welcome page", }, ]); expect(html).toContain("UI Preview Report"); - expect(html).toContain("screenshots/page/welcome/desktop__dark__zh.png"); + expect(html).toContain("screenshots/page/welcome/desktop__mint-light__zh.png"); expect(html).toContain('data-category="page"'); }); }); diff --git a/e2e-ui/report/build-report.ts b/e2e-ui/report/build-report.ts index 0be760802..2f2365664 100644 --- a/e2e-ui/report/build-report.ts +++ b/e2e-ui/report/build-report.ts @@ -16,7 +16,7 @@ export interface UiManifestEntry { category: string; source: string; device: "desktop" | "mobile"; - theme: "dark" | "light"; + theme: string; locale: "zh" | "en"; path: string; description: string; diff --git a/e2e-ui/scenes/index.ts b/e2e-ui/scenes/index.ts index 09bff156c..210ddd312 100644 --- a/e2e-ui/scenes/index.ts +++ b/e2e-ui/scenes/index.ts @@ -2,7 +2,7 @@ import { UI_PREVIEW_SCENE_METADATA } from "../../packages/web/src/ui-preview/sce export interface UiCaptureVariant { device: "desktop" | "mobile"; - theme: "dark" | "light"; + theme: string; locale: "zh" | "en"; } diff --git a/e2e/specs/quality/general.spec.ts b/e2e/specs/quality/general.spec.ts index efac49bac..3ab05ab96 100644 --- a/e2e/specs/quality/general.spec.ts +++ b/e2e/specs/quality/general.spec.ts @@ -7,10 +7,10 @@ interface PerformanceWithMemory extends Performance { } test.describe("@phase4 quality acceptance", () => { - test("P4-01 light theme tokens defined", async ({ page }) => { + test("P4-01 named theme tokens defined", async ({ page }) => { await page.goto("/"); - // Verify light theme tokens exist in CSS + // Verify named theme token blocks exist in CSS const tokensExist = true; expect(tokensExist).toBe(true); }); @@ -36,11 +36,11 @@ test.describe("@phase4 quality acceptance", () => { test("P4-03 theme persisted to localStorage", async ({ page }) => { await page.goto("/"); - // Theme should be stored in localStorage (default is 'dark') + // Theme should be stored in localStorage as a themeId. // atomWithStorage may not immediately write default value - const theme = await page.evaluate(() => localStorage.getItem("ui.theme")); + const theme = await page.evaluate(() => localStorage.getItem("ui.themeId")); // Either the theme is stored or it will be stored when user interacts - expect(theme === null || theme === '"dark"' || theme === "dark").toBe(true); + expect(theme === null || theme === '"mint-dark"' || theme === "mint-dark").toBe(true); }); test("P4-04 performance optimizations configured", async ({ page }) => { @@ -302,12 +302,12 @@ test.describe("@phase4 quality acceptance", () => { test("P4-28 theme persistence after restart", async ({ page }) => { await page.goto("/"); // Set theme - await page.evaluate(() => localStorage.setItem("ui.theme", '"light"')); + await page.evaluate(() => localStorage.setItem("ui.themeId", '"mint-light"')); await page.reload(); - const theme = await page.evaluate(() => localStorage.getItem("ui.theme")); - expect(theme).toBe('"light"'); + const theme = await page.evaluate(() => localStorage.getItem("ui.themeId")); + expect(theme).toBe('"mint-light"'); // Cleanup - await page.evaluate(() => localStorage.setItem("ui.theme", '"dark"')); + await page.evaluate(() => localStorage.setItem("ui.themeId", '"mint-dark"')); }); test("P4-29 locale persistence", async ({ page }) => { diff --git a/e2e/specs/settings/general.spec.ts b/e2e/specs/settings/general.spec.ts index 7410673f9..b9553856f 100644 --- a/e2e/specs/settings/general.spec.ts +++ b/e2e/specs/settings/general.spec.ts @@ -45,6 +45,10 @@ test.describe("@phase2 settings acceptance", () => { await expect( page.locator(".settings-group-title").filter({ hasText: settingsGroupPattern("theme") }) ).toBeVisible(); + await expect(page.getByRole("button", { name: /^(?:Mint|薄荷)$/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /^(?:Graphite)$/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /^(?:Nord)$/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /^(?:High Contrast|高对比)$/ })).toBeVisible(); await expect(page.getByRole("button", { name: /^(?:深色|Dark)$/ })).toBeVisible(); await expect(page.getByRole("button", { name: /^(?:浅色|Light)$/ })).toBeVisible(); }); diff --git a/e2e/specs/settings/mobile-copy-on-select.spec.ts b/e2e/specs/settings/mobile-copy-on-select.spec.ts new file mode 100644 index 000000000..d6e952e88 --- /dev/null +++ b/e2e/specs/settings/mobile-copy-on-select.spec.ts @@ -0,0 +1,429 @@ +import { expect, type Locator, type Page, test } from "@playwright/test"; +import { translateForE2E, translatePatternForE2E } from "../../fixtures/i18n"; +import { openSettingsSection } from "../../fixtures/phase2-i18n"; + +const MOBILE_VIEWPORT = { width: 430, height: 932 }; +const LONG_LINE_TEXT = "MOBILE_COPY_MODE_LONG_LINE_0123456789_".repeat(12); +const SHORT_LINE_TEXT = "MOBILE_COPY_MODE_SHORT"; + +function directoryRow(page: Page, name: string): Locator { + return page + .locator(".fp-dir") + .filter({ + has: page.locator(".fp-dir-name").filter({ hasText: new RegExp(`^${escapeRegExp(name)}$`) }), + }) + .first(); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function openMobileWorkspace(page: Page): Promise { + await page.goto("/workspace"); + await page.waitForFunction( + () => { + const loading = document.querySelector( + '.app-loading-shell, [data-testid="workspace-resolving-shell"]' + ); + const shell = document.querySelector('[data-testid="mobile-shell"]'); + const workspaceEntry = Array.from(document.querySelectorAll("button")).some((button) => { + const label = (button.getAttribute("aria-label") || button.textContent || "").trim(); + return /^(Open Workspace|打开工作区|New workspace|新建工作区)$/.test(label); + }); + + return !loading && (Boolean(shell) || workspaceEntry); + }, + { timeout: 20000 } + ); + + if ( + await page + .getByTestId("mobile-shell") + .isVisible() + .catch(() => false) + ) { + return; + } + + const openWorkspace = page.getByRole("button", { + name: translatePatternForE2E("action.open_workspace"), + }); + const newWorkspace = page.getByRole("button", { + name: translatePatternForE2E("tooltip.new_workspace"), + }); + + if ( + await openWorkspace + .first() + .isVisible() + .catch(() => false) + ) { + try { + await openWorkspace.first().click(); + } catch (error) { + if ( + await page + .getByTestId("mobile-shell") + .isVisible() + .catch(() => false) + ) { + return; + } + + if ( + await page + .locator(".mobile-sheet--launch, .launch-modal") + .first() + .isVisible() + .catch(() => false) + ) { + // launch UI is already open + } else { + throw error; + } + } + } else if ( + await newWorkspace + .first() + .isVisible() + .catch(() => false) + ) { + await newWorkspace.first().click(); + } else { + await page + .getByRole("button", { + name: translatePatternForE2E("mobile.topbar.switch_workspace"), + }) + .click(); + await page + .getByRole("button", { + name: translatePatternForE2E("tooltip.new_workspace"), + }) + .click(); + } + + await expect(page.locator(".mobile-sheet--launch, .launch-modal").first()).toBeVisible({ + timeout: 10000, + }); + + const homeChip = page.locator(".fp-chip").filter({ hasText: "/home/spencer" }).first(); + if (await homeChip.isVisible().catch(() => false)) { + await homeChip.click(); + await expect(page.locator(".fp-dir-list .directory-loading")).toHaveCount(0); + } + + await directoryRow(page, "workspace").dblclick(); + await expect(page.locator(".fp-dir-list .directory-loading")).toHaveCount(0); + await directoryRow(page, "coder-studio").click(); + + const startButton = page.getByRole("button", { + name: translatePatternForE2E("workspace.launch.start"), + }); + await expect(startButton).toBeEnabled(); + await startButton.click(); + + await expect(page).toHaveURL(/\/workspace$/, { timeout: 15000 }); + await expect(page.getByTestId("mobile-shell")).toBeVisible({ timeout: 15000 }); +} + +async function openMobileTerminalSheet(page: Page): Promise { + await page + .getByRole("button", { name: translatePatternForE2E("mobile.dock.open_terminal") }) + .click(); + await expect(page.locator(".mobile-sheet--terminal")).toBeVisible({ timeout: 10000 }); +} + +async function ensureTerminalExists(page: Page): Promise { + const xterm = page.locator(".mobile-sheet--terminal .xterm").first(); + if (await xterm.isVisible().catch(() => false)) { + return; + } + + await page + .locator(".mobile-sheet--terminal") + .getByRole("button", { name: translatePatternForE2E("terminal.new_terminal") }) + .first() + .click(); + await expect(page.locator(".mobile-sheet--terminal .xterm textarea").first()).toBeVisible({ + timeout: 15000, + }); +} + +async function seedLongTerminalLine(page: Page): Promise { + const terminalInput = page.locator(".mobile-sheet--terminal .xterm textarea").first(); + await expect(terminalInput).toBeVisible({ timeout: 10000 }); + await terminalInput.click(); + await page.keyboard.type(`printf '${LONG_LINE_TEXT}\\n'`); + await page.keyboard.press("Enter"); + await expect(page.locator(".mobile-sheet--terminal .xterm-rows").first()).toContainText( + "MOBILE_COPY_MODE_LONG_LINE", + { + timeout: 10000, + } + ); +} + +async function seedShortTerminalLine(page: Page): Promise { + const terminalInput = page.locator(".mobile-sheet--terminal .xterm textarea").first(); + await expect(terminalInput).toBeVisible({ timeout: 10000 }); + await terminalInput.click(); + await page.keyboard.type(`printf '${SHORT_LINE_TEXT}\\n'`); + await page.keyboard.press("Enter"); + await expect(page.locator(".mobile-sheet--terminal .xterm-rows").first()).toContainText( + SHORT_LINE_TEXT, + { + timeout: 10000, + } + ); +} + +async function longPressTerminalRows(page: Page, rowIndex = 1): Promise { + const rows = page.locator(".mobile-sheet--terminal .xterm-rows").first(); + await expect(rows).toBeVisible({ timeout: 10000 }); + + const targetRow = rows.locator(":scope > div").nth(rowIndex); + await expect(targetRow).toBeVisible({ timeout: 10000 }); + + const box = await targetRow.boundingBox(); + expect(box).toBeTruthy(); + if (!box) { + throw new Error("xterm row bounding box missing"); + } + + const x = box.x + Math.min(48, Math.max(16, box.width / 4)); + const y = box.y + Math.min(16, Math.max(8, box.height / 2)); + + await targetRow.evaluate( + (node, { clientX, clientY }) => { + if (!(node instanceof HTMLElement)) { + throw new Error("xterm row missing"); + } + + const touches = [{ identifier: 1, clientX, clientY, target: node }]; + const buildEvent = ( + type: string, + activeTouches: typeof touches, + changedTouches = activeTouches + ) => { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "touches", { value: activeTouches }); + Object.defineProperty(event, "targetTouches", { value: activeTouches }); + Object.defineProperty(event, "changedTouches", { value: changedTouches }); + return event; + }; + + node.dispatchEvent(buildEvent("touchstart", touches)); + window.setTimeout(() => { + node.dispatchEvent(buildEvent("touchend", [], touches)); + }, 650); + }, + { clientX: x, clientY: y } + ); +} + +async function findPrintedLineRowIndex(page: Page): Promise { + const rowTexts = await page + .locator(".mobile-sheet--terminal .xterm-rows > div") + .allTextContents(); + return rowTexts.findIndex((text) => text.startsWith("MOBILE_COPY_MODE_LONG_LINE")); +} + +async function findShortPrintedLineRowIndex(page: Page): Promise { + const rowTexts = await page + .locator(".mobile-sheet--terminal .xterm-rows > div") + .allTextContents(); + return rowTexts.findIndex((text) => text.startsWith(SHORT_LINE_TEXT)); +} + +async function longPressTerminalRowBlankRightSide(page: Page, rowIndex: number): Promise { + const rows = page.locator(".mobile-sheet--terminal .xterm-rows").first(); + await expect(rows).toBeVisible({ timeout: 10000 }); + + const targetRow = rows.locator(":scope > div").nth(rowIndex); + await expect(targetRow).toBeVisible({ timeout: 10000 }); + + const box = await targetRow.boundingBox(); + expect(box).toBeTruthy(); + if (!box) { + throw new Error("xterm row bounding box missing"); + } + + const x = box.x + Math.max(box.width - 12, box.width * 0.85); + const y = box.y + Math.min(16, Math.max(8, box.height / 2)); + + await targetRow.evaluate( + (node, { clientX, clientY }) => { + if (!(node instanceof HTMLElement)) { + throw new Error("xterm row missing"); + } + + const touches = [{ identifier: 1, clientX, clientY, target: node }]; + const buildEvent = ( + type: string, + activeTouches: typeof touches, + changedTouches = activeTouches + ) => { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "touches", { value: activeTouches }); + Object.defineProperty(event, "targetTouches", { value: activeTouches }); + Object.defineProperty(event, "changedTouches", { value: changedTouches }); + return event; + }; + + node.dispatchEvent(buildEvent("touchstart", touches)); + window.setTimeout(() => { + node.dispatchEvent(buildEvent("touchend", [], touches)); + }, 650); + }, + { clientX: x, clientY: y } + ); +} + +async function setMobileCopyOnSelect(page: Page, enabled: boolean): Promise { + await page.goto("/settings"); + await expect(page.locator(".settings-page")).toBeVisible({ timeout: 15000 }); + await openSettingsSection(page, "general"); + + const toggle = page.getByRole("switch", { + name: translatePatternForE2E("settings.copy_on_select"), + }); + await expect(toggle).toBeVisible({ timeout: 10000 }); + + const checked = (await toggle.getAttribute("aria-checked")) === "true"; + if (checked !== enabled) { + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-checked", enabled ? "true" : "false"); + } +} + +test.describe("mobile copy on select", () => { + test.use({ viewport: MOBILE_VIEWPORT, hasTouch: true, isMobile: true }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + + let copiedText = ""; + Object.defineProperty(window, "__mobileCopiedText", { + configurable: true, + get() { + return copiedText; + }, + }); + + if (navigator.clipboard) { + Object.defineProperty(navigator.clipboard, "writeText", { + configurable: true, + value: async (text: string) => { + copiedText = text; + }, + }); + return; + } + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: async (text: string) => { + copiedText = text; + }, + }, + }); + }); + }); + + test("mobile long press copies the wrapped logical line without opening a copy overlay", async ({ + page, + }) => { + await setMobileCopyOnSelect(page, true); + await openMobileWorkspace(page); + await openMobileTerminalSheet(page); + await ensureTerminalExists(page); + await seedLongTerminalLine(page); + + await expect + .poll(async () => { + return page.locator(".mobile-sheet--terminal .xterm-rows > div").count(); + }) + .toBeGreaterThan(1); + + await expect + .poll(async () => { + return findPrintedLineRowIndex(page); + }) + .not.toBe(-1); + const printedLineRowIndex = await findPrintedLineRowIndex(page); + await longPressTerminalRows(page, printedLineRowIndex); + + await expect(page.getByText(translateForE2E("terminal.copied_current_line", "en"))).toBeVisible( + { timeout: 5000 } + ); + await expect(page.locator(".mobile-terminal-copy-mode")).toHaveCount(0); + + await expect + .poll(async () => { + return page.evaluate(() => { + return (window as Window & { __mobileCopiedText?: string }).__mobileCopiedText ?? ""; + }); + }) + .toBe(LONG_LINE_TEXT); + }); + + test("mobile long press does not copy when copy on select is disabled", async ({ page }) => { + await setMobileCopyOnSelect(page, false); + await openMobileWorkspace(page); + await openMobileTerminalSheet(page); + await ensureTerminalExists(page); + await seedLongTerminalLine(page); + + await expect + .poll(async () => { + return findPrintedLineRowIndex(page); + }) + .not.toBe(-1); + const printedLineRowIndex = await findPrintedLineRowIndex(page); + await longPressTerminalRows(page, printedLineRowIndex); + + await expect(page.locator(".mobile-terminal-copy-mode")).toHaveCount(0); + await expect(page.getByText(translateForE2E("terminal.copied_current_line", "en"))).toHaveCount( + 0 + ); + await expect + .poll(async () => { + return page.evaluate(() => { + return (window as Window & { __mobileCopiedText?: string }).__mobileCopiedText ?? ""; + }); + }) + .toBe(""); + }); + + test("mobile long press does not copy when pressing the blank area to the right of a short row", async ({ + page, + }) => { + await setMobileCopyOnSelect(page, true); + await openMobileWorkspace(page); + await openMobileTerminalSheet(page); + await ensureTerminalExists(page); + await seedShortTerminalLine(page); + + await expect + .poll(async () => { + return findShortPrintedLineRowIndex(page); + }) + .not.toBe(-1); + const printedLineRowIndex = await findShortPrintedLineRowIndex(page); + await longPressTerminalRowBlankRightSide(page, printedLineRowIndex); + + await expect(page.getByText(translateForE2E("terminal.copied_current_line", "en"))).toHaveCount( + 0 + ); + await expect + .poll(async () => { + return page.evaluate(() => { + return (window as Window & { __mobileCopiedText?: string }).__mobileCopiedText ?? ""; + }); + }) + .toBe(""); + }); +}); diff --git a/packages/cli/src/bin.test.ts b/packages/cli/src/bin.test.ts index 5db2ff8dc..d7fc1af32 100644 --- a/packages/cli/src/bin.test.ts +++ b/packages/cli/src/bin.test.ts @@ -13,6 +13,7 @@ const { readCliConfig, startManagedServer, startServer, + verifyLocalDatabaseCompatibility, stopRunningServer, writeCliConfig, } = vi.hoisted(() => ({ @@ -25,6 +26,7 @@ const { readCliConfig: vi.fn(), startManagedServer: vi.fn(), startServer: vi.fn(), + verifyLocalDatabaseCompatibility: vi.fn(), stopRunningServer: vi.fn(), writeCliConfig: vi.fn(), })); @@ -50,6 +52,7 @@ vi.mock("./auth-control.js", () => ({ vi.mock("./server-runner.js", () => ({ startServer, + verifyLocalDatabaseCompatibility, })); vi.mock("./prompts.js", () => ({ @@ -69,6 +72,7 @@ beforeEach(() => { writeCliConfig.mockImplementation(() => undefined); startManagedServer.mockResolvedValue(undefined); startServer.mockResolvedValue({ stop: vi.fn() }); + verifyLocalDatabaseCompatibility.mockImplementation(() => undefined); stopRunningServer.mockResolvedValue(false); clearAuthBlockByIp.mockResolvedValue(false); listAuthBlocks.mockResolvedValue([]); @@ -147,6 +151,33 @@ describe("main", () => { expect(logSpy).toHaveBeenCalledWith("Starting Coder Studio Server in foreground..."); }); + it("prompts to delete and rebuild when foreground startup sees an incompatible schema", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "cs-cli-db-rebuild-")); + const dbPath = join(tempDir, "coder-studio.db"); + writeFileSync(dbPath, "broken", "utf-8"); + + const incompatibleError = Object.assign(new Error("schema mismatch"), { + code: "db_incompatible_schema", + dbPath, + }); + startServer.mockRejectedValueOnce(incompatibleError).mockResolvedValueOnce({ stop: vi.fn() }); + confirmYesNo.mockResolvedValue(true); + + try { + await expect(main(["serve", "--foreground"])).resolves.toBeUndefined(); + + expect(confirmYesNo).toHaveBeenCalledWith( + expect.stringContaining("Delete and rebuild the local database") + ); + expect(startServer).toHaveBeenCalledTimes(2); + expect(existsSync(dbPath)).toBe(false); + } finally { + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + } + }); + it("starts pm2-managed mode for bare serve", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -161,6 +192,45 @@ describe("main", () => { expect(logSpy).toHaveBeenCalledWith("Run `coder-studio status` to inspect the server."); }); + it("preflights incompatible schemas before managed startup and rebuilds before launching pm2", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "cs-cli-db-preflight-")); + const dbPath = join(tempDir, "coder-studio.db"); + writeFileSync(dbPath, "broken", "utf-8"); + + const incompatibleError = Object.assign(new Error("schema mismatch"), { + code: "db_incompatible_schema", + dbPath, + }); + verifyLocalDatabaseCompatibility + .mockImplementationOnce(() => { + throw incompatibleError; + }) + .mockImplementationOnce(() => undefined); + confirmYesNo.mockResolvedValue(true); + + try { + await main(["serve"]); + + expect(confirmYesNo).toHaveBeenCalledWith( + expect.stringContaining("Delete and rebuild the local database") + ); + expect(verifyLocalDatabaseCompatibility).toHaveBeenCalledTimes(2); + expect(startManagedServer).toHaveBeenCalledTimes(1); + const verifyOrder = verifyLocalDatabaseCompatibility.mock.invocationCallOrder[0]; + const startOrder = startManagedServer.mock.invocationCallOrder[0]; + expect(verifyOrder).toBeDefined(); + expect(startOrder).toBeDefined(); + expect(verifyOrder ?? Number.POSITIVE_INFINITY).toBeLessThan( + startOrder ?? Number.POSITIVE_INFINITY + ); + expect(existsSync(dbPath)).toBe(false); + } finally { + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + } + }); + it("prints status output for status command", async () => { getServerStatus.mockResolvedValue({ status: "running", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7996e2a7a..91697aa4b 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,4 +1,4 @@ -import { existsSync } from "fs"; +import { existsSync, rmSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { clearAuthBlockByIp, listAuthBlocks } from "./auth-control.js"; @@ -11,7 +11,7 @@ import { parseArgs } from "./parse-args.js"; import { startManagedServer } from "./pm2-control.js"; import { confirmYesNo, isInteractiveSession } from "./prompts.js"; import { getServerStatus, type ServerStatus, stopRunningServer } from "./server-control.js"; -import { startServer } from "./server-runner.js"; +import { startServer, verifyLocalDatabaseCompatibility } from "./server-runner.js"; import { getBrowserUrl, getListenIp, getListenUrl } from "./server-url.js"; const MANAGED_SERVER_WAIT_MS = 5000; @@ -221,6 +221,60 @@ async function startManagedServerFlow(): Promise { }); } +interface IncompatibleSchemaErrorLike { + code: "db_incompatible_schema"; + dbPath: string; +} + +function parseIncompatibleSchemaError(error: unknown): IncompatibleSchemaErrorLike | null { + if (!error || typeof error !== "object") { + return null; + } + + const candidate = error as { code?: unknown; dbPath?: unknown }; + if (candidate.code !== "db_incompatible_schema" || typeof candidate.dbPath !== "string") { + return null; + } + + return { + code: "db_incompatible_schema", + dbPath: candidate.dbPath, + }; +} + +async function handleIncompatibleSchema(error: unknown): Promise { + const payload = parseIncompatibleSchemaError(error); + if (!payload) { + return false; + } + + const approved = isInteractiveSession() + ? await confirmYesNo( + `Local database is incompatible at ${payload.dbPath}. Delete and rebuild the local database? [y/N] ` + ) + : false; + + if (!approved) { + throw error; + } + + rmSync(payload.dbPath, { force: true }); + return true; +} + +async function verifyManagedDatabaseCompatibility(): Promise { + try { + verifyLocalDatabaseCompatibility(); + } catch (error) { + const rebuilt = await handleIncompatibleSchema(error); + if (!rebuilt) { + throw error; + } + + verifyLocalDatabaseCompatibility(); + } +} + async function openManagedServerInBrowser(existingStatus?: ServerStatus | null): Promise { const status = existingStatus ?? (await getServerStatus()); const browserUrl = getBrowserUrl(status); @@ -313,6 +367,7 @@ export async function main(argv = process.argv.slice(2)): Promise { if (args.command === "open") { const startup = await prepareManagedStartup(args.restart); if (startup.existingStatus === null) { + await verifyManagedDatabaseCompatibility(); await startManagedServerFlow(); } @@ -331,7 +386,15 @@ export async function main(argv = process.argv.slice(2)): Promise { } console.log("Starting Coder Studio Server in foreground..."); - await startServer(); + try { + await startServer(); + } catch (error) { + const rebuilt = await handleIncompatibleSchema(error); + if (!rebuilt) { + throw error; + } + await startServer(); + } return; } @@ -340,6 +403,7 @@ export async function main(argv = process.argv.slice(2)): Promise { return; } + await verifyManagedDatabaseCompatibility(); await startManagedServerFlow(); console.log("Coder Studio server started in background."); diff --git a/packages/cli/src/server-runner.test.ts b/packages/cli/src/server-runner.test.ts index 9407998ae..e03e57d6d 100644 --- a/packages/cli/src/server-runner.test.ts +++ b/packages/cli/src/server-runner.test.ts @@ -2,8 +2,19 @@ import { fileURLToPath } from "url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { getCliVersion } from "./package-manifest.js"; -const { createServer, readCliConfig, hasWebAssets, getStaticAssetsDir } = vi.hoisted(() => ({ +const { + createServer, + parseServerConfig, + openDatabase, + closeDatabase, + readCliConfig, + hasWebAssets, + getStaticAssetsDir, +} = vi.hoisted(() => ({ createServer: vi.fn(), + parseServerConfig: vi.fn(), + openDatabase: vi.fn(), + closeDatabase: vi.fn(), readCliConfig: vi.fn(), hasWebAssets: vi.fn(), getStaticAssetsDir: vi.fn(), @@ -11,6 +22,9 @@ const { createServer, readCliConfig, hasWebAssets, getStaticAssetsDir } = vi.hoi vi.mock("@coder-studio/server", () => ({ createServer, + parseServerConfig, + openDatabase, + closeDatabase, })); vi.mock("./config-store.js", () => ({ @@ -22,7 +36,12 @@ vi.mock("./embed.js", () => ({ getStaticAssetsDir, })); -import { buildServerConfig, runServerEntrypoint, startServer } from "./server-runner"; +import { + buildServerConfig, + runServerEntrypoint, + startServer, + verifyLocalDatabaseCompatibility, +} from "./server-runner"; describe("server-runner", () => { afterEach(() => { @@ -97,6 +116,30 @@ describe("server-runner", () => { expect(processExitSpy).toHaveBeenCalledWith(0); }); + it("verifies local database compatibility using the resolved server config", () => { + readCliConfig.mockReturnValue({ + dataDir: "/tmp/cs-data/coder-studio.db", + }); + hasWebAssets.mockReturnValue(true); + getStaticAssetsDir.mockReturnValue("/tmp/web"); + parseServerConfig.mockReturnValue({ + dataDir: "/tmp/cs-data/coder-studio.db", + }); + + const db = { close: vi.fn() }; + openDatabase.mockReturnValue(db); + + verifyLocalDatabaseCompatibility(); + + expect(parseServerConfig).toHaveBeenCalledWith({ + appVersion: getCliVersion(import.meta.url), + dataDir: "/tmp/cs-data/coder-studio.db", + webRoot: "/tmp/web", + }); + expect(openDatabase).toHaveBeenCalledWith("/tmp/cs-data/coder-studio.db"); + expect(closeDatabase).toHaveBeenCalledWith(db); + }); + it("starts the server when executed as the entrypoint", async () => { readCliConfig.mockReturnValue(null); hasWebAssets.mockReturnValue(true); diff --git a/packages/cli/src/server-runner.ts b/packages/cli/src/server-runner.ts index 30b23d3bf..46ea9025f 100644 --- a/packages/cli/src/server-runner.ts +++ b/packages/cli/src/server-runner.ts @@ -1,4 +1,7 @@ import type { Server, ServerConfig } from "@coder-studio/server"; +import { closeDatabase, openDatabase, parseServerConfig } from "@coder-studio/server"; +import { mkdirSync } from "fs"; +import { dirname } from "path"; import { fileURLToPath } from "url"; import { readCliConfig } from "./config-store.js"; import { getStaticAssetsDir, hasWebAssets } from "./embed.js"; @@ -35,6 +38,16 @@ export const buildServerConfig = (): Partial => { return config; }; +export const verifyLocalDatabaseCompatibility = (): void => { + const config = parseServerConfig(buildServerConfig()); + if (config.dataDir !== ":memory:") { + mkdirSync(dirname(config.dataDir), { recursive: true }); + } + + const db = openDatabase(config.dataDir); + closeDatabase(db); +}; + const createShutdownHandler = (server: Server) => async () => { await server.stop(); process.exit(0); diff --git a/packages/core/src/domain/supervisor.ts b/packages/core/src/domain/supervisor.ts index 248295123..bbe0cc39f 100644 --- a/packages/core/src/domain/supervisor.ts +++ b/packages/core/src/domain/supervisor.ts @@ -1,10 +1,27 @@ // Supervisor domain types (PRD §16) -export type SupervisorState = "inactive" | "idle" | "evaluating" | "injecting" | "paused" | "error"; +export type SupervisorState = + | "inactive" + | "idle" + | "evaluating" + | "injecting" + | "paused" + | "error" + | "stopped"; -export type CycleStatus = "queued" | "evaluating" | "completed" | "injected" | "failed"; +export type CycleStatus = + | "queued" + | "evaluating" + | "completed" + | "injected" + | "failed" + | "cancelled"; -export type CycleTrigger = "turn_completed" | "manual"; +export type CycleTrigger = "turn_completed" | "manual" | "scheduled"; + +export type SupervisorStopReason = "objective_complete" | "max_supervision_count_reached"; + +export type SupervisorCycleAttemptStatus = "evaluating" | "completed" | "failed" | "cancelled"; export type EvidenceSource = "headless_snapshot" | "transcript" | "terminal_fallback"; @@ -26,6 +43,24 @@ export interface SupervisorCycle { errorReason?: string; } +export interface SupervisorCycleAttempt { + id: string; + cycleId: string; + attemptIndex: number; + status: SupervisorCycleAttemptStatus; + startedAt: number; + completedAt?: number; + errorReason?: string; + providerModel?: string; +} + +export interface SupervisorCycleAttemptPatch { + status?: SupervisorCycleAttemptStatus; + completedAt?: number | null; + errorReason?: string | null; + providerModel?: string | null; +} + export interface Supervisor { id: string; sessionId: string; @@ -33,6 +68,11 @@ export interface Supervisor { state: SupervisorState; objective: string; evaluatorProviderId: string; + evaluatorModel?: string; + maxSupervisionCount: number; + completedSupervisionCount: number; + scheduledAt?: number; + stopReason?: SupervisorStopReason; cycles: SupervisorCycle[]; lastCycleAt?: number; lastEvaluatedTurnId?: string; @@ -50,6 +90,13 @@ export interface SupervisorConfig { export const DEFAULT_SUPERVISOR_EVALUATION_TIMEOUT_SEC = 600; export const MAX_SUPERVISOR_EVALUATION_TIMEOUT_SEC = 86_400; +export const DEFAULT_SUPERVISOR_RETRY_ENABLED = false; +export const DEFAULT_SUPERVISOR_RETRY_MAX_COUNT = 0; +export const MAX_SUPERVISOR_RETRY_MAX_COUNT = 20; +export const DEFAULT_SUPERVISOR_RETRY_DELAY_SEC = 10; +export const MAX_SUPERVISOR_RETRY_DELAY_SEC = 3_600; +export const DEFAULT_SUPERVISOR_RETRY_ON_TIMEOUT = true; +export const DEFAULT_SUPERVISOR_RETRY_ON_EVALUATOR_ERROR = false; export function resolveSupervisorEvaluationTimeoutSec(value: unknown): number { if (typeof value !== "number" || !Number.isFinite(value) || !Number.isSafeInteger(value)) { @@ -63,6 +110,42 @@ export function resolveSupervisorEvaluationTimeoutSec(value: unknown): number { return value; } +export function resolveSupervisorRetryEnabled(value: unknown): boolean { + return typeof value === "boolean" ? value : DEFAULT_SUPERVISOR_RETRY_ENABLED; +} + +export function resolveSupervisorRetryMaxCount(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || !Number.isSafeInteger(value)) { + return DEFAULT_SUPERVISOR_RETRY_MAX_COUNT; + } + + if (value < 0 || value > MAX_SUPERVISOR_RETRY_MAX_COUNT) { + return DEFAULT_SUPERVISOR_RETRY_MAX_COUNT; + } + + return value; +} + +export function resolveSupervisorRetryDelaySec(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || !Number.isSafeInteger(value)) { + return DEFAULT_SUPERVISOR_RETRY_DELAY_SEC; + } + + if (value < 1 || value > MAX_SUPERVISOR_RETRY_DELAY_SEC) { + return DEFAULT_SUPERVISOR_RETRY_DELAY_SEC; + } + + return value; +} + +export function resolveSupervisorRetryOnTimeout(value: unknown): boolean { + return typeof value === "boolean" ? value : DEFAULT_SUPERVISOR_RETRY_ON_TIMEOUT; +} + +export function resolveSupervisorRetryOnEvaluatorError(value: unknown): boolean { + return typeof value === "boolean" ? value : DEFAULT_SUPERVISOR_RETRY_ON_EVALUATOR_ERROR; +} + export const DEFAULT_SUPERVISOR_CONFIG: SupervisorConfig = { maxCyclesPerSession: 100, terminalLinesForEvaluation: 500, diff --git a/packages/core/src/domain/types.ts b/packages/core/src/domain/types.ts index 9d27f7df1..da9abc970 100644 --- a/packages/core/src/domain/types.ts +++ b/packages/core/src/domain/types.ts @@ -154,7 +154,7 @@ export interface Settings { evaluationTimeoutSec: number; }; appearance: { - theme: "dark"; + themeId: string; terminalRenderer: "standard" | "compatibility"; locale: "zh" | "en"; }; diff --git a/packages/providers/src/codex/config-schema.ts b/packages/providers/src/codex/config-schema.ts index 4ad46fb49..6e492130f 100644 --- a/packages/providers/src/codex/config-schema.ts +++ b/packages/providers/src/codex/config-schema.ts @@ -4,6 +4,7 @@ import { z } from "zod"; * Codex configuration schema */ export const codexConfigSchema = z.object({ + model: z.string().min(1).optional(), additionalArgs: z.array(z.string()).default([]), envVars: z.record(z.string(), z.string()).default({}), }); diff --git a/packages/providers/src/codex/definition.test.ts b/packages/providers/src/codex/definition.test.ts index 865d4e57a..3ed53b424 100644 --- a/packages/providers/src/codex/definition.test.ts +++ b/packages/providers/src/codex/definition.test.ts @@ -179,6 +179,24 @@ describe("Codex Provider Definition", () => { expect(promptIdx).toBe(argv.length - 1); expect(configIdx).toBeLessThan(promptIdx); }); + + it("passes the model override through to codex exec", () => { + const result = codexDefinition.buildSupervisorEvalCommand?.( + { + model: "gpt-4.1", + additionalArgs: [], + envVars: {}, + }, + { + prompt: "Return strict JSON", + sessionId: "sess-1", + workspacePath: "/workspace", + model: "o3", + } + ); + + expect(result?.argv).toEqual(expect.arrayContaining(["-m", "o3"])); + }); }); describe("defaultConfig", () => { diff --git a/packages/providers/src/codex/supervisor-eval.ts b/packages/providers/src/codex/supervisor-eval.ts index 937a5bc18..34c3b1b36 100644 --- a/packages/providers/src/codex/supervisor-eval.ts +++ b/packages/providers/src/codex/supervisor-eval.ts @@ -26,6 +26,7 @@ export function buildCodexSupervisorEvalCommand( "-s", "read-only", "--skip-git-repo-check", + ...(req.model ? ["-m", req.model] : []), ...cfg.additionalArgs, req.prompt, ], diff --git a/packages/server/src/__tests__/activation-commands.test.ts b/packages/server/src/__tests__/activation-commands.test.ts new file mode 100644 index 000000000..1101badf8 --- /dev/null +++ b/packages/server/src/__tests__/activation-commands.test.ts @@ -0,0 +1,240 @@ +import type { FastifyRequest } from "fastify"; +import { describe, expect, it, vi } from "vitest"; +import { ActivationManager } from "../ws/activation.js"; +import { type CommandContext, dispatch } from "../ws/dispatch.js"; +import type { Broadcaster } from "../ws/hub.js"; +import "../commands/activation.js"; +import "../commands/workspace.js"; + +function createMockRequest(): FastifyRequest { + return { + ip: "127.0.0.1", + headers: { "user-agent": "test-agent" }, + } as unknown as FastifyRequest; +} + +function createBaseContext(overrides?: { + broadcaster?: Broadcaster; + activationMgr?: ActivationManager; +}): CommandContext { + return { + workspaceMgr: { + list: vi.fn(() => [{ id: "workspace-1" }]), + } as unknown as CommandContext["workspaceMgr"], + sessionMgr: {} as never, + terminalMgr: {} as never, + eventBus: {} as never, + broadcaster: + overrides?.broadcaster ?? + ({ + broadcast: vi.fn(), + sendToClient: vi.fn(() => true), + sendBinaryToClient: vi.fn(() => true), + } as unknown as Broadcaster), + db: {} as never, + providerRegistry: [], + fencingMgr: {} as never, + supervisorMgr: {} as never, + autoFetch: {} as never, + activationMgr: overrides?.activationMgr ?? new ActivationManager(), + }; +} + +describe("activation commands", () => { + it("returns generation data from activation.claim", async () => { + const request = createMockRequest(); + const broadcaster = { + broadcast: vi.fn(), + sendToClient: vi.fn(() => true), + sendBinaryToClient: vi.fn(() => true), + getRequestMetadata: vi.fn(() => request), + } satisfies Broadcaster; + const ctx = createBaseContext({ broadcaster }); + + const result = await dispatch( + { + kind: "command", + id: "00000000-0000-4000-8000-000000000001", + op: "activation.claim", + args: { clientInstanceId: "client-a" }, + }, + ctx, + "ws-a" + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + active: true, + generation: 1, + recoveryMode: "fresh", + }); + expect(broadcaster.getRequestMetadata).toHaveBeenCalledWith("ws-a"); + expect(ctx.activationMgr.getLease()).toMatchObject({ + clientInstanceId: "client-a", + wsClientId: "ws-a", + ip: "127.0.0.1", + userAgent: "test-agent", + }); + }); + + it("rejects non-activation commands for websocket clients without an active lease", async () => { + const broadcaster = { + broadcast: vi.fn(), + sendToClient: vi.fn(() => true), + sendBinaryToClient: vi.fn(() => true), + getRequestMetadata: vi.fn(() => createMockRequest()), + } satisfies Broadcaster; + const ctx = createBaseContext({ broadcaster }); + + const result = await dispatch( + { + kind: "command", + id: "00000000-0000-4000-8000-000000000002", + op: "workspace.list", + args: {}, + }, + ctx, + "ws-a" + ); + + expect(result.ok).toBe(false); + expect(result.error).toEqual({ + code: "activation_required", + message: "This tab is no longer the active session", + }); + }); + + it("rejects non-activation websocket commands when metadata lookup returns undefined", async () => { + const broadcaster = { + broadcast: vi.fn(), + sendToClient: vi.fn(() => true), + sendBinaryToClient: vi.fn(() => true), + getRequestMetadata: vi.fn(() => undefined), + } satisfies Broadcaster; + const ctx = createBaseContext({ broadcaster }); + + const result = await dispatch( + { + kind: "command", + id: "00000000-0000-4000-8000-000000000004", + op: "workspace.list", + args: {}, + }, + ctx, + "ws-a" + ); + + expect(result.ok).toBe(false); + expect(result.error).toEqual({ + code: "activation_required", + message: "This tab is no longer the active session", + }); + }); + + it("does not allow a stale websocket to release after same-client rebind", async () => { + const request = createMockRequest(); + const broadcaster = { + broadcast: vi.fn(), + sendToClient: vi.fn(() => true), + sendBinaryToClient: vi.fn(() => true), + getRequestMetadata: vi.fn(() => request), + } satisfies Broadcaster; + const ctx = createBaseContext({ broadcaster }); + + await dispatch( + { + kind: "command", + id: "claim-3", + op: "activation.claim", + args: { clientInstanceId: "client-a" }, + }, + ctx, + "ws-a" + ); + + await dispatch( + { + kind: "command", + id: "claim-4", + op: "activation.claim", + args: { clientInstanceId: "client-a" }, + }, + ctx, + "ws-b" + ); + + const release = await dispatch( + { + kind: "command", + id: "release-stale", + op: "activation.release", + args: { clientInstanceId: "client-a", generation: 1 }, + }, + ctx, + "ws-a" + ); + + expect(release.ok).toBe(true); + expect(release.data).toEqual({ ok: false }); + expect(ctx.activationMgr.getLease()).toMatchObject({ + clientInstanceId: "client-a", + wsClientId: "ws-b", + generation: 1, + }); + }); + + it("claiming from a second client revokes the previous websocket", async () => { + const request = createMockRequest(); + const revokeAndCloseClient = vi.fn(); + const ctx = createBaseContext({ + broadcaster: { + broadcast: vi.fn(), + sendToClient: vi.fn(() => true), + sendBinaryToClient: vi.fn(() => true), + getRequestMetadata: vi.fn(() => request), + revokeAndCloseClient, + } as unknown as Broadcaster, + }); + + await dispatch( + { + kind: "command", + id: "00000000-0000-4000-8000-000000000003", + op: "activation.claim", + args: { clientInstanceId: "client-a" }, + }, + ctx, + "ws-a" + ); + + await dispatch( + { + kind: "command", + id: "00000000-0000-4000-8000-000000000004", + op: "activation.claim", + args: { clientInstanceId: "client-b" }, + }, + ctx, + "ws-b" + ); + + expect(revokeAndCloseClient).toHaveBeenCalledWith("ws-a", 2); + }); + + it("does not block direct internal dispatches without websocket request metadata", async () => { + const ctx = createBaseContext(); + + const result = await dispatch( + { + kind: "command", + id: "00000000-0000-4000-8000-000000000003", + op: "workspace.list", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual([{ id: "workspace-1" }]); + }); +}); diff --git a/packages/server/src/__tests__/activation-manager.test.ts b/packages/server/src/__tests__/activation-manager.test.ts new file mode 100644 index 000000000..3e6ba9e2d --- /dev/null +++ b/packages/server/src/__tests__/activation-manager.test.ts @@ -0,0 +1,97 @@ +import type { FastifyRequest } from "fastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ActivationManager } from "../ws/activation.js"; + +function createMockRequest(): FastifyRequest { + return { + ip: "127.0.0.1", + headers: { "user-agent": "test-agent" }, + } as unknown as FastifyRequest; +} + +describe("ActivationManager", () => { + let manager: ActivationManager; + const request = createMockRequest(); + + beforeEach(() => { + vi.useRealTimers(); + manager = new ActivationManager({ + graceMs: 3_000, + }); + }); + + it("grants the first claimant as active generation 1", () => { + const result = manager.claim("client-a", "ws-a", request); + + expect(result.active).toBe(true); + expect(result.generation).toBe(1); + expect(result.recoveryMode).toBe("fresh"); + expect(result.displacedWsClientId).toBeNull(); + }); + + it("displaces the current holder when a different client claims", () => { + manager.claim("client-a", "ws-a", request); + const displaced = manager.claim("client-b", "ws-b", request); + + expect(displaced.active).toBe(true); + expect(displaced.generation).toBe(2); + expect(displaced.recoveryMode).toBe("takeover"); + expect(displaced.displacedWsClientId).toBe("ws-a"); + expect(manager.getLease()?.clientInstanceId).toBe("client-b"); + }); + + it("allows the same client instance to recover during grace", () => { + const first = manager.claim("client-a", "ws-a", request); + manager.onSocketClosed("ws-a"); + + const recovered = manager.claim("client-a", "ws-a-reloaded", request); + + expect(recovered.active).toBe(true); + expect(recovered.generation).toBe(first.generation); + expect(recovered.recoveryMode).toBe("grace_recover"); + expect(recovered.displacedWsClientId).toBeNull(); + }); + + it("rebinds the same client instance to a new websocket without bumping generation", () => { + const first = manager.claim("client-a", "ws-a", request); + + const rebound = manager.claim("client-a", "ws-a-reloaded", request); + + expect(rebound.active).toBe(true); + expect(rebound.generation).toBe(first.generation); + expect(rebound.recoveryMode).toBe("grace_recover"); + expect(rebound.displacedWsClientId).toBe("ws-a"); + expect(manager.getLease()).toMatchObject({ + clientInstanceId: "client-a", + wsClientId: "ws-a-reloaded", + generation: first.generation, + graceUntil: null, + }); + }); + + it("ignores release for stale generations and clears only the current lease", () => { + const first = manager.claim("client-a", "ws-a", request); + const current = manager.claim("client-b", "ws-b", request); + + manager.release("client-a", first.generation); + expect(manager.getLease()?.clientInstanceId).toBe("client-b"); + + manager.release("client-b", current.generation); + expect(manager.getLease()).toBeNull(); + }); + + it("retains the active lease without idle expiration", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-12T00:00:00.000Z")); + + const lease = manager.claim("client-a", "ws-a", request); + + vi.advanceTimersByTime(12 * 60 * 60 * 1000); + + expect(manager.getLease()).toMatchObject({ + clientInstanceId: "client-a", + wsClientId: "ws-a", + generation: lease.generation, + }); + }); +}); diff --git a/packages/server/src/__tests__/db.test.ts b/packages/server/src/__tests__/db.test.ts index f322b3881..45a5b07b5 100644 --- a/packages/server/src/__tests__/db.test.ts +++ b/packages/server/src/__tests__/db.test.ts @@ -1,9 +1,14 @@ -import type { DatabaseSync } from "node:sqlite"; +import { DatabaseSync } from "node:sqlite"; import { mkdtempSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { closeDatabase, openDatabase } from "../storage/index.js"; +import { + CURRENT_SCHEMA_VERSION, + IncompatibleSchemaError, + V1_SCHEMA_SQL, +} from "../storage/schema-version.js"; describe("Database", () => { let db: DatabaseSync; @@ -57,6 +62,14 @@ describe("Database", () => { expect(result).toBeUndefined(); }); + it("should stamp user_version on fresh empty database initialization", () => { + const dbPath = join(tempDir, "fresh.db"); + db = openDatabase(dbPath); + + const userVersion = db.prepare("PRAGMA user_version").get() as { user_version: number }; + expect(userVersion.user_version).toBe(CURRENT_SCHEMA_VERSION); + }); + it("should keep the schema stable on subsequent opens", () => { const dbPath = join(tempDir, "test.db"); @@ -85,6 +98,79 @@ describe("Database", () => { expect(tables.map((table) => table.name)).not.toContain("_migrations"); }); + it("should upgrade a known v1 supervisor schema to v2 when user_version is unset", () => { + const dbPath = join(tempDir, "v1.db"); + const rawDb = new DatabaseSync(dbPath); + rawDb.exec("PRAGMA user_version = 0"); + rawDb.exec(V1_SCHEMA_SQL); + rawDb.close(); + + db = openDatabase(dbPath); + + const userVersion = db.prepare("PRAGMA user_version").get() as { user_version: number }; + expect(userVersion.user_version).toBe(2); + + const supervisorColumns = db.prepare("PRAGMA table_info(supervisors)").all() as Array<{ + name: string; + }>; + expect(supervisorColumns.map((column) => column.name)).toEqual( + expect.arrayContaining([ + "evaluator_model", + "max_supervision_count", + "completed_supervision_count", + "scheduled_at", + "stop_reason", + ]) + ); + + const upgradedTable = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='supervisor_cycle_attempts'" + ) + .get() as { name: string } | undefined; + expect(upgradedTable?.name).toBe("supervisor_cycle_attempts"); + + const upgradedIndex = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_supervisor_cycle_attempts_cycle'" + ) + .get() as { name: string } | undefined; + expect(upgradedIndex?.name).toBe("idx_supervisor_cycle_attempts_cycle"); + }); + + it("should restamp user_version for an already-current schema when it is unset", () => { + const dbPath = join(tempDir, "current-unstamped.db"); + db = openDatabase(dbPath); + closeDatabase(db); + + const rawDb = new DatabaseSync(dbPath); + rawDb.exec("PRAGMA user_version = 0"); + rawDb.close(); + + db = openDatabase(dbPath); + + const userVersion = db.prepare("PRAGMA user_version").get() as { user_version: number }; + expect(userVersion.user_version).toBe(CURRENT_SCHEMA_VERSION); + }); + + it("should throw a typed incompatible-schema error for unknown schemas", () => { + const dbPath = join(tempDir, "incompatible.db"); + const rawDb = new DatabaseSync(dbPath); + rawDb.exec("CREATE TABLE unexpected_table (id TEXT PRIMARY KEY)"); + rawDb.close(); + + let thrown: unknown; + try { + openDatabase(dbPath); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(IncompatibleSchemaError); + expect(thrown).toMatchObject({ code: "db_incompatible_schema" }); + expect((thrown as Error).message).toContain("db_incompatible_schema"); + }); + it("should create all required tables", () => { const dbPath = join(tempDir, "test.db"); db = openDatabase(dbPath); @@ -104,6 +190,7 @@ describe("Database", () => { "auth_sessions", "supervisors", "supervisor_cycles", + "supervisor_cycle_attempts", "auth_login_blocks", "auth_login_failures", ]) @@ -134,6 +221,7 @@ describe("Database", () => { "idx_supervisors_id_session", "idx_supervisor_cycles_supervisor", "idx_supervisor_cycles_session", + "idx_supervisor_cycle_attempts_cycle", "idx_auth_login_blocks_blocked_until", "idx_auth_login_failures_ip_failed_at", ]) diff --git a/packages/server/src/__tests__/fs/watcher.worktree.test.ts b/packages/server/src/__tests__/fs/watcher.worktree.test.ts new file mode 100644 index 000000000..1630cd8f0 --- /dev/null +++ b/packages/server/src/__tests__/fs/watcher.worktree.test.ts @@ -0,0 +1,97 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { Topics } from "@coder-studio/core"; +import { afterEach, describe, expect, it } from "vitest"; +import { WorkspaceWatcher } from "../../fs/watcher.js"; + +const execFileAsync = promisify(execFile); + +async function runGit(cwd: string, args: string[]) { + await execFileAsync("git", args, { cwd }); +} + +async function waitFor( + predicate: () => boolean, + onTimeout: () => string, + timeoutMs = 5_000, + intervalMs = 50 +): Promise { + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + if (predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(`Timed out waiting for expected watcher event. ${onTimeout()}`); +} + +describe("WorkspaceWatcher worktree events", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + it("broadcasts worktreeChanged when git worktree add and remove mutate git metadata", async () => { + const repoDir = await mkdtemp(join(tmpdir(), "watcher-worktree-")); + tempDirs.push(repoDir); + + await runGit(repoDir, ["init", "-b", "main"]); + await runGit(repoDir, ["config", "user.email", "test@example.com"]); + await runGit(repoDir, ["config", "user.name", "Watcher Test"]); + await writeFile(join(repoDir, ".gitignore"), ".worktrees/\n"); + await writeFile(join(repoDir, "README.md"), "root\n"); + await runGit(repoDir, ["add", "."]); + await runGit(repoDir, ["commit", "-m", "init"]); + + const broadcasts: Array<{ topic: string; payload: unknown }> = []; + const watcher = new WorkspaceWatcher("ws-test", repoDir, { + broadcast(topic, payload) { + broadcasts.push({ topic, payload }); + }, + }); + + try { + await new Promise((resolve) => setTimeout(resolve, 300)); + await runGit(repoDir, ["worktree", "add", ".worktrees/feature-a", "-b", "feature/a"]); + + await waitFor( + () => + broadcasts.some( + (event) => + event.topic === Topics.workspaceGitState("ws-test") && + event.payload && + typeof event.payload === "object" && + "worktreeChanged" in event.payload && + (event.payload as { worktreeChanged?: boolean }).worktreeChanged === true + ), + () => `Observed broadcasts after add: ${JSON.stringify(broadcasts)}` + ); + + broadcasts.length = 0; + + await runGit(repoDir, ["worktree", "remove", ".worktrees/feature-a", "--force"]); + + await waitFor( + () => + broadcasts.some( + (event) => + event.topic === Topics.workspaceGitState("ws-test") && + event.payload && + typeof event.payload === "object" && + "worktreeChanged" in event.payload && + (event.payload as { worktreeChanged?: boolean }).worktreeChanged === true + ), + () => `Observed broadcasts after remove: ${JSON.stringify(broadcasts)}` + ); + } finally { + await watcher.close(); + } + }, 15_000); +}); diff --git a/packages/server/src/__tests__/supervisor-commands.test.ts b/packages/server/src/__tests__/supervisor-commands.test.ts index da1439fa8..08de15697 100644 --- a/packages/server/src/__tests__/supervisor-commands.test.ts +++ b/packages/server/src/__tests__/supervisor-commands.test.ts @@ -12,6 +12,10 @@ describe("supervisor commands", () => { state: "idle", objective: input.objective, evaluatorProviderId: input.evaluatorProviderId, + evaluatorModel: input.evaluatorModel, + maxSupervisionCount: input.maxSupervisionCount ?? 0, + completedSupervisionCount: 0, + scheduledAt: input.scheduledAt, cycles: [], createdAt: 1, updatedAt: 1, @@ -24,6 +28,10 @@ describe("supervisor commands", () => { state: "idle", objective: patch.objective ?? "existing objective", evaluatorProviderId: patch.evaluatorProviderId ?? "claude", + evaluatorModel: patch.evaluatorModel ?? undefined, + maxSupervisionCount: patch.maxSupervisionCount ?? 0, + completedSupervisionCount: 0, + scheduledAt: patch.scheduledAt ?? undefined, cycles: [], createdAt: 1, updatedAt: 2, @@ -73,6 +81,35 @@ describe("supervisor commands", () => { ); }); + it("passes evaluatorModel, maxSupervisionCount, and scheduledAt through supervisor.create", async () => { + const result = await dispatch( + { + kind: "command", + id: "cmd-1b", + op: "supervisor.create", + args: { + sessionId: "sess-1", + workspaceId: "ws-1", + objective: "Ship execution policy", + evaluatorProviderId: "codex", + evaluatorModel: "o3", + maxSupervisionCount: 5, + scheduledAt: 1_746_950_400_000, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(supervisorMgr.create).toHaveBeenCalledWith( + expect.objectContaining({ + evaluatorModel: "o3", + maxSupervisionCount: 5, + scheduledAt: 1_746_950_400_000, + }) + ); + }); + it("rejects legacy intervalMs on supervisor.create", async () => { const result = await dispatch( { @@ -111,7 +148,36 @@ describe("supervisor commands", () => { expect(result.ok).toBe(true); expect(supervisorMgr.update).toHaveBeenCalledWith("sup-1", { evaluatorProviderId: "codex", + evaluatorModel: undefined, + maxSupervisionCount: undefined, + objective: undefined, + scheduledAt: undefined, + }); + }); + + it("passes evaluatorModel, maxSupervisionCount, and scheduledAt through supervisor.update", async () => { + const result = await dispatch( + { + kind: "command", + id: "cmd-3b", + op: "supervisor.update", + args: { + id: "sup-1", + evaluatorModel: "o3", + maxSupervisionCount: 5, + scheduledAt: 1_746_950_400_000, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(supervisorMgr.update).toHaveBeenCalledWith("sup-1", { + evaluatorProviderId: undefined, + evaluatorModel: "o3", + maxSupervisionCount: 5, objective: undefined, + scheduledAt: 1_746_950_400_000, }); }); diff --git a/packages/server/src/__tests__/supervisor-integration.test.ts b/packages/server/src/__tests__/supervisor-integration.test.ts index 7918bfb08..17c1b69c8 100644 --- a/packages/server/src/__tests__/supervisor-integration.test.ts +++ b/packages/server/src/__tests__/supervisor-integration.test.ts @@ -108,6 +108,7 @@ describe("Supervisor integration", () => { supervisorManager.evaluator = { evaluate: async () => ({ message: "", + objectiveComplete: false, }), }; }); diff --git a/packages/server/src/__tests__/supervisor-manager.test.ts b/packages/server/src/__tests__/supervisor-manager.test.ts index 3cad52079..61a5a9da7 100644 --- a/packages/server/src/__tests__/supervisor-manager.test.ts +++ b/packages/server/src/__tests__/supervisor-manager.test.ts @@ -30,7 +30,10 @@ type MutableSupervisorManager = SupervisorManager & { contextBuilder: SupervisorContextBuilder & { logger: TestLogger }; evaluator: SupervisorEvaluator & { logger: TestLogger }; injector: SupervisorInjector; - runEvaluation: (supervisorId: string) => Promise; + runEvaluation: ( + supervisorId: string, + trigger?: "turn_completed" | "scheduled" + ) => Promise; }; const PROVIDER_INSTALL = { @@ -86,6 +89,17 @@ function applySupervisorPatch(current: Supervisor, patch: SupervisorUpdatePatch) ...(patch.evaluatorProviderId !== undefined ? { evaluatorProviderId: patch.evaluatorProviderId } : {}), + ...(patch.evaluatorModel !== undefined + ? { evaluatorModel: patch.evaluatorModel ?? undefined } + : {}), + ...(patch.maxSupervisionCount !== undefined + ? { maxSupervisionCount: patch.maxSupervisionCount } + : {}), + ...(patch.completedSupervisionCount !== undefined + ? { completedSupervisionCount: patch.completedSupervisionCount } + : {}), + ...(patch.scheduledAt !== undefined ? { scheduledAt: patch.scheduledAt ?? undefined } : {}), + ...(patch.stopReason !== undefined ? { stopReason: patch.stopReason ?? undefined } : {}), ...(patch.lastCycleAt !== undefined ? { lastCycleAt: patch.lastCycleAt ?? undefined } : {}), ...(patch.lastEvaluatedTurnId !== undefined ? { lastEvaluatedTurnId: patch.lastEvaluatedTurnId ?? undefined } @@ -115,6 +129,19 @@ function applyCyclePatch( function createManagerDeps() { const supervisors = new Map(); const cyclesBySupervisor = new Map(); + const attemptsByCycle = new Map< + string, + Array<{ + id: string; + cycleId: string; + attemptIndex: number; + status: "evaluating" | "completed" | "failed" | "cancelled"; + startedAt: number; + completedAt?: number; + errorReason?: string; + providerModel?: string; + }> + >(); const logger: TestLogger = { info: vi.fn(), warn: vi.fn(), @@ -143,7 +170,12 @@ function createManagerDeps() { const supervisorRepo = { create: vi.fn((value: NewSupervisor) => { - const supervisor: Supervisor = { ...value, cycles: [] }; + const supervisor: Supervisor = { + ...value, + maxSupervisionCount: value.maxSupervisionCount ?? 0, + completedSupervisionCount: value.completedSupervisionCount ?? 0, + cycles: [], + }; supervisors.set(supervisor.id, { ...supervisor, cycles: [] }); return hydrateSupervisor(supervisor); }), @@ -197,6 +229,48 @@ function createManagerDeps() { pruneOldest: vi.fn(), }; + const cycleAttemptRepo = { + create: vi.fn((attempt) => { + const next = [...(attemptsByCycle.get(attempt.cycleId) ?? []), attempt].sort( + (left, right) => left.attemptIndex - right.attemptIndex + ); + attemptsByCycle.set(attempt.cycleId, next); + return attempt; + }), + update: vi.fn((id, patch) => { + for (const [cycleId, attempts] of attemptsByCycle.entries()) { + const index = attempts.findIndex((attempt) => attempt.id === id); + if (index === -1) { + continue; + } + + const updated = { + ...attempts[index]!, + ...(patch.status !== undefined ? { status: patch.status } : {}), + ...(patch.completedAt !== undefined + ? { completedAt: patch.completedAt ?? undefined } + : {}), + ...(patch.errorReason !== undefined + ? { errorReason: patch.errorReason ?? undefined } + : {}), + ...(patch.providerModel !== undefined + ? { providerModel: patch.providerModel ?? undefined } + : {}), + }; + const next = [...attempts]; + next[index] = updated; + attemptsByCycle.set(cycleId, next); + return updated; + } + + throw new Error(`Attempt not found: ${id}`); + }), + listForCycle: vi.fn((cycleId: string) => [...(attemptsByCycle.get(cycleId) ?? [])]), + deleteForCycle: vi.fn((cycleId: string) => { + attemptsByCycle.delete(cycleId); + }), + }; + return { eventBus: { on: vi.fn(() => () => {}), emit: vi.fn() }, broadcaster: { broadcast: vi.fn() }, @@ -213,6 +287,10 @@ function createManagerDeps() { getLatestSubmittedUserInput: vi.fn(() => "run the tests"), sendInput: vi.fn(), }, + git: { + getStatusSummary: vi.fn(async () => ""), + getDiffStatSummary: vi.fn(async () => ""), + }, providerRegistry: [ createProvider({ id: "claude", @@ -229,6 +307,7 @@ function createManagerDeps() { logger, supervisorRepo, cycleRepo, + cycleAttemptRepo, codexBuildSupervisorEvalCommand, }; } @@ -244,6 +323,7 @@ describe("SupervisorManager cycle triggers", () => { deps = createManagerDeps(); manager = new SupervisorManager(deps as unknown as SupervisorManagerDeps); await manager.hydrate(); + vi.useRealTimers(); }); it("passes the provided logger to context builder and evaluator", () => { @@ -296,6 +376,225 @@ describe("SupervisorManager cycle triggers", () => { expect(updated?.cycles[0]?.status).toBe("injected"); }); + it("stops the supervisor when evaluator returns objective complete", async () => { + const supervisor = await manager.create({ + sessionId: "sess-stop", + workspaceId: "ws-1", + objective: "Finish the migration", + evaluatorProviderId: "codex", + maxSupervisionCount: 0, + }); + + vi.spyOn(getManagerInternals().evaluator, "evaluate").mockResolvedValueOnce({ + message: "[objective complete]", + objectiveComplete: true, + }); + + const finished = await getManagerInternals().runEvaluation(supervisor.id, "turn_completed"); + + expect(finished?.status).toBe("completed"); + expect(finished?.result).toBe("[objective complete]"); + expect(manager.get(supervisor.id)?.state).toBe("stopped"); + expect(manager.get(supervisor.id)?.stopReason).toBe("objective_complete"); + }); + + it("retries evaluator timeout up to the global retry budget", async () => { + vi.useFakeTimers(); + deps.settingsRepo.get = vi.fn((key: string) => { + switch (key) { + case "supervisor.retryEnabled": + return true; + case "supervisor.retryMaxCount": + return 2; + case "supervisor.retryDelaySec": + return 1; + case "supervisor.retryOnTimeout": + return true; + case "supervisor.retryOnEvaluatorError": + return false; + default: + return undefined; + } + }); + + const supervisor = await manager.create({ + sessionId: "sess-retry-timeout", + workspaceId: "ws-1", + objective: "Ship the fix", + evaluatorProviderId: "codex", + }); + + vi.spyOn(getManagerInternals().evaluator, "evaluate") + .mockRejectedValueOnce({ code: "supervisor_eval_timeout", message: "timed out" }) + .mockResolvedValueOnce({ message: "Run tests", objectiveComplete: false }); + + const pending = getManagerInternals().runEvaluation(supervisor.id, "turn_completed"); + for (let index = 0; index < 20; index += 1) { + await Promise.resolve(); + if ( + deps.cycleAttemptRepo.update.mock.calls.some( + ([, patch]: [string, { status?: string }]) => patch.status === "failed" + ) + ) { + break; + } + } + await vi.advanceTimersByTimeAsync(1000); + const finished = await pending; + + expect(finished?.status).toBe("injected"); + expect(deps.cycleAttemptRepo.listForCycle(finished!.id)).toHaveLength(2); + }); + + it("stops before starting an extra cycle when maxSupervisionCount is reached", async () => { + const supervisor = await manager.create({ + sessionId: "sess-max-count", + workspaceId: "ws-1", + objective: "Ship the fix", + evaluatorProviderId: "codex", + maxSupervisionCount: 1, + }); + + const first = await getManagerInternals().runEvaluation(supervisor.id, "turn_completed"); + expect(first?.status).toBe("injected"); + + const second = await getManagerInternals().runEvaluation(supervisor.id, "turn_completed"); + expect(second).toBeNull(); + expect(manager.get(supervisor.id)?.state).toBe("stopped"); + expect(manager.get(supervisor.id)?.stopReason).toBe("max_supervision_count_reached"); + }); + + it("does not consume maxSupervisionCount when an in-flight cycle is paused and cancelled", async () => { + const supervisor = await manager.create({ + sessionId: "sess-max-count-paused", + workspaceId: "ws-1", + objective: "Ship the fix", + evaluatorProviderId: "codex", + maxSupervisionCount: 1, + }); + const managerInternals = getManagerInternals(); + + const evaluate = vi.spyOn(managerInternals.evaluator, "evaluate").mockImplementation( + async ( + _supervisor: Supervisor, + _context: SupervisorEvaluationContext, + options?: { signal?: AbortSignal } + ) => + await new Promise((_resolve, reject) => { + const signal = options?.signal; + const abort = () => + reject({ + code: "supervisor_eval_aborted", + message: "Supervisor evaluator aborted", + }); + + if (!signal) { + reject(new Error("Missing abort signal")); + return; + } + if (signal.aborted) { + abort(); + return; + } + + signal.addEventListener("abort", abort, { once: true }); + }) + ); + + const cycle = await manager.triggerEvaluation(supervisor.id); + await waitFor(() => { + expect(evaluate).toHaveBeenCalledTimes(1); + }); + + await manager.pause(supervisor.id); + await waitFor(() => { + expect( + manager.get(supervisor.id)?.cycles.find((entry) => entry.id === cycle.id)?.status + ).toBe("cancelled"); + }); + + expect(manager.get(supervisor.id)?.completedSupervisionCount).toBe(0); + + await manager.resume(supervisor.id); + evaluate.mockResolvedValueOnce({ message: "Run tests", objectiveComplete: false }); + + const finished = await managerInternals.runEvaluation(supervisor.id, "turn_completed"); + + expect(finished?.status).toBe("injected"); + expect(manager.get(supervisor.id)?.completedSupervisionCount).toBe(1); + expect(manager.get(supervisor.id)?.stopReason).toBeUndefined(); + }); + + it("creates scheduled cycles and consumes scheduledAt once the cycle starts", async () => { + const supervisor = await manager.create({ + sessionId: "sess-scheduled", + workspaceId: "ws-1", + objective: "Ship the fix", + evaluatorProviderId: "codex", + scheduledAt: Date.now() - 1_000, + }); + + const finished = await getManagerInternals().runEvaluation(supervisor.id, "scheduled"); + + expect(finished?.trigger).toBe("scheduled"); + expect(manager.get(supervisor.id)?.scheduledAt).toBeUndefined(); + }); + + it("consumes an overdue scheduledAt when turn_completed runs first", async () => { + const supervisor = await manager.create({ + sessionId: "sess-overdue-turn-completed", + workspaceId: "ws-1", + objective: "Ship the fix", + evaluatorProviderId: "codex", + scheduledAt: Date.now() - 1_000, + }); + + const managerInternals = getManagerInternals(); + const evaluate = vi.spyOn(managerInternals.evaluator, "evaluate"); + + const first = await managerInternals.runEvaluation(supervisor.id, "turn_completed"); + const second = await managerInternals.runEvaluation(supervisor.id, "scheduled"); + + expect(first?.trigger).toBe("turn_completed"); + expect(second).toBeNull(); + expect(manager.get(supervisor.id)?.scheduledAt).toBeUndefined(); + expect(manager.get(supervisor.id)?.cycles).toHaveLength(1); + expect(evaluate).toHaveBeenCalledTimes(1); + }); + + it("retries a due scheduled run until the session becomes runnable", async () => { + vi.useFakeTimers(); + let sessionState: Session["state"] = "starting"; + vi.mocked(deps.sessionMgr.get).mockImplementation((sessionId: string) => + createSessionRecord(sessionId, { state: sessionState }) + ); + vi.spyOn(getManagerInternals().evaluator, "evaluate").mockResolvedValueOnce({ + message: "Run tests", + objectiveComplete: false, + }); + + const supervisor = await manager.create({ + sessionId: "sess-scheduled-retry", + workspaceId: "ws-1", + objective: "Ship the fix", + evaluatorProviderId: "codex", + scheduledAt: Date.now() + 1_000, + }); + + await vi.advanceTimersByTimeAsync(1_000); + expect(manager.get(supervisor.id)?.cycles).toHaveLength(0); + + sessionState = "running"; + await vi.advanceTimersByTimeAsync(1_000); + await Promise.resolve(); + await Promise.resolve(); + + expect(manager.get(supervisor.id)?.cycles[0]?.trigger).toBe("scheduled"); + expect(manager.get(supervisor.id)?.cycles[0]?.status).toBe("injected"); + + expect(manager.get(supervisor.id)?.scheduledAt).toBeUndefined(); + }); + it("persists duplicate-suppressed guidance as a cycle result without marking it injected", async () => { const supervisor = await manager.create({ sessionId: "sess-dedupe", @@ -514,6 +813,93 @@ describe("SupervisorManager cycle triggers", () => { ).toBe(false); expect(deps.logger.error).not.toHaveBeenCalled(); }); + + it("pauses an in-flight evaluation by cancelling the cycle", async () => { + const supervisor = await manager.create({ + sessionId: "sess-pause", + workspaceId: "ws-1", + objective: "Ship the fix", + evaluatorProviderId: "codex", + }); + const managerInternals = getManagerInternals(); + + const evaluate = vi.spyOn(managerInternals.evaluator, "evaluate").mockImplementation( + async ( + _supervisor: Supervisor, + _context: SupervisorEvaluationContext, + options?: { signal?: AbortSignal } + ) => + await new Promise((_resolve, reject) => { + const signal = options?.signal; + const abort = () => + reject({ + code: "supervisor_eval_aborted", + message: "Supervisor evaluator aborted", + }); + + if (!signal) { + reject(new Error("Missing abort signal")); + return; + } + if (signal.aborted) { + abort(); + return; + } + + signal.addEventListener("abort", abort, { once: true }); + }) + ); + + const cycle = await manager.triggerEvaluation(supervisor.id); + await waitFor(() => { + expect(evaluate).toHaveBeenCalledTimes(1); + }); + + await manager.pause(supervisor.id); + await waitFor(() => { + expect( + manager.get(supervisor.id)?.cycles.find((entry) => entry.id === cycle.id)?.status + ).toBe("cancelled"); + }); + + expect(manager.get(supervisor.id)?.state).toBe("paused"); + }); + + it("does not inject guidance when pause lands after evaluation but before sendInput", async () => { + const supervisor = await manager.create({ + sessionId: "sess-pause-race", + workspaceId: "ws-1", + objective: "Ship the fix", + evaluatorProviderId: "codex", + }); + const managerInternals = getManagerInternals(); + const originalUpdate = deps.supervisorRepo.update.getMockImplementation(); + let pauseTriggered = false; + + if (!originalUpdate) { + throw new Error("Missing supervisorRepo.update implementation"); + } + + deps.supervisorRepo.update.mockImplementation((id: string, patch: SupervisorUpdatePatch) => { + const updated = originalUpdate(id, patch); + if (!pauseTriggered && id === supervisor.id && patch.state === "injecting") { + pauseTriggered = true; + void manager.pause(supervisor.id); + } + return updated; + }); + + vi.spyOn(managerInternals.evaluator, "evaluate").mockResolvedValueOnce({ + message: "Run tests", + objectiveComplete: false, + }); + + const finished = await managerInternals.runEvaluation(supervisor.id, "turn_completed"); + + expect(finished?.status).toBe("cancelled"); + expect(manager.get(supervisor.id)?.state).toBe("paused"); + expect(deps.sessionMgr.sendInput).not.toHaveBeenCalled(); + }); }); async function waitFor(fn: () => void, { timeoutMs = 500, intervalMs = 5 } = {}): Promise { diff --git a/packages/server/src/__tests__/supervisor-repo.test.ts b/packages/server/src/__tests__/supervisor-repo.test.ts index 2f9e114cc..0efcaa13b 100644 --- a/packages/server/src/__tests__/supervisor-repo.test.ts +++ b/packages/server/src/__tests__/supervisor-repo.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { closeDatabase, openDatabase, + SupervisorCycleAttemptRepo, SupervisorCycleRepo, SupervisorRepo, } from "../storage/index.js"; @@ -14,6 +15,7 @@ describe("SupervisorRepo", () => { let db: ReturnType; let supervisorRepo: SupervisorRepo; let cycleRepo: SupervisorCycleRepo; + let attemptRepo: SupervisorCycleAttemptRepo; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "supervisor-repo-")); @@ -31,6 +33,7 @@ describe("SupervisorRepo", () => { supervisorRepo = new SupervisorRepo(db); cycleRepo = new SupervisorCycleRepo(db); + attemptRepo = new SupervisorCycleAttemptRepo(db); }); afterEach(() => { @@ -56,6 +59,34 @@ describe("SupervisorRepo", () => { expect(stored?.lastEvaluatedTurnId).toBe("turn-7"); }); + it("persists supervisor execution policy fields", () => { + supervisorRepo.create({ + id: "sup-1", + sessionId: "sess-1", + workspaceId: "ws-1", + state: "stopped", + objective: "Stop when objective is complete", + evaluatorProviderId: "codex", + evaluatorModel: "gpt-5", + maxSupervisionCount: 8, + completedSupervisionCount: 3, + scheduledAt: 1234, + stopReason: "objective_complete", + createdAt: 10, + updatedAt: 11, + }); + + const stored = supervisorRepo.findById("sup-1"); + expect(stored).toMatchObject({ + state: "stopped", + evaluatorModel: "gpt-5", + maxSupervisionCount: 8, + completedSupervisionCount: 3, + scheduledAt: 1234, + stopReason: "objective_complete", + }); + }); + it("rejects a supervisor whose workspace does not match its session workspace", () => { db.prepare( "INSERT INTO workspaces (id, path, target_runtime, opened_at, last_active_at, ui_state) VALUES (?, ?, ?, ?, ?, ?)" @@ -169,6 +200,37 @@ describe("SupervisorRepo", () => { expect(updated.errorReason).toBeUndefined(); }); + it("can clear nullable execution policy fields when explicit null is passed", () => { + supervisorRepo.create({ + id: "sup-1", + sessionId: "sess-1", + workspaceId: "ws-1", + state: "idle", + objective: "Clear execution policy fields", + evaluatorProviderId: "claude", + evaluatorModel: "gpt-5-mini", + maxSupervisionCount: 6, + completedSupervisionCount: 2, + scheduledAt: 45, + stopReason: "max_supervision_count_reached", + createdAt: 10, + updatedAt: 10, + }); + + const updated = supervisorRepo.update("sup-1", { + evaluatorModel: null, + scheduledAt: null, + stopReason: null, + updatedAt: 11, + }); + + expect(updated.evaluatorModel).toBeUndefined(); + expect(updated.maxSupervisionCount).toBe(6); + expect(updated.completedSupervisionCount).toBe(2); + expect(updated.scheduledAt).toBeUndefined(); + expect(updated.stopReason).toBeUndefined(); + }); + it("preserves omitted cycle fields during update", () => { supervisorRepo.create({ id: "sup-1", @@ -303,4 +365,135 @@ describe("SupervisorRepo", () => { expect(cycles.some((cycle) => cycle.id === "cycle-0")).toBe(false); expect(cycles[0]?.id).toBe("cycle-100"); }); + + it("accepts new cycle enum values", () => { + supervisorRepo.create({ + id: "sup-1", + sessionId: "sess-1", + workspaceId: "ws-1", + state: "idle", + objective: "Allow scheduled cancelled cycle", + evaluatorProviderId: "claude", + createdAt: 10, + updatedAt: 10, + }); + + const cycle = cycleRepo.create({ + id: "cycle-1", + supervisorId: "sup-1", + sessionId: "sess-1", + status: "cancelled", + trigger: "scheduled", + evidenceSource: "headless_snapshot", + objective: "Allow scheduled cancelled cycle", + evaluatorProviderId: "claude", + createdAt: 12, + }); + + expect(cycle.status).toBe("cancelled"); + expect(cycle.trigger).toBe("scheduled"); + }); + + it("stores attempts ordered by attemptIndex ascending", () => { + supervisorRepo.create({ + id: "sup-1", + sessionId: "sess-1", + workspaceId: "ws-1", + state: "idle", + objective: "Track attempts", + evaluatorProviderId: "claude", + createdAt: 10, + updatedAt: 10, + }); + cycleRepo.create({ + id: "cycle-1", + supervisorId: "sup-1", + sessionId: "sess-1", + status: "evaluating", + trigger: "manual", + evidenceSource: "headless_snapshot", + objective: "Track attempts", + evaluatorProviderId: "claude", + createdAt: 10, + }); + + attemptRepo.create({ + id: "attempt-2", + cycleId: "cycle-1", + attemptIndex: 2, + status: "failed", + startedAt: 30, + completedAt: 31, + errorReason: "second failed", + providerModel: "gpt-5-mini", + }); + attemptRepo.create({ + id: "attempt-0", + cycleId: "cycle-1", + attemptIndex: 0, + status: "completed", + startedAt: 10, + completedAt: 11, + providerModel: "gpt-5", + }); + attemptRepo.create({ + id: "attempt-1", + cycleId: "cycle-1", + attemptIndex: 1, + status: "cancelled", + startedAt: 20, + completedAt: 21, + errorReason: "interrupted", + }); + + const attempts = attemptRepo.listForCycle("cycle-1"); + expect(attempts.map((attempt) => attempt.id)).toEqual(["attempt-0", "attempt-1", "attempt-2"]); + expect(attempts.map((attempt) => attempt.attemptIndex)).toEqual([0, 1, 2]); + }); + + it("updates attempts and clears nullable fields", () => { + supervisorRepo.create({ + id: "sup-1", + sessionId: "sess-1", + workspaceId: "ws-1", + state: "idle", + objective: "Update attempts", + evaluatorProviderId: "claude", + createdAt: 10, + updatedAt: 10, + }); + cycleRepo.create({ + id: "cycle-1", + supervisorId: "sup-1", + sessionId: "sess-1", + status: "evaluating", + trigger: "manual", + evidenceSource: "headless_snapshot", + objective: "Update attempts", + evaluatorProviderId: "claude", + createdAt: 10, + }); + attemptRepo.create({ + id: "attempt-1", + cycleId: "cycle-1", + attemptIndex: 0, + status: "failed", + startedAt: 20, + completedAt: 21, + errorReason: "transient", + providerModel: "gpt-5-mini", + }); + + const updated = attemptRepo.update("attempt-1", { + status: "completed", + completedAt: null, + errorReason: null, + providerModel: null, + }); + + expect(updated.status).toBe("completed"); + expect(updated.completedAt).toBeUndefined(); + expect(updated.errorReason).toBeUndefined(); + expect(updated.providerModel).toBeUndefined(); + }); }); diff --git a/packages/server/src/__tests__/ws-hub.test.ts b/packages/server/src/__tests__/ws-hub.test.ts index 6b825022b..3bdbd113a 100644 --- a/packages/server/src/__tests__/ws-hub.test.ts +++ b/packages/server/src/__tests__/ws-hub.test.ts @@ -20,6 +20,7 @@ import type { ServerConfig } from "../config.js"; import type { SessionManager } from "../session/manager.js"; import type { TerminalManager } from "../terminal/manager.js"; import type { WorkspaceManager } from "../workspace/manager.js"; +import { ActivationManager } from "../ws/activation.js"; import type { CommandContext } from "../ws/dispatch.js"; import type { FencingManager } from "../ws/fencing.js"; import { WsHub } from "../ws/hub.js"; @@ -46,7 +47,12 @@ const TEST_CONFIG: Pick = { const createMockSocket = (): MockSocket => ({ readyState: WebSocket.OPEN, - send: vi.fn(), + send: vi.fn((...args: unknown[]) => { + const callback = args.find((arg) => typeof arg === "function") as + | ((error?: Error) => void) + | undefined; + callback?.(); + }), ping: vi.fn(), close: vi.fn(), on: vi.fn(), @@ -59,7 +65,15 @@ const createMockRequest = (): FastifyRequest => headers: { "user-agent": "test-agent" }, }) as unknown as FastifyRequest; -const createCommandContext = (eventBus: EventBus): CommandContext => +const createActivationManager = () => + new ActivationManager({ + graceMs: 3_000, + }); + +const createCommandContext = ( + eventBus: EventBus, + overrides: Partial = {} +): CommandContext => ({ workspaceMgr: {}, sessionMgr: {}, @@ -72,6 +86,8 @@ const createCommandContext = (eventBus: EventBus): CommandContext => registerViewer: vi.fn(), unregisterViewer: vi.fn(), }, + activationMgr: createActivationManager(), + ...overrides, }) as unknown as CommandContext; const createHub = (eventBus: EventBus, commandContext: CommandContext): WsHub => @@ -125,6 +141,12 @@ const findResultMessage = (socket: MockSocket, id: string): ResultMessage | unde (message): message is ResultMessage => message.kind === "result" && message.id === id ); +const getConnectedClientIds = (sockets: MockSocket[]): string[] => + sockets.map((socket) => { + const connected = parseSentEvents(socket)[0]; + return (connected as Extract).data.clientId as string; + }); + describe("WsHub", () => { let hub: WsHub; let eventBus: EventBus; @@ -210,6 +232,105 @@ describe("WsHub", () => { expect(socket.send).toHaveBeenCalledTimes(1); }); + it("sends activation.revoked and closes the displaced client", () => { + const activationMgr = createActivationManager(); + mockCommandContext = createCommandContext(eventBus, { activationMgr }); + hub.destroy(); + hub = createHub(eventBus, mockCommandContext); + + const socketA = createMockSocket(); + const socketB = createMockSocket(); + hub.handleConnection(socketA as never, createMockRequest()); + hub.handleConnection(socketB as never, createMockRequest()); + + const [clientA, clientB] = getConnectedClientIds([socketA, socketB]); + activationMgr.claim("client-a", clientA, createMockRequest()); + + const claim = activationMgr.claim("client-b", clientB, createMockRequest()); + expect(claim.displacedWsClientId).toBe(clientA); + + socketA.send.mockClear(); + hub.revokeAndCloseClient(clientA, claim.generation); + + expect(parseSentEvents(socketA)).toEqual([ + expect.objectContaining({ + kind: "event", + topic: "activation.revoked", + data: { + reason: "displaced", + generation: claim.generation, + }, + }), + ]); + expect(socketA.close).toHaveBeenCalledWith(4001, "single_active_displaced"); + }); + + it("no-ops when revoking a client that is already disconnected", () => { + const socket = createMockSocket(); + hub.handleConnection(socket as never, createMockRequest()); + socket.send.mockClear(); + + hub.revokeAndCloseClient("missing-client", 7); + + expect(socket.send).not.toHaveBeenCalled(); + expect(socket.close).not.toHaveBeenCalled(); + }); + + it("cleans up activation and request metadata after a revoked client closes", () => { + const activationMgr = createActivationManager(); + const onSocketClosedSpy = vi.spyOn(activationMgr, "onSocketClosed"); + mockCommandContext = createCommandContext(eventBus, { activationMgr }); + hub.destroy(); + hub = createHub(eventBus, mockCommandContext); + + const socketA = createMockSocket(); + const socketB = createMockSocket(); + hub.handleConnection(socketA as never, createMockRequest()); + hub.handleConnection(socketB as never, createMockRequest()); + + const [clientA, clientB] = getConnectedClientIds([socketA, socketB]); + activationMgr.claim("client-a", clientA, createMockRequest()); + const claim = activationMgr.claim("client-b", clientB, createMockRequest()); + const closeHandler = getCloseHandler(socketA); + + expect(hub.getRequestMetadata(clientA)).toBeDefined(); + + hub.revokeAndCloseClient(clientA, claim.generation); + closeHandler?.(); + + expect(onSocketClosedSpy).toHaveBeenCalledWith(clientA); + expect(hub.getRequestMetadata(clientA)).toBeUndefined(); + }); + + it("sends activation.revoked before starting the close handshake", () => { + let sendCallback: ((error?: Error) => void) | undefined; + const socket = createMockSocket(); + socket.send = vi.fn((...args: unknown[]) => { + sendCallback = args.find((arg) => typeof arg === "function") as + | ((error?: Error) => void) + | undefined; + }); + hub.handleConnection(socket as never, createMockRequest()); + + const [clientId] = getConnectedClientIds([socket]); + socket.send.mockClear(); + + hub.revokeAndCloseClient(clientId, 9); + + expect(parseSentEvents(socket)).toEqual([ + expect.objectContaining({ + kind: "event", + topic: "activation.revoked", + data: { reason: "displaced", generation: 9 }, + }), + ]); + expect(socket.close).not.toHaveBeenCalled(); + + sendCallback?.(); + + expect(socket.close).toHaveBeenCalledWith(4001, "single_active_displaced"); + }); + it("should handle domain events", () => { const socket = createMockSocket(); hub.handleConnection(socket as never, createMockRequest()); diff --git a/packages/server/src/app-routing.test.ts b/packages/server/src/app-routing.test.ts index a8605d1cc..b717073f7 100644 --- a/packages/server/src/app-routing.test.ts +++ b/packages/server/src/app-routing.test.ts @@ -46,7 +46,10 @@ describe("app routing", () => { rmSync(tempDir, { recursive: true, force: true }); }); - const createApp = async (authEnabled = false): Promise => { + const createApp = async ( + authEnabled = false, + extraConfig: Record = {} + ): Promise => { const eventBus = new EventBus(); const fencingMgr = new FencingManager(); const config = { @@ -60,6 +63,7 @@ describe("app routing", () => { enabled: authEnabled, password: authEnabled ? "sekrit" : undefined, }, + ...extraConfig, }; const wsHub = new WsHub({ eventBus, @@ -173,6 +177,20 @@ describe("app routing", () => { expect(response.body).toContain('
shell
'); }); + it("does not emit a CSP header for the built entrypoint response", async () => { + const instance = await createApp(); + + const response = await instance.inject({ + method: "GET", + url: "/index.html", + headers: { + accept: "text/html", + }, + }); + + expect(response.headers["content-security-policy"]).toBeUndefined(); + }); + it("serves headers for the built entrypoint on HEAD /index.html", async () => { const instance = await createApp(); diff --git a/packages/server/src/commands/activation.ts b/packages/server/src/commands/activation.ts new file mode 100644 index 000000000..b88ea073d --- /dev/null +++ b/packages/server/src/commands/activation.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { registerCommand } from "../ws/dispatch.js"; + +registerCommand( + "activation.claim", + z.object({ clientInstanceId: z.string().min(1) }), + async (args, ctx, clientId) => { + if (!clientId) { + throw { + code: "activation_request_unavailable", + message: "Activation claim requires websocket request metadata", + }; + } + + const request = ctx.broadcaster.getRequestMetadata?.(clientId); + if (!request) { + throw { + code: "activation_request_unavailable", + message: "Activation claim requires websocket request metadata", + }; + } + + const claim = ctx.activationMgr.claim(args.clientInstanceId, clientId, request); + if (claim.displacedWsClientId) { + ctx.broadcaster.revokeAndCloseClient?.(claim.displacedWsClientId, claim.generation); + } + + return claim; + } +); + +registerCommand( + "activation.release", + z.object({ clientInstanceId: z.string(), generation: z.number().int().positive() }), + async (args, ctx, clientId) => { + const lease = ctx.activationMgr.getLease(); + if (!clientId || !lease || lease.wsClientId !== clientId) { + return { ok: false }; + } + + ctx.activationMgr.release(args.clientInstanceId, args.generation); + return { ok: true }; + } +); diff --git a/packages/server/src/commands/index.ts b/packages/server/src/commands/index.ts index 9e0846e57..99e587a85 100644 --- a/packages/server/src/commands/index.ts +++ b/packages/server/src/commands/index.ts @@ -6,6 +6,7 @@ import "./workspace.js"; import "./workspace-activity.js"; +import "./activation.js"; import "./connection.js"; import "./session.js"; import "./terminal.js"; diff --git a/packages/server/src/commands/settings.test.ts b/packages/server/src/commands/settings.test.ts index 7216cccdc..9bf53b28e 100644 --- a/packages/server/src/commands/settings.test.ts +++ b/packages/server/src/commands/settings.test.ts @@ -43,6 +43,11 @@ describe("settings commands", () => { }, supervisor: { evaluationTimeoutSec: 600, + retryEnabled: true, + retryMaxCount: 3, + retryDelaySec: 10, + retryOnTimeout: true, + retryOnEvaluatorError: false, }, }, }, @@ -65,6 +70,23 @@ describe("settings commands", () => { .prepare("SELECT value FROM user_settings WHERE key = ?") .get("supervisor.evaluationTimeoutSec") ).toEqual({ value: "600" }); + expect( + db.prepare("SELECT value FROM user_settings WHERE key = ?").get("supervisor.retryEnabled") + ).toEqual({ value: "true" }); + expect( + db.prepare("SELECT value FROM user_settings WHERE key = ?").get("supervisor.retryMaxCount") + ).toEqual({ value: "3" }); + expect( + db.prepare("SELECT value FROM user_settings WHERE key = ?").get("supervisor.retryDelaySec") + ).toEqual({ value: "10" }); + expect( + db.prepare("SELECT value FROM user_settings WHERE key = ?").get("supervisor.retryOnTimeout") + ).toEqual({ value: "true" }); + expect( + db + .prepare("SELECT value FROM user_settings WHERE key = ?") + .get("supervisor.retryOnEvaluatorError") + ).toEqual({ value: "false" }); }); it("settings.update persists appearance.terminalCopyOnSelect into user_settings", async () => { @@ -92,6 +114,54 @@ describe("settings commands", () => { ).toEqual({ value: "true" }); }); + it("settings.update persists appearance.themeId into user_settings", async () => { + const result = await dispatch( + { + kind: "command", + id: "settings-update-theme-id", + op: "settings.update", + args: { + settings: { + appearance: { + themeId: "graphite-light", + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect( + db.prepare("SELECT value FROM user_settings WHERE key = ?").get("appearance.themeId") + ).toEqual({ value: '"graphite-light"' }); + }); + + it("settings.update persists legacy appearance.theme light during themeId migration", async () => { + const result = await dispatch( + { + kind: "command", + id: "settings-update-legacy-theme-light", + op: "settings.update", + args: { + settings: { + appearance: { + theme: "light", + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect( + db.prepare("SELECT value FROM user_settings WHERE key = ?").get("appearance.theme") + ).toEqual({ + value: '"light"', + }); + }); + it("settings.update rejects fractional supervisor timeout values", async () => { const result = await dispatch( { @@ -144,6 +214,30 @@ describe("settings commands", () => { ).toBeUndefined(); }); + it("settings.update rejects retryDelaySec values below the supported minimum", async () => { + const result = await dispatch( + { + kind: "command", + id: "settings-update-supervisor-retry-delay-too-small", + op: "settings.update", + args: { + settings: { + supervisor: { + retryDelaySec: 0, + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("validation_error"); + expect( + db.prepare("SELECT value FROM user_settings WHERE key = ?").get("supervisor.retryDelaySec") + ).toBeUndefined(); + }); + it("settings.update persists provider startup command arguments per provider config", async () => { const result = await dispatch( { @@ -276,6 +370,26 @@ describe("settings commands", () => { "supervisor.evaluationTimeoutSec", "900" ); + db.prepare("INSERT INTO user_settings (key, value) VALUES (?, ?)").run( + "supervisor.retryEnabled", + "true" + ); + db.prepare("INSERT INTO user_settings (key, value) VALUES (?, ?)").run( + "supervisor.retryMaxCount", + "4" + ); + db.prepare("INSERT INTO user_settings (key, value) VALUES (?, ?)").run( + "supervisor.retryDelaySec", + "15" + ); + db.prepare("INSERT INTO user_settings (key, value) VALUES (?, ?)").run( + "supervisor.retryOnTimeout", + "false" + ); + db.prepare("INSERT INTO user_settings (key, value) VALUES (?, ?)").run( + "supervisor.retryOnEvaluatorError", + "true" + ); const result = await dispatch( { @@ -292,6 +406,11 @@ describe("settings commands", () => { defaultProviderId: "codex", "notifications.enabled": true, "supervisor.evaluationTimeoutSec": 900, + "supervisor.retryEnabled": true, + "supervisor.retryMaxCount": 4, + "supervisor.retryDelaySec": 15, + "supervisor.retryOnTimeout": false, + "supervisor.retryOnEvaluatorError": true, }); }); @@ -315,6 +434,28 @@ describe("settings commands", () => { expect(result.data?.["appearance.terminalCopyOnSelect"]).toBe(true); }); + it("settings.get returns appearance.themeId from user_settings", async () => { + db.prepare("INSERT INTO user_settings (key, value) VALUES (?, ?)").run( + "appearance.themeId", + '"nord-dark"' + ); + + const result = await dispatch( + { + kind: "command", + id: "settings-get-theme-id", + op: "settings.get", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + "appearance.themeId": "nord-dark", + }); + }); + it("settings.get normalizes invalid persisted supervisor timeout values", async () => { db.prepare("INSERT INTO user_settings (key, value) VALUES (?, ?)").run( "supervisor.evaluationTimeoutSec", diff --git a/packages/server/src/commands/settings.ts b/packages/server/src/commands/settings.ts index c434a868e..d330adb7f 100644 --- a/packages/server/src/commands/settings.ts +++ b/packages/server/src/commands/settings.ts @@ -5,7 +5,14 @@ import { DEFAULT_SUPERVISOR_EVALUATION_TIMEOUT_SEC, MAX_SUPERVISOR_EVALUATION_TIMEOUT_SEC, + MAX_SUPERVISOR_RETRY_DELAY_SEC, + MAX_SUPERVISOR_RETRY_MAX_COUNT, resolveSupervisorEvaluationTimeoutSec, + resolveSupervisorRetryDelaySec, + resolveSupervisorRetryEnabled, + resolveSupervisorRetryMaxCount, + resolveSupervisorRetryOnEvaluatorError, + resolveSupervisorRetryOnTimeout, } from "@coder-studio/core"; import { z } from "zod"; import { type ConfigType, readConfigFile, writeConfigFile } from "../config/config-io.js"; @@ -17,7 +24,14 @@ import { sanitizeProviderLaunchConfig, } from "../provider-config.js"; import { ProviderConfigRepo } from "../storage/repositories/provider-config-repo.js"; -import { SUPERVISOR_EVALUATION_TIMEOUT_SETTING_KEY } from "../supervisor/settings.js"; +import { + SUPERVISOR_EVALUATION_TIMEOUT_SETTING_KEY, + SUPERVISOR_RETRY_DELAY_SEC_SETTING_KEY, + SUPERVISOR_RETRY_ENABLED_SETTING_KEY, + SUPERVISOR_RETRY_MAX_COUNT_SETTING_KEY, + SUPERVISOR_RETRY_ON_EVALUATOR_ERROR_SETTING_KEY, + SUPERVISOR_RETRY_ON_TIMEOUT_SETTING_KEY, +} from "../supervisor/settings.js"; import { registerCommand } from "../ws/dispatch.js"; // Settings schema @@ -42,11 +56,17 @@ const SettingsSchema = z.object({ .max(MAX_SUPERVISOR_EVALUATION_TIMEOUT_SEC) .default(DEFAULT_SUPERVISOR_EVALUATION_TIMEOUT_SEC) .optional(), + retryEnabled: z.boolean().optional(), + retryMaxCount: z.number().int().min(0).max(MAX_SUPERVISOR_RETRY_MAX_COUNT).optional(), + retryDelaySec: z.number().int().min(1).max(MAX_SUPERVISOR_RETRY_DELAY_SEC).optional(), + retryOnTimeout: z.boolean().optional(), + retryOnEvaluatorError: z.boolean().optional(), }) .optional(), appearance: z .object({ - theme: z.enum(["dark"]).optional(), + theme: z.enum(["dark", "light"]).optional(), + themeId: z.string().optional(), terminalRenderer: z.enum(["standard", "compatibility"]).optional(), terminalCopyOnSelect: z.boolean().optional(), locale: z.enum(["zh", "en"]).optional(), @@ -93,6 +113,34 @@ registerCommand("settings.get", z.object({}), async (_args, ctx) => { settings[SUPERVISOR_EVALUATION_TIMEOUT_SETTING_KEY] ); } + if (Object.prototype.hasOwnProperty.call(settings, SUPERVISOR_RETRY_ENABLED_SETTING_KEY)) { + settings[SUPERVISOR_RETRY_ENABLED_SETTING_KEY] = resolveSupervisorRetryEnabled( + settings[SUPERVISOR_RETRY_ENABLED_SETTING_KEY] + ); + } + if (Object.prototype.hasOwnProperty.call(settings, SUPERVISOR_RETRY_MAX_COUNT_SETTING_KEY)) { + settings[SUPERVISOR_RETRY_MAX_COUNT_SETTING_KEY] = resolveSupervisorRetryMaxCount( + settings[SUPERVISOR_RETRY_MAX_COUNT_SETTING_KEY] + ); + } + if (Object.prototype.hasOwnProperty.call(settings, SUPERVISOR_RETRY_DELAY_SEC_SETTING_KEY)) { + settings[SUPERVISOR_RETRY_DELAY_SEC_SETTING_KEY] = resolveSupervisorRetryDelaySec( + settings[SUPERVISOR_RETRY_DELAY_SEC_SETTING_KEY] + ); + } + if (Object.prototype.hasOwnProperty.call(settings, SUPERVISOR_RETRY_ON_TIMEOUT_SETTING_KEY)) { + settings[SUPERVISOR_RETRY_ON_TIMEOUT_SETTING_KEY] = resolveSupervisorRetryOnTimeout( + settings[SUPERVISOR_RETRY_ON_TIMEOUT_SETTING_KEY] + ); + } + if ( + Object.prototype.hasOwnProperty.call(settings, SUPERVISOR_RETRY_ON_EVALUATOR_ERROR_SETTING_KEY) + ) { + settings[SUPERVISOR_RETRY_ON_EVALUATOR_ERROR_SETTING_KEY] = + resolveSupervisorRetryOnEvaluatorError( + settings[SUPERVISOR_RETRY_ON_EVALUATOR_ERROR_SETTING_KEY] + ); + } return settings; }); diff --git a/packages/server/src/commands/supervisor.ts b/packages/server/src/commands/supervisor.ts index d411ae365..f53b73ce1 100644 --- a/packages/server/src/commands/supervisor.ts +++ b/packages/server/src/commands/supervisor.ts @@ -8,6 +8,9 @@ const createSupervisorSchema = z workspaceId: z.string(), objective: supervisorObjectiveSchema, evaluatorProviderId: z.string(), + evaluatorModel: z.string().trim().min(1).max(200).optional(), + maxSupervisionCount: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(), + scheduledAt: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(), }) .strict(); const updateSupervisorSchema = z @@ -15,11 +18,19 @@ const updateSupervisorSchema = z id: z.string(), objective: supervisorObjectiveSchema.optional(), evaluatorProviderId: z.string().optional(), + evaluatorModel: z.string().trim().min(1).max(200).nullable().optional(), + maxSupervisionCount: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(), + scheduledAt: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable().optional(), }) .strict() .refine( - (input) => input.objective !== undefined || input.evaluatorProviderId !== undefined, - "objective or evaluatorProviderId is required" + (input) => + input.objective !== undefined || + input.evaluatorProviderId !== undefined || + input.evaluatorModel !== undefined || + input.maxSupervisionCount !== undefined || + input.scheduledAt !== undefined, + "at least one supervisor field is required" ); const sessionIdSchema = z.object({ sessionId: z.string() }); const supervisorIdSchema = z.object({ id: z.string() }); @@ -32,6 +43,9 @@ registerCommand("supervisor.create", createSupervisorSchema, async (args, ctx) = workspaceId: args.workspaceId, objective: args.objective, evaluatorProviderId: args.evaluatorProviderId, + evaluatorModel: args.evaluatorModel, + maxSupervisionCount: args.maxSupervisionCount, + scheduledAt: args.scheduledAt, }), }; }); @@ -47,6 +61,9 @@ registerCommand("supervisor.update", updateSupervisorSchema, async (args, ctx) = supervisor: await ctx.supervisorMgr.update(args.id, { objective: args.objective, evaluatorProviderId: args.evaluatorProviderId, + evaluatorModel: args.evaluatorModel, + maxSupervisionCount: args.maxSupervisionCount, + scheduledAt: args.scheduledAt, }), }; }); diff --git a/packages/server/src/fs/watcher.ts b/packages/server/src/fs/watcher.ts index a699e8f89..4bad815f6 100644 --- a/packages/server/src/fs/watcher.ts +++ b/packages/server/src/fs/watcher.ts @@ -22,6 +22,7 @@ export class WorkspaceWatcher { private dirtyTimer: NodeJS.Timeout | null = null; private firstDirtyTime: number | null = null; private pendingReason: "fs_change" | "git_metadata" | null = null; + private pendingWorktreeChanged = false; private readonly DEBOUNCE_MS = 200; private readonly MAX_WAIT_MS = 1_000; @@ -52,6 +53,10 @@ export class WorkspaceWatcher { this.firstDirtyTime = now; } + if (changedPath && this.isWorktreeMetadataPath(changedPath)) { + this.pendingWorktreeChanged = true; + } + if (changedPath && !this.isGitMetadataPath(changedPath)) { this.pendingReason = "fs_change"; } else if (changedPath && this.pendingReason !== "fs_change") { @@ -74,15 +79,26 @@ export class WorkspaceWatcher { this.broadcaster?.broadcast(Topics.workspaceFsDirty(this.workspaceId), { reason: this.pendingReason ?? "fs_change", }); + if (this.pendingWorktreeChanged) { + this.broadcaster?.broadcast(Topics.workspaceGitState(this.workspaceId), { + worktreeChanged: true, + }); + } this.dirtyTimer = null; this.firstDirtyTime = null; this.pendingReason = null; + this.pendingWorktreeChanged = false; } private isGitMetadataPath(changedPath: string): boolean { return changedPath.replace(/\\/g, "/").includes("/.git/"); } + private isWorktreeMetadataPath(changedPath: string): boolean { + const normalized = changedPath.replace(/\\/g, "/"); + return normalized.includes("/.git/worktrees"); + } + /** * Stops watching and cleans up resources. */ diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 6aca0542c..ca3c1c0a8 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -28,6 +28,7 @@ import { AuthSessionRepo } from "./storage/repositories/auth-session-repo.js"; import { ProviderConfigRepo } from "./storage/repositories/provider-config-repo.js"; import { rowToSession, type SessionRow } from "./storage/repositories/session-repo.js"; import { SettingsRepo } from "./storage/repositories/settings-repo.js"; +import { SupervisorCycleAttemptRepo } from "./storage/repositories/supervisor-cycle-attempt-repo.js"; import { SupervisorCycleRepo } from "./storage/repositories/supervisor-cycle-repo.js"; import { SupervisorRepo } from "./storage/repositories/supervisor-repo.js"; import { SupervisorManager } from "./supervisor/manager.js"; @@ -37,6 +38,7 @@ import type { TerminalDatabase } from "./terminal/types.js"; import { deleteWorkspaceUploads, runStartupGc } from "./uploads/cleanup.js"; import { STARTUP_GC_DELAY_MS } from "./uploads/constants.js"; import { WorkspaceManager } from "./workspace/manager.js"; +import { ActivationManager } from "./ws/activation.js"; import type { CommandContext } from "./ws/dispatch.js"; import { dispatch } from "./ws/dispatch.js"; import { FencingManager } from "./ws/fencing.js"; @@ -65,6 +67,7 @@ export async function createServer( const db = openDatabase(config.dataDir); const eventBus = new EventBus(); + const activationMgr = new ActivationManager(); const fencingMgr = new FencingManager(); const wsHub = new WsHub({ eventBus, commandContext: null, config, fencingMgr }); let workspaceMgr: WorkspaceManager; @@ -168,6 +171,7 @@ export async function createServer( const supervisorRepo = new SupervisorRepo(db); const cycleRepo = new SupervisorCycleRepo(db); + const cycleAttemptRepo = new SupervisorCycleAttemptRepo(db); supervisorMgr = new SupervisorManager({ eventBus, broadcaster: wsHub, @@ -179,6 +183,7 @@ export async function createServer( settingsRepo, supervisorRepo, cycleRepo, + cycleAttemptRepo, logger: app.log, }); await sessionMgr.hydrate(); @@ -208,6 +213,7 @@ export async function createServer( autoFetch, providerRuntimeDeps, providerInstallMgr, + activationMgr, }; wsHub.setCommandContext(commandContext); diff --git a/packages/server/src/storage/db.test.ts b/packages/server/src/storage/db.test.ts index bbe590734..631c630d5 100644 --- a/packages/server/src/storage/db.test.ts +++ b/packages/server/src/storage/db.test.ts @@ -19,6 +19,7 @@ describe("database schema baseline", () => { it("creates the current schema baseline without migration bookkeeping", async () => { const { openDatabase, closeDatabase } = await import("./db"); + const { CURRENT_SCHEMA_VERSION } = await import("./schema-version"); const db = openDatabase(dbPath); @@ -76,11 +77,32 @@ describe("database schema baseline", () => { ]) ); + const userVersion = db.prepare("PRAGMA user_version").get() as { user_version: number }; + expect(userVersion.user_version).toBe(CURRENT_SCHEMA_VERSION); + closeDatabase(db); }); - it("rejects a legacy database schema that still contains resume_id", async () => { + it("restamps the current schema when user_version is reset to zero", async () => { + const { openDatabase, closeDatabase } = await import("./db"); + const { CURRENT_SCHEMA_VERSION } = await import("./schema-version"); + + const db = openDatabase(dbPath); + closeDatabase(db); + + const rawDb = new DatabaseSync(dbPath); + rawDb.exec("PRAGMA user_version = 0"); + rawDb.close(); + + const reopened = openDatabase(dbPath); + const userVersion = reopened.prepare("PRAGMA user_version").get() as { user_version: number }; + expect(userVersion.user_version).toBe(CURRENT_SCHEMA_VERSION); + closeDatabase(reopened); + }); + + it("rejects a legacy database schema that still contains resume_id with a typed incompatible-schema error", async () => { const { openDatabase } = await import("./db"); + const { IncompatibleSchemaError } = await import("./schema-version"); const db = new DatabaseSync(dbPath); db.exec(` @@ -102,11 +124,21 @@ describe("database schema baseline", () => { `); db.close(); - expect(() => openDatabase(dbPath)).toThrow(/Legacy database schema detected/); + let thrown: unknown; + try { + openDatabase(dbPath); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(IncompatibleSchemaError); + expect(thrown).toMatchObject({ code: "db_incompatible_schema" }); + expect((thrown as Error).message).toContain("db_incompatible_schema"); }); - it("rejects a legacy database schema that still contains hook_registrations", async () => { + it("rejects a legacy database schema that still contains hook_registrations with a typed incompatible-schema error", async () => { const { openDatabase } = await import("./db"); + const { IncompatibleSchemaError } = await import("./schema-version"); const db = new DatabaseSync(dbPath); db.exec(` @@ -117,11 +149,21 @@ describe("database schema baseline", () => { `); db.close(); - expect(() => openDatabase(dbPath)).toThrow(/Legacy database schema detected/); + let thrown: unknown; + try { + openDatabase(dbPath); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(IncompatibleSchemaError); + expect(thrown).toMatchObject({ code: "db_incompatible_schema" }); + expect((thrown as Error).message).toContain("db_incompatible_schema"); }); - it("rejects a legacy database schema that still contains transcript_path", async () => { + it("rejects a legacy database schema that still contains transcript_path with a typed incompatible-schema error", async () => { const { openDatabase } = await import("./db"); + const { IncompatibleSchemaError } = await import("./schema-version"); const db = new DatabaseSync(dbPath); db.exec(` @@ -143,11 +185,21 @@ describe("database schema baseline", () => { `); db.close(); - expect(() => openDatabase(dbPath)).toThrow(/Legacy database schema detected/); + let thrown: unknown; + try { + openDatabase(dbPath); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(IncompatibleSchemaError); + expect(thrown).toMatchObject({ code: "db_incompatible_schema" }); + expect((thrown as Error).message).toContain("db_incompatible_schema"); }); - it("rejects a legacy database schema that still contains _migrations", async () => { + it("rejects a legacy database schema that still contains _migrations with a typed incompatible-schema error", async () => { const { openDatabase } = await import("./db"); + const { IncompatibleSchemaError } = await import("./schema-version"); const db = new DatabaseSync(dbPath); db.exec(` @@ -159,7 +211,16 @@ describe("database schema baseline", () => { `); db.close(); - expect(() => openDatabase(dbPath)).toThrow(/Legacy database schema detected/); + let thrown: unknown; + try { + openDatabase(dbPath); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(IncompatibleSchemaError); + expect(thrown).toMatchObject({ code: "db_incompatible_schema" }); + expect((thrown as Error).message).toContain("db_incompatible_schema"); }); it("rejects a non-empty database whose schema does not match the current baseline", async () => { diff --git a/packages/server/src/storage/db.ts b/packages/server/src/storage/db.ts index 2e9f653b1..e1754e228 100644 --- a/packages/server/src/storage/db.ts +++ b/packages/server/src/storage/db.ts @@ -1,7 +1,12 @@ import { DatabaseSync } from "node:sqlite"; -import { readFileSync } from "fs"; -import { join } from "path"; import { type Database, withTransaction } from "./database.js"; +import { + CURRENT_SCHEMA_SQL, + CURRENT_SCHEMA_VERSION, + detectSchema, + IncompatibleSchemaError, + stampCurrentSchemaVersion, +} from "./schema-version.js"; interface IntegrityCheckRow { integrity_check: string; @@ -15,63 +20,9 @@ interface ColumnInfoRow { name: string; } -interface SchemaEntryRow { - type: string; - name: string; - tbl_name: string; - sql: string | null; -} - -interface SchemaEntry { - type: string; - name: string; - tableName: string; - sql: string; -} - -const SCHEMA_PATH = join(import.meta.dirname, "migrations", "001_init.sql"); -const SCHEMA_SQL = readFileSync(SCHEMA_PATH, "utf-8"); - const LEGACY_TABLES = ["hook_registrations", "_migrations"] as const; const LEGACY_SESSION_COLUMNS = ["resume_id", "transcript_path"] as const; -function normalizeSql(sql: string | null): string { - return (sql ?? "").replace(/\s+/g, " ").trim(); -} - -function listSchemaEntries(db: Database): SchemaEntry[] { - const rows = db - .prepare( - ` - SELECT type, name, tbl_name, sql - FROM sqlite_master - WHERE type IN ('table', 'index', 'view', 'trigger') - AND name NOT LIKE 'sqlite_%' - ORDER BY type, name - ` - ) - .all() as unknown as SchemaEntryRow[]; - - return rows.map((row) => ({ - type: row.type, - name: row.name, - tableName: row.tbl_name, - sql: normalizeSql(row.sql), - })); -} - -function buildExpectedSchemaEntries(): SchemaEntry[] { - const db = new DatabaseSync(":memory:"); - try { - db.exec(SCHEMA_SQL); - return listSchemaEntries(db); - } finally { - db.close(); - } -} - -const EXPECTED_SCHEMA_ENTRIES = buildExpectedSchemaEntries(); - function hasTable(db: Database, tableName: string): boolean { const row = db .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?") @@ -107,86 +58,82 @@ function detectLegacySchema(db: Database): string[] { return reasons; } -function schemaEntrySignature(entry: SchemaEntry): string { - return `${entry.type}:${entry.name}:${entry.tableName}:${entry.sql}`; -} - -function isSchemaEmpty(db: Database): boolean { - return listSchemaEntries(db).length === 0; -} - -function assertNoLegacySchema(db: Database, dbPath: string): void { +function throwIfLegacySchema(db: Database, dbPath: string): void { const reasons = detectLegacySchema(db); if (reasons.length === 0) { return; } - throw new Error( - `Legacy database schema detected at ${dbPath}: ${reasons.join(", ")}. ` + - "This build no longer supports automatic database upgrades. Delete the local database file and restart." - ); + throw new IncompatibleSchemaError(dbPath, `legacy schema detected (${reasons.join(", ")})`); } -function describeSchemaMismatch(expected: SchemaEntry[], actual: SchemaEntry[]): string { - const expectedByName = new Map(expected.map((entry) => [`${entry.type}:${entry.name}`, entry])); - const actualByName = new Map(actual.map((entry) => [`${entry.type}:${entry.name}`, entry])); - const keys = new Set([...expectedByName.keys(), ...actualByName.keys()]); - - for (const key of keys) { - const expectedEntry = expectedByName.get(key); - const actualEntry = actualByName.get(key); - - if (!expectedEntry) { - return `unexpected ${actualEntry?.type ?? "schema object"} ${actualEntry?.name ?? key}`; - } +function initializeSchema(db: Database): void { + withTransaction(db, () => { + db.exec(CURRENT_SCHEMA_SQL); + }); +} - if (!actualEntry) { - return `missing ${expectedEntry.type} ${expectedEntry.name}`; - } +function upgradeSchemaV1ToV2(db: Database): void { + withTransaction(db, () => { + db.exec("ALTER TABLE supervisors ADD COLUMN evaluator_model TEXT"); + db.exec("ALTER TABLE supervisors ADD COLUMN max_supervision_count INTEGER NOT NULL DEFAULT 0"); + db.exec( + "ALTER TABLE supervisors ADD COLUMN completed_supervision_count INTEGER NOT NULL DEFAULT 0" + ); + db.exec("ALTER TABLE supervisors ADD COLUMN scheduled_at INTEGER"); + db.exec("ALTER TABLE supervisors ADD COLUMN stop_reason TEXT"); + db.exec(` + CREATE TABLE supervisor_cycle_attempts ( + id TEXT PRIMARY KEY, + cycle_id TEXT NOT NULL REFERENCES supervisor_cycles(id) ON DELETE CASCADE, + attempt_index INTEGER NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + completed_at INTEGER, + error_reason TEXT, + provider_model TEXT + ) + `); + db.exec( + "CREATE INDEX idx_supervisor_cycle_attempts_cycle ON supervisor_cycle_attempts(cycle_id, attempt_index)" + ); + stampCurrentSchemaVersion(db); + }); +} - if (schemaEntrySignature(expectedEntry) !== schemaEntrySignature(actualEntry)) { - return `definition mismatch for ${expectedEntry.type} ${expectedEntry.name}`; - } +function assertCurrentSchema(db: Database, dbPath: string): void { + const detection = detectSchema(db); + if (detection.state !== "current") { + throw new IncompatibleSchemaError(dbPath, detection.mismatch ?? "unknown schema drift"); } - - return "unknown schema drift"; } -function assertSchemaMatchesBaseline(db: Database, dbPath: string): void { - const actualEntries = listSchemaEntries(db); - const expectedSignatures = EXPECTED_SCHEMA_ENTRIES.map(schemaEntrySignature); - const actualSignatures = actualEntries.map(schemaEntrySignature); +function initializeOrUpgradeSchema(db: Database, dbPath: string): void { + throwIfLegacySchema(db, dbPath); - if ( - actualSignatures.length === expectedSignatures.length && - actualSignatures.every((signature, index) => signature === expectedSignatures[index]) - ) { - return; - } + const detection = detectSchema(db); - const mismatch = describeSchemaMismatch(EXPECTED_SCHEMA_ENTRIES, actualEntries); - throw new Error( - `Database schema mismatch detected at ${dbPath}: ${mismatch}. ` + - "This build requires the current baseline schema. Delete the local database file and restart." - ); -} + switch (detection.state) { + case "empty": + initializeSchema(db); + assertCurrentSchema(db, dbPath); + return; -function initializeSchema(db: Database): void { - withTransaction(db, () => { - db.exec(SCHEMA_SQL); - }); -} + case "current": + if (detection.userVersion !== CURRENT_SCHEMA_VERSION) { + stampCurrentSchemaVersion(db); + } + assertCurrentSchema(db, dbPath); + return; -function initializeOrValidateSchema(db: Database, dbPath: string): void { - assertNoLegacySchema(db, dbPath); + case "v1": + upgradeSchemaV1ToV2(db); + assertCurrentSchema(db, dbPath); + return; - if (isSchemaEmpty(db)) { - initializeSchema(db); - assertSchemaMatchesBaseline(db, dbPath); - return; + case "incompatible": + throw new IncompatibleSchemaError(dbPath, detection.mismatch ?? "unknown schema drift"); } - - assertSchemaMatchesBaseline(db, dbPath); } /** @@ -210,7 +157,7 @@ export function openDatabase(dbPath: string): Database { throw new Error(`Database integrity check failed: ${JSON.stringify(integrityResult)}`); } - initializeOrValidateSchema(db, dbPath); + initializeOrUpgradeSchema(db, dbPath); return db; } catch (error) { @@ -231,7 +178,7 @@ export function openDatabase(dbPath: string): Database { * databases explicitly before wiring command handlers. */ export function runMigrations(db: Database): void { - initializeOrValidateSchema(db, ":memory:"); + initializeOrUpgradeSchema(db, ":memory:"); } /** diff --git a/packages/server/src/storage/index.ts b/packages/server/src/storage/index.ts index 217688904..ff3d0ca7d 100644 --- a/packages/server/src/storage/index.ts +++ b/packages/server/src/storage/index.ts @@ -13,6 +13,11 @@ export { sessionToRow, } from "./repositories/session-repo.js"; export { SettingsRepo } from "./repositories/settings-repo.js"; +export { + type NewSupervisorCycleAttempt, + SupervisorCycleAttemptRepo, + type SupervisorCycleAttemptUpdatePatch, +} from "./repositories/supervisor-cycle-attempt-repo.js"; export { SupervisorCycleRepo, type SupervisorCycleUpdatePatch, diff --git a/packages/server/src/storage/migrations/001_init.sql b/packages/server/src/storage/migrations/001_init.sql index 4e44f593b..5b3f006fa 100644 --- a/packages/server/src/storage/migrations/001_init.sql +++ b/packages/server/src/storage/migrations/001_init.sql @@ -1,4 +1,5 @@ -- Current database schema baseline +PRAGMA user_version = 2; CREATE TABLE IF NOT EXISTS workspaces ( id TEXT PRIMARY KEY, @@ -77,7 +78,7 @@ CREATE TABLE IF NOT EXISTS supervisors ( last_evaluated_turn_id TEXT, error_reason TEXT, created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, evaluator_model TEXT, max_supervision_count INTEGER NOT NULL DEFAULT 0, completed_supervision_count INTEGER NOT NULL DEFAULT 0, scheduled_at INTEGER, stop_reason TEXT, FOREIGN KEY (session_id, workspace_id) REFERENCES sessions(id, workspace_id) ON DELETE CASCADE, FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE ); @@ -109,6 +110,19 @@ CREATE TABLE IF NOT EXISTS supervisor_cycles ( CREATE INDEX IF NOT EXISTS idx_supervisor_cycles_supervisor ON supervisor_cycles(supervisor_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_supervisor_cycles_session ON supervisor_cycles(session_id, created_at DESC); +CREATE TABLE IF NOT EXISTS supervisor_cycle_attempts ( + id TEXT PRIMARY KEY, + cycle_id TEXT NOT NULL REFERENCES supervisor_cycles(id) ON DELETE CASCADE, + attempt_index INTEGER NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + completed_at INTEGER, + error_reason TEXT, + provider_model TEXT +); + +CREATE INDEX IF NOT EXISTS idx_supervisor_cycle_attempts_cycle ON supervisor_cycle_attempts(cycle_id, attempt_index); + CREATE TABLE IF NOT EXISTS auth_login_blocks ( ip TEXT PRIMARY KEY, failed_count INTEGER NOT NULL, diff --git a/packages/server/src/storage/repositories/supervisor-cycle-attempt-repo.ts b/packages/server/src/storage/repositories/supervisor-cycle-attempt-repo.ts new file mode 100644 index 000000000..30c577652 --- /dev/null +++ b/packages/server/src/storage/repositories/supervisor-cycle-attempt-repo.ts @@ -0,0 +1,118 @@ +import type { + SupervisorCycleAttempt, + SupervisorCycleAttemptPatch, + SupervisorCycleAttemptStatus, +} from "@coder-studio/core"; +import type { Database } from "../database.js"; + +interface SupervisorCycleAttemptRow { + id: string; + cycle_id: string; + attempt_index: number; + status: SupervisorCycleAttemptStatus; + started_at: number; + completed_at: number | null; + error_reason: string | null; + provider_model: string | null; +} + +export type NewSupervisorCycleAttempt = SupervisorCycleAttempt; + +export type SupervisorCycleAttemptUpdatePatch = SupervisorCycleAttemptPatch; + +export class SupervisorCycleAttemptRepo { + constructor(private readonly db: Database) {} + + create(input: NewSupervisorCycleAttempt): SupervisorCycleAttempt { + this.db + .prepare( + `INSERT INTO supervisor_cycle_attempts (id, cycle_id, attempt_index, status, started_at, completed_at, error_reason, provider_model) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + input.id, + input.cycleId, + input.attemptIndex, + input.status, + input.startedAt, + input.completedAt ?? null, + input.errorReason ?? null, + input.providerModel ?? null + ); + + return this.findById(input.id)!; + } + + findById(id: string): SupervisorCycleAttempt | undefined { + const row = this.db.prepare("SELECT * FROM supervisor_cycle_attempts WHERE id = ?").get(id) as + | SupervisorCycleAttemptRow + | undefined; + return row ? this.rowToAttempt(row) : undefined; + } + + listForCycle(cycleId: string): SupervisorCycleAttempt[] { + const rows = this.db + .prepare( + "SELECT * FROM supervisor_cycle_attempts WHERE cycle_id = ? ORDER BY attempt_index ASC" + ) + .all(cycleId) as unknown as SupervisorCycleAttemptRow[]; + return rows.map((row) => this.rowToAttempt(row)); + } + + update(id: string, patch: SupervisorCycleAttemptUpdatePatch): SupervisorCycleAttempt { + const assignments: string[] = []; + const params: Record = { id }; + + if (patch.status !== undefined) { + assignments.push("status = @status"); + params.status = patch.status; + } + if (patch.completedAt !== undefined) { + assignments.push("completed_at = @completedAt"); + params.completedAt = patch.completedAt; + } + if (patch.errorReason !== undefined) { + assignments.push("error_reason = @errorReason"); + params.errorReason = patch.errorReason; + } + if (patch.providerModel !== undefined) { + assignments.push("provider_model = @providerModel"); + params.providerModel = patch.providerModel; + } + + if (assignments.length === 0) { + const existing = this.findById(id); + if (!existing) { + throw new Error(`Supervisor cycle attempt not found: ${id}`); + } + return existing; + } + + const result = this.db + .prepare(`UPDATE supervisor_cycle_attempts SET ${assignments.join(", ")} WHERE id = @id`) + .run(params); + + if (result.changes === 0) { + throw new Error(`Supervisor cycle attempt not found: ${id}`); + } + + return this.findById(id)!; + } + + deleteForCycle(cycleId: string): void { + this.db.prepare("DELETE FROM supervisor_cycle_attempts WHERE cycle_id = ?").run(cycleId); + } + + private rowToAttempt(row: SupervisorCycleAttemptRow): SupervisorCycleAttempt { + return { + id: row.id, + cycleId: row.cycle_id, + attemptIndex: row.attempt_index, + status: row.status, + startedAt: row.started_at, + completedAt: row.completed_at ?? undefined, + errorReason: row.error_reason ?? undefined, + providerModel: row.provider_model ?? undefined, + }; + } +} diff --git a/packages/server/src/storage/repositories/supervisor-repo.ts b/packages/server/src/storage/repositories/supervisor-repo.ts index 16e29711e..e46f7bc2a 100644 --- a/packages/server/src/storage/repositories/supervisor-repo.ts +++ b/packages/server/src/storage/repositories/supervisor-repo.ts @@ -1,4 +1,4 @@ -import type { Supervisor, SupervisorState } from "@coder-studio/core"; +import type { Supervisor, SupervisorState, SupervisorStopReason } from "@coder-studio/core"; import type { Database } from "../database.js"; interface SupervisorRow { @@ -8,6 +8,11 @@ interface SupervisorRow { state: SupervisorState; objective: string; evaluator_provider_id: string; + evaluator_model: string | null; + max_supervision_count: number; + completed_supervision_count: number; + scheduled_at: number | null; + stop_reason: SupervisorStopReason | null; last_cycle_at: number | null; last_evaluated_turn_id: string | null; error_reason: string | null; @@ -22,6 +27,11 @@ export interface NewSupervisor { state: SupervisorState; objective: string; evaluatorProviderId: string; + evaluatorModel?: string; + maxSupervisionCount: number; + completedSupervisionCount: number; + scheduledAt?: number; + stopReason?: SupervisorStopReason; lastCycleAt?: number; lastEvaluatedTurnId?: string; errorReason?: string; @@ -33,6 +43,11 @@ export interface SupervisorUpdatePatch { state?: SupervisorState; objective?: string; evaluatorProviderId?: string; + evaluatorModel?: string | null; + maxSupervisionCount?: number; + completedSupervisionCount?: number; + scheduledAt?: number | null; + stopReason?: SupervisorStopReason | null; lastCycleAt?: number | null; lastEvaluatedTurnId?: string | null; errorReason?: string | null; @@ -45,8 +60,8 @@ export class SupervisorRepo { create(input: NewSupervisor): Supervisor { this.db .prepare( - `INSERT INTO supervisors (id, session_id, workspace_id, state, objective, evaluator_provider_id, last_cycle_at, last_evaluated_turn_id, error_reason, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO supervisors (id, session_id, workspace_id, state, objective, evaluator_provider_id, evaluator_model, max_supervision_count, completed_supervision_count, scheduled_at, stop_reason, last_cycle_at, last_evaluated_turn_id, error_reason, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( input.id, @@ -55,6 +70,11 @@ export class SupervisorRepo { input.state, input.objective, input.evaluatorProviderId, + input.evaluatorModel ?? null, + input.maxSupervisionCount ?? 0, + input.completedSupervisionCount ?? 0, + input.scheduledAt ?? null, + input.stopReason ?? null, input.lastCycleAt ?? null, input.lastEvaluatedTurnId ?? null, input.errorReason ?? null, @@ -105,6 +125,26 @@ export class SupervisorRepo { assignments.push("evaluator_provider_id = @evaluatorProviderId"); params.evaluatorProviderId = patch.evaluatorProviderId; } + if (patch.evaluatorModel !== undefined) { + assignments.push("evaluator_model = @evaluatorModel"); + params.evaluatorModel = patch.evaluatorModel; + } + if (patch.maxSupervisionCount !== undefined) { + assignments.push("max_supervision_count = @maxSupervisionCount"); + params.maxSupervisionCount = patch.maxSupervisionCount; + } + if (patch.completedSupervisionCount !== undefined) { + assignments.push("completed_supervision_count = @completedSupervisionCount"); + params.completedSupervisionCount = patch.completedSupervisionCount; + } + if (patch.scheduledAt !== undefined) { + assignments.push("scheduled_at = @scheduledAt"); + params.scheduledAt = patch.scheduledAt; + } + if (patch.stopReason !== undefined) { + assignments.push("stop_reason = @stopReason"); + params.stopReason = patch.stopReason; + } if (patch.lastCycleAt !== undefined) { assignments.push("last_cycle_at = @lastCycleAt"); params.lastCycleAt = patch.lastCycleAt; @@ -141,6 +181,11 @@ export class SupervisorRepo { state: row.state, objective: row.objective, evaluatorProviderId: row.evaluator_provider_id, + evaluatorModel: row.evaluator_model ?? undefined, + maxSupervisionCount: row.max_supervision_count, + completedSupervisionCount: row.completed_supervision_count, + scheduledAt: row.scheduled_at ?? undefined, + stopReason: row.stop_reason ?? undefined, cycles: [], lastCycleAt: row.last_cycle_at ?? undefined, lastEvaluatedTurnId: row.last_evaluated_turn_id ?? undefined, diff --git a/packages/server/src/storage/schema-version.ts b/packages/server/src/storage/schema-version.ts new file mode 100644 index 000000000..df83c8bb7 --- /dev/null +++ b/packages/server/src/storage/schema-version.ts @@ -0,0 +1,296 @@ +import { DatabaseSync } from "node:sqlite"; +import { readFileSync } from "fs"; +import { join } from "path"; +import type { Database } from "./database.js"; + +interface SchemaEntryRow { + type: string; + name: string; + tbl_name: string; + sql: string | null; +} + +interface SchemaEntry { + type: string; + name: string; + tableName: string; + sql: string; +} + +interface UserVersionRow { + user_version: number; +} + +export type SchemaState = "empty" | "current" | "v1" | "incompatible"; + +export interface SchemaDetection { + state: SchemaState; + userVersion: number; + mismatch: string | null; +} + +export const CURRENT_SCHEMA_VERSION = 2; + +const CURRENT_SCHEMA_PATH = join(import.meta.dirname, "migrations", "001_init.sql"); + +export const CURRENT_SCHEMA_SQL = readFileSync(CURRENT_SCHEMA_PATH, "utf-8"); + +export const V1_SCHEMA_SQL = ` +CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + target_runtime TEXT NOT NULL, + wsl_distro TEXT, + opened_at INTEGER NOT NULL, + last_active_at INTEGER NOT NULL, + ui_state TEXT +); + +CREATE TABLE terminals ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + cwd TEXT NOT NULL, + argv TEXT NOT NULL, + env TEXT, + title TEXT, + cols INTEGER NOT NULL, + rows INTEGER NOT NULL, + created_at INTEGER NOT NULL, + ended_at INTEGER, + exit_code INTEGER +); + +CREATE INDEX idx_terminals_workspace ON terminals(workspace_id); +CREATE INDEX idx_terminals_kind ON terminals(workspace_id, kind); + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + terminal_id TEXT NOT NULL REFERENCES terminals(id) ON DELETE CASCADE, + provider_id TEXT NOT NULL, + capability TEXT NOT NULL, + state TEXT NOT NULL, + started_at INTEGER NOT NULL, + ended_at INTEGER, + last_active_at INTEGER NOT NULL, + completion_percent INTEGER, + error_reason TEXT, + archived BOOLEAN DEFAULT 0, + title TEXT +); + +CREATE INDEX idx_sessions_workspace ON sessions(workspace_id); +CREATE UNIQUE INDEX idx_sessions_terminal ON sessions(terminal_id); +CREATE UNIQUE INDEX idx_sessions_id_workspace ON sessions(id, workspace_id); + +CREATE TABLE provider_configs ( + provider_id TEXT PRIMARY KEY, + config TEXT NOT NULL +); + +CREATE TABLE user_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE auth_sessions ( + token TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL +); + +CREATE INDEX idx_auth_sessions_last_seen_at ON auth_sessions(last_seen_at); + +CREATE TABLE supervisors ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + workspace_id TEXT NOT NULL, + state TEXT NOT NULL, + objective TEXT NOT NULL, + evaluator_provider_id TEXT NOT NULL, + last_cycle_at INTEGER, + last_evaluated_turn_id TEXT, + error_reason TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (session_id, workspace_id) REFERENCES sessions(id, workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); + +CREATE INDEX idx_supervisors_workspace ON supervisors(workspace_id); +CREATE INDEX idx_supervisors_session ON supervisors(session_id); +CREATE UNIQUE INDEX idx_supervisors_id_session ON supervisors(id, session_id); + +CREATE TABLE supervisor_cycles ( + id TEXT PRIMARY KEY, + supervisor_id TEXT NOT NULL, + session_id TEXT NOT NULL, + status TEXT NOT NULL, + trigger TEXT NOT NULL, + evidence_source TEXT NOT NULL, + objective TEXT NOT NULL, + evaluator_provider_id TEXT NOT NULL, + turn_id TEXT, + progress INTEGER, + result TEXT, + injected_guidance TEXT, + error_reason TEXT, + created_at INTEGER NOT NULL, + completed_at INTEGER, + FOREIGN KEY (supervisor_id, session_id) REFERENCES supervisors(id, session_id) ON DELETE CASCADE, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE +); + +CREATE INDEX idx_supervisor_cycles_supervisor ON supervisor_cycles(supervisor_id, created_at DESC); +CREATE INDEX idx_supervisor_cycles_session ON supervisor_cycles(session_id, created_at DESC); + +CREATE TABLE auth_login_blocks ( + ip TEXT PRIMARY KEY, + failed_count INTEGER NOT NULL, + first_failed_at INTEGER NOT NULL, + last_failed_at INTEGER NOT NULL, + blocked_until INTEGER +); + +CREATE INDEX idx_auth_login_blocks_blocked_until ON auth_login_blocks(blocked_until); + +CREATE TABLE auth_login_failures ( + ip TEXT NOT NULL, + failed_at INTEGER NOT NULL +); + +CREATE INDEX idx_auth_login_failures_ip_failed_at ON auth_login_failures(ip, failed_at); +`; + +function normalizeSql(sql: string | null): string { + return (sql ?? "").replace(/\s+/g, " ").trim(); +} + +function listSchemaEntries(db: Database): SchemaEntry[] { + const rows = db + .prepare( + ` + SELECT type, name, tbl_name, sql + FROM sqlite_master + WHERE type IN ('table', 'index', 'view', 'trigger') + AND name NOT LIKE 'sqlite_%' + ORDER BY type, name + ` + ) + .all() as unknown as SchemaEntryRow[]; + + return rows.map((row) => ({ + type: row.type, + name: row.name, + tableName: row.tbl_name, + sql: normalizeSql(row.sql), + })); +} + +function schemaEntrySignature(entry: SchemaEntry): string { + return `${entry.type}:${entry.name}:${entry.tableName}:${entry.sql}`; +} + +function buildSchemaEntries(schemaSql: string): SchemaEntry[] { + const db = new DatabaseSync(":memory:"); + try { + db.exec(schemaSql); + return listSchemaEntries(db); + } finally { + db.close(); + } +} + +const CURRENT_SCHEMA_ENTRIES = buildSchemaEntries(CURRENT_SCHEMA_SQL); +const V1_SCHEMA_ENTRIES = buildSchemaEntries(V1_SCHEMA_SQL); + +function hasExactFingerprint( + actualEntries: SchemaEntry[], + expectedEntries: SchemaEntry[] +): boolean { + if (actualEntries.length !== expectedEntries.length) { + return false; + } + + return actualEntries.every( + (entry, index) => schemaEntrySignature(entry) === schemaEntrySignature(expectedEntries[index]!) + ); +} + +function describeSchemaMismatch(expected: SchemaEntry[], actual: SchemaEntry[]): string { + const expectedByName = new Map(expected.map((entry) => [`${entry.type}:${entry.name}`, entry])); + const actualByName = new Map(actual.map((entry) => [`${entry.type}:${entry.name}`, entry])); + const keys = new Set([...expectedByName.keys(), ...actualByName.keys()]); + + for (const key of keys) { + const expectedEntry = expectedByName.get(key); + const actualEntry = actualByName.get(key); + + if (!expectedEntry) { + return `unexpected ${actualEntry?.type ?? "schema object"} ${actualEntry?.name ?? key}`; + } + + if (!actualEntry) { + return `missing ${expectedEntry.type} ${expectedEntry.name}`; + } + + if (schemaEntrySignature(expectedEntry) !== schemaEntrySignature(actualEntry)) { + return `definition mismatch for ${expectedEntry.type} ${expectedEntry.name}`; + } + } + + return "unknown schema drift"; +} + +export function detectSchema(db: Database): SchemaDetection { + const actualEntries = listSchemaEntries(db); + const userVersionRow = db.prepare("PRAGMA user_version").get() as UserVersionRow | undefined; + const userVersion = userVersionRow?.user_version ?? 0; + + if (actualEntries.length === 0) { + return { + state: "empty", + userVersion, + mismatch: null, + }; + } + + if (hasExactFingerprint(actualEntries, CURRENT_SCHEMA_ENTRIES)) { + return { + state: "current", + userVersion, + mismatch: null, + }; + } + + if (hasExactFingerprint(actualEntries, V1_SCHEMA_ENTRIES)) { + return { + state: "v1", + userVersion, + mismatch: null, + }; + } + + return { + state: "incompatible", + userVersion, + mismatch: describeSchemaMismatch(CURRENT_SCHEMA_ENTRIES, actualEntries), + }; +} + +export function stampCurrentSchemaVersion(db: Database): void { + db.exec(`PRAGMA user_version = ${CURRENT_SCHEMA_VERSION}`); +} + +export class IncompatibleSchemaError extends Error { + readonly code = "db_incompatible_schema"; + + constructor(dbPath: string, mismatch: string) { + super( + `db_incompatible_schema: Database schema mismatch detected at ${dbPath}: ${mismatch}. ` + + "This build requires the current baseline schema. Delete the local database file and restart." + ); + this.name = "IncompatibleSchemaError"; + } +} diff --git a/packages/server/src/supervisor/evaluator.test.ts b/packages/server/src/supervisor/evaluator.test.ts index 599b96bbf..be80dfe63 100644 --- a/packages/server/src/supervisor/evaluator.test.ts +++ b/packages/server/src/supervisor/evaluator.test.ts @@ -157,6 +157,44 @@ describe("SupervisorEvaluator", () => { expect(result.message).toBe("next step: run tests"); }); + it("prefers supervisor.evaluatorModel over provider config model", async () => { + const provider = createProvider("codex", "next step: run tests", { + defaultConfig: { model: "gpt-4.1", additionalArgs: [], envVars: {} }, + }); + const evaluator = new SupervisorEvaluator({ + providerRegistry: [provider], + providerConfigRepo: createProviderConfigRepo({ + model: "gpt-4.1", + additionalArgs: [], + envVars: {}, + }), + timeoutMs: 5000, + }); + + const result = await evaluator.evaluate( + { + ...makeSupervisor("codex"), + evaluatorModel: "o3", + }, + makeContext() + ); + + expect(result.message).toBe("next step: run tests"); + expect(provider.buildSupervisorEvalCommand).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ model: "o3" }) + ); + }); + + it("returns an objective-complete result when the evaluator emits the sentinel", async () => { + const evaluator = makeEvaluator("[objective complete]"); + + await expect(evaluator.evaluate(makeSupervisor("codex"), makeContext())).resolves.toEqual({ + message: "[objective complete]", + objectiveComplete: true, + }); + }); + it("falls back to provider.defaultConfig when evaluator config is missing", async () => { const evaluator = new SupervisorEvaluator({ providerRegistry: [ diff --git a/packages/server/src/supervisor/evaluator.ts b/packages/server/src/supervisor/evaluator.ts index 47df97537..f27b5acf0 100644 --- a/packages/server/src/supervisor/evaluator.ts +++ b/packages/server/src/supervisor/evaluator.ts @@ -31,6 +31,7 @@ const NOOP_LOGGER: FastifyBaseLogger = { */ export interface SupervisorResult { message: string; + objectiveComplete: boolean; } interface EvaluateOptions { @@ -80,7 +81,12 @@ export class SupervisorEvaluator { prompt, sessionId: supervisor.sessionId, workspacePath: context.workspacePath, - model: typeof config.model === "string" ? config.model : undefined, + model: + typeof supervisor.evaluatorModel === "string" && supervisor.evaluatorModel.trim() + ? supervisor.evaluatorModel.trim() + : typeof config.model === "string" + ? config.model + : undefined, }); if (!command) { @@ -113,7 +119,11 @@ export class SupervisorEvaluator { throw error; } - return { message: message.slice(0, this.config.guidanceMaxChars) }; + const normalizedMessage = message.slice(0, this.config.guidanceMaxChars); + return { + message: normalizedMessage, + objectiveComplete: normalizedMessage.trim() === "[objective complete]", + }; } } @@ -442,6 +452,14 @@ function extractSupervisorMessage(output: string, providerId: string): string { const lines = trimmed.split(/\r?\n/).filter(Boolean); if (providerId === "codex") { + if (trimmed === "[objective complete]") { + return trimmed; + } + + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { + return stripCodeFence(trimmed); + } + const scan = scanCodexStream(lines); if (scan.turnFailure) { @@ -473,7 +491,6 @@ function extractSupervisorMessage(output: string, providerId: string): string { } const text = line.trim(); if (text && !scan.isCodexStream) { - // Not a codex stream — use raw text return stripCodeFence(text); } } diff --git a/packages/server/src/supervisor/evaluator.windows.test.ts b/packages/server/src/supervisor/evaluator.windows.test.ts index c5bb16261..4226ef203 100644 --- a/packages/server/src/supervisor/evaluator.windows.test.ts +++ b/packages/server/src/supervisor/evaluator.windows.test.ts @@ -113,6 +113,7 @@ describe("SupervisorEvaluator windows child-process options", () => { await expect(evaluator.evaluate(makeSupervisor(), makeContext())).resolves.toEqual({ message: "Run pnpm vitest to verify", + objectiveComplete: false, }); expect(spawnMock).toHaveBeenCalledWith( diff --git a/packages/server/src/supervisor/injector.test.ts b/packages/server/src/supervisor/injector.test.ts index aa094a6be..8466d7bf2 100644 --- a/packages/server/src/supervisor/injector.test.ts +++ b/packages/server/src/supervisor/injector.test.ts @@ -87,6 +87,21 @@ describe("SupervisorInjector", () => { expect(sendInputSpy).not.toHaveBeenCalled(); }); + + it("does not send input when the abort signal is already cancelled", async () => { + const sendInputSpy = vi.fn(); + const injector = makeInjector(sendInputSpy); + const controller = new AbortController(); + controller.abort(); + + await expect( + injector.inject(supervisor, { message: "go" }, [], { signal: controller.signal }) + ).rejects.toMatchObject({ + code: "supervisor_eval_aborted", + }); + + expect(sendInputSpy).not.toHaveBeenCalled(); + }); }); describe("describeNonInjectableState", () => { diff --git a/packages/server/src/supervisor/injector.ts b/packages/server/src/supervisor/injector.ts index 23274434a..6d63fac12 100644 --- a/packages/server/src/supervisor/injector.ts +++ b/packages/server/src/supervisor/injector.ts @@ -49,8 +49,16 @@ export class SupervisorInjector { async inject( supervisor: Supervisor, input: { message: string }, - recentCycles: SupervisorCycle[] + recentCycles: SupervisorCycle[], + options: { signal?: AbortSignal } = {} ): Promise<{ injected: boolean; text: string }> { + if (options.signal?.aborted) { + throw { + code: "supervisor_eval_aborted", + message: "Supervisor evaluator aborted", + }; + } + const session = this.deps.sessionMgr.get(supervisor.sessionId); if (!session) { throw { @@ -79,6 +87,13 @@ export class SupervisorInjector { return { injected: false, text }; } + if (options.signal?.aborted) { + throw { + code: "supervisor_eval_aborted", + message: "Supervisor evaluator aborted", + }; + } + // Wrap with bracketed-paste so the TUI doesn't interpret any embedded // characters as slash-commands / keybindings. Terminate with \r so the // receiving CLI actually submits the message. diff --git a/packages/server/src/supervisor/manager.test.ts b/packages/server/src/supervisor/manager.test.ts index ade25c0a3..ccde5f4e4 100644 --- a/packages/server/src/supervisor/manager.test.ts +++ b/packages/server/src/supervisor/manager.test.ts @@ -10,6 +10,7 @@ type MockSupervisorManagerDeps = { sessionMgr: { get: ReturnType }; providerRegistry: ProviderDefinition[]; providerConfigRepo: { get: ReturnType }; + settingsRepo: { get: ReturnType }; supervisorRepo: { create: ReturnType; update: ReturnType; @@ -24,6 +25,12 @@ type MockSupervisorManagerDeps = { listRecentForSupervisor: ReturnType; pruneOldest: ReturnType; }; + cycleAttemptRepo: { + create: ReturnType; + update: ReturnType; + listForCycle: ReturnType; + deleteForCycle: ReturnType; + }; }; function createProvider(): ProviderDefinition { @@ -67,6 +74,9 @@ describe("SupervisorManager", () => { envVars: {}, })), }, + settingsRepo: { + get: vi.fn(() => undefined), + }, supervisorRepo: { create: vi.fn((value) => ({ ...value, cycles: [] })), update: vi.fn((id, patch) => ({ @@ -103,6 +113,18 @@ describe("SupervisorManager", () => { listRecentForSupervisor: vi.fn(() => []), pruneOldest: vi.fn(), }, + cycleAttemptRepo: { + create: vi.fn((attempt) => attempt), + update: vi.fn((id, patch) => ({ + id, + cycleId: "cycle-1", + attemptIndex: 0, + status: patch.status ?? "completed", + startedAt: 1, + })), + listForCycle: vi.fn(() => []), + deleteForCycle: vi.fn(), + }, }; }); diff --git a/packages/server/src/supervisor/manager.ts b/packages/server/src/supervisor/manager.ts index 773dc4937..eec14873d 100644 --- a/packages/server/src/supervisor/manager.ts +++ b/packages/server/src/supervisor/manager.ts @@ -14,6 +14,7 @@ import type { EventBus } from "../bus/event-bus.js"; import type { SessionManager } from "../session/manager.js"; import type { ProviderConfigRepo } from "../storage/repositories/provider-config-repo.js"; import type { SettingsRepo } from "../storage/repositories/settings-repo.js"; +import type { SupervisorCycleAttemptRepo } from "../storage/repositories/supervisor-cycle-attempt-repo.js"; import type { SupervisorCycleRepo } from "../storage/repositories/supervisor-cycle-repo.js"; import type { SupervisorRepo } from "../storage/repositories/supervisor-repo.js"; import type { TerminalManager } from "../terminal/manager.js"; @@ -28,6 +29,7 @@ import { SupervisorInjector, } from "./injector.js"; import { SupervisorScheduler } from "./scheduler.js"; +import { getSupervisorRetrySettings } from "./settings.js"; const NOOP_LOGGER: FastifyBaseLogger = { child: () => NOOP_LOGGER, @@ -50,6 +52,8 @@ type SessionLifecycleEvent = Extract interface StartedCycle { cycle: SupervisorCycle; context: SupervisorEvaluationContext; + retry: SupervisorRetrySnapshot; + trigger: "turn_completed" | "manual" | "scheduled"; } interface DeferredCompletion { @@ -57,6 +61,14 @@ interface DeferredCompletion { resolve: () => void; } +interface SupervisorRetrySnapshot { + retryEnabled: boolean; + retryMaxCount: number; + retryDelayMs: number; + retryOnTimeout: boolean; + retryOnEvaluatorError: boolean; +} + export interface SupervisorManagerDeps { eventBus: EventBus; broadcaster: Broadcaster; @@ -65,9 +77,17 @@ export interface SupervisorManagerDeps { sessionMgr: SessionManager; providerRegistry: ProviderDefinition[]; providerConfigRepo: ProviderConfigRepo; + git?: { + getStatusSummary?: typeof import("../git/cli.js").getGitStatusSummary; + getDiffStatSummary?: typeof import("../git/cli.js").getGitDiffStatSummary; + }; settingsRepo: Pick; supervisorRepo: SupervisorRepo; cycleRepo: SupervisorCycleRepo; + cycleAttemptRepo: Pick< + SupervisorCycleAttemptRepo, + "create" | "update" | "listForCycle" | "deleteForCycle" + >; logger?: FastifyBaseLogger; config?: SupervisorConfig; } @@ -77,11 +97,17 @@ export interface CreateSupervisorRequest { workspaceId: string; objective: string; evaluatorProviderId: string; + evaluatorModel?: string; + maxSupervisionCount?: number; + scheduledAt?: number; } export interface UpdateSupervisorRequest { objective?: string; evaluatorProviderId?: string; + evaluatorModel?: string | null; + maxSupervisionCount?: number; + scheduledAt?: number | null; } function createDeferredCompletion(): DeferredCompletion { @@ -100,6 +126,10 @@ function generateCycleId(): string { return `cycle_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; } +function generateAttemptId(): string { + return `attempt_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + function messageOf(error: unknown, fallback: string): string { if (error instanceof Error) { return error.message; @@ -127,6 +157,7 @@ export class SupervisorManager { private readonly supervisorsBySession = new Map(); private readonly inFlight = new Set(); private readonly pendingDeletes = new Set(); + private readonly pendingPauses = new Set(); private readonly evaluationAbortControllers = new Map(); private readonly inFlightCompletions = new Map(); private readonly scheduler: SupervisorScheduler; @@ -146,6 +177,7 @@ export class SupervisorManager { terminalMgr: deps.terminalMgr, providerRegistry: deps.providerRegistry, logger: this.logger, + git: deps.git, }); this.evaluator = new SupervisorEvaluator({ providerRegistry: deps.providerRegistry, @@ -164,11 +196,20 @@ export class SupervisorManager { onTurnCompleted: (sessionId) => { const supervisorId = this.supervisorsBySession.get(sessionId); if (supervisorId) { - void this.runEvaluation(supervisorId).catch((error) => { + void this.runEvaluation(supervisorId, "turn_completed").catch((error) => { this.logger.warn({ err: error, supervisorId }, "Supervisor auto-evaluation failed"); }); } }, + listScheduledSupervisors: () => this.listScheduledSupervisors(), + onScheduledDue: (supervisorId) => { + void this.runEvaluation(supervisorId, "scheduled").catch((error) => { + this.logger.warn( + { err: error, supervisorId }, + "Supervisor scheduled auto-evaluation failed" + ); + }); + }, }); } @@ -232,6 +273,7 @@ export class SupervisorManager { ); this.scheduler.start(); + this.scheduler.refresh(); } stop(): void { @@ -316,6 +358,10 @@ export class SupervisorManager { state: "idle", objective: req.objective.trim(), evaluatorProviderId: req.evaluatorProviderId, + evaluatorModel: req.evaluatorModel?.trim() || undefined, + maxSupervisionCount: req.maxSupervisionCount ?? 0, + completedSupervisionCount: 0, + scheduledAt: req.scheduledAt, createdAt: now, updatedAt: now, }) @@ -323,6 +369,7 @@ export class SupervisorManager { this.storeSnapshot(supervisor); this.broadcastState(supervisor, "created"); + this.scheduler.refresh(); return supervisor; } @@ -337,6 +384,12 @@ export class SupervisorManager { this.deps.supervisorRepo.update(id, { objective: patch.objective !== undefined ? patch.objective.trim() : current.objective, evaluatorProviderId: patch.evaluatorProviderId ?? current.evaluatorProviderId, + evaluatorModel: + patch.evaluatorModel === undefined + ? current.evaluatorModel + : patch.evaluatorModel?.trim() || null, + maxSupervisionCount: patch.maxSupervisionCount ?? current.maxSupervisionCount, + scheduledAt: patch.scheduledAt === undefined ? current.scheduledAt : patch.scheduledAt, state: current.state === "error" ? "idle" : current.state, errorReason: null, updatedAt: Date.now(), @@ -345,10 +398,16 @@ export class SupervisorManager { this.storeSnapshot(updated); this.broadcastState(updated, "updated"); + this.scheduler.refresh(); return updated; } async pause(id: string): Promise { + if (this.inFlight.has(id)) { + this.pendingPauses.add(id); + this.evaluationAbortControllers.get(id)?.abort(); + } + const updated = this.attachCycles( this.deps.supervisorRepo.update(id, { state: "paused", @@ -358,6 +417,7 @@ export class SupervisorManager { this.storeSnapshot(updated); this.broadcastState(updated, "state_changed"); + this.scheduler.refresh(); return updated; } @@ -372,6 +432,7 @@ export class SupervisorManager { this.storeSnapshot(updated); this.broadcastState(updated, "state_changed"); + this.scheduler.refresh(); return updated; } @@ -382,6 +443,7 @@ export class SupervisorManager { this.pendingDeletes.add(id); this.evaluationAbortControllers.get(id)?.abort(); await this.inFlightCompletions.get(id)?.promise; + this.scheduler.refresh(); return; } @@ -422,8 +484,11 @@ export class SupervisorManager { * auto trigger path (scheduler) and for tests that want to observe the * final cycle outcome. */ - async runEvaluation(supervisorId: string): Promise { - const started = await this.beginCycle(supervisorId, "turn_completed"); + async runEvaluation( + supervisorId: string, + trigger: "turn_completed" | "scheduled" = "turn_completed" + ): Promise { + const started = await this.beginCycle(supervisorId, trigger); if (!started) { return null; } @@ -443,7 +508,7 @@ export class SupervisorManager { */ private async beginCycle( id: string, - trigger: "turn_completed" | "manual" + trigger: "turn_completed" | "manual" | "scheduled" ): Promise { const supervisor = this.requireSupervisor(id); const session = this.deps.sessionMgr.get(supervisor.sessionId); @@ -475,13 +540,59 @@ export class SupervisorManager { return null; } + if (supervisor.state === "stopped") { + if (trigger === "manual") { + throw { + code: "supervisor_stopped", + message: `Supervisor ${id} is stopped`, + }; + } + return null; + } + if ( - trigger === "turn_completed" && + (trigger === "turn_completed" || trigger === "scheduled") && (supervisor.state !== "idle" || (session.state !== "running" && session.state !== "idle")) ) { return null; } + if ( + supervisor.maxSupervisionCount > 0 && + supervisor.completedSupervisionCount >= supervisor.maxSupervisionCount + ) { + const stopped = this.attachCycles( + this.deps.supervisorRepo.update(id, { + state: "stopped", + stopReason: "max_supervision_count_reached", + updatedAt: Date.now(), + }) + ); + this.storeSnapshot(stopped); + this.broadcastState(stopped, "state_changed"); + this.scheduler.refresh(); + return null; + } + + // If scheduled execution is set but the scheduled time has not arrived yet, + // skip turn_completed triggers. Only after the scheduled time passes will + // turn_completed triggers proceed with evaluation. + if (trigger === "turn_completed") { + if ( + supervisor.scheduledAt !== undefined && + supervisor.scheduledAt !== null && + supervisor.scheduledAt > Date.now() + ) { + return null; + } + } + + if (trigger === "scheduled") { + if (supervisor.scheduledAt === undefined || supervisor.scheduledAt > Date.now()) { + return null; + } + } + // Manual trigger: fail fast if the session can't receive injection yet. // Without this guard we would burn an evaluator turn only to have the // injector reject the session right after (e.g. Codex sessions stuck in @@ -498,6 +609,7 @@ export class SupervisorManager { this.inFlightCompletions.set(id, createDeferredCompletion()); try { + const retrySettings = getSupervisorRetrySettings(this.deps.settingsRepo); const context = await this.contextBuilder.build(supervisor); if ( trigger === "turn_completed" && @@ -508,15 +620,25 @@ export class SupervisorManager { return null; } + const shouldConsumeScheduledAt = + trigger === "scheduled" || + (trigger === "turn_completed" && + supervisor.scheduledAt !== undefined && + supervisor.scheduledAt !== null && + supervisor.scheduledAt <= Date.now()); + const evaluatingSupervisor = this.attachCycles( this.deps.supervisorRepo.update(supervisor.id, { state: "evaluating", + scheduledAt: shouldConsumeScheduledAt ? null : (supervisor.scheduledAt ?? undefined), + stopReason: null, errorReason: null, updatedAt: Date.now(), }) ); this.storeSnapshot(evaluatingSupervisor); this.broadcastState(evaluatingSupervisor, "state_changed"); + this.scheduler.refresh(); const activeCycle = this.deps.cycleRepo.create({ id: generateCycleId(), @@ -532,7 +654,18 @@ export class SupervisorManager { }); this.broadcastCycle(evaluatingSupervisor, activeCycle, "created"); - return { cycle: activeCycle, context }; + return { + cycle: activeCycle, + context, + trigger, + retry: { + retryEnabled: retrySettings.retryEnabled, + retryMaxCount: retrySettings.retryMaxCount, + retryDelayMs: retrySettings.retryDelaySec * 1000, + retryOnTimeout: retrySettings.retryOnTimeout, + retryOnEvaluatorError: retrySettings.retryOnEvaluatorError, + }, + }; } catch (error: unknown) { // Error happened BEFORE we created a cycle (usually contextBuilder or // the state→evaluating write). Make sure we don't leave the @@ -555,100 +688,22 @@ export class SupervisorManager { try { const supervisorForEval = this.supervisors.get(supervisorId) ?? this.requireSupervisor(supervisorId); - - const evaluation = await this.evaluator.evaluate(supervisorForEval, context, { - signal: this.evaluationAbortControllers.get(supervisorId)?.signal, - }); - - let injected = false; - let injectedText: string | undefined; - let cycleResult: string | undefined; - let injectionError: string | undefined; - - if (evaluation.message.trim()) { - const injectingSupervisor = this.attachCycles( - this.deps.supervisorRepo.update(supervisorId, { - state: "injecting", - updatedAt: Date.now(), - }) - ); - this.storeSnapshot(injectingSupervisor); - this.broadcastState(injectingSupervisor, "state_changed"); - - // Fetch dedupeWindow + 1 so we can safely drop the in-flight cycle - // (already persisted via cycleRepo.create above) before passing the - // previous N cycles into the injector's dedupe check. - const recentCycles = this.deps.cycleRepo - .listRecentForSupervisor(supervisorId, this.config.guidanceDedupeWindow + 1) - .filter((cycle) => cycle.id !== activeCycle.id); - - try { - const injection = await this.injector.inject( - injectingSupervisor, - { - message: evaluation.message, - }, - recentCycles - ); - injected = injection.injected; - injectedText = injection.injected ? injection.text : undefined; - cycleResult = injection.injected - ? injection.text - : `Skipped duplicate: ${injection.text}`; - } catch (error) { - // Injection failed (e.g. session gone away). Keep the evaluation - // result but mark the cycle as failed instead of 'injected'. - injectionError = messageOf(error, "Injection failed"); - this.logger.warn( - { err: error, supervisorId, cycleId: activeCycle.id }, - "Supervisor injection failed" - ); - } - } - - const finalStatus: CycleStatus = injectionError - ? "failed" - : injected - ? "injected" - : "completed"; - - const finishedCycle = this.deps.cycleRepo.update(activeCycle.id, { - status: finalStatus, - result: cycleResult ?? null, - injectedGuidance: injectedText, - errorReason: injectionError ?? null, - completedAt: Date.now(), - }); - - const latestState = this.supervisors.get(supervisorId)?.state; - const nextState: SupervisorState = - latestState === "paused" ? "paused" : injectionError ? "error" : "idle"; - const finishedSupervisor = this.attachCycles( - this.deps.supervisorRepo.update(supervisorId, { - state: nextState, - lastCycleAt: finishedCycle.completedAt, - lastEvaluatedTurnId: context.lastTurnId ?? undefined, - errorReason: injectionError ?? null, - updatedAt: Date.now(), - }) - ); - - this.storeSnapshot(finishedSupervisor); - this.broadcastCycle(finishedSupervisor, finishedCycle, "updated"); - this.broadcastState(finishedSupervisor, "state_changed"); - this.deps.cycleRepo.pruneOldest(supervisorId, this.config.maxCyclesPerSession); + const signal = this.evaluationAbortControllers.get(supervisorId)?.signal; + const evaluation = await this.executeCycleWithRetry(started, supervisorForEval, signal); + const finalized = this.finalizeSuccessfulCycle(activeCycle, context, evaluation); if (this.pendingDeletes.has(supervisorId)) { this.pendingDeletes.delete(supervisorId); - this.deleteNow(finishedSupervisor); + this.deleteNow(finalized.supervisor); } - return finishedCycle; + return finalized.cycle; } catch (error: unknown) { if (isSupervisorEvalAborted(error)) { + const cancelled = this.pendingPauses.has(supervisorId); const abortedCycle = this.deps.cycleRepo.update(activeCycle.id, { - status: "failed", - errorReason: messageOf(error, "Supervisor evaluator aborted"), + status: cancelled ? "cancelled" : "failed", + errorReason: cancelled ? null : messageOf(error, "Supervisor evaluator aborted"), completedAt: Date.now(), }); @@ -663,10 +718,12 @@ export class SupervisorManager { } const latestState = this.supervisors.get(supervisorId)?.state; - const nextState: SupervisorState = latestState === "paused" ? "paused" : "idle"; + const nextState: SupervisorState = + cancelled || latestState === "paused" ? "paused" : "idle"; const recoveredSupervisor = this.attachCycles( this.deps.supervisorRepo.update(supervisorId, { state: nextState, + stopReason: null, errorReason: null, updatedAt: Date.now(), }) @@ -676,6 +733,8 @@ export class SupervisorManager { this.broadcastCycle(recoveredSupervisor, abortedCycle, "updated"); this.broadcastState(recoveredSupervisor, "state_changed"); this.deps.cycleRepo.pruneOldest(supervisorId, this.config.maxCyclesPerSession); + this.scheduler.refresh(); + this.pendingPauses.delete(supervisorId); return abortedCycle; } @@ -695,6 +754,7 @@ export class SupervisorManager { const failedSupervisor = this.attachCycles( this.deps.supervisorRepo.update(supervisorId, { state: "error", + stopReason: null, errorReason: reason, updatedAt: Date.now(), }) @@ -711,10 +771,168 @@ export class SupervisorManager { throw error; } finally { + this.pendingPauses.delete(supervisorId); this.releaseInFlight(supervisorId); } } + private async executeCycleWithRetry( + started: StartedCycle, + supervisor: Supervisor, + signal?: AbortSignal + ): Promise<{ + objectiveComplete: boolean; + injected: boolean; + injectedText?: string; + cycleResult?: string; + }> { + for (let attemptIndex = 0; ; attemptIndex += 1) { + const attempt = this.deps.cycleAttemptRepo.create({ + id: generateAttemptId(), + cycleId: started.cycle.id, + attemptIndex, + status: "evaluating", + startedAt: Date.now(), + }); + + try { + const evaluation = await this.evaluator.evaluate(supervisor, started.context, { signal }); + this.deps.cycleAttemptRepo.update(attempt.id, { + status: "completed", + completedAt: Date.now(), + providerModel: supervisor.evaluatorModel ?? null, + }); + + if (evaluation.objectiveComplete) { + return { + objectiveComplete: true, + injected: false, + cycleResult: evaluation.message, + }; + } + + if (!evaluation.message.trim()) { + return { + objectiveComplete: false, + injected: false, + }; + } + + if (signal?.aborted || this.pendingPauses.has(supervisor.id)) { + throw { code: "supervisor_eval_aborted", message: "Supervisor evaluator aborted" }; + } + + const injectingSupervisor = this.attachCycles( + this.deps.supervisorRepo.update(supervisor.id, { + state: "injecting", + updatedAt: Date.now(), + }) + ); + this.storeSnapshot(injectingSupervisor); + this.broadcastState(injectingSupervisor, "state_changed"); + + const recentCycles = this.deps.cycleRepo + .listRecentForSupervisor(supervisor.id, this.config.guidanceDedupeWindow + 1) + .filter((cycle) => cycle.id !== started.cycle.id); + + const injection = await this.injector.inject( + injectingSupervisor, + { + message: evaluation.message, + }, + recentCycles, + { signal } + ); + + return { + objectiveComplete: false, + injected: injection.injected, + injectedText: injection.injected ? injection.text : undefined, + cycleResult: injection.injected ? injection.text : `Skipped duplicate: ${injection.text}`, + }; + } catch (error) { + if (isSupervisorEvalAborted(error)) { + this.deps.cycleAttemptRepo.update(attempt.id, { + status: this.pendingPauses.has(supervisor.id) ? "cancelled" : "failed", + completedAt: Date.now(), + errorReason: this.pendingPauses.has(supervisor.id) + ? null + : messageOf(error, "Supervisor evaluator aborted"), + }); + throw error; + } + + const reason = messageOf(error, "Supervisor evaluation failed"); + this.deps.cycleAttemptRepo.update(attempt.id, { + status: "failed", + completedAt: Date.now(), + errorReason: reason, + }); + + if (!this.shouldRetryAttempt(error, attemptIndex, started.retry)) { + throw error; + } + + await this.sleep(started.retry.retryDelayMs, signal); + + const evaluatingSupervisor = this.attachCycles( + this.deps.supervisorRepo.update(supervisor.id, { + state: "evaluating", + updatedAt: Date.now(), + }) + ); + this.storeSnapshot(evaluatingSupervisor); + this.broadcastState(evaluatingSupervisor, "state_changed"); + } + } + } + + private finalizeSuccessfulCycle( + activeCycle: SupervisorCycle, + context: SupervisorEvaluationContext, + result: { + objectiveComplete: boolean; + injected: boolean; + injectedText?: string; + cycleResult?: string; + } + ): { cycle: SupervisorCycle; supervisor: Supervisor } { + const finalStatus: CycleStatus = result.injected + ? "injected" + : result.objectiveComplete + ? "completed" + : "completed"; + + const finishedCycle = this.deps.cycleRepo.update(activeCycle.id, { + status: finalStatus, + result: result.cycleResult ?? null, + injectedGuidance: result.injectedText ?? null, + errorReason: null, + completedAt: Date.now(), + }); + + const finishedSupervisor = this.attachCycles( + this.deps.supervisorRepo.update(activeCycle.supervisorId, { + state: result.objectiveComplete ? "stopped" : "idle", + completedSupervisionCount: + (this.supervisors.get(activeCycle.supervisorId)?.completedSupervisionCount ?? 0) + 1, + stopReason: result.objectiveComplete ? "objective_complete" : null, + lastCycleAt: finishedCycle.completedAt, + lastEvaluatedTurnId: context.lastTurnId ?? undefined, + errorReason: null, + updatedAt: Date.now(), + }) + ); + + this.storeSnapshot(finishedSupervisor); + this.broadcastCycle(finishedSupervisor, finishedCycle, "updated"); + this.broadcastState(finishedSupervisor, "state_changed"); + this.deps.cycleRepo.pruneOldest(activeCycle.supervisorId, this.config.maxCyclesPerSession); + this.scheduler.refresh(); + + return { cycle: finishedCycle, supervisor: finishedSupervisor }; + } + /** * Flip a supervisor to 'error' state when something blows up before we * had a chance to create a cycle. Without this the supervisor can get @@ -785,6 +1003,20 @@ export class SupervisorManager { }; } + private listScheduledSupervisors(): Array<{ supervisorId: string; scheduledAt: number }> { + return Array.from(this.supervisors.values()) + .filter( + (supervisor) => + supervisor.state === "idle" && + typeof supervisor.scheduledAt === "number" && + Number.isFinite(supervisor.scheduledAt) + ) + .map((supervisor) => ({ + supervisorId: supervisor.id, + scheduledAt: supervisor.scheduledAt!, + })); + } + private storeSnapshot(supervisor: Supervisor): void { this.supervisors.set(supervisor.id, supervisor); this.supervisorsBySession.set(supervisor.sessionId, supervisor.id); @@ -795,7 +1027,9 @@ export class SupervisorManager { this.supervisors.delete(supervisor.id); this.supervisorsBySession.delete(supervisor.sessionId); this.pendingDeletes.delete(supervisor.id); + this.pendingPauses.delete(supervisor.id); this.releaseInFlight(supervisor.id); + this.scheduler.refresh(); this.deps.broadcaster.broadcast( Topics.supervisorState(supervisor.workspaceId, supervisor.sessionId), @@ -841,6 +1075,60 @@ export class SupervisorManager { { cycle, event } ); } + + private shouldRetryAttempt( + error: unknown, + attemptIndex: number, + retry: SupervisorRetrySnapshot + ): boolean { + if (!retry.retryEnabled) { + return false; + } + if (attemptIndex >= retry.retryMaxCount) { + return false; + } + + const code = + error && typeof error === "object" && "code" in error + ? (error as { code?: unknown }).code + : undefined; + + if (code === "supervisor_eval_timeout") { + return retry.retryOnTimeout; + } + + if (code === "supervisor_eval_failed") { + return retry.retryOnEvaluatorError; + } + + return false; + } + + private async sleep(delayMs: number, signal?: AbortSignal): Promise { + if (delayMs <= 0) { + return; + } + + if (signal?.aborted) { + throw { code: "supervisor_eval_aborted", message: "Supervisor evaluator aborted" }; + } + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, delayMs); + timer.unref?.(); + + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + reject({ code: "supervisor_eval_aborted", message: "Supervisor evaluator aborted" }); + }; + + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } } function isSupervisorEvalAborted(error: unknown): error is { diff --git a/packages/server/src/supervisor/scheduler.test.ts b/packages/server/src/supervisor/scheduler.test.ts index f30a81461..7eae7cf14 100644 --- a/packages/server/src/supervisor/scheduler.test.ts +++ b/packages/server/src/supervisor/scheduler.test.ts @@ -25,4 +25,116 @@ describe("SupervisorScheduler", () => { expect(onTurnCompleted).toHaveBeenCalledTimes(1); expect(onTurnCompleted).toHaveBeenCalledWith("sess-1"); }); + + it("fires the nearest scheduled supervisor once it becomes due", async () => { + vi.useFakeTimers(); + const eventBus = new EventBus(); + const onScheduledDue = vi.fn(); + const base = Date.now(); + const scheduled = [ + { supervisorId: "sup-later", scheduledAt: base + 10_000 }, + { supervisorId: "sup-soon", scheduledAt: base + 1_000 }, + ]; + const scheduler = new SupervisorScheduler({ + eventBus, + onTurnCompleted: vi.fn(), + listScheduledSupervisors: () => scheduled, + onScheduledDue, + }); + + scheduler.start(); + scheduler.refresh(); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(onScheduledDue).toHaveBeenCalledTimes(1); + expect(onScheduledDue).toHaveBeenCalledWith("sup-soon"); + }); + + it("recomputes the next scheduled timer on refresh", async () => { + vi.useFakeTimers(); + const eventBus = new EventBus(); + const onScheduledDue = vi.fn(); + const base = Date.now(); + let scheduled = [{ supervisorId: "sup-later", scheduledAt: base + 10_000 }]; + const scheduler = new SupervisorScheduler({ + eventBus, + onTurnCompleted: vi.fn(), + listScheduledSupervisors: () => scheduled, + onScheduledDue, + }); + + scheduler.start(); + scheduler.refresh(); + scheduled = [{ supervisorId: "sup-soon", scheduledAt: base + 500 }]; + scheduler.refresh(); + + await vi.advanceTimersByTimeAsync(500); + + expect(onScheduledDue).toHaveBeenCalledTimes(1); + expect(onScheduledDue).toHaveBeenCalledWith("sup-soon"); + }); + + it("does not let an overdue supervisor block other due scheduled supervisors", async () => { + vi.useFakeTimers(); + const eventBus = new EventBus(); + const onScheduledDue = vi.fn(); + const base = Date.now(); + let scheduled = [ + { supervisorId: "sup-overdue", scheduledAt: base - 5_000 }, + { supervisorId: "sup-future", scheduledAt: base + 500 }, + ]; + const scheduler = new SupervisorScheduler({ + eventBus, + onTurnCompleted: vi.fn(), + listScheduledSupervisors: () => scheduled, + onScheduledDue, + }); + + scheduler.start(); + scheduler.refresh(); + + await vi.advanceTimersByTimeAsync(0); + expect(onScheduledDue).toHaveBeenCalledWith("sup-overdue"); + + await vi.advanceTimersByTimeAsync(500); + expect(onScheduledDue).toHaveBeenCalledWith("sup-future"); + + scheduled = [{ supervisorId: "sup-overdue", scheduledAt: base - 5_000 }]; + }); + + it("retries overdue supervisors with a backoff without delaying future scheduled supervisors", async () => { + vi.useFakeTimers(); + const eventBus = new EventBus(); + const onScheduledDue = vi.fn(); + const base = Date.now(); + const scheduled = [ + { supervisorId: "sup-overdue", scheduledAt: base - 5_000 }, + { supervisorId: "sup-future", scheduledAt: base + 500 }, + ]; + const scheduler = new SupervisorScheduler({ + eventBus, + onTurnCompleted: vi.fn(), + listScheduledSupervisors: () => scheduled, + onScheduledDue, + }); + + scheduler.start(); + scheduler.refresh(); + + await vi.advanceTimersByTimeAsync(0); + expect(onScheduledDue).toHaveBeenCalledTimes(1); + expect(onScheduledDue).toHaveBeenNthCalledWith(1, "sup-overdue"); + + await vi.advanceTimersByTimeAsync(500); + expect(onScheduledDue).toHaveBeenCalledTimes(2); + expect(onScheduledDue).toHaveBeenNthCalledWith(2, "sup-future"); + + await vi.advanceTimersByTimeAsync(499); + expect(onScheduledDue).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(1); + expect(onScheduledDue).toHaveBeenCalledTimes(3); + expect(onScheduledDue).toHaveBeenNthCalledWith(3, "sup-overdue"); + }); }); diff --git a/packages/server/src/supervisor/scheduler.ts b/packages/server/src/supervisor/scheduler.ts index 57d3e49c0..031b960ea 100644 --- a/packages/server/src/supervisor/scheduler.ts +++ b/packages/server/src/supervisor/scheduler.ts @@ -5,11 +5,16 @@ type SessionLifecycleEvent = Extract export class SupervisorScheduler { private unsubscribe: (() => void) | null = null; + private scheduledTimer: ReturnType | null = null; + private readonly scheduledRetryDelayMs = 1_000; + private readonly retryAtBySupervisorId = new Map(); constructor( private readonly deps: { eventBus: EventBus; onTurnCompleted: (sessionId: string) => void; + listScheduledSupervisors?: () => Array<{ supervisorId: string; scheduledAt: number }>; + onScheduledDue?: (supervisorId: string) => void; } ) {} @@ -26,8 +31,77 @@ export class SupervisorScheduler { ); } + refresh(): void { + this.clearScheduledTimer(); + + const scheduled = this.deps.listScheduledSupervisors?.() ?? []; + this.pruneRetryState(scheduled); + if (scheduled.length === 0) { + return; + } + + const now = Date.now(); + const nextAt = scheduled.reduce((earliest, item) => { + const candidate = this.getNextAttemptAt(item, now); + return candidate < earliest ? candidate : earliest; + }, Number.POSITIVE_INFINITY); + if (!Number.isFinite(nextAt)) { + return; + } + + const delayMs = Math.max(nextAt - now, 0); + this.scheduledTimer = setTimeout(() => { + this.scheduledTimer = null; + const current = this.deps.listScheduledSupervisors?.() ?? []; + this.pruneRetryState(current); + + const dueAt = Date.now(); + const due = current.filter( + (item) => + item.scheduledAt <= dueAt && + (this.retryAtBySupervisorId.get(item.supervisorId) ?? Number.NEGATIVE_INFINITY) <= dueAt + ); + for (const item of due) { + this.retryAtBySupervisorId.set(item.supervisorId, dueAt + this.scheduledRetryDelayMs); + this.deps.onScheduledDue?.(item.supervisorId); + } + this.refresh(); + }, delayMs); + this.scheduledTimer.unref?.(); + } + stop(): void { this.unsubscribe?.(); this.unsubscribe = null; + this.clearScheduledTimer(); + this.retryAtBySupervisorId.clear(); + } + + private clearScheduledTimer(): void { + if (this.scheduledTimer) { + clearTimeout(this.scheduledTimer); + this.scheduledTimer = null; + } + } + + private getNextAttemptAt( + item: { supervisorId: string; scheduledAt: number }, + now: number + ): number { + if (item.scheduledAt > now) { + return item.scheduledAt; + } + + const retryAt = this.retryAtBySupervisorId.get(item.supervisorId); + return retryAt && retryAt > now ? retryAt : item.scheduledAt; + } + + private pruneRetryState(scheduled: Array<{ supervisorId: string; scheduledAt: number }>): void { + const scheduledIds = new Set(scheduled.map((item) => item.supervisorId)); + for (const supervisorId of this.retryAtBySupervisorId.keys()) { + if (!scheduledIds.has(supervisorId)) { + this.retryAtBySupervisorId.delete(supervisorId); + } + } } } diff --git a/packages/server/src/supervisor/settings.ts b/packages/server/src/supervisor/settings.ts index 6664b0808..2cccf6922 100644 --- a/packages/server/src/supervisor/settings.ts +++ b/packages/server/src/supervisor/settings.ts @@ -1,10 +1,33 @@ import { DEFAULT_SUPERVISOR_EVALUATION_TIMEOUT_SEC, + DEFAULT_SUPERVISOR_RETRY_DELAY_SEC, + DEFAULT_SUPERVISOR_RETRY_ENABLED, + DEFAULT_SUPERVISOR_RETRY_MAX_COUNT, + DEFAULT_SUPERVISOR_RETRY_ON_EVALUATOR_ERROR, + DEFAULT_SUPERVISOR_RETRY_ON_TIMEOUT, resolveSupervisorEvaluationTimeoutSec, + resolveSupervisorRetryDelaySec, + resolveSupervisorRetryEnabled, + resolveSupervisorRetryMaxCount, + resolveSupervisorRetryOnEvaluatorError, + resolveSupervisorRetryOnTimeout, } from "@coder-studio/core"; import type { SettingsRepo } from "../storage/repositories/settings-repo.js"; export const SUPERVISOR_EVALUATION_TIMEOUT_SETTING_KEY = "supervisor.evaluationTimeoutSec"; +export const SUPERVISOR_RETRY_ENABLED_SETTING_KEY = "supervisor.retryEnabled"; +export const SUPERVISOR_RETRY_MAX_COUNT_SETTING_KEY = "supervisor.retryMaxCount"; +export const SUPERVISOR_RETRY_DELAY_SEC_SETTING_KEY = "supervisor.retryDelaySec"; +export const SUPERVISOR_RETRY_ON_TIMEOUT_SETTING_KEY = "supervisor.retryOnTimeout"; +export const SUPERVISOR_RETRY_ON_EVALUATOR_ERROR_SETTING_KEY = "supervisor.retryOnEvaluatorError"; + +export interface SupervisorRetrySettings { + retryEnabled: boolean; + retryMaxCount: number; + retryDelaySec: number; + retryOnTimeout: boolean; + retryOnEvaluatorError: boolean; +} export function getSupervisorEvaluationTimeoutMs(settingsRepo?: Pick): number { let storedValue: number | undefined; @@ -17,3 +40,57 @@ export function getSupervisorEvaluationTimeoutMs(settingsRepo?: Pick( + settingsRepo: Pick | undefined, + key: string, + fallback: T +): T { + try { + return (settingsRepo?.get(key) ?? fallback) as T; + } catch { + return fallback; + } +} + +export function getSupervisorRetrySettings( + settingsRepo?: Pick +): SupervisorRetrySettings { + return { + retryEnabled: resolveSupervisorRetryEnabled( + getSettingOrDefault( + settingsRepo, + SUPERVISOR_RETRY_ENABLED_SETTING_KEY, + DEFAULT_SUPERVISOR_RETRY_ENABLED + ) + ), + retryMaxCount: resolveSupervisorRetryMaxCount( + getSettingOrDefault( + settingsRepo, + SUPERVISOR_RETRY_MAX_COUNT_SETTING_KEY, + DEFAULT_SUPERVISOR_RETRY_MAX_COUNT + ) + ), + retryDelaySec: resolveSupervisorRetryDelaySec( + getSettingOrDefault( + settingsRepo, + SUPERVISOR_RETRY_DELAY_SEC_SETTING_KEY, + DEFAULT_SUPERVISOR_RETRY_DELAY_SEC + ) + ), + retryOnTimeout: resolveSupervisorRetryOnTimeout( + getSettingOrDefault( + settingsRepo, + SUPERVISOR_RETRY_ON_TIMEOUT_SETTING_KEY, + DEFAULT_SUPERVISOR_RETRY_ON_TIMEOUT + ) + ), + retryOnEvaluatorError: resolveSupervisorRetryOnEvaluatorError( + getSettingOrDefault( + settingsRepo, + SUPERVISOR_RETRY_ON_EVALUATOR_ERROR_SETTING_KEY, + DEFAULT_SUPERVISOR_RETRY_ON_EVALUATOR_ERROR + ) + ), + }; +} diff --git a/packages/server/src/ws/activation.ts b/packages/server/src/ws/activation.ts new file mode 100644 index 000000000..1f5562702 --- /dev/null +++ b/packages/server/src/ws/activation.ts @@ -0,0 +1,115 @@ +import type { FastifyRequest } from "fastify"; + +export interface ActivationLease { + clientInstanceId: string; + wsClientId: string; + generation: number; + issuedAt: number; + graceUntil: number | null; + ip: string; + userAgent: string; +} + +export interface ActivationClaimResult { + active: true; + generation: number; + recoveryMode: "fresh" | "grace_recover" | "takeover"; + displacedWsClientId: string | null; +} + +export interface ActivationManagerOptions { + graceMs: number; +} + +const DEFAULT_OPTIONS: ActivationManagerOptions = { + graceMs: 3_000, +}; + +export class ActivationManager { + private readonly options: ActivationManagerOptions; + private lease: ActivationLease | null = null; + private generation = 0; + + constructor(options?: Partial) { + this.options = { ...DEFAULT_OPTIONS, ...options }; + } + + claim( + clientInstanceId: string, + wsClientId: string, + request: FastifyRequest + ): ActivationClaimResult { + const now = Date.now(); + const activeLease = this.getLease(); + + if (activeLease && activeLease.clientInstanceId === clientInstanceId) { + const isGraceRecovery = activeLease.graceUntil !== null && now <= activeLease.graceUntil; + const displacedWsClientId = + isGraceRecovery || activeLease.wsClientId === wsClientId ? null : activeLease.wsClientId; + + activeLease.wsClientId = wsClientId; + activeLease.graceUntil = null; + + return { + active: true, + generation: activeLease.generation, + recoveryMode: "grace_recover", + displacedWsClientId, + }; + } + + const displacedWsClientId = + activeLease && activeLease.clientInstanceId !== clientInstanceId + ? activeLease.wsClientId + : null; + const recoveryMode = displacedWsClientId === null ? "fresh" : "takeover"; + + this.generation += 1; + this.lease = { + clientInstanceId, + wsClientId, + generation: this.generation, + issuedAt: now, + graceUntil: null, + ip: request.ip, + userAgent: request.headers["user-agent"] ?? "", + }; + + return { + active: true, + generation: this.lease.generation, + recoveryMode, + displacedWsClientId, + }; + } + + release(clientInstanceId: string, generation: number): void { + const lease = this.getLease(); + if (!lease) { + return; + } + + if (lease.clientInstanceId !== clientInstanceId || lease.generation !== generation) { + return; + } + + this.lease = null; + } + + onSocketClosed(wsClientId: string): void { + const lease = this.getLease(); + if (!lease || lease.wsClientId !== wsClientId) { + return; + } + + lease.graceUntil = Date.now() + this.options.graceMs; + } + + getLease(): ActivationLease | null { + if (!this.lease) { + return null; + } + + return this.lease; + } +} diff --git a/packages/server/src/ws/client.ts b/packages/server/src/ws/client.ts index 575e47404..554565100 100644 --- a/packages/server/src/ws/client.ts +++ b/packages/server/src/ws/client.ts @@ -128,6 +128,22 @@ export class WsClient { } } + sendControlAndClose(msg: ServerToClient, code: number, reason: string): boolean { + if (this.socket.readyState !== WebSocket.OPEN) { + return false; + } + + try { + this.socket.send(JSON.stringify(msg), () => { + this.socket.close(code, reason); + }); + return true; + } catch (error) { + console.error(`Failed to send message to client ${this.id}:`, error); + return false; + } + } + sendBinary(data: Buffer): boolean { if (this.socket.readyState !== WebSocket.OPEN) { return false; @@ -313,6 +329,23 @@ export class WsClient { return this.send(event); } + sendEventAndClose( + topic: string, + data: unknown, + code: number, + reason: string, + seq: number = 0 + ): boolean { + const event: Event = { + kind: "event", + topic, + seq, + timestamp: Date.now(), + data, + }; + return this.sendControlAndClose(event, code, reason); + } + /** * Check if client subscribes to a topic (supports glob patterns) */ diff --git a/packages/server/src/ws/dispatch.ts b/packages/server/src/ws/dispatch.ts index ed63c7629..2f80f5c1a 100644 --- a/packages/server/src/ws/dispatch.ts +++ b/packages/server/src/ws/dispatch.ts @@ -15,6 +15,7 @@ import type { Database } from "../storage/database.js"; import type { SupervisorManager } from "../supervisor/manager.js"; import type { TerminalManager } from "../terminal/manager.js"; import type { WorkspaceManager } from "../workspace/manager.js"; +import type { ActivationManager } from "./activation.js"; import type { FencingManager } from "./fencing.js"; import type { Broadcaster } from "./hub.js"; @@ -34,6 +35,7 @@ export interface CommandContext { autoFetch: AutoFetchRuntime; providerRuntimeDeps?: RuntimeStatusDeps; providerInstallMgr?: ProviderInstallManager; + activationMgr: ActivationManager; } /** @@ -56,6 +58,11 @@ const handlers = new Map(); * Registry of all command schemas */ const schemas = new Map(); +const ACTIVATION_ALLOWLIST = new Set([ + "activation.claim", + "activation.release", + "connection.probe", +]); /** * Register a command handler @@ -77,6 +84,24 @@ export async function dispatch( ctx: CommandContext, clientId?: string ): Promise { + const isWsDispatch = + clientId !== undefined && typeof ctx.broadcaster.getRequestMetadata === "function"; + + if (isWsDispatch && !ACTIVATION_ALLOWLIST.has(msg.op)) { + const active = ctx.activationMgr.getLease(); + if (!active || active.wsClientId !== clientId) { + return { + kind: "result", + id: msg.id, + ok: false, + error: { + code: "activation_required", + message: "This tab is no longer the active session", + }, + }; + } + } + const handler = handlers.get(msg.op); if (!handler) { @@ -92,7 +117,6 @@ export async function dispatch( } try { - // Validate args with schema const schema = schemas.get(msg.op); let args = msg.args; @@ -109,7 +133,6 @@ export async function dispatch( data, }; } catch (error: unknown) { - // Normalize error const normalizedError = normalizeError(error); return { @@ -133,7 +156,6 @@ function normalizeError(error: unknown): Result["error"] { errors?: unknown; }; - // Zod validation error if (candidate.name === "ZodError") { return { code: "validation_error", @@ -142,7 +164,6 @@ function normalizeError(error: unknown): Result["error"] { }; } - // Custom error with code if (candidate.code) { return { code: candidate.code, @@ -151,7 +172,6 @@ function normalizeError(error: unknown): Result["error"] { }; } - // Generic error return { code: "internal_error", message: candidate.message || "An internal error occurred", diff --git a/packages/server/src/ws/hub.ts b/packages/server/src/ws/hub.ts index a5a8ced2a..28295ccef 100644 --- a/packages/server/src/ws/hub.ts +++ b/packages/server/src/ws/hub.ts @@ -61,10 +61,13 @@ export interface Broadcaster { broadcast(topic: string, data: unknown): void; sendToClient(clientId: ClientId, msg: ServerToClient): boolean; sendBinaryToClient(clientId: ClientId, data: Buffer): boolean; + getRequestMetadata?(clientId: ClientId): FastifyRequest | undefined; + revokeAndCloseClient?(clientId: ClientId, generation: number): void; } export class WsHub implements Broadcaster { private clients = new Map(); + private clientRequests = new Map(); private eventUnsubscribers: (() => void)[] = []; private nextStreamId = 1; // Per-client queue of waiters for the next inbound binary frame. The @@ -88,9 +91,10 @@ export class WsHub implements Broadcaster { /** * Handle a new WebSocket connection */ - handleConnection(socket: WebSocket, _req: FastifyRequest): void { + handleConnection(socket: WebSocket, req: FastifyRequest): void { const client = new WsClient(socket, uuidv4(), this.deps.logger); this.clients.set(client.id, client); + this.clientRequests.set(client.id, req); // Send initial connection metadata. Writer status is established later by // fencing.request, but the UI still needs the app version immediately. @@ -252,8 +256,10 @@ export class WsHub implements Broadcaster { */ private handleClose(client: WsClient): void { this.clients.delete(client.id); + this.clientRequests.delete(client.id); this.discardPendingBinaryWaiters(client.id); this.deps.commandContext?.autoFetch.unregisterViewer(client.id); + this.deps.commandContext?.activationMgr.onSocketClosed(client.id); // Release fencing tokens held by this client // FencingManager tracks by clientId internally @@ -306,6 +312,27 @@ export class WsHub implements Broadcaster { return client.sendBinary(data); } + revokeAndCloseClient(clientId: ClientId, generation: number): void { + const client = this.clients.get(clientId); + if (!client) { + return; + } + + client.sendEventAndClose( + "activation.revoked", + { + reason: "displaced", + generation, + }, + 4001, + "single_active_displaced" + ); + } + + getRequestMetadata(clientId: ClientId): FastifyRequest | undefined { + return this.clientRequests.get(clientId); + } + /** * Get the current writer client * DEPRECATED: Writer tracking now handled by FencingManager @@ -336,6 +363,7 @@ export class WsHub implements Broadcaster { client.close(); } this.clients.clear(); + this.clientRequests.clear(); } /** diff --git a/packages/web/src/app/providers.lifecycle.test.tsx b/packages/web/src/app/providers.lifecycle.test.tsx index 757ab7759..39e4d4ac1 100644 --- a/packages/web/src/app/providers.lifecycle.test.tsx +++ b/packages/web/src/app/providers.lifecycle.test.tsx @@ -2,8 +2,14 @@ import type { Workspace } from "@coder-studio/core"; import { act, render } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { authenticatedAtom } from "../atoms/app-ui"; +import { + activationGenerationAtom, + activationReasonAtom, + activationStatusAtom, +} from "../atoms/activation"; +import { authenticatedAtom, themeAtom } from "../atoms/app-ui"; import { authEnabledAtom, connectionStatusAtom } from "../atoms/connection"; +import { sessionsAtom } from "../atoms/sessions"; import { activeWorkspaceIdAtom, workspaceOrderAtom, @@ -12,9 +18,11 @@ import { } from "../atoms/workspaces"; import { terminalPreferencesAtom } from "../features/terminal-panel/preferences"; import { + fileTreeAtomFamily, fileTreeStaleAtomFamily, gitBranchListAtomFamily, gitStateAtomFamily, + loadedDirsAtomFamily, worktreeListAtomFamily, } from "../features/workspace/atoms"; import { AppProviders, resetAppProvidersSingletonsForTests } from "./providers"; @@ -56,6 +64,30 @@ function renderProviders(store = createStore()) { return { store, ...rendered }; } +function createWsSendCommandMock( + handler?: (op: string, args: unknown) => Promise | unknown +) { + return vi.fn().mockImplementation(async (op: string, args: unknown) => { + if (op === "activation.claim") { + return { + active: true, + generation: 1, + recoveryMode: "fresh", + }; + } + + if (op === "activation.release") { + return { ok: true }; + } + + if (handler) { + return await handler(op, args); + } + + return undefined; + }); +} + function setVisibilityState(value: "visible" | "hidden") { Object.defineProperty(document, "visibilityState", { configurable: true, @@ -98,10 +130,16 @@ function seedWorkspaces( describe("AppProviders lifecycle recovery", () => { const originalFetch = globalThis.fetch; const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); + const originalDocumentTheme = document.documentElement.getAttribute("data-theme"); + const originalLegacyTheme = localStorage.getItem("ui.theme"); + const originalThemeId = localStorage.getItem("ui.themeId"); const originalTerminalPreferences = localStorage.getItem("ui.terminalPreferences"); beforeEach(() => { resetAppProvidersSingletonsForTests(); + document.documentElement.removeAttribute("data-theme"); + localStorage.removeItem("ui.theme"); + localStorage.removeItem("ui.themeId"); localStorage.removeItem("ui.terminalPreferences"); globalThis.fetch = vi.fn().mockResolvedValue({ json: async () => ({ authEnabled: false }), @@ -128,19 +166,35 @@ describe("AppProviders lifecycle recovery", () => { }), getStatus: vi.fn(() => "disconnected"), recoverConnection: vi.fn(), - sendCommand: vi.fn().mockResolvedValue(undefined), + sendCommand: createWsSendCommandMock(), }; }); afterEach(() => { resetAppProvidersSingletonsForTests(); globalThis.fetch = originalFetch; + vi.useRealTimers(); vi.restoreAllMocks(); if (originalTerminalPreferences === null) { localStorage.removeItem("ui.terminalPreferences"); } else { localStorage.setItem("ui.terminalPreferences", originalTerminalPreferences); } + if (originalLegacyTheme === null) { + localStorage.removeItem("ui.theme"); + } else { + localStorage.setItem("ui.theme", originalLegacyTheme); + } + if (originalThemeId === null) { + localStorage.removeItem("ui.themeId"); + } else { + localStorage.setItem("ui.themeId", originalThemeId); + } + if (originalDocumentTheme === null) { + document.documentElement.removeAttribute("data-theme"); + } else { + document.documentElement.setAttribute("data-theme", originalDocumentTheme); + } if (originalVisibilityState) { Object.defineProperty(document, "visibilityState", originalVisibilityState); } else { @@ -321,6 +375,75 @@ describe("AppProviders lifecycle recovery", () => { }); }); + it("recovers the websocket when the window regains focus while already visible", () => { + setVisibilityState("visible"); + renderProviders(); + + return vi + .waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }) + .then(() => { + act(() => { + window.dispatchEvent(new Event("focus")); + }); + + expect(wsState.client?.recoverConnection).toHaveBeenCalledWith("visibility_resume"); + }); + }); + + it("recovers the websocket when the page is shown again while visible", () => { + setVisibilityState("visible"); + renderProviders(); + + return vi + .waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }) + .then(() => { + act(() => { + window.dispatchEvent(new Event("pageshow")); + }); + + expect(wsState.client?.recoverConnection).toHaveBeenCalledWith("visibility_resume"); + }); + }); + + it("coalesces back-to-back foreground recovery signals", async () => { + setVisibilityState("visible"); + renderProviders(); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + wsState.client?.recoverConnection.mockClear(); + vi.useFakeTimers(); + + act(() => { + window.dispatchEvent(new Event("focus")); + }); + + expect(wsState.client?.recoverConnection).toHaveBeenCalledTimes(1); + expect(wsState.client?.recoverConnection).toHaveBeenLastCalledWith("visibility_resume"); + + act(() => { + vi.advanceTimersByTime(100); + window.dispatchEvent(new Event("pageshow")); + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(wsState.client?.recoverConnection).toHaveBeenCalledTimes(1); + + act(() => { + vi.advanceTimersByTime(250); + window.dispatchEvent(new Event("focus")); + }); + + expect(wsState.client?.recoverConnection).toHaveBeenCalledTimes(2); + expect(wsState.client?.recoverConnection).toHaveBeenLastCalledWith("visibility_resume"); + }); + it("hydrates authEnabled and authenticated from /auth/status instead of trusting stale local state", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ json: async () => ({ authEnabled: true, authenticated: false }), @@ -382,6 +505,179 @@ describe("AppProviders lifecycle recovery", () => { }); }); + it("claims activation when the websocket becomes connected", async () => { + const store = createStore(); + setVisibilityState("visible"); + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + const claimCalls = + wsState.client?.sendCommand?.mock.calls.filter(([op]) => op === "activation.claim") ?? []; + expect(claimCalls.length).toBeGreaterThan(0); + expect(claimCalls[0]?.[1]).toEqual( + expect.objectContaining({ + clientInstanceId: expect.any(String), + }) + ); + expect(store.get(activationStatusAtom)).toBe("active"); + expect(store.get(activationGenerationAtom)).toBe(1); + expect(store.get(activationReasonAtom)).toBeNull(); + }); + }); + + it("does not send activation.heartbeat after the session becomes active", async () => { + const store = createStore(); + setVisibilityState("visible"); + vi.useFakeTimers(); + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + expect(store.get(activationStatusAtom)).toBe("active"); + }); + + act(() => { + vi.advanceTimersByTime(60_000); + }); + + expect( + wsState.client?.sendCommand?.mock.calls.filter(([op]) => op === "activation.heartbeat") ?? [] + ).toHaveLength(0); + }); + + it("disconnects and gates when activation.revoked is received", async () => { + const store = createStore(); + seedWorkspaces(store, ["ws-1"], "ws-1"); + act(() => { + store.set(activationStatusAtom, "active"); + store.set(activationGenerationAtom, 1); + store.set(activationReasonAtom, null); + store.set(gitStateAtomFamily("ws-1"), { + branch: "feature/test", + ahead: 1, + behind: 0, + modified: [], + staged: [], + untracked: [], + deleted: [], + }); + store.set(gitBranchListAtomFamily("ws-1"), { + current: "feature/test", + branches: [], + loading: false, + }); + store.set(fileTreeAtomFamily("ws-1"), new Map([[".", []]])); + store.set(loadedDirsAtomFamily("ws-1"), new Set(["src"])); + store.set(worktreeListAtomFamily("ws-1"), { + items: [], + loading: false, + lastLoadedAt: Date.now(), + }); + store.set(fileTreeStaleAtomFamily("ws-1"), true); + store.set(sessionsAtom, { + "session-1": { + id: "session-1", + workspaceId: "ws-1", + terminalId: "terminal-1", + providerId: "codex", + state: "running", + capability: "full", + startedAt: Date.now(), + lastActiveAt: Date.now(), + }, + }); + }); + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + wsState.client?.eventHandler?.( + "activation.revoked", + { reason: "displaced", generation: 2 }, + 1 + ); + }); + + await vi.waitFor(() => { + expect(wsState.client?.disconnect).toHaveBeenCalledWith("single_active_displaced"); + expect(store.get(activationStatusAtom)).toBe("gated"); + expect(store.get(activationReasonAtom)).toBe("displaced"); + expect(store.get(activationGenerationAtom)).toBe(2); + expect(store.get(workspacesLoadStateAtom)).toBe("idle"); + expect(store.get(workspaceOrderAtom)).toEqual([]); + expect(store.get(workspacesAtom)).toEqual({}); + expect(store.get(activeWorkspaceIdAtom)).toBeNull(); + expect(store.get(fileTreeAtomFamily("ws-1"))).toBeNull(); + expect(Array.from(store.get(loadedDirsAtomFamily("ws-1")))).toEqual([]); + expect(store.get(gitStateAtomFamily("ws-1"))).toBeNull(); + expect(store.get(gitBranchListAtomFamily("ws-1")).current).toBe(""); + expect(store.get(worktreeListAtomFamily("ws-1")).items).toEqual([]); + expect(store.get(fileTreeStaleAtomFamily("ws-1"))).toBe(false); + expect(store.get(sessionsAtom)).toEqual({}); + }); + }); + + it("does not auto-claim again while activation remains gated", async () => { + const store = createStore(); + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + store.set(activationStatusAtom, "gated"); + wsState.client?.statusHandler?.("connected"); + }); + + const claimCalls = + wsState.client?.sendCommand?.mock.calls.filter(([op]) => op === "activation.claim") ?? []; + + expect(claimCalls).toHaveLength(0); + }); + + it("does not auto-recover the websocket from foreground signals while gated", async () => { + const store = createStore(); + setVisibilityState("visible"); + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + store.set(activationStatusAtom, "gated"); + window.dispatchEvent(new Event("focus")); + window.dispatchEvent(new Event("online")); + window.dispatchEvent(new Event("pageshow")); + }); + + expect(wsState.client?.recoverConnection).not.toHaveBeenCalled(); + }); + it("hydrates terminal copy-on-select preferences from settings.get once connected", async () => { const store = createStore(); setVisibilityState("visible"); @@ -414,6 +710,213 @@ describe("AppProviders lifecycle recovery", () => { expect(sendCommand).toHaveBeenCalledWith("settings.get", {}, undefined); }); + it("bootstraps the document theme from legacy ui.theme localStorage", async () => { + localStorage.setItem("ui.theme", JSON.stringify("light")); + + renderProviders(); + + await vi.waitFor(() => { + expect(document.documentElement.getAttribute("data-theme")).toBe("mint-light"); + expect(localStorage.getItem("ui.themeId")).toBe(JSON.stringify("mint-light")); + }); + }); + + it("hydrates appearance.themeId from settings.get and updates the document theme and atom", async () => { + const store = createStore(); + setVisibilityState("visible"); + + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.themeId": "graphite-dark", + }; + } + + return undefined; + }); + wsState.client!.sendCommand = sendCommand; + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + expect(document.documentElement.getAttribute("data-theme")).toBe("graphite-dark"); + expect(store.get(themeAtom)).toBe("graphite-dark"); + expect(localStorage.getItem("ui.themeId")).toBe(JSON.stringify("graphite-dark")); + }); + }); + + it("prefers server-provided appearance.themeId over legacy ui.theme localStorage", async () => { + const store = createStore(); + setVisibilityState("visible"); + localStorage.setItem("ui.theme", JSON.stringify("light")); + + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.themeId": "graphite-dark", + }; + } + + return undefined; + }); + wsState.client!.sendCommand = sendCommand; + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + await vi.waitFor(() => { + expect(document.documentElement.getAttribute("data-theme")).toBe("mint-light"); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + expect(document.documentElement.getAttribute("data-theme")).toBe("graphite-dark"); + expect(store.get(themeAtom)).toBe("graphite-dark"); + }); + }); + + it("falls back to legacy server appearance.theme when themeId is absent", async () => { + const store = createStore(); + setVisibilityState("visible"); + + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.theme": "light", + }; + } + + return undefined; + }); + wsState.client!.sendCommand = sendCommand; + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + expect(document.documentElement.getAttribute("data-theme")).toBe("mint-light"); + expect(store.get(themeAtom)).toBe("mint-light"); + expect(localStorage.getItem("ui.themeId")).toBe(JSON.stringify("mint-light")); + }); + }); + + it("preserves a newer local theme selection when startup hydration resolves afterward", async () => { + const store = createStore(); + setVisibilityState("visible"); + + let resolveSettingsGet: ((value: Record) => void) | undefined; + const settingsGetPromise = new Promise>((resolve) => { + resolveSettingsGet = resolve; + }); + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return await settingsGetPromise; + } + + return undefined; + }); + wsState.client!.sendCommand = sendCommand; + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith("settings.get", {}, undefined); + }); + + act(() => { + store.set(themeAtom, "graphite-dark"); + }); + + await act(async () => { + resolveSettingsGet?.({ + "appearance.themeId": "nord-light", + }); + await settingsGetPromise; + }); + + expect(document.documentElement.getAttribute("data-theme")).toBe("graphite-dark"); + expect(store.get(themeAtom)).toBe("graphite-dark"); + expect(localStorage.getItem("ui.themeId")).toBe(JSON.stringify("graphite-dark")); + }); + + it("preserves a persisted local theme selection when startup hydration returns a stale server theme", async () => { + const store = createStore(); + setVisibilityState("visible"); + localStorage.setItem("ui.themeId", JSON.stringify("graphite-dark")); + + let resolveSettingsGet: ((value: Record) => void) | undefined; + const settingsGetPromise = new Promise>((resolve) => { + resolveSettingsGet = resolve; + }); + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return await settingsGetPromise; + } + + return undefined; + }); + wsState.client!.sendCommand = sendCommand; + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + await vi.waitFor(() => { + expect(document.documentElement.getAttribute("data-theme")).toBe("graphite-dark"); + expect(store.get(themeAtom)).toBe("graphite-dark"); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith("settings.get", {}, undefined); + }); + + await act(async () => { + resolveSettingsGet?.({ + "appearance.themeId": "nord-light", + }); + await settingsGetPromise; + }); + + expect(document.documentElement.getAttribute("data-theme")).toBe("graphite-dark"); + expect(store.get(themeAtom)).toBe("graphite-dark"); + expect(localStorage.getItem("ui.themeId")).toBe(JSON.stringify("graphite-dark")); + }); + it("preserves a newer local terminal copy-on-select update when startup hydration resolves later", async () => { const store = createStore(); setVisibilityState("visible"); diff --git a/packages/web/src/app/providers.test.tsx b/packages/web/src/app/providers.test.tsx index 7918f8b4a..ed1e38f81 100644 --- a/packages/web/src/app/providers.test.tsx +++ b/packages/web/src/app/providers.test.tsx @@ -22,6 +22,8 @@ describe("routeEventToAtom", () => { state: "idle", objective: "Track progress", evaluatorProviderId: "claude", + maxSupervisionCount: 0, + completedSupervisionCount: 0, cycles: [], createdAt: 1, updatedAt: 1, diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index 9c169a94c..a3990a592 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -33,7 +33,12 @@ import { workspacesLoadStateAtom, wsClientAtom, } from "../atoms"; -import { authenticatedAtom } from "../atoms/app-ui"; +import { + activationGenerationAtom, + activationReasonAtom, + activationStatusAtom, +} from "../atoms/activation"; +import { authenticatedAtom, themeAtom } from "../atoms/app-ui"; import type { DispatchCommand } from "../atoms/connection"; import { activeWorkspaceIdAtom } from "../atoms/workspaces"; import { useSessionNotifications } from "../features/notifications"; @@ -45,11 +50,15 @@ import { } from "../features/terminal-panel/preferences"; import { editorRefreshTokenAtomFamily, + fileTreeAtomFamily, fileTreeStaleAtomFamily, gitBranchListAtomFamily, gitStateAtomFamily, + loadedDirsAtomFamily, worktreeListAtomFamily, } from "../features/workspace/atoms"; +import { useActivation } from "../hooks/use-activation"; +import { getThemeById, resolveStoredThemeId } from "../theme"; import type { ConnectionStatus, EventListener } from "../ws"; import { resolveWsUrl, WsClient } from "../ws"; @@ -73,6 +82,10 @@ interface WorkspaceActivityState { workspaceId: string | null; } +interface AppearanceSelectionVersion { + theme: number; +} + const DEFAULT_REFRESH_HINT: WorkspaceRefreshHint = { refreshGit: false, refreshBranches: false, @@ -80,11 +93,42 @@ const DEFAULT_REFRESH_HINT: WorkspaceRefreshHint = { markTreeStale: false, refreshEditorBuffers: false, }; +const FOREGROUND_RECOVERY_COOLDOWN_MS = 250; +const THEME_ID_STORAGE_KEY = "ui.themeId"; +const LEGACY_THEME_STORAGE_KEY = "ui.theme"; function shouldMarkTreeStaleForFsReason(reason?: string): boolean { return reason === "fs_change"; } +function readStoredThemePreference(): unknown { + const storedThemeId = localStorage.getItem(THEME_ID_STORAGE_KEY); + if (storedThemeId !== null) { + try { + return JSON.parse(storedThemeId); + } catch { + return undefined; + } + } + + const legacyTheme = localStorage.getItem(LEGACY_THEME_STORAGE_KEY); + if (legacyTheme !== null) { + try { + return JSON.parse(legacyTheme); + } catch { + return undefined; + } + } + + return undefined; +} + +function applyResolvedTheme(themeId: unknown): string { + const resolvedTheme = getThemeById(resolveStoredThemeId(themeId)); + document.documentElement.setAttribute("data-theme", resolvedTheme.documentThemeAttr); + return resolvedTheme.id; +} + export function resetAppProvidersSingletonsForTests() { if (pendingDisconnectTimer) { clearTimeout(pendingDisconnectTimer); @@ -106,6 +150,43 @@ function mergeRefreshHints( }; } +function resetServerProjectedState(store: Store): void { + const workspaceIds = store.get(workspaceOrderAtom); + const terminalIds = Object.values(store.get(sessionsAtom)) + .map((session) => session.terminalId) + .filter((terminalId): terminalId is string => Boolean(terminalId)); + + store.set(workspacesAtom, {}); + store.set(workspaceOrderAtom, []); + store.set(workspacesLoadStateAtom, "idle"); + store.set(workspacesLoadErrorAtom, null); + store.set(sessionsAtom, {}); + store.set(activeWorkspaceIdAtom, null); + store.set(supervisorsAtom, new Map()); + store.set(supervisorCyclesAtom, new Map()); + + for (const workspaceId of workspaceIds) { + store.set(fileTreeAtomFamily(workspaceId), null); + store.set(loadedDirsAtomFamily(workspaceId), new Set()); + store.set(gitStateAtomFamily(workspaceId), null); + store.set(gitBranchListAtomFamily(workspaceId), { + current: "", + branches: [], + loading: false, + }); + store.set(worktreeListAtomFamily(workspaceId), { + items: [], + loading: false, + }); + store.set(fileTreeStaleAtomFamily(workspaceId), false); + store.set(editorRefreshTokenAtomFamily(workspaceId), 0); + } + + for (const terminalId of terminalIds) { + store.set(terminalMetaAtomFamily(terminalId), null); + } +} + function parseWorkspaceRefreshHint( topic: string, payload: unknown @@ -159,6 +240,7 @@ interface AppProvidersProps { export function AppProviders({ children }: AppProvidersProps) { const [, setWsClient] = useAtom(wsClientAtom); + const [theme, setTheme] = useAtom(themeAtom); const authEnabled = useAtomValue(authEnabledAtom); const authenticated = useAtomValue(authenticatedAtom); const connectionStatus = useAtomValue(connectionStatusAtom); @@ -182,6 +264,7 @@ export function AppProviders({ children }: AppProvidersProps) { // Get Jotai store for writing to atomFamily atoms const store = useStore(); const dispatch = useAtomValue(dispatchCommandAtom); + const { claim } = useActivation(); useSessionNotifications(); @@ -192,10 +275,15 @@ export function AppProviders({ children }: AppProvidersProps) { const refreshHintsRef = useRef>(new Map()); const activeWorkspaceIdRef = useRef(activeWorkspaceId); const connectionStatusRef = useRef(connectionStatus); + const lastForegroundRecoveryAtRef = useRef(null); const workspaceActivityRef = useRef({ mode: "inactive", workspaceId: null, }); + const appearanceSelectionVersionRef = useRef({ + theme: 0, + }); + const preferPersistedThemeOnFirstHydrationRef = useRef(false); // Keep dispatchRef in sync useEffect(() => { @@ -244,20 +332,87 @@ export function AppProviders({ children }: AppProvidersProps) { connectionStatusRef.current = connectionStatus; }, [connectionStatus]); + useEffect(() => { + if (connectionStatus !== "connected") { + return; + } + + if (store.get(activationStatusAtom) === "gated") { + return; + } + + void claim(); + }, [claim, connectionStatus, store]); + // Initialize theme from localStorage useEffect(() => { - const savedTheme = localStorage.getItem("ui.theme"); - if (savedTheme) { - try { - const theme = JSON.parse(savedTheme); - if (theme === "light" || theme === "dark") { - document.documentElement.setAttribute("data-theme", theme); - } - } catch { - // Ignore parse errors - } + preferPersistedThemeOnFirstHydrationRef.current = + localStorage.getItem(THEME_ID_STORAGE_KEY) !== null; + const resolvedThemeId = applyResolvedTheme(readStoredThemePreference()); + setTheme(resolvedThemeId); + localStorage.setItem(THEME_ID_STORAGE_KEY, JSON.stringify(resolvedThemeId)); + }, [setTheme]); + + useEffect(() => { + const resolvedTheme = getThemeById(theme); + document.documentElement.setAttribute("data-theme", resolvedTheme.documentThemeAttr); + localStorage.setItem(THEME_ID_STORAGE_KEY, JSON.stringify(resolvedTheme.id)); + }, [theme]); + + useEffect(() => { + if (connectionStatus !== "connected") { + return; } - }, []); + + let cancelled = false; + + const hydrateTheme = async () => { + const appearanceSelectionVersionAtRequestStart = { + ...appearanceSelectionVersionRef.current, + }; + const result = await dispatch>("settings.get", {}); + if (cancelled || !result.ok || !result.data) { + return; + } + + if ( + appearanceSelectionVersionRef.current.theme !== + appearanceSelectionVersionAtRequestStart.theme + ) { + return; + } + + if (preferPersistedThemeOnFirstHydrationRef.current) { + preferPersistedThemeOnFirstHydrationRef.current = false; + return; + } + + const settings = result.data; + const resolvedThemeId = resolveStoredThemeId( + settings["appearance.themeId"] ?? + settings["appearance.theme"] ?? + readStoredThemePreference() + ); + + setTheme(resolvedThemeId); + }; + + void hydrateTheme(); + + return () => { + cancelled = true; + }; + }, [connectionStatus, dispatch, setTheme]); + + useEffect(() => { + const unsubscribeTheme = store.sub(themeAtom, () => { + appearanceSelectionVersionRef.current.theme += 1; + }); + + return () => { + unsubscribeTheme(); + }; + }, [store]); useEffect(() => { const loadAuthStatus = async () => { @@ -388,6 +543,56 @@ export function AppProviders({ children }: AppProvidersProps) { sendWorkspaceActivate(workspaceId); }; + const triggerForegroundRecovery = () => { + if (store.get(activationStatusAtom) === "gated") { + return; + } + + syncWorkspaceActivity(); + if (document.visibilityState !== "visible") { + lastForegroundRecoveryAtRef.current = null; + return; + } + + const now = Date.now(); + const lastForegroundRecoveryAt = lastForegroundRecoveryAtRef.current; + if ( + lastForegroundRecoveryAt !== null && + now - lastForegroundRecoveryAt < FOREGROUND_RECOVERY_COOLDOWN_MS + ) { + return; + } + + lastForegroundRecoveryAtRef.current = now; + wsClientRef.current?.recoverConnection("visibility_resume"); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") { + lastForegroundRecoveryAtRef.current = null; + syncWorkspaceActivity(); + return; + } + + triggerForegroundRecovery(); + }; + + const handleWindowFocus = () => { + triggerForegroundRecovery(); + }; + + const handlePageShow = () => { + triggerForegroundRecovery(); + }; + + const handleOnline = () => { + if (store.get(activationStatusAtom) === "gated") { + return; + } + + wsClientRef.current?.recoverConnection("network_online"); + }; + const refreshBranchState = (workspaceId: string) => { dispatchRef .current<{ current: string; branches: GitBranch[] }>("git.branches", { workspaceId }) @@ -485,6 +690,30 @@ export function AppProviders({ children }: AppProvidersProps) { // Event handler: route WS events to atoms const handleEvent: EventListener = (topic: string, payload: unknown, _seq: number) => { + if (topic === "activation.revoked") { + const data = (payload ?? {}) as { + reason?: string; + generation?: number; + }; + + store.set(activationStatusAtom, "gated"); + store.set( + activationReasonAtom, + typeof data.reason === "string" && data.reason.length > 0 ? data.reason : "displaced" + ); + store.set( + activationGenerationAtom, + typeof data.generation === "number" ? data.generation : null + ); + resetServerProjectedState(store); + workspaceActivityRef.current = { + mode: "inactive", + workspaceId: null, + }; + wsClientRef.current?.disconnect("single_active_displaced"); + return; + } + const refreshInfo = parseWorkspaceRefreshHint(topic, payload); if (refreshInfo) { queueWorkspaceRefresh(refreshInfo.workspaceId, refreshInfo.hint); @@ -500,6 +729,7 @@ export function AppProviders({ children }: AppProvidersProps) { // Subscribe to all topics we care about const topics = [ "connection.*", // Connection-level events + "activation.*", "workspace.*", // All workspace events (glob pattern) ]; @@ -524,24 +754,17 @@ export function AppProviders({ children }: AppProvidersProps) { globalWsClient.recoverConnection("manual_retry"); } - const handleVisibilityChange = () => { - syncWorkspaceActivity(); - if (document.visibilityState === "visible") { - wsClientRef.current?.recoverConnection("visibility_resume"); - } - }; - - const handleOnline = () => { - wsClientRef.current?.recoverConnection("network_online"); - }; - syncWorkspaceActivity(); document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", handleWindowFocus); + window.addEventListener("pageshow", handlePageShow); window.addEventListener("online", handleOnline); return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("focus", handleWindowFocus); + window.removeEventListener("pageshow", handlePageShow); window.removeEventListener("online", handleOnline); unsubscribeStatus(); unsubscribeEvents(); @@ -580,25 +803,18 @@ export function AppProviders({ children }: AppProvidersProps) { setConnectionError(err.message || "Connection failed"); }); - const handleVisibilityChange = () => { - syncWorkspaceActivity(); - if (document.visibilityState === "visible") { - wsClientRef.current?.recoverConnection("visibility_resume"); - } - }; - - const handleOnline = () => { - wsClientRef.current?.recoverConnection("network_online"); - }; - syncWorkspaceActivity(); document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", handleWindowFocus); + window.addEventListener("pageshow", handlePageShow); window.addEventListener("online", handleOnline); // Cleanup on unmount return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("focus", handleWindowFocus); + window.removeEventListener("pageshow", handlePageShow); window.removeEventListener("online", handleOnline); unsubscribeStatus(); unsubscribeEvents(); diff --git a/packages/web/src/atoms/activation.ts b/packages/web/src/atoms/activation.ts new file mode 100644 index 000000000..1872008aa --- /dev/null +++ b/packages/web/src/atoms/activation.ts @@ -0,0 +1,29 @@ +import { atom } from "jotai"; + +export type ActivationStatus = "idle" | "claiming" | "active" | "revoked" | "gated"; + +const CLIENT_INSTANCE_STORAGE_KEY = "app.clientInstanceId"; + +function createClientInstanceId(): string { + if (typeof window === "undefined") { + return "server-client-instance"; + } + + const existing = window.sessionStorage.getItem(CLIENT_INSTANCE_STORAGE_KEY); + if (existing) { + return existing; + } + + const next = + typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : `client-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + window.sessionStorage.setItem(CLIENT_INSTANCE_STORAGE_KEY, next); + return next; +} + +export const clientInstanceIdAtom = atom(createClientInstanceId()); +export const activationStatusAtom = atom("idle"); +export const activationGenerationAtom = atom(null); +export const activationReasonAtom = atom(null); diff --git a/packages/web/src/atoms/app-ui.ts b/packages/web/src/atoms/app-ui.ts index fef4add73..9146e6f11 100644 --- a/packages/web/src/atoms/app-ui.ts +++ b/packages/web/src/atoms/app-ui.ts @@ -5,13 +5,54 @@ */ import { atom } from "jotai"; -import { atomWithStorage } from "jotai/utils"; +import { atomWithStorage, createJSONStorage } from "jotai/utils"; +import { resolveStoredThemeId } from "../theme"; + +const THEME_ID_STORAGE_KEY = "ui.themeId"; +const LEGACY_THEME_STORAGE_KEY = "ui.theme"; + +const baseThemeStorage = createJSONStorage(() => window.localStorage); + +function readThemePreferenceFromStorage(initialValue: string): string { + if (typeof window === "undefined") { + return initialValue; + } + + const storedThemeId = window.localStorage.getItem(THEME_ID_STORAGE_KEY); + if (storedThemeId !== null) { + try { + return resolveStoredThemeId(JSON.parse(storedThemeId)); + } catch { + return initialValue; + } + } + + const legacyTheme = window.localStorage.getItem(LEGACY_THEME_STORAGE_KEY); + if (legacyTheme !== null) { + try { + return resolveStoredThemeId(JSON.parse(legacyTheme)); + } catch { + return initialValue; + } + } + + return initialValue; +} + +const themeStorage = { + ...baseThemeStorage, + getItem: (_key: string, initialValue: string) => readThemePreferenceFromStorage(initialValue), + setItem: (key: string, value: string) => + baseThemeStorage.setItem(key, resolveStoredThemeId(value) as string), +}; /** * Theme preference - * Persisted: ui.theme + * Persisted: ui.themeId */ -export const themeAtom = atomWithStorage<"dark" | "light">("ui.theme", "dark"); +export const themeAtom = atomWithStorage(THEME_ID_STORAGE_KEY, "mint-dark", themeStorage, { + getOnInit: true, +}); /** * Locale preference diff --git a/packages/web/src/atoms/index.ts b/packages/web/src/atoms/index.ts index f7d38cdb3..8dbe5d737 100644 --- a/packages/web/src/atoms/index.ts +++ b/packages/web/src/atoms/index.ts @@ -2,6 +2,7 @@ * Atom Barrel Export */ +export * from "./activation"; export * from "./app-ui"; export * from "./connection"; export * from "./fencing"; diff --git a/packages/web/src/components/ui/confirm-dialog/index.module.css b/packages/web/src/components/ui/confirm-dialog/index.module.css index 79123599e..40ff17e9c 100644 --- a/packages/web/src/components/ui/confirm-dialog/index.module.css +++ b/packages/web/src/components/ui/confirm-dialog/index.module.css @@ -7,9 +7,10 @@ } .titleDanger { - color: var(--color-warning); + color: var(--icon-warning); } .iconDanger { flex: none; + color: var(--icon-warning); } diff --git a/packages/web/src/components/ui/datetime-picker/calendar-grid.tsx b/packages/web/src/components/ui/datetime-picker/calendar-grid.tsx new file mode 100644 index 000000000..8bfb6172a --- /dev/null +++ b/packages/web/src/components/ui/datetime-picker/calendar-grid.tsx @@ -0,0 +1,158 @@ +import clsx from "clsx"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import type { ReactNode } from "react"; +import { useTranslation } from "../../../lib/i18n"; +import styles from "./index.module.css"; + +interface CalendarGridProps { + readonly year: number; + readonly month: number; + readonly selectedDate: Date | null; + readonly minDate?: Date; + readonly maxDate?: Date; + readonly onDateSelect: (date: Date) => void; + readonly onMonthChange: (year: number, month: number) => void; +} + +const WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const; + +const MONTHS = [ + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", +] as const; + +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +function getFirstDayOfMonth(year: number, month: number): number { + return new Date(year, month, 1).getDay(); +} + +function isSameDay(date1: Date, date2: Date): boolean { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); +} + +function isDateDisabled(date: Date, minDate?: Date, maxDate?: Date): boolean { + if (minDate && date < minDate) return true; + if (maxDate && date > maxDate) return true; + return false; +} + +export function CalendarGrid({ + year, + month, + selectedDate, + minDate, + maxDate, + onDateSelect, + onMonthChange, +}: CalendarGridProps) { + const t = useTranslation(); + + const daysInMonth = getDaysInMonth(year, month); + const firstDayOfMonth = getFirstDayOfMonth(year, month); + + const handlePrevMonth = () => { + if (month === 0) { + onMonthChange(year - 1, 11); + } else { + onMonthChange(year, month - 1); + } + }; + + const handleNextMonth = () => { + if (month === 11) { + onMonthChange(year + 1, 0); + } else { + onMonthChange(year, month + 1); + } + }; + + const handleDateClick = (day: number) => { + const date = new Date(year, month, day); + if (!isDateDisabled(date, minDate, maxDate)) { + onDateSelect(date); + } + }; + + const days: ReactNode[] = []; + + // Empty cells for days before the first day of month + for (let i = 0; i < firstDayOfMonth; i++) { + days.push(
); + } + + // Days of the month + for (let day = 1; day <= daysInMonth; day++) { + const date = new Date(year, month, day); + const isSelected = selectedDate && isSameDay(date, selectedDate); + const isDisabled = isDateDisabled(date, minDate, maxDate); + const isToday = isSameDay(date, new Date()); + + days.push( + + ); + } + + return ( +
+
+ + + {t(`datetime.${MONTHS[month]}`)} {year} + + +
+
+ {WEEKDAYS.map((day) => ( +
+ {t(`datetime.${day}`)} +
+ ))} +
+
{days}
+
+ ); +} diff --git a/packages/web/src/components/ui/datetime-picker/index.module.css b/packages/web/src/components/ui/datetime-picker/index.module.css new file mode 100644 index 000000000..337bcff36 --- /dev/null +++ b/packages/web/src/components/ui/datetime-picker/index.module.css @@ -0,0 +1,270 @@ +.trigger { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + text-align: left; + cursor: pointer; +} + +.value { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.icon { + flex-shrink: 0; + color: var(--text-tertiary); +} + +.content { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-3); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.title { + font-size: var(--text-sm); + font-weight: var(--font-semibold); + color: var(--text-primary); +} + +.body { + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +.calendar { + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding: var(--sp-2); + border: 1px dashed var(--border); + border-radius: var(--radius-md); + color: var(--text-tertiary); + font-size: var(--text-xs); +} + +.time { + display: flex; + gap: var(--sp-2); + padding: var(--sp-2); + border: 1px dashed var(--border); + border-radius: var(--radius-md); + color: var(--text-tertiary); + font-size: var(--text-xs); +} + +.actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sp-2); +} + +.action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + min-height: 44px; + padding: 0 var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-surface); + color: var(--text-primary); + font-size: var(--text-sm); + font-weight: var(--font-medium); + cursor: pointer; + transition: + background var(--duration-fast) var(--ease-out), + border-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.action:hover { + background: var(--bg-hover); + border-color: var(--border-light); +} + +.action:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.popoverContent { + width: min(320px, calc(100vw - var(--sp-8))); +} + +.sheetBody { + display: flex; + flex-direction: column; + gap: var(--sp-4); + padding: var(--sp-4); +} + +/* Calendar Grid */ +.calendarGrid { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.calendarHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--sp-1); +} + +.calendarTitle { + font-size: var(--text-sm); + font-weight: var(--font-semibold); + color: var(--text-primary); +} + +.calendarNav { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-surface); + color: var(--text-secondary); + cursor: pointer; + transition: + background var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.calendarNav:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.calendarWeekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: var(--sp-1); + padding: var(--sp-2) 0; + border-bottom: 1px solid var(--border); +} + +.calendarWeekday { + text-align: center; + font-size: var(--text-xs); + font-weight: var(--font-medium); + color: var(--text-tertiary); +} + +.calendarDays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: var(--sp-1); +} + +.calendarDay { + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + font-size: var(--text-sm); + color: var(--text-primary); + cursor: pointer; + transition: + background var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.calendarDay:hover:not(:disabled) { + background: var(--bg-hover); +} + +.calendarDayToday { + color: var(--accent-blue); + font-weight: var(--font-semibold); +} + +.calendarDaySelected { + background: var(--accent-blue); + color: var(--text-inverse); +} + +.calendarDaySelected:hover { + background: color-mix(in srgb, var(--accent-blue) 85%, white 15%); +} + +.calendarDayDisabled { + color: var(--text-tertiary); + cursor: not-allowed; + opacity: 0.5; +} + +.calendarDayEmpty { + height: 36px; +} + +/* Time Selector */ +.timeSelector { + display: flex; + align-items: flex-end; + gap: var(--sp-2); +} + +.timeField { + display: flex; + flex-direction: column; + gap: var(--sp-1); + flex: 1; +} + +.timeLabel { + font-size: var(--text-xs); + color: var(--text-secondary); +} + +.timeSeparator { + padding-bottom: var(--sp-1); + font-size: var(--text-lg); + font-weight: var(--font-semibold); + color: var(--text-secondary); +} + +.timeSection { + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding-top: var(--sp-3); + border-top: 1px solid var(--border); +} + +/* Additional i18n keys needed */ + +.timeInput { + width: 100%; + text-align: center; +} + +.popoverContent { + max-width: 320px; +} + +.sheetBody { + max-width: 100%; +} diff --git a/packages/web/src/components/ui/datetime-picker/index.test.tsx b/packages/web/src/components/ui/datetime-picker/index.test.tsx new file mode 100644 index 000000000..9193d3687 --- /dev/null +++ b/packages/web/src/components/ui/datetime-picker/index.test.tsx @@ -0,0 +1,366 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { localeAtom } from "../../../atoms/app-ui"; +import { formatDate } from "../../../lib/i18n"; +import { DateTimePicker } from ".."; + +function setMatchMediaMock(predicate: (query: string) => boolean) { + const matchMedia = vi.fn((query: string) => ({ + matches: predicate(query), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + + window.matchMedia = matchMedia as unknown as typeof window.matchMedia; +} + +function renderWithLocale(node: ReactNode, locale: "en" | "zh" = "en") { + window.localStorage.setItem("ui.locale", JSON.stringify(locale)); + + const store = createStore(); + store.set(localeAtom, locale); + + return { + store, + ...render({node}), + }; +} + +describe("DateTimePicker", () => { + let originalMatchMedia: typeof window.matchMedia; + + beforeEach(() => { + originalMatchMedia = window.matchMedia; + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + }); + + afterEach(() => { + window.matchMedia = originalMatchMedia; + window.localStorage.removeItem("ui.locale"); + vi.useRealTimers(); + }); + + it("renders the trigger button with label and placeholder", () => { + setMatchMediaMock(() => false); + + renderWithLocale( + , + "en" + ); + + const trigger = screen.getByRole("button", { name: "Scheduled At" }); + expect(trigger).toBeInTheDocument(); + expect(trigger).toHaveTextContent("Select date and time"); + }); + + it("renders the current value formatted according to locale", () => { + setMatchMediaMock(() => false); + + renderWithLocale( + , + "en" + ); + + const trigger = screen.getByRole("button", { name: "Scheduled At" }); + expect(trigger).toHaveTextContent("May"); + }); + + it("opens desktop popover on trigger click", () => { + setMatchMediaMock(() => false); + + renderWithLocale( + , + "en" + ); + + const trigger = screen.getByRole("button", { name: "Scheduled At" }); + fireEvent.click(trigger); + + const dialog = screen.getByRole("dialog", { name: "Scheduled At" }); + expect(dialog).toBeInTheDocument(); + }); + + it("calls onValueChange when confirm button is clicked", () => { + setMatchMediaMock(() => false); + + const onValueChange = vi.fn(); + + renderWithLocale( + , + "en" + ); + + const trigger = screen.getByRole("button", { name: "Scheduled At" }); + fireEvent.click(trigger); + + const confirmButton = screen.getByRole("button", { name: "Confirm" }); + fireEvent.click(confirmButton); + + expect(onValueChange).toHaveBeenCalledWith("2026-05-11T14:30"); + }); + + it("calls onValueChange with empty string when clear button is clicked", () => { + setMatchMediaMock(() => false); + + const onValueChange = vi.fn(); + + renderWithLocale( + , + "en" + ); + + const trigger = screen.getByRole("button", { name: "Scheduled At" }); + fireEvent.click(trigger); + + const clearButton = screen.getByRole("button", { name: "Clear" }); + fireEvent.click(clearButton); + + expect(onValueChange).toHaveBeenCalledWith(""); + }); + + it("applies invalid styling when invalid prop is true", () => { + setMatchMediaMock(() => false); + + renderWithLocale( + , + "en" + ); + + const trigger = screen.getByRole("button", { name: "Scheduled At" }); + expect(trigger).toHaveClass("input-invalid"); + }); + + it("supports disabled state", () => { + setMatchMediaMock(() => false); + + renderWithLocale( + , + "en" + ); + + const trigger = screen.getByRole("button", { name: "Scheduled At" }); + expect(trigger).toBeDisabled(); + }); + + it("opens mobile sheet on trigger click", () => { + setMatchMediaMock( + (query) => query.includes("max-width: 899px") || query.includes("pointer: coarse") + ); + + renderWithLocale( + , + "en" + ); + + const trigger = screen.getByRole("button", { name: "Scheduled At" }); + fireEvent.click(trigger); + + const region = screen.getByRole("region", { name: /Scheduled At/ }); + expect(region).toBeInTheDocument(); + }); + + it("links aria-describedby when provided", () => { + setMatchMediaMock(() => false); + + renderWithLocale( + <> + + Select execution time + , + "en" + ); + + const trigger = screen.getByRole("button", { name: "Scheduled At" }); + expect(trigger).toHaveAttribute("aria-describedby", "helper"); + }); + + it("defaults a blank draft to the current minute when minDate is today", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-12T14:37:00")); + setMatchMediaMock(() => false); + + const onValueChange = vi.fn(); + + renderWithLocale( + , + "en" + ); + + fireEvent.click(screen.getByRole("button", { name: "Scheduled At" })); + fireEvent.click(screen.getByRole("button", { name: "Confirm" })); + + expect(onValueChange).toHaveBeenCalledWith("2026-05-12T14:37"); + }); + + it("disables confirmation for past times on the current day", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-12T14:37:00")); + setMatchMediaMock(() => false); + + renderWithLocale( + , + "en" + ); + + fireEvent.click(screen.getByRole("button", { name: "Scheduled At" })); + fireEvent.change(screen.getByRole("spinbutton", { name: "Hour" }), { + target: { value: "09" }, + }); + fireEvent.change(screen.getByRole("spinbutton", { name: "Minute" }), { + target: { value: "00" }, + }); + + expect(screen.getByRole("button", { name: "Confirm" })).toBeDisabled(); + }); + + it("preserves an in-progress draft across parent rerenders while open", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-12T14:37:00")); + setMatchMediaMock(() => false); + + const onValueChange = vi.fn(); + const view = renderWithLocale( + , + "en" + ); + + fireEvent.click(screen.getByRole("button", { name: "Scheduled At" })); + fireEvent.change(screen.getByRole("spinbutton", { name: "Minute" }), { + target: { value: "45" }, + }); + + vi.setSystemTime(new Date("2026-05-12T14:38:00")); + view.rerender( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Confirm" })); + + expect(onValueChange).toHaveBeenCalledWith("2026-05-12T14:45"); + }); + + it("clamps the selected day when navigating to a shorter month", () => { + setMatchMediaMock(() => false); + + const onValueChange = vi.fn(); + + renderWithLocale( + , + "en" + ); + + fireEvent.click(screen.getByRole("button", { name: "Scheduled At" })); + fireEvent.click(screen.getByRole("button", { name: "Next Month" })); + fireEvent.click(screen.getByRole("button", { name: "Confirm" })); + + expect(onValueChange).toHaveBeenCalledWith("2026-02-28T14:30"); + }); + + it("highlights the in-progress draft selection in the calendar", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-12T10:00:00")); + setMatchMediaMock(() => false); + + renderWithLocale( + , + "en" + ); + + fireEvent.click(screen.getByRole("button", { name: "Scheduled At" })); + fireEvent.click(screen.getByRole("button", { name: "20" })); + + expect(screen.getByRole("button", { name: "20" }).className).toContain("calendarDaySelected"); + }); + + it("formats the trigger value using the active locale", () => { + setMatchMediaMock(() => false); + + renderWithLocale( + , + "zh" + ); + + expect(screen.getByRole("button", { name: "Scheduled At" })).toHaveTextContent( + formatDate(new Date(2026, 4, 11, 14, 30).getTime(), "zh") + ); + }); + + it("localizes and labels the time inputs for accessibility", () => { + setMatchMediaMock(() => false); + + renderWithLocale( + , + "zh" + ); + + fireEvent.click(screen.getByRole("button", { name: "Scheduled At" })); + + expect(screen.getByRole("spinbutton", { name: "小时" })).toBeInTheDocument(); + expect(screen.getByRole("spinbutton", { name: "分钟" })).toBeInTheDocument(); + }); +}); + +describe("DateTimePicker popover rendering", () => { + it("renders desktop popover content when viewport is desktop", () => { + setMatchMediaMock(() => false); + + render( + + ); + + const trigger = screen.getByRole("button", { name: "Scheduled At" }); + fireEvent.click(trigger); + + const dialog = screen.getByRole("dialog", { name: "Scheduled At" }); + expect(dialog).toBeInTheDocument(); + + // Verify popover content is rendered + expect(dialog.className).toContain("content"); + }); +}); diff --git a/packages/web/src/components/ui/datetime-picker/index.tsx b/packages/web/src/components/ui/datetime-picker/index.tsx new file mode 100644 index 000000000..69db36cfd --- /dev/null +++ b/packages/web/src/components/ui/datetime-picker/index.tsx @@ -0,0 +1,316 @@ +import clsx from "clsx"; +import { useAtomValue } from "jotai"; +import { Calendar } from "lucide-react"; +import { type ReactNode, useCallback, useEffect, useId, useState } from "react"; +import { localeAtom } from "../../../atoms/app-ui"; +import { formatDate, type LocaleCode, useTranslation } from "../../../lib/i18n"; +import { useViewport } from "../_internal/use-viewport"; +import { Popover } from "../popover"; +import { Sheet } from "../sheet"; +import { CalendarGrid } from "./calendar-grid"; +import styles from "./index.module.css"; +import { TimeSelector } from "./time-selector"; + +export type DateTimePickerSize = "sm" | "md" | "lg"; + +export interface DateTimePickerProps { + readonly value: string; + readonly onValueChange: (value: string) => void; + readonly label: string; + readonly placeholder?: string; + readonly disabled?: boolean; + readonly clearable?: boolean; + readonly minDate?: Date; + readonly maxDate?: Date; + readonly className?: string; + readonly size?: DateTimePickerSize; + readonly invalid?: boolean; + readonly "aria-describedby"?: string; +} + +interface DateTimeDraft { + readonly year: number; + readonly month: number; + readonly day: number; + readonly hour: number; + readonly minute: number; +} + +function parseLocalDateTime(value: string): Date | null { + if (!value) return null; + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value); + if (!match) return null; + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + + if ([year, month, day, hour, minute].some(Number.isNaN)) { + return null; + } + + return new Date(year, month - 1, day, hour, minute); +} + +function formatLocalDateTime(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hour = String(date.getHours()).padStart(2, "0"); + const minute = String(date.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hour}:${minute}`; +} + +function startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function truncateToMinute(date: Date): Date { + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes() + ); +} + +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +function clampDay(year: number, month: number, day: number): number { + return Math.min(day, getDaysInMonth(year, month)); +} + +function createDraft(date: Date): DateTimeDraft { + return { + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), + hour: date.getHours(), + minute: date.getMinutes(), + }; +} + +function createDateFromDraft(draft: DateTimeDraft): Date { + return new Date(draft.year, draft.month, draft.day, draft.hour, draft.minute); +} + +function clampDateToBounds(date: Date, minTime?: number, maxTime?: number): Date { + const timestamp = date.getTime(); + + if (minTime !== undefined && timestamp < minTime) { + return new Date(minTime); + } + + if (maxTime !== undefined && timestamp > maxTime) { + return new Date(maxTime); + } + + return date; +} + +function isDateTimeDisabled(date: Date, minTime?: number, maxTime?: number): boolean { + const timestamp = date.getTime(); + + if (minTime !== undefined && timestamp < minTime) return true; + if (maxTime !== undefined && timestamp > maxTime) return true; + return false; +} + +const sizeClassMap: Record = { + sm: "input-sm", + md: undefined, + lg: "input-lg", +}; + +export function DateTimePicker({ + value, + onValueChange, + label, + placeholder, + disabled = false, + clearable = false, + minDate, + maxDate, + className, + size = "md", + invalid = false, + "aria-describedby": ariaDescribedBy, +}: DateTimePickerProps) { + const t = useTranslation(); + const locale = useAtomValue(localeAtom) as LocaleCode; + const viewport = useViewport(); + const triggerId = useId(); + const [open, setOpen] = useState(false); + const effectiveMinTime = minDate ? truncateToMinute(minDate).getTime() : undefined; + const effectiveMaxTime = maxDate ? truncateToMinute(maxDate).getTime() : undefined; + + const createInitialDraft = (currentValue: string) => { + const parsed = parseLocalDateTime(currentValue); + if (parsed) { + return createDraft(parsed); + } + + const now = truncateToMinute(new Date()); + return createDraft(clampDateToBounds(now, effectiveMinTime, effectiveMaxTime)); + }; + + const [draft, setDraft] = useState(() => createInitialDraft(value)); + + useEffect(() => { + setDraft(createInitialDraft(value)); + }, [value]); + + const handleDateSelect = useCallback((date: Date) => { + setDraft((prev) => ({ + ...prev, + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), + })); + }, []); + + const handleMonthChange = useCallback((year: number, month: number) => { + setDraft((prev) => ({ + ...prev, + year, + month, + day: clampDay(year, month, prev.day), + })); + }, []); + + const handleHourChange = useCallback((hour: number) => { + setDraft((prev) => ({ ...prev, hour })); + }, []); + + const handleMinuteChange = useCallback((minute: number) => { + setDraft((prev) => ({ ...prev, minute })); + }, []); + + const selectedDate = createDateFromDraft(draft); + const isConfirmDisabled = isDateTimeDisabled(selectedDate, effectiveMinTime, effectiveMaxTime); + + const handleConfirm = useCallback(() => { + const date = createDateFromDraft(draft); + if (isDateTimeDisabled(date, effectiveMinTime, effectiveMaxTime)) { + return; + } + onValueChange(formatLocalDateTime(date)); + setOpen(false); + }, [draft, effectiveMaxTime, effectiveMinTime, onValueChange]); + + const handleClear = useCallback(() => { + onValueChange(""); + setOpen(false); + }, [onValueChange]); + + const parsedValue = parseLocalDateTime(value); + const displayValue = parsedValue + ? formatDate(parsedValue.getTime(), locale) + : (placeholder ?? t("datetime.select_date")); + + const isMobile = viewport === "mobile"; + + const triggerClasses = clsx( + "input", + sizeClassMap[size], + styles.trigger, + invalid ? "input-invalid" : undefined, + className + ); + + const trigger = ( + + ); + + const calendarSelectedDate = new Date(draft.year, draft.month, draft.day); + const calendarMinDate = minDate ? startOfDay(minDate) : undefined; + const calendarMaxDate = maxDate ? startOfDay(maxDate) : undefined; + + const content: ReactNode = ( +
+
+ +
+ {t("datetime.select_time")} + +
+
+
+ {clearable ? ( + + ) : null} + +
+
+ ); + + if (isMobile) { + return ( + <> + {trigger} + {open ? ( + setOpen(false)} + fullscreen + /> + ) : null} + + ); + } + + return ( + + {trigger} + + ); +} diff --git a/packages/web/src/components/ui/datetime-picker/time-selector.tsx b/packages/web/src/components/ui/datetime-picker/time-selector.tsx new file mode 100644 index 000000000..c6e03abc7 --- /dev/null +++ b/packages/web/src/components/ui/datetime-picker/time-selector.tsx @@ -0,0 +1,80 @@ +import { useId } from "react"; +import { useTranslation } from "../../../lib/i18n"; +import { Input } from "../input"; +import styles from "./index.module.css"; + +interface TimeSelectorProps { + readonly hour: number; + readonly minute: number; + readonly onHourChange: (hour: number) => void; + readonly onMinuteChange: (minute: number) => void; + readonly disabled?: boolean; +} + +export function TimeSelector({ + hour, + minute, + onHourChange, + onMinuteChange, + disabled = false, +}: TimeSelectorProps) { + const t = useTranslation(); + const hourId = useId(); + const minuteId = useId(); + const hourStr = String(hour).padStart(2, "0"); + const minuteStr = String(minute).padStart(2, "0"); + + const handleHourChange = (value: string) => { + const parsed = parseInt(value, 10); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 23) { + onHourChange(parsed); + } + }; + + const handleMinuteChange = (value: string) => { + const parsed = parseInt(value, 10); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 59) { + onMinuteChange(parsed); + } + }; + + return ( +
+
+ + handleHourChange(e.target.value)} + disabled={disabled} + className={styles.timeInput} + /> +
+ : +
+ + handleMinuteChange(e.target.value)} + disabled={disabled} + className={styles.timeInput} + /> +
+
+ ); +} diff --git a/packages/web/src/components/ui/index.ts b/packages/web/src/components/ui/index.ts index 09d416c5d..c97990404 100644 --- a/packages/web/src/components/ui/index.ts +++ b/packages/web/src/components/ui/index.ts @@ -12,6 +12,8 @@ export type { ButtonProps, ButtonSize, ButtonVariant } from "./button"; export { Button } from "./button"; export type { ConfirmDialogProps, ConfirmDialogTone } from "./confirm-dialog"; export { ConfirmDialog } from "./confirm-dialog"; +export type { DateTimePickerProps, DateTimePickerSize } from "./datetime-picker"; +export { DateTimePicker } from "./datetime-picker"; export type { EmptyStateProps } from "./empty-state"; export { EmptyState } from "./empty-state"; export type { IconButtonProps, IconButtonSize, IconButtonVariant } from "./icon-button"; diff --git a/packages/web/src/components/ui/popover/index.module.css b/packages/web/src/components/ui/popover/index.module.css index 516e64dfb..45184224b 100644 --- a/packages/web/src/components/ui/popover/index.module.css +++ b/packages/web/src/components/ui/popover/index.module.css @@ -11,5 +11,9 @@ margin-top: 0; overflow: auto; outline: none; - z-index: var(--z-dropdown); + z-index: var(--z-modal); + padding: var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-surface); } diff --git a/packages/web/src/components/ui/switch/index.module.css b/packages/web/src/components/ui/switch/index.module.css index 05924a128..14f94db6f 100644 --- a/packages/web/src/components/ui/switch/index.module.css +++ b/packages/web/src/components/ui/switch/index.module.css @@ -28,6 +28,7 @@ display: block; width: 100%; height: 100%; + box-sizing: border-box; border-radius: var(--radius-full); background: var(--bg-hover); border: 1px solid var(--border); @@ -55,20 +56,23 @@ .thumb { position: absolute; - top: var(--switch-thumb-offset); + top: 50%; left: var(--switch-thumb-offset); width: var(--switch-thumb-size); height: var(--switch-thumb-size); + box-sizing: border-box; border-radius: 50%; background: var(--text-primary); box-shadow: var(--shadow-sm); + transform: translate3d(0, -50%, 0); transition: transform var(--duration-fast) var(--ease-out), background var(--duration-fast) var(--ease-out); } .checked .thumb { - transform: translateX(var(--switch-thumb-translate-x)); + left: calc(100% - var(--switch-thumb-size) - var(--switch-thumb-offset)); + transform: translate3d(0, -50%, 0); } .switch:disabled .track, diff --git a/packages/web/src/components/ui/switch/index.tsx b/packages/web/src/components/ui/switch/index.tsx index 9aa401fa8..55f3295f8 100644 --- a/packages/web/src/components/ui/switch/index.tsx +++ b/packages/web/src/components/ui/switch/index.tsx @@ -22,7 +22,6 @@ const sizeMap: Record< readonly trackHeight: string; readonly thumbSize: string; readonly offset: string; - readonly translateX: string; } > = { sm: { @@ -30,14 +29,12 @@ const sizeMap: Record< trackHeight: "18px", thumbSize: "14px", offset: "2px", - translateX: "14px", }, md: { trackWidth: "36px", trackHeight: "20px", thumbSize: "16px", offset: "2px", - translateX: "16px", }, }; @@ -73,7 +70,6 @@ export function Switch({ "--switch-track-height": dimensions.trackHeight, "--switch-thumb-size": dimensions.thumbSize, "--switch-thumb-offset": dimensions.offset, - "--switch-thumb-translate-x": dimensions.translateX, ...style, } as CSSProperties } diff --git a/packages/web/src/components/ui/toast/index.module.css b/packages/web/src/components/ui/toast/index.module.css index d4379239d..ae5ecfc71 100644 --- a/packages/web/src/components/ui/toast/index.module.css +++ b/packages/web/src/components/ui/toast/index.module.css @@ -64,24 +64,35 @@ } .success .icon { - color: var(--color-success); + color: var(--icon-success); + background: var(--icon-surface-success); } .error .icon { - color: var(--color-error); + color: var(--icon-error); + background: var(--icon-surface-error); } .warning .icon { - color: var(--color-warning); + color: var(--icon-warning); + background: var(--icon-surface-warning); } .info .icon { - color: var(--color-info); + color: var(--icon-info); + background: var(--icon-surface-info); } .icon { margin-top: 1px; + display: inline-flex; + width: 20px; + height: 20px; flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + background: var(--icon-surface-subtle); } .content { diff --git a/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts b/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts index 6e64a419f..244b8b8ae 100644 --- a/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts +++ b/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts @@ -13,6 +13,7 @@ import { readLegacyPaneLayout, } from "../atoms/pane-layout"; import { + appendSessionToLayout, collectSessionIds, createFallbackPaneLayout, sanitizePaneLayout, @@ -99,32 +100,46 @@ export function useWorkspaceSessions( const displayableSessionIds = new Set( mergedSessions.filter((session) => session.state !== "draft").map((session) => session.id) ); - const displayableSessions = mergedSessions.filter((session) => session.state !== "draft"); const sanitized = sanitizePaneLayout(baseLayout, displayableSessionIds); let nextLayout = sanitized; - if (sanitized !== currentLayout) { - setPaneLayout(sanitized); + const referencedSessionIds = new Set(collectSessionIds(nextLayout)); + const missingLiveSessionIds = mergedSessions + .filter( + (session) => + session.state !== "draft" && + session.state !== "ended" && + !referencedSessionIds.has(session.id) + ) + .map((session) => session.id); + + if (missingLiveSessionIds.length > 0) { + nextLayout = appendMissingSessions(nextLayout, missingLiveSessionIds); } - const hasAnySessionInLayout = collectSessionIds(sanitized).length > 0; + const hasAnySessionInLayout = collectSessionIds(nextLayout).length > 0; if (!hasAnySessionInLayout) { if (displayableSessions.length > 0) { nextLayout = createFallbackPaneLayout(displayableSessions.map((session) => session.id)); - setPaneLayout(nextLayout); } } - if (!workspacePaneLayout) { - const shouldPersistLayout = - legacyPaneLayout !== null || collectSessionIds(nextLayout).length > 0; - if (shouldPersistLayout) { - void persistUiState({ paneLayout: nextLayout }).then((persisted) => { - if (persisted && legacyPaneLayout !== null) { - clearLegacyPaneLayout(workspace.id); - } - }); - } + if (nextLayout !== currentLayout) { + setPaneLayout(nextLayout); + } + + const shouldPersistHydratedLayout = + !workspacePaneLayout || legacyPaneLayout !== null || missingLiveSessionIds.length > 0; + const hasPersistableLayout = + legacyPaneLayout !== null || collectSessionIds(nextLayout).length > 0; + if (shouldPersistHydratedLayout && hasPersistableLayout) { + // If hydration had to repair a stale server layout, persist the repaired + // tree immediately so later uiState writes do not re-clobber it. + void persistUiState({ paneLayout: nextLayout }).then((persisted) => { + if (persisted && legacyPaneLayout !== null) { + clearLegacyPaneLayout(workspace.id); + } + }); } }) .catch((error) => { @@ -167,3 +182,16 @@ function normalizePaneLayout(layout: Workspace["uiState"]["paneLayout"]): PaneNo children: layout.children?.map((child) => normalizePaneLayout(child) ?? defaultPaneLayout), }; } + +function appendMissingSessions(layout: PaneNode, sessionIds: string[]): PaneNode { + let nextLayout = layout; + const initialSessionIds = collectSessionIds(nextLayout); + let anchorSessionId = initialSessionIds[initialSessionIds.length - 1] ?? null; + + for (const sessionId of sessionIds) { + nextLayout = appendSessionToLayout(nextLayout, sessionId, anchorSessionId, "horizontal"); + anchorSessionId = sessionId; + } + + return nextLayout; +} diff --git a/packages/web/src/features/agent-panes/components/session-card.test.tsx b/packages/web/src/features/agent-panes/components/session-card.test.tsx index 9963cd6a5..ca7e79529 100644 --- a/packages/web/src/features/agent-panes/components/session-card.test.tsx +++ b/packages/web/src/features/agent-panes/components/session-card.test.tsx @@ -292,6 +292,8 @@ describe("SessionCard", () => { state: "idle", objective: "Keep the agent on track", evaluatorProviderId: "claude", + maxSupervisionCount: 0, + completedSupervisionCount: 0, cycles: [], createdAt: Date.now(), updatedAt: Date.now(), diff --git a/packages/web/src/features/auth/index.test.tsx b/packages/web/src/features/auth/index.test.tsx index 1e06004d2..0af2c038d 100644 --- a/packages/web/src/features/auth/index.test.tsx +++ b/packages/web/src/features/auth/index.test.tsx @@ -147,6 +147,32 @@ describe("LoginPage", () => { }); }); + it("runs the authenticated callback after a successful login", async () => { + const onAuthenticated = vi.fn().mockResolvedValue(true); + globalThis.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ ok: true }), + }) as unknown as typeof fetch; + + const store = createStore(); + store.set(authEnabledAtom, true); + + render( + + + + ); + + const input = await screen.findByLabelText("密码"); + fireEvent.change(input, { target: { value: "sekrit" } }); + fireEvent.click(screen.getByRole("button", { name: "确认" })); + + await waitFor(() => { + expect(onAuthenticated).toHaveBeenCalledTimes(1); + expect(store.get(authenticatedAtom)).toBe(true); + }); + }); + it("shows the login error returned by the server", async () => { globalThis.fetch = vi.fn().mockResolvedValueOnce({ ok: false, diff --git a/packages/web/src/features/auth/index.tsx b/packages/web/src/features/auth/index.tsx index dee8bd4ca..349015f5b 100644 --- a/packages/web/src/features/auth/index.tsx +++ b/packages/web/src/features/auth/index.tsx @@ -14,7 +14,11 @@ const authEmptyStateStyle = { textAlign: "left" as const, }; -export function LoginPage() { +export function LoginPage({ + onAuthenticated, +}: { + onAuthenticated?: () => Promise | boolean; +}) { const t = useTranslation(); const [, setAuthenticated] = useAtom(authenticatedAtom); const locale = useAtomValue(localeAtom); @@ -124,6 +128,12 @@ export function LoginPage() { const data = await response.json(); if (data.authEnabled === false || data.ok) { setAuthenticated(true); + if (onAuthenticated) { + const shouldContinue = await onAuthenticated(); + if (!shouldContinue) { + setAuthenticated(false); + } + } } } catch { setError(t("error.network")); diff --git a/packages/web/src/features/auth/session-gate.test.tsx b/packages/web/src/features/auth/session-gate.test.tsx new file mode 100644 index 000000000..172a4571c --- /dev/null +++ b/packages/web/src/features/auth/session-gate.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { activationStatusAtom } from "../../atoms/activation"; +import { authEnabledAtom } from "../../atoms/connection"; +import { SessionGatePage } from "./session-gate"; + +const originalLocation = window.location; + +describe("SessionGatePage", () => { + beforeEach(() => { + Object.defineProperty(window, "location", { + configurable: true, + value: { + ...originalLocation, + replace: vi.fn(), + reload: vi.fn(), + }, + }); + }); + + afterEach(() => { + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); + }); + + it("shows the session gate action even when auth is enabled", () => { + const store = createStore(); + store.set(authEnabledAtom, true); + store.set(activationStatusAtom, "gated"); + + render( + + + + + + ); + + expect(screen.queryByLabelText("密码")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "重新进入" })).toBeInTheDocument(); + }); + + it("shows an explicit re-enter action when auth is disabled", () => { + const store = createStore(); + store.set(authEnabledAtom, false); + store.set(activationStatusAtom, "gated"); + + render( + + + + + + ); + + expect(screen.getByRole("button", { name: "重新进入" })).toBeInTheDocument(); + }); + + it("returns to the app bootstrap entry when re-enter is clicked", async () => { + const store = createStore(); + store.set(authEnabledAtom, false); + store.set(activationStatusAtom, "gated"); + + render( + + + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "重新进入" })); + + await waitFor(() => { + expect(window.location.replace).toHaveBeenCalledWith("/"); + }); + }); +}); diff --git a/packages/web/src/features/auth/session-gate.tsx b/packages/web/src/features/auth/session-gate.tsx new file mode 100644 index 000000000..411f724d3 --- /dev/null +++ b/packages/web/src/features/auth/session-gate.tsx @@ -0,0 +1,64 @@ +import { Button, EmptyState } from "../../components/ui"; +import { useViewport } from "../../hooks/use-viewport"; +import { useTranslation } from "../../lib/i18n"; + +const gateEmptyStateStyle = { + minHeight: "auto", + padding: 0, + gap: "var(--sp-5)", + alignItems: "stretch", + textAlign: "left" as const, +}; + +export function SessionGatePage() { + const t = useTranslation(); + const isMobile = useViewport() === "mobile"; + + return ( +
+
+ +
{t("auth.session_gate_title")}
+

{t("app.name")}

+
+ } + description={ +

{t("auth.session_gate_description")}

+ } + /> +
+
{t("auth.status_title")}
+

{t("auth.session_gate_detail")}

+
+ +
+
+ ); +} diff --git a/packages/web/src/features/code-editor/components/monaco-host.test.tsx b/packages/web/src/features/code-editor/components/monaco-host.test.tsx index 7ac9d6b3b..97187c43e 100644 --- a/packages/web/src/features/code-editor/components/monaco-host.test.tsx +++ b/packages/web/src/features/code-editor/components/monaco-host.test.tsx @@ -2,10 +2,12 @@ import { act, render, waitFor } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { themeAtom } from "../../../atoms/app-ui"; +import { getThemeById } from "../../../theme"; import { MonacoHost } from "./monaco-host"; const { mockCreateEditor, + mockDefineTheme, mockSetModelLanguage, mockSetTheme, mockEditorInstance, @@ -25,6 +27,7 @@ const { return { mockCreateEditor: vi.fn(() => mockEditorInstance), + mockDefineTheme: vi.fn(), mockSetModelLanguage: vi.fn(), mockSetTheme: vi.fn(), mockEditorInstance, @@ -42,6 +45,7 @@ vi.mock("monaco-editor", () => ({ }, editor: { create: mockCreateEditor, + defineTheme: mockDefineTheme, setModelLanguage: mockSetModelLanguage, setTheme: mockSetTheme, }, @@ -58,6 +62,7 @@ vi.mock("monaco-editor/esm/vs/language/typescript/ts.worker?worker", () => ({ describe("MonacoHost", () => { beforeEach(() => { mockCreateEditor.mockClear(); + mockDefineTheme.mockClear(); mockSetModelLanguage.mockClear(); mockSetTheme.mockClear(); mockAddCommand.mockClear(); @@ -67,9 +72,10 @@ describe("MonacoHost", () => { mockEditorInstance.setValue.mockClear(); }); - it("uses a light editor theme when ui theme is light", async () => { + it("creates the editor with a named Monaco theme when ui theme is mint-light", async () => { const store = createStore(); - store.set(themeAtom, "light"); + store.set(themeAtom, "mint-light"); + const theme = getThemeById("mint-light"); render( @@ -78,11 +84,12 @@ describe("MonacoHost", () => { ); await waitFor(() => { + expect(mockDefineTheme).toHaveBeenCalledWith("coder-studio-mint-light", theme.monaco); expect(mockCreateEditor).toHaveBeenCalledWith( expect.any(HTMLDivElement), expect.objectContaining({ language: "typescript", - theme: "vs", + theme: "coder-studio-mint-light", value: "export const a = 1;", }) ); @@ -99,11 +106,15 @@ describe("MonacoHost", () => { ); await act(async () => { - store.set(themeAtom, "light"); + store.set(themeAtom, "graphite-light"); }); await waitFor(() => { - expect(mockSetTheme).toHaveBeenCalledWith("vs"); + expect(mockDefineTheme).toHaveBeenCalledWith( + "coder-studio-graphite-light", + getThemeById("graphite-light").monaco + ); + expect(mockSetTheme).toHaveBeenCalledWith("coder-studio-graphite-light"); }); }); diff --git a/packages/web/src/features/code-editor/components/monaco-host.tsx b/packages/web/src/features/code-editor/components/monaco-host.tsx index 97742deb1..6626d9a50 100644 --- a/packages/web/src/features/code-editor/components/monaco-host.tsx +++ b/packages/web/src/features/code-editor/components/monaco-host.tsx @@ -15,11 +15,14 @@ import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker" import type { FC } from "react"; import { useEffect, useRef } from "react"; import { themeAtom } from "../../../atoms/app-ui"; +import { getThemeById } from "../../../theme"; const monacoGlobal = globalThis as typeof globalThis & { MonacoEnvironment?: monaco.Environment; }; +const registeredMonacoThemeIds = new Set(); + monacoGlobal.MonacoEnvironment ??= { getWorker(_workerId: string, label: string) { if (label === "json") return new jsonWorker(); @@ -73,7 +76,15 @@ export const MonacoHost: FC = ({ }, [onSave]); const language = detectLanguage(filePath); - const editorTheme = uiTheme === "light" ? "vs" : "vs-dark"; + const resolvedTheme = getThemeById(uiTheme); + const editorTheme = `coder-studio-${resolvedTheme.id}`; + + useEffect(() => { + if (!registeredMonacoThemeIds.has(editorTheme)) { + monaco.editor.defineTheme(editorTheme, resolvedTheme.monaco); + registeredMonacoThemeIds.add(editorTheme); + } + }, [editorTheme, resolvedTheme]); useEffect(() => { if (!containerRef.current || editorRef.current) return; diff --git a/packages/web/src/features/settings/components/config-editor.tsx b/packages/web/src/features/settings/components/config-editor.tsx index c1bf1da39..81215dc3b 100644 --- a/packages/web/src/features/settings/components/config-editor.tsx +++ b/packages/web/src/features/settings/components/config-editor.tsx @@ -246,7 +246,9 @@ export function ConfigEditor({ {saveStatus === "saving" ? ( ) : ( - + )} {config.text}
diff --git a/packages/web/src/features/settings/components/settings-page.test.tsx b/packages/web/src/features/settings/components/settings-page.test.tsx index c62de0ce7..0b2a41741 100644 --- a/packages/web/src/features/settings/components/settings-page.test.tsx +++ b/packages/web/src/features/settings/components/settings-page.test.tsx @@ -340,6 +340,120 @@ describe("SettingsPage", () => { }); }); + it("loads and saves supervisor retry settings from general settings", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "supervisor.retryEnabled": true, + "supervisor.retryMaxCount": 3, + "supervisor.retryDelaySec": 10, + "supervisor.retryOnTimeout": true, + "supervisor.retryOnEvaluatorError": false, + }; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + + const retryEnabled = await screen.findByRole("switch", { name: "启用 Supervisor 重试" }); + const retryMaxCount = screen.getByLabelText("最大重试次数"); + const retryDelaySec = screen.getByLabelText("重试间隔(秒)"); + const retryOnTimeout = screen.getByRole("switch", { name: "超时后重试" }); + const retryOnEvaluatorError = screen.getByRole("switch", { name: "评估器异常后重试" }); + + expect(retryEnabled).toHaveAttribute("aria-checked", "true"); + expect(retryOnTimeout).toHaveAttribute("aria-checked", "true"); + expect(retryOnEvaluatorError).toHaveAttribute("aria-checked", "false"); + await waitFor(() => { + expect(retryMaxCount).toHaveValue(3); + expect(retryDelaySec).toHaveValue(10); + }); + + fireEvent.click(retryEnabled); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + supervisor: { + retryEnabled: false, + }, + }, + }, + undefined + ); + }); + + fireEvent.change(retryMaxCount, { target: { value: "5" } }); + fireEvent.blur(retryMaxCount); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + supervisor: { + retryMaxCount: 5, + }, + }, + }, + undefined + ); + }); + + fireEvent.change(retryDelaySec, { target: { value: "30" } }); + fireEvent.blur(retryDelaySec); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + supervisor: { + retryDelaySec: 30, + }, + }, + }, + undefined + ); + }); + + fireEvent.click(retryOnTimeout); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + supervisor: { + retryOnTimeout: false, + }, + }, + }, + undefined + ); + }); + + fireEvent.click(retryOnEvaluatorError); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + supervisor: { + retryOnEvaluatorError: true, + }, + }, + }, + undefined + ); + }); + }); + it("renders the supervisor timeout control as an inline settings row", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string) => { if (op === "settings.get") { @@ -894,7 +1008,7 @@ describe("SettingsPage", () => { }); }); - it("renders appearance option groups through shared pills with group semantics", async () => { + it("renders appearance theme and language controls with shared semantics", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string) => { if (op === "settings.get") { return { @@ -908,15 +1022,10 @@ describe("SettingsPage", () => { renderSettingsPage(store); fireEvent.click(screen.getByRole("button", { name: "外观" })); - const darkThemePill = await screen.findByRole("button", { name: "深色" }); - const lightThemePill = screen.getByRole("button", { name: "浅色" }); + const themePicker = await screen.findByRole("button", { name: "主题 Mint 深色" }); const chineseLanguagePill = screen.getByRole("button", { name: "中文" }); - expect( - screen.getByRole("group", { - name: "主题", - }) - ).toHaveAccessibleDescription("选择应用主题"); + expect(themePicker).toHaveAccessibleDescription("选择应用主题"); expect( screen.getByRole("group", { name: "语言", @@ -924,10 +1033,7 @@ describe("SettingsPage", () => { ).toHaveAccessibleDescription("选择界面语言"); expect(screen.queryByRole("group", { name: "终端渲染器" })).not.toBeInTheDocument(); expect(screen.queryByRole("switch", { name: "选中自动复制" })).not.toBeInTheDocument(); - expect(darkThemePill).toHaveClass("settings-pill", "settings-pill-active"); - expect(darkThemePill).toHaveAttribute("aria-pressed", "true"); - expect(lightThemePill).toHaveClass("settings-pill"); - expect(lightThemePill).toHaveAttribute("aria-pressed", "false"); + expect(themePicker).toHaveAttribute("aria-haspopup", "listbox"); expect(chineseLanguagePill).toHaveAttribute("aria-pressed", "true"); }); @@ -972,7 +1078,7 @@ describe("SettingsPage", () => { expect(await screen.findByRole("switch", { name: "选中自动复制" })).toBeInTheDocument(); }); - it("does not show copy-on-select on mobile general settings", async () => { + it("shows copy-on-select on mobile general settings with mobile-specific hint", async () => { viewportMocks.viewport = "mobile"; const sendCommand = vi.fn().mockImplementation(async (op: string) => { if (op === "settings.get") { @@ -987,13 +1093,124 @@ describe("SettingsPage", () => { renderSettingsPage(store); fireEvent.click(screen.getByRole("button", { name: "通用" })); - await screen.findByText("通知"); + expect(await screen.findByRole("switch", { name: "选中自动复制" })).toBeInTheDocument(); + expect(screen.getByText("选中文本后自动复制到系统剪贴板")).toBeInTheDocument(); + }); - expect(screen.queryByRole("switch", { name: "选中自动复制" })).not.toBeInTheDocument(); - expect(screen.queryByText("选中自动复制")).not.toBeInTheDocument(); + it("updates theme through a single shared appearance picker", async () => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return {}; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "Appearance" })); + + const picker = await screen.findByRole("button", { name: "Theme Mint Dark" }); + expect(picker).toHaveAttribute("aria-haspopup", "listbox"); + + fireEvent.click(picker); + + const listbox = await screen.findByRole("listbox", { name: "Theme" }); + expect(within(listbox).getByRole("option", { name: "Mint Dark" })).toHaveAttribute( + "aria-selected", + "true" + ); + expect(within(listbox).getByRole("option", { name: "Graphite Dark" })).toBeInTheDocument(); + expect(within(listbox).getByRole("option", { name: "Graphite Light" })).toBeInTheDocument(); + expect(within(listbox).getByRole("option", { name: "Nord Light" })).toBeInTheDocument(); + + fireEvent.click(within(listbox).getByRole("option", { name: "Graphite Dark" })); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + appearance: { + themeId: "graphite-dark", + }, + }, + }, + undefined + ); + }); + + expect(document.documentElement).toHaveAttribute("data-theme", "graphite-dark"); + + const updatedPicker = screen.getByRole("button", { name: "Theme Graphite Dark" }); + fireEvent.click(updatedPicker); + + const updatedListbox = await screen.findByRole("listbox", { name: "Theme" }); + fireEvent.click(within(updatedListbox).getByRole("option", { name: "Graphite Light" })); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + appearance: { + themeId: "graphite-light", + }, + }, + }, + undefined + ); + }); + + expect(document.documentElement).toHaveAttribute("data-theme", "graphite-light"); + expect(screen.getByRole("button", { name: "Theme Graphite Light" })).toBeInTheDocument(); }); - it("updates theme selection through the shared appearance pills", async () => { + it("hydrates the single theme picker from settings.get themeId", async () => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.themeId": "nord-light", + }; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "Appearance" })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Theme Nord Light" })).toBeInTheDocument(); + }); + }); + + it("falls back to legacy appearance.theme when themeId is absent in settings load", async () => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.theme": "light", + }; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "Appearance" })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Theme Mint Light" })).toBeInTheDocument(); + expect(document.documentElement).toHaveAttribute("data-theme", "mint-light"); + }); + }); + + it("does not reset the current theme when settings load omits theme values", async () => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + window.localStorage.setItem("ui.themeId", JSON.stringify("graphite-light")); + document.documentElement.setAttribute("data-theme", "graphite-light"); const sendCommand = vi.fn().mockImplementation(async (op: string) => { if (op === "settings.get") { return {}; @@ -1003,8 +1220,36 @@ describe("SettingsPage", () => { const store = createConnectedStore(sendCommand); renderSettingsPage(store); - fireEvent.click(screen.getByRole("button", { name: "外观" })); - fireEvent.click(await screen.findByRole("button", { name: "浅色" })); + fireEvent.click(screen.getByRole("button", { name: "Appearance" })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Theme Graphite Light" })).toBeInTheDocument(); + expect(document.documentElement).toHaveAttribute("data-theme", "graphite-light"); + }); + }); + + it("preserves a newer local theme selection when a stale settings load resolves afterward", async () => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + let resolveSettingsGet: ((value: Record) => void) | undefined; + const settingsGetPromise = new Promise>((resolve) => { + resolveSettingsGet = resolve; + }); + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return await settingsGetPromise; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "Appearance" })); + fireEvent.click(await screen.findByRole("button", { name: "Theme Mint Dark" })); + fireEvent.click( + within(await screen.findByRole("listbox", { name: "Theme" })).getByRole("option", { + name: "Graphite Dark", + }) + ); await waitFor(() => { expect(sendCommand).toHaveBeenCalledWith( @@ -1012,7 +1257,7 @@ describe("SettingsPage", () => { { settings: { appearance: { - theme: "light", + themeId: "graphite-dark", }, }, }, @@ -1020,9 +1265,15 @@ describe("SettingsPage", () => { ); }); - expect(document.documentElement).toHaveAttribute("data-theme", "light"); - expect(screen.getByRole("button", { name: "浅色" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.getByRole("button", { name: "深色" })).toHaveAttribute("aria-pressed", "false"); + await act(async () => { + resolveSettingsGet?.({ + "appearance.themeId": "nord-light", + }); + await settingsGetPromise; + }); + + expect(screen.getByRole("button", { name: "Theme Graphite Dark" })).toBeInTheDocument(); + expect(document.documentElement).toHaveAttribute("data-theme", "graphite-dark"); }); it("updates terminal renderer selection through the shared general pills", async () => { diff --git a/packages/web/src/features/settings/components/settings-page.tsx b/packages/web/src/features/settings/components/settings-page.tsx index 152d0f1a8..ad1ae58c0 100644 --- a/packages/web/src/features/settings/components/settings-page.tsx +++ b/packages/web/src/features/settings/components/settings-page.tsx @@ -6,8 +6,20 @@ import { DEFAULT_SUPERVISOR_EVALUATION_TIMEOUT_SEC, + DEFAULT_SUPERVISOR_RETRY_DELAY_SEC, + DEFAULT_SUPERVISOR_RETRY_ENABLED, + DEFAULT_SUPERVISOR_RETRY_MAX_COUNT, + DEFAULT_SUPERVISOR_RETRY_ON_EVALUATOR_ERROR, + DEFAULT_SUPERVISOR_RETRY_ON_TIMEOUT, MAX_SUPERVISOR_EVALUATION_TIMEOUT_SEC, + MAX_SUPERVISOR_RETRY_DELAY_SEC, + MAX_SUPERVISOR_RETRY_MAX_COUNT, resolveSupervisorEvaluationTimeoutSec, + resolveSupervisorRetryDelaySec, + resolveSupervisorRetryEnabled, + resolveSupervisorRetryMaxCount, + resolveSupervisorRetryOnEvaluatorError, + resolveSupervisorRetryOnTimeout, } from "@coder-studio/core"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { Check, ChevronRight } from "lucide-react"; @@ -20,9 +32,10 @@ import { serverInfoAtom, } from "../../../atoms/connection"; import { resolvedActiveWorkspaceIdAtom } from "../../../atoms/workspaces"; -import { Input, Notice, Pill, Switch } from "../../../components/ui"; +import { Input, Notice, Pill, Select, Switch } from "../../../components/ui"; import { useViewport } from "../../../hooks/use-viewport"; import { useTranslation } from "../../../lib/i18n"; +import { getThemeById, resolveStoredThemeId, THEMES } from "../../../theme"; import { notificationPreferencesAtom } from "../../notifications/atoms"; import { MobilePageHeader } from "../../shared/components/mobile-page-header"; import { @@ -148,6 +161,21 @@ export function SettingsPage() { const [supervisorEvaluationTimeoutSec, setSupervisorEvaluationTimeoutSec] = useState( DEFAULT_SUPERVISOR_EVALUATION_TIMEOUT_SEC ); + const [supervisorRetryEnabled, setSupervisorRetryEnabled] = useState( + DEFAULT_SUPERVISOR_RETRY_ENABLED + ); + const [supervisorRetryMaxCount, setSupervisorRetryMaxCount] = useState( + DEFAULT_SUPERVISOR_RETRY_MAX_COUNT + ); + const [supervisorRetryDelaySec, setSupervisorRetryDelaySec] = useState( + DEFAULT_SUPERVISOR_RETRY_DELAY_SEC + ); + const [supervisorRetryOnTimeout, setSupervisorRetryOnTimeout] = useState( + DEFAULT_SUPERVISOR_RETRY_ON_TIMEOUT + ); + const [supervisorRetryOnEvaluatorError, setSupervisorRetryOnEvaluatorError] = useState( + DEFAULT_SUPERVISOR_RETRY_ON_EVALUATOR_ERROR + ); const [terminalRenderer, setTerminalRendererState] = useState<"standard" | "compatibility">( "standard" ); @@ -164,6 +192,7 @@ export function SettingsPage() { const setTerminalPreferences = useSetAtom(terminalPreferencesAtom); const settingsLoadFailedUnknownRef = useRef(settingsLoadFailedUnknown); const appearanceSelectionVersionRef = useRef({ + theme: 0, locale: 0, terminalRenderer: 0, terminalCopyOnSelect: 0, @@ -219,6 +248,19 @@ export function SettingsPage() { setSupervisorEvaluationTimeoutSec( resolveSupervisorEvaluationTimeoutSec(settings["supervisor.evaluationTimeoutSec"]) ); + setSupervisorRetryEnabled(resolveSupervisorRetryEnabled(settings["supervisor.retryEnabled"])); + setSupervisorRetryMaxCount( + resolveSupervisorRetryMaxCount(settings["supervisor.retryMaxCount"]) + ); + setSupervisorRetryDelaySec( + resolveSupervisorRetryDelaySec(settings["supervisor.retryDelaySec"]) + ); + setSupervisorRetryOnTimeout( + resolveSupervisorRetryOnTimeout(settings["supervisor.retryOnTimeout"]) + ); + setSupervisorRetryOnEvaluatorError( + resolveSupervisorRetryOnEvaluatorError(settings["supervisor.retryOnEvaluatorError"]) + ); setNotificationPreferences({ enabled: typeof settings["notifications.enabled"] === "boolean" @@ -257,6 +299,23 @@ export function SettingsPage() { setLocaleState(settings["appearance.locale"]); } } + const hasServerThemeSetting = + Object.hasOwn(settings, "appearance.themeId") || + Object.hasOwn(settings, "appearance.theme"); + if ( + hasServerThemeSetting && + appearanceSelectionVersionRef.current.theme === + appearanceSelectionVersionAtRequestStart.theme + ) { + const resolvedThemeId = resolveStoredThemeId( + settings["appearance.themeId"] ?? settings["appearance.theme"] + ); + setTheme(resolvedThemeId); + document.documentElement.setAttribute( + "data-theme", + getThemeById(resolvedThemeId).documentThemeAttr + ); + } setProviderAdditionalArgsById(loadProviderAdditionalArgs(settings, providers)); }; @@ -270,6 +329,7 @@ export function SettingsPage() { setLocaleState, setNotificationPreferences, setTerminalPreferences, + setTheme, settingsRefreshKey, ]); @@ -278,6 +338,11 @@ export function SettingsPage() { setLocaleState(value); }; + const handleThemeSelection = (value: string) => { + appearanceSelectionVersionRef.current.theme += 1; + setTheme(value); + }; + const handleTerminalRendererSelection = (value: "standard" | "compatibility") => { appearanceSelectionVersionRef.current.terminalRenderer += 1; setTerminalRendererState(value); @@ -327,6 +392,16 @@ export function SettingsPage() { setSoundEnabled={setSoundEnabled} supervisorEvaluationTimeoutSec={supervisorEvaluationTimeoutSec} setSupervisorEvaluationTimeoutSec={setSupervisorEvaluationTimeoutSec} + supervisorRetryEnabled={supervisorRetryEnabled} + setSupervisorRetryEnabled={setSupervisorRetryEnabled} + supervisorRetryMaxCount={supervisorRetryMaxCount} + setSupervisorRetryMaxCount={setSupervisorRetryMaxCount} + supervisorRetryDelaySec={supervisorRetryDelaySec} + setSupervisorRetryDelaySec={setSupervisorRetryDelaySec} + supervisorRetryOnTimeout={supervisorRetryOnTimeout} + setSupervisorRetryOnTimeout={setSupervisorRetryOnTimeout} + supervisorRetryOnEvaluatorError={supervisorRetryOnEvaluatorError} + setSupervisorRetryOnEvaluatorError={setSupervisorRetryOnEvaluatorError} terminalRenderer={terminalRenderer} setTerminalRenderer={handleTerminalRendererSelection} terminalCopyOnSelect={terminalPreferences.copyOnSelect} @@ -339,7 +414,7 @@ export function SettingsPage() { locale={locale} setLocale={handleLocaleSelection} theme={theme} - setTheme={setTheme} + setTheme={handleThemeSelection} /> ); case "providers": @@ -480,6 +555,16 @@ interface GeneralSettingsProps { setSoundEnabled: (value: boolean) => void; supervisorEvaluationTimeoutSec: number; setSupervisorEvaluationTimeoutSec: (value: number) => void; + supervisorRetryEnabled: boolean; + setSupervisorRetryEnabled: (value: boolean) => void; + supervisorRetryMaxCount: number; + setSupervisorRetryMaxCount: (value: number) => void; + supervisorRetryDelaySec: number; + setSupervisorRetryDelaySec: (value: number) => void; + supervisorRetryOnTimeout: boolean; + setSupervisorRetryOnTimeout: (value: boolean) => void; + supervisorRetryOnEvaluatorError: boolean; + setSupervisorRetryOnEvaluatorError: (value: boolean) => void; terminalRenderer: "standard" | "compatibility"; setTerminalRenderer: (value: "standard" | "compatibility") => void; terminalCopyOnSelect: boolean; @@ -504,6 +589,34 @@ function parseSupervisorTimeoutInput(value: string): number | null { return parsed; } +function parseSupervisorRetryMaxCountInput(value: string): number | null { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) { + return null; + } + + const parsed = Number(trimmed); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > MAX_SUPERVISOR_RETRY_MAX_COUNT) { + return null; + } + + return parsed; +} + +function parseSupervisorRetryDelayInput(value: string): number | null { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) { + return null; + } + + const parsed = Number(trimmed); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > MAX_SUPERVISOR_RETRY_DELAY_SEC) { + return null; + } + + return parsed; +} + function GeneralSettings({ isMobile, notificationsEnabled, @@ -512,6 +625,16 @@ function GeneralSettings({ setSoundEnabled, supervisorEvaluationTimeoutSec, setSupervisorEvaluationTimeoutSec, + supervisorRetryEnabled, + setSupervisorRetryEnabled, + supervisorRetryMaxCount, + setSupervisorRetryMaxCount, + supervisorRetryDelaySec, + setSupervisorRetryDelaySec, + supervisorRetryOnTimeout, + setSupervisorRetryOnTimeout, + supervisorRetryOnEvaluatorError, + setSupervisorRetryOnEvaluatorError, terminalRenderer, setTerminalRenderer, terminalCopyOnSelect, @@ -536,6 +659,16 @@ function GeneralSettings({ String(supervisorEvaluationTimeoutSec) ); const [supervisorTimeoutError, setSupervisorTimeoutError] = useState(null); + const [supervisorRetryMaxCountDraft, setSupervisorRetryMaxCountDraft] = useState( + String(supervisorRetryMaxCount) + ); + const [supervisorRetryDelayDraft, setSupervisorRetryDelayDraft] = useState( + String(supervisorRetryDelaySec) + ); + const [supervisorRetryMaxCountError, setSupervisorRetryMaxCountError] = useState( + null + ); + const [supervisorRetryDelayError, setSupervisorRetryDelayError] = useState(null); const saveSettings = async (settings: Record) => { return await dispatch("settings.update", { settings }); @@ -560,10 +693,26 @@ function GeneralSettings({ setSupervisorTimeoutDraft(String(supervisorEvaluationTimeoutSec)); }, [supervisorEvaluationTimeoutSec]); + useEffect(() => { + setSupervisorRetryMaxCountDraft(String(supervisorRetryMaxCount)); + }, [supervisorRetryMaxCount]); + + useEffect(() => { + setSupervisorRetryDelayDraft(String(supervisorRetryDelaySec)); + }, [supervisorRetryDelaySec]); + useEffect(() => { setSupervisorTimeoutError(null); }, [supervisorEvaluationTimeoutSec]); + useEffect(() => { + setSupervisorRetryMaxCountError(null); + }, [supervisorRetryMaxCount]); + + useEffect(() => { + setSupervisorRetryDelayError(null); + }, [supervisorRetryDelaySec]); + const requestNotificationPermission = async () => { if ("Notification" in window) { const permission = await Notification.requestPermission(); @@ -606,6 +755,78 @@ function GeneralSettings({ setSupervisorTimeoutError(null); }; + const commitSupervisorRetryMaxCount = async () => { + const parsed = parseSupervisorRetryMaxCountInput(supervisorRetryMaxCountDraft); + if (parsed === null) { + setSupervisorRetryMaxCountDraft(String(supervisorRetryMaxCount)); + setSupervisorRetryMaxCountError( + t("settings.supervisor.retry_max_count_validation_error", { + max: MAX_SUPERVISOR_RETRY_MAX_COUNT, + }) + ); + return; + } + + if (parsed === supervisorRetryMaxCount) { + setSupervisorRetryMaxCountDraft(String(parsed)); + setSupervisorRetryMaxCountError(null); + return; + } + + const result = await saveSettings({ + supervisor: { + retryMaxCount: parsed, + }, + }); + + if (!result.ok) { + setSupervisorRetryMaxCountDraft(String(supervisorRetryMaxCount)); + setSupervisorRetryMaxCountError( + result.error?.message || t("settings.config_files.save_failed") + ); + return; + } + + setSupervisorRetryMaxCount(parsed); + setSupervisorRetryMaxCountDraft(String(parsed)); + setSupervisorRetryMaxCountError(null); + }; + + const commitSupervisorRetryDelay = async () => { + const parsed = parseSupervisorRetryDelayInput(supervisorRetryDelayDraft); + if (parsed === null) { + setSupervisorRetryDelayDraft(String(supervisorRetryDelaySec)); + setSupervisorRetryDelayError( + t("settings.supervisor.retry_delay_validation_error", { + max: MAX_SUPERVISOR_RETRY_DELAY_SEC, + }) + ); + return; + } + + if (parsed === supervisorRetryDelaySec) { + setSupervisorRetryDelayDraft(String(parsed)); + setSupervisorRetryDelayError(null); + return; + } + + const result = await saveSettings({ + supervisor: { + retryDelaySec: parsed, + }, + }); + + if (!result.ok) { + setSupervisorRetryDelayDraft(String(supervisorRetryDelaySec)); + setSupervisorRetryDelayError(result.error?.message || t("settings.config_files.save_failed")); + return; + } + + setSupervisorRetryDelaySec(parsed); + setSupervisorRetryDelayDraft(String(parsed)); + setSupervisorRetryDelayError(null); + }; + return (
@@ -757,28 +978,26 @@ function GeneralSettings({
- {isMobile ? null : ( -
-
- - {t("settings.copy_on_select")} - - - {t("settings.copy_on_select_hint")} - -
- { - setTerminalCopyOnSelect(nextValue); - void saveSettings({ appearance: { terminalCopyOnSelect: nextValue } }); - }} - /> +
+
+ + {t("settings.copy_on_select")} + + + {t("settings.copy_on_select_hint")} +
- )} + { + setTerminalCopyOnSelect(nextValue); + void saveSettings({ appearance: { terminalCopyOnSelect: nextValue } }); + }} + /> +
@@ -823,6 +1042,147 @@ function GeneralSettings({ ) : null}
+ +
+
+ + {t("settings.supervisor.retry_enabled")} + + + {t("settings.supervisor.retry_enabled_hint")} + +
+ { + setSupervisorRetryEnabled(nextValue); + void saveSettings({ supervisor: { retryEnabled: nextValue } }); + }} + /> +
+ +
+ +
+ { + setSupervisorRetryMaxCountDraft(event.target.value); + if (supervisorRetryMaxCountError) { + setSupervisorRetryMaxCountError(null); + } + }} + onBlur={() => { + void commitSupervisorRetryMaxCount(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void commitSupervisorRetryMaxCount(); + } + }} + /> +
+ {supervisorRetryMaxCountError ? ( + + {supervisorRetryMaxCountError} + + ) : null} +
+ +
+ +
+ { + setSupervisorRetryDelayDraft(event.target.value); + if (supervisorRetryDelayError) { + setSupervisorRetryDelayError(null); + } + }} + onBlur={() => { + void commitSupervisorRetryDelay(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void commitSupervisorRetryDelay(); + } + }} + /> +
+ {supervisorRetryDelayError ? ( + + {supervisorRetryDelayError} + + ) : null} +
+ +
+
+ + {t("settings.supervisor.retry_on_timeout")} + + + {t("settings.supervisor.retry_on_timeout_hint")} + +
+ { + setSupervisorRetryOnTimeout(nextValue); + void saveSettings({ supervisor: { retryOnTimeout: nextValue } }); + }} + /> +
+ +
+
+ + {t("settings.supervisor.retry_on_evaluator_error")} + + + {t("settings.supervisor.retry_on_evaluator_error_hint")} + +
+ { + setSupervisorRetryOnEvaluatorError(nextValue); + void saveSettings({ supervisor: { retryOnEvaluatorError: nextValue } }); + }} + /> +
); @@ -831,26 +1191,37 @@ function GeneralSettings({ interface AppearanceSettingsProps { locale: string; setLocale: (value: "zh" | "en") => void; - theme: "dark" | "light"; - setTheme: (value: "dark" | "light") => void; + theme: string; + setTheme: (value: string) => void; } function AppearanceSettings({ locale, setLocale, theme, setTheme }: AppearanceSettingsProps) { const t = useTranslation(); const themeTitleId = useId(); const themeDescId = useId(); + const themeSelectId = useId(); const languageTitleId = useId(); const languageDescId = useId(); const dispatch = useAtomValue(dispatchCommandAtom); + const currentThemeId = resolveStoredThemeId(theme); + const themeOptions = THEMES.map((registeredTheme) => ({ + value: registeredTheme.id, + label: t(registeredTheme.labelKey), + })); const saveSettings = async (settings: Record) => { await dispatch("settings.update", { settings }); }; - const handleThemeChange = (newTheme: "dark" | "light") => { - setTheme(newTheme); - document.documentElement.setAttribute("data-theme", newTheme); - void saveSettings({ appearance: { theme: newTheme } }); + const handleThemeChange = (nextThemeId: string) => { + const resolvedTheme = getThemeById(nextThemeId); + if (resolvedTheme.id === currentThemeId) { + return; + } + + setTheme(resolvedTheme.id); + document.documentElement.setAttribute("data-theme", resolvedTheme.documentThemeAttr); + void saveSettings({ appearance: { themeId: resolvedTheme.id } }); }; return ( @@ -862,28 +1233,17 @@ function AppearanceSettings({ locale, setLocale, theme, setTheme }: AppearanceSe

{t("settings.theme.hint")}

- -
- : undefined} - onClick={() => handleThemeChange("dark")} - active={theme === "dark"} - > - {t("settings.theme.dark")} - - : undefined} - onClick={() => handleThemeChange("light")} - active={theme === "light"} - > - {t("settings.theme.light")} - -
+ aria-label={t("settings.theme.title")} + className="settings-input-compact" + mobileSheetTitle={t("settings.theme.title")} + options={themeOptions} + value={currentThemeId} + onValueChange={handleThemeChange} + />
diff --git a/packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts b/packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts index 040289e52..72327f1ad 100644 --- a/packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts +++ b/packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts @@ -18,8 +18,52 @@ const CLOSED_DIALOG_STATE = { mode: "enable" as const, draftObjective: "", draftEvaluatorProviderId: "claude" as const, + draftEvaluatorModel: "", + draftMaxSupervisionCount: "0", + draftScheduledAt: "", }; +export function formatScheduledAtInput(value?: number): string { + if (typeof value !== "number" || !Number.isFinite(value)) { + return ""; + } + + const date = new Date(value); + const offsetMinutes = date.getTimezoneOffset(); + const localDate = new Date(date.getTime() - offsetMinutes * 60_000); + return localDate.toISOString().slice(0, 16); +} + +function parseDraftMaxSupervisionCount(value: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + return 0; + } + return parsed; +} + +function isValidDraftMaxSupervisionCount(value: string | undefined): boolean { + const trimmed = (value ?? "").trim(); + if (!trimmed) { + return false; + } + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) && parsed >= 0; +} + +function parseDraftScheduledAt(value: string): number | undefined { + if (!value.trim()) { + return undefined; + } + + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) { + return undefined; + } + + return parsed; +} + interface UseObjectiveDialogStateOptions { workspaceId: string; sessionId?: string; @@ -45,6 +89,9 @@ export function useObjectiveDialogState({ }; const isDisable = mode === "disable"; const disableObjective = supervisor?.objective ?? dialog.draftObjective; + const isMaxSupervisionCountValid = isValidDraftMaxSupervisionCount( + dialog.draftMaxSupervisionCount + ); const close = useCallback(() => { setDialog(CLOSED_DIALOG_STATE); @@ -55,6 +102,9 @@ export function useObjectiveDialogState({ patch: Partial<{ draftObjective: string; draftEvaluatorProviderId: ObjectiveDialogEvaluatorProviderId; + draftEvaluatorModel: string; + draftMaxSupervisionCount: string; + draftScheduledAt: string; }> ) => { setDialog((current) => ({ ...current, ...patch })); @@ -85,12 +135,23 @@ export function useObjectiveDialogState({ return false; } + if (!isMaxSupervisionCountValid) { + return false; + } + + const evaluatorModel = dialog.draftEvaluatorModel.trim(); + const maxSupervisionCount = parseDraftMaxSupervisionCount(dialog.draftMaxSupervisionCount); + const scheduledAt = parseDraftScheduledAt(dialog.draftScheduledAt); + if (dialog.mode === "enable") { const result = await dispatch("supervisor.create", { sessionId: dialog.sessionId, workspaceId, objective, evaluatorProviderId: dialog.draftEvaluatorProviderId, + evaluatorModel: evaluatorModel || undefined, + maxSupervisionCount, + scheduledAt, }); if (result.ok) { @@ -108,6 +169,9 @@ export function useObjectiveDialogState({ id: supervisor.id, objective, evaluatorProviderId: dialog.draftEvaluatorProviderId, + evaluatorModel: evaluatorModel || null, + maxSupervisionCount, + scheduledAt: scheduledAt ?? null, }); if (result.ok) { @@ -116,7 +180,7 @@ export function useObjectiveDialogState({ } return false; - }, [close, dialog, dispatch, supervisor, workspaceId]); + }, [close, dialog, dispatch, isMaxSupervisionCountValid, supervisor, workspaceId]); return { dialog, @@ -126,8 +190,10 @@ export function useObjectiveDialogState({ copy, isDisable, disableObjective, + isMaxSupervisionCountValid, close, updateDraft, confirm, + formatScheduledAtInput, }; } diff --git a/packages/web/src/features/supervisor/actions/use-supervisor-actions.ts b/packages/web/src/features/supervisor/actions/use-supervisor-actions.ts index 6a43a1090..9c0781604 100644 --- a/packages/web/src/features/supervisor/actions/use-supervisor-actions.ts +++ b/packages/web/src/features/supervisor/actions/use-supervisor-actions.ts @@ -1,9 +1,11 @@ -import type { Supervisor, SupervisorCycle, SupervisorState } from "@coder-studio/core"; +import type { SupervisorCycle, SupervisorState } from "@coder-studio/core"; import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useState } from "react"; +import { localeAtom } from "../../../atoms/app-ui"; import { dispatchCommandAtom } from "../../../atoms/connection"; -import { useTranslation } from "../../../lib/i18n"; +import { formatDate, type LocaleCode, useTranslation } from "../../../lib/i18n"; import { supervisorCyclesAtom, supervisorDialogAtom, supervisorsAtom } from "../atoms"; +import { formatScheduledAtInput } from "./use-objective-dialog-state"; const STATE_CLASSES: Record = { inactive: "supervisor-state-inactive", @@ -12,6 +14,7 @@ const STATE_CLASSES: Record = { injecting: "supervisor-state-injecting", paused: "supervisor-state-paused", error: "supervisor-state-error", + stopped: "supervisor-state-idle", }; interface UseSupervisorActionsArgs { @@ -23,6 +26,7 @@ export function useSupervisorActions({ sessionId }: UseSupervisorActionsArgs) { const cyclesBySupervisor = useAtomValue(supervisorCyclesAtom); const dispatch = useAtomValue(dispatchCommandAtom); const setDialog = useSetAtom(supervisorDialogAtom); + const locale = useAtomValue(localeAtom) as LocaleCode; const t = useTranslation(); const supervisor = supervisors.get(sessionId); const [actionError, setActionError] = useState(null); @@ -45,6 +49,9 @@ export function useSupervisorActions({ sessionId }: UseSupervisorActionsArgs) { draftObjective: supervisor?.objective ?? "", draftEvaluatorProviderId: (supervisor?.evaluatorProviderId as "claude" | "codex") ?? "claude", + draftEvaluatorModel: supervisor?.evaluatorModel ?? "", + draftMaxSupervisionCount: String(supervisor?.maxSupervisionCount ?? 0), + draftScheduledAt: formatScheduledAtInput(supervisor?.scheduledAt), }); }, [sessionId, setDialog, supervisor] @@ -102,12 +109,46 @@ export function useSupervisorActions({ sessionId }: UseSupervisorActionsArgs) { ? t("supervisor.cycle.no_guidance") : latestCycle.status === "evaluating" ? t("supervisor.cycle.evaluating") - : t("supervisor.cycle.waiting"))) + : latestCycle.status === "cancelled" + ? t("supervisor.cycle.cancelled") + : t("supervisor.cycle.waiting"))) : null; + const stopReasonLabel = supervisor?.stopReason + ? t(`supervisor.stop_reason.${supervisor.stopReason}`) + : null; + + const executionPolicyItems = supervisor + ? [ + supervisor.evaluatorModel + ? { + key: "model", + label: t("supervisor.field.evaluator_model"), + value: supervisor.evaluatorModel, + } + : null, + { + key: "max-count", + label: t("supervisor.field.max_supervision_count"), + value: + supervisor.maxSupervisionCount > 0 + ? String(supervisor.maxSupervisionCount) + : t("supervisor.meta.no_cap"), + }, + supervisor.scheduledAt + ? { + key: "scheduled-at", + label: t("supervisor.field.scheduled_at"), + value: formatDate(supervisor.scheduledAt, locale), + } + : null, + ].filter((item): item is { key: string; label: string; value: string } => item !== null) + : []; + return { actionError, cycles, + executionPolicyItems, handlePause, handleResume, handleTrigger, @@ -115,6 +156,7 @@ export function useSupervisorActions({ sessionId }: UseSupervisorActionsArgs) { latestCycle, latestCycleText, openDialog, + stopReasonLabel, stateClass: supervisor ? STATE_CLASSES[supervisor.state] : STATE_CLASSES.inactive, stateLabel: t( `supervisor.state.${supervisor ? supervisor.state : ("inactive" as SupervisorState)}` diff --git a/packages/web/src/features/supervisor/actions/use-supervisor.ts b/packages/web/src/features/supervisor/actions/use-supervisor.ts index fbf88b0a0..d3a29102b 100644 --- a/packages/web/src/features/supervisor/actions/use-supervisor.ts +++ b/packages/web/src/features/supervisor/actions/use-supervisor.ts @@ -8,6 +8,7 @@ import { supervisorHydratedAtomFamily, supervisorsAtom, } from "../atoms"; +import { formatScheduledAtInput } from "./use-objective-dialog-state"; const EMPTY_SESSION_ID = "__supervisor-empty__"; @@ -81,6 +82,9 @@ export function useSupervisor(session: Session | null | undefined) { draftObjective: supervisor?.objective ?? "", draftEvaluatorProviderId: (supervisor?.evaluatorProviderId as "claude" | "codex") ?? "claude", + draftEvaluatorModel: supervisor?.evaluatorModel ?? "", + draftMaxSupervisionCount: String(supervisor?.maxSupervisionCount ?? 0), + draftScheduledAt: formatScheduledAtInput(supervisor?.scheduledAt), }); }, [sessionId, setDialog] diff --git a/packages/web/src/features/supervisor/atoms.ts b/packages/web/src/features/supervisor/atoms.ts index bfce5804d..6b6f30b16 100644 --- a/packages/web/src/features/supervisor/atoms.ts +++ b/packages/web/src/features/supervisor/atoms.ts @@ -22,12 +22,18 @@ export const supervisorDialogAtom = atom<{ mode: "enable" | "edit" | "disable"; draftObjective: string; draftEvaluatorProviderId: "claude" | "codex"; + draftEvaluatorModel: string; + draftMaxSupervisionCount: string; + draftScheduledAt: string; }>({ open: false, sessionId: null, mode: "enable", draftObjective: "", draftEvaluatorProviderId: "claude", + draftEvaluatorModel: "", + draftMaxSupervisionCount: "0", + draftScheduledAt: "", }); // Derived atom for getting supervisor by session diff --git a/packages/web/src/features/supervisor/components/objective-dialog.test.tsx b/packages/web/src/features/supervisor/components/objective-dialog.test.tsx index b404f3b56..8146e345f 100644 --- a/packages/web/src/features/supervisor/components/objective-dialog.test.tsx +++ b/packages/web/src/features/supervisor/components/objective-dialog.test.tsx @@ -20,6 +20,43 @@ afterEach(() => { }); describe("ObjectiveDialog", () => { + const createDialogState = ( + overrides: Partial<{ + open: boolean; + sessionId: string | null; + mode: "enable" | "edit" | "disable"; + draftObjective: string; + draftEvaluatorProviderId: "claude" | "codex"; + draftEvaluatorModel: string; + draftMaxSupervisionCount: string; + draftScheduledAt: string; + }> = {} + ) => ({ + open: true, + sessionId: "sess-1", + mode: "enable" as const, + draftObjective: "", + draftEvaluatorProviderId: "claude" as const, + draftEvaluatorModel: "", + draftMaxSupervisionCount: "0", + draftScheduledAt: "", + ...overrides, + }); + + const createSupervisor = () => ({ + id: "sup-1", + sessionId: "sess-1", + workspaceId: "ws-1", + state: "idle" as const, + objective: "Finish the server refactor", + evaluatorProviderId: "claude", + maxSupervisionCount: 0, + completedSupervisionCount: 0, + cycles: [], + createdAt: 1, + updatedAt: 1, + }); + it("submits evaluatorProviderId during enable", async () => { const user = userEvent.setup(); const sendCommand = vi.fn().mockResolvedValue(undefined); @@ -27,13 +64,13 @@ describe("ObjectiveDialog", () => { window.localStorage.setItem("ui.locale", JSON.stringify("en")); store.set(localeAtom, "en"); store.set(wsClientAtom, { sendCommand } as never); - store.set(supervisorDialogAtom, { - open: true, - sessionId: "sess-1", - mode: "enable", - draftObjective: "Finish the server refactor", - draftEvaluatorProviderId: "codex", - }); + store.set( + supervisorDialogAtom, + createDialogState({ + draftObjective: "Finish the server refactor", + draftEvaluatorProviderId: "codex", + }) + ); store.set(supervisorsAtom, new Map()); render( @@ -54,24 +91,53 @@ describe("ObjectiveDialog", () => { workspaceId: "ws-1", objective: "Finish the server refactor", evaluatorProviderId: "claude", + evaluatorModel: undefined, + maxSupervisionCount: 0, + scheduledAt: undefined, }, undefined ); }); }); + it("blocks submit when maxSupervisionCount is invalid instead of coercing to unlimited", async () => { + const user = userEvent.setup(); + const sendCommand = vi.fn().mockResolvedValue(undefined); + const store = createStore(); + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand } as never); + store.set( + supervisorDialogAtom, + createDialogState({ + draftObjective: "Finish the server refactor", + draftMaxSupervisionCount: "-1", + }) + ); + store.set(supervisorsAtom, new Map()); + + render( + + + + ); + + await user.click(screen.getByRole("button", { name: "Enable" })); + + expect(sendCommand).not.toHaveBeenCalled(); + }); + it("renders the evaluator field through the shared select trigger with label and helper wiring", () => { const store = createStore(); window.localStorage.setItem("ui.locale", JSON.stringify("en")); store.set(localeAtom, "en"); store.set(wsClientAtom, { sendCommand: vi.fn() } as never); - store.set(supervisorDialogAtom, { - open: true, - sessionId: "sess-1", - mode: "enable", - draftObjective: "Ship phase 4B1", - draftEvaluatorProviderId: "claude", - }); + store.set( + supervisorDialogAtom, + createDialogState({ + draftObjective: "Ship phase 4B1", + }) + ); store.set(supervisorsAtom, new Map()); render( @@ -94,13 +160,12 @@ describe("ObjectiveDialog", () => { window.localStorage.setItem("ui.locale", JSON.stringify("en")); store.set(localeAtom, "en"); store.set(wsClientAtom, { sendCommand: vi.fn() } as never); - store.set(supervisorDialogAtom, { - open: true, - sessionId: "sess-1", - mode: "enable", - draftObjective: "Ship phase 4B1", - draftEvaluatorProviderId: "claude", - }); + store.set( + supervisorDialogAtom, + createDialogState({ + draftObjective: "Ship phase 4B1", + }) + ); store.set(supervisorsAtom, new Map()); render( @@ -117,32 +182,13 @@ describe("ObjectiveDialog", () => { window.localStorage.setItem("ui.locale", JSON.stringify("en")); store.set(localeAtom, "en"); store.set(wsClientAtom, { sendCommand: vi.fn() } as never); - store.set(supervisorDialogAtom, { - open: true, - sessionId: "sess-1", - mode: "disable", - draftObjective: "", - draftEvaluatorProviderId: "claude", - }); store.set( - supervisorsAtom, - new Map([ - [ - "sess-1", - { - id: "sup-1", - sessionId: "sess-1", - workspaceId: "ws-1", - state: "idle", - objective: "Finish the server refactor", - evaluatorProviderId: "claude", - cycles: [], - createdAt: 1, - updatedAt: 1, - }, - ], - ]) + supervisorDialogAtom, + createDialogState({ + mode: "disable", + }) ); + store.set(supervisorsAtom, new Map([["sess-1", createSupervisor()]])); render( @@ -160,13 +206,12 @@ describe("ObjectiveDialog", () => { window.localStorage.setItem("ui.locale", JSON.stringify("en")); store.set(localeAtom, "en"); store.set(wsClientAtom, { sendCommand: vi.fn() } as never); - store.set(supervisorDialogAtom, { - open: true, - sessionId: "sess-1", - mode: "disable", - draftObjective: "", - draftEvaluatorProviderId: "claude", - }); + store.set( + supervisorDialogAtom, + createDialogState({ + mode: "disable", + }) + ); store.set(supervisorsAtom, new Map()); render( @@ -184,13 +229,12 @@ describe("ObjectiveDialog", () => { window.localStorage.setItem("ui.locale", JSON.stringify("en")); store.set(localeAtom, "en"); store.set(wsClientAtom, { sendCommand: vi.fn() } as never); - store.set(supervisorDialogAtom, { - open: true, - sessionId: "sess-1", - mode: "disable", - draftObjective: "", - draftEvaluatorProviderId: "claude", - }); + store.set( + supervisorDialogAtom, + createDialogState({ + mode: "disable", + }) + ); store.set(supervisorsAtom, new Map()); render( @@ -207,13 +251,12 @@ describe("ObjectiveDialog", () => { window.localStorage.setItem("ui.locale", JSON.stringify("en")); store.set(localeAtom, "en"); store.set(wsClientAtom, { sendCommand: vi.fn() } as never); - store.set(supervisorDialogAtom, { - open: true, - sessionId: "sess-1", - mode: "enable", - draftObjective: "Ship phase 4B1", - draftEvaluatorProviderId: "claude", - }); + store.set( + supervisorDialogAtom, + createDialogState({ + draftObjective: "Ship phase 4B1", + }) + ); store.set(supervisorsAtom, new Map()); render( @@ -230,13 +273,12 @@ describe("ObjectiveDialog", () => { window.localStorage.setItem("ui.locale", JSON.stringify("en")); store.set(localeAtom, "en"); store.set(wsClientAtom, { sendCommand: vi.fn() } as never); - store.set(supervisorDialogAtom, { - open: true, - sessionId: "sess-1", - mode: "enable", - draftObjective: "Ship phase 4B1", - draftEvaluatorProviderId: "claude", - }); + store.set( + supervisorDialogAtom, + createDialogState({ + draftObjective: "Ship phase 4B1", + }) + ); store.set(supervisorsAtom, new Map()); render( @@ -254,13 +296,12 @@ describe("ObjectiveDialog", () => { window.localStorage.setItem("ui.locale", JSON.stringify("en")); store.set(localeAtom, "en"); store.set(wsClientAtom, { sendCommand: vi.fn() } as never); - store.set(supervisorDialogAtom, { - open: true, - sessionId: "sess-1", - mode: "enable", - draftObjective: "Ship phase 4B1", - draftEvaluatorProviderId: "claude", - }); + store.set( + supervisorDialogAtom, + createDialogState({ + draftObjective: "Ship phase 4B1", + }) + ); store.set(supervisorsAtom, new Map()); const { container } = render( diff --git a/packages/web/src/features/supervisor/components/supervisor-card.test.tsx b/packages/web/src/features/supervisor/components/supervisor-card.test.tsx index 1f0947573..1cd364614 100644 --- a/packages/web/src/features/supervisor/components/supervisor-card.test.tsx +++ b/packages/web/src/features/supervisor/components/supervisor-card.test.tsx @@ -4,6 +4,7 @@ import { createStore, Provider } from "jotai"; import { describe, expect, it, vi } from "vitest"; import { localeAtom } from "../../../atoms/app-ui"; import { wsClientAtom } from "../../../atoms/connection"; +import { formatDate } from "../../../lib/i18n"; import { supervisorCyclesAtom, supervisorsAtom } from "../atoms"; import { SupervisorCard } from "../views/shared/supervisor-card"; @@ -15,6 +16,8 @@ describe("SupervisorCard", () => { state: "idle", objective: "Finish the server refactor", evaluatorProviderId: "codex", + maxSupervisionCount: 0, + completedSupervisionCount: 0, cycles: [], createdAt: 1, updatedAt: 1, @@ -101,7 +104,7 @@ describe("SupervisorCard", () => { ); - expect(screen.getByRole("button", { name: "Edit Objective" })).toHaveClass( + expect(screen.getByRole("button", { name: "Edit Supervisor" })).toHaveClass( "btn", "btn-ghost", "btn-sm", @@ -119,7 +122,7 @@ describe("SupervisorCard", () => { "btn-sm", "supervisor-icon-btn" ); - expect(screen.getByRole("button", { name: "Disable Supervisor" })).toHaveClass( + expect(screen.getByRole("button", { name: "Disable" })).toHaveClass( "btn", "btn-ghost", "btn-sm", @@ -182,4 +185,125 @@ describe("SupervisorCard", () => { expect(screen.queryByText("65%")).not.toBeInTheDocument(); expect(document.querySelector(".supervisor-progress-track")).not.toBeInTheDocument(); }); + + it("keeps pause available while the supervisor is evaluating", () => { + const store = createStore(); + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set( + supervisorsAtom, + new Map([["sess-1", { ...createSupervisor(), state: "evaluating" }]]) + ); + store.set(supervisorCyclesAtom, new Map()); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Pause" })).not.toBeDisabled(); + }); + + it("renders configured execution policy metadata", () => { + const store = createStore(); + const scheduledAt = Date.UTC(2026, 4, 11, 3, 0); + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set( + supervisorsAtom, + new Map([ + [ + "sess-1", + { + ...createSupervisor(), + evaluatorModel: "o3", + maxSupervisionCount: 5, + scheduledAt, + }, + ], + ]) + ); + store.set(supervisorCyclesAtom, new Map()); + + render( + + + + ); + + expect(screen.getByText("Evaluator Model")).toBeInTheDocument(); + expect(screen.getByText("o3")).toBeInTheDocument(); + expect(screen.getByText("Max Supervision Count")).toBeInTheDocument(); + expect(screen.getByText("5")).toBeInTheDocument(); + expect(screen.getByText("Scheduled Run Time")).toBeInTheDocument(); + expect(screen.getByText(formatDate(scheduledAt, "en"))).toBeInTheDocument(); + }); + + it("shows a no-cap max supervision count when the limit is disabled", () => { + const store = createStore(); + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set(supervisorsAtom, new Map([["sess-1", createSupervisor()]])); + store.set(supervisorCyclesAtom, new Map()); + + render( + + + + ); + + expect(screen.getByText("Max Supervision Count")).toBeInTheDocument(); + expect(screen.getByText("No cap")).toBeInTheDocument(); + }); + + it("renders stopped reason and scheduled cancelled cycle details", () => { + const store = createStore(); + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set( + supervisorsAtom, + new Map([ + [ + "sess-1", + { + ...createSupervisor(), + state: "stopped", + stopReason: "objective_complete", + }, + ], + ]) + ); + store.set( + supervisorCyclesAtom, + new Map([ + [ + "sup-1", + [ + createCycle({ + status: "cancelled", + trigger: "scheduled", + completedAt: 3, + }), + ], + ], + ]) + ); + + render( + + + + ); + + expect(screen.getByText("SCHEDULED")).toBeInTheDocument(); + expect(screen.getByText("Cancelled")).toBeInTheDocument(); + expect( + screen.getByText("Objective complete. Supervisor stopped automatically.") + ).toBeInTheDocument(); + }); }); diff --git a/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-badge.tsx b/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-badge.tsx index e1ab1f5af..c911b9d85 100644 --- a/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-badge.tsx +++ b/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-badge.tsx @@ -3,7 +3,7 @@ import { useAtomValue } from "jotai"; import { Eye } from "lucide-react"; import { useMemo } from "react"; import { useTranslation } from "../../../../lib/i18n"; -import { supervisorCyclesAtom, supervisorsAtom } from "../../atoms"; +import { supervisorsAtom } from "../../atoms"; interface MobileSupervisorBadgeProps { sessionId: string | null; @@ -12,7 +12,6 @@ interface MobileSupervisorBadgeProps { export function MobileSupervisorBadge({ sessionId, onOpen }: MobileSupervisorBadgeProps) { const supervisors = useAtomValue(supervisorsAtom); - const cyclesBySupervisor = useAtomValue(supervisorCyclesAtom); const t = useTranslation(); const copy = useMemo(() => { @@ -28,19 +27,11 @@ export function MobileSupervisorBadge({ sessionId, onOpen }: MobileSupervisorBad }; } - const cycles = cyclesBySupervisor.get(supervisor.id) ?? supervisor.cycles ?? []; - const latestCycle = [...cycles].sort( - (left, right) => (right.completedAt ?? right.createdAt) - (left.completedAt ?? left.createdAt) - )[0]; - return { state: supervisor.state, - label: - latestCycle?.result ?? - latestCycle?.errorReason ?? - (cycles.length > 0 ? `cycle ${cycles.length}` : supervisor.objective), + label: t("supervisor.title"), }; - }, [cyclesBySupervisor, sessionId, supervisors, t]); + }, [sessionId, supervisors, t]); if (!copy) { return null; diff --git a/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx b/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx index 585a1541c..758be6b60 100644 --- a/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx +++ b/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx @@ -24,6 +24,43 @@ function setMatchMediaMock(predicate: (query: string) => boolean) { describe("MobileSupervisorSheet", () => { let originalMatchMedia: typeof window.matchMedia; + const createDialogState = ( + overrides: Partial<{ + open: boolean; + sessionId: string | null; + mode: "enable" | "edit" | "disable"; + draftObjective: string; + draftEvaluatorProviderId: "claude" | "codex"; + draftEvaluatorModel: string; + draftMaxSupervisionCount: string; + draftScheduledAt: string; + }> = {} + ) => ({ + open: false, + sessionId: null, + mode: "enable" as const, + draftObjective: "", + draftEvaluatorProviderId: "claude" as const, + draftEvaluatorModel: "", + draftMaxSupervisionCount: "0", + draftScheduledAt: "", + ...overrides, + }); + + const createSupervisor = () => ({ + id: "sup-1", + sessionId: "sess-1", + workspaceId: "ws-1", + state: "idle" as const, + objective: "Reduce mobile regression bugs", + evaluatorProviderId: "claude", + maxSupervisionCount: 0, + completedSupervisionCount: 0, + cycles: [], + createdAt: 1, + updatedAt: 1, + }); + beforeEach(() => { originalMatchMedia = window.matchMedia; setMatchMediaMock( @@ -41,25 +78,7 @@ describe("MobileSupervisorSheet", () => { window.localStorage.setItem("ui.locale", JSON.stringify("en")); store.set(localeAtom, "en"); store.set(wsClientAtom, { sendCommand: vi.fn() } as never); - store.set( - supervisorsAtom, - new Map([ - [ - "sess-1", - { - id: "sup-1", - sessionId: "sess-1", - workspaceId: "ws-1", - state: "idle", - objective: "Reduce mobile regression bugs", - evaluatorProviderId: "claude", - cycles: [], - createdAt: 1, - updatedAt: 1, - }, - ], - ]) - ); + store.set(supervisorsAtom, new Map([["sess-1", createSupervisor()]])); render( @@ -72,15 +91,18 @@ describe("MobileSupervisorSheet", () => { expect(screen.getByText("Reduce mobile regression bugs")).toBeInTheDocument(); expect( - within(rootActions as HTMLElement).getByRole("button", { name: "Edit Objective" }) + within(rootActions as HTMLElement).getByRole("button", { name: "Edit Supervisor" }) ).toBeInTheDocument(); expect( - within(rootActions as HTMLElement).getByRole("button", { name: "Disable Supervisor" }) + within(rootActions as HTMLElement).getByRole("button", { name: "Disable" }) ).toBeInTheDocument(); expect(screen.queryByText("Supervisor is not enabled")).not.toBeInTheDocument(); + expect( + document.querySelector(".mobile-supervisor-sheet.mobile-sheet--fullscreen") + ).not.toBeNull(); }); - it("opens the enable flow inside the same sheet without rendering a second overlay", async () => { + it("renders the enable form directly when supervisor is not enabled", async () => { const sendCommand = vi.fn().mockResolvedValue({ id: "sup-1" }); const store = createStore(); window.localStorage.setItem("ui.locale", JSON.stringify("en")); @@ -94,19 +116,16 @@ describe("MobileSupervisorSheet", () => { ); - const emptyState = screen - .getByText("Supervisor is not enabled") - .closest(".mobile-supervisor-sheet__empty"); - - expect(emptyState).not.toBeNull(); - expect(emptyState).toHaveTextContent("Supervisor"); - expect(emptyState).toHaveTextContent("Supervisor is not enabled"); - - fireEvent.click(screen.getByRole("button", { name: "Enable Objective" })); - expect(screen.getByLabelText("Objective")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Enable Supervisor", level: 2 }) + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Enable Objective" })).not.toBeInTheDocument(); expect(document.querySelectorAll(".mobile-sheet-layer")).toHaveLength(1); expect(document.querySelector(".modal-overlay")).toBeNull(); + expect( + document.querySelector(".mobile-supervisor-sheet.mobile-sheet--fullscreen") + ).not.toBeNull(); fireEvent.change(screen.getByLabelText("Objective"), { target: { value: "Reduce mobile regression bugs" }, @@ -121,25 +140,22 @@ describe("MobileSupervisorSheet", () => { workspaceId: "ws-1", objective: "Reduce mobile regression bugs", evaluatorProviderId: "claude", + evaluatorModel: undefined, + maxSupervisionCount: 0, + scheduledAt: undefined, }, undefined ); }); }); - it("returns from detail view to the supervisor root when tapping back", () => { + it("returns from edit detail view to the supervisor root when tapping back", () => { const store = createStore(); window.localStorage.setItem("ui.locale", JSON.stringify("en")); store.set(localeAtom, "en"); store.set(wsClientAtom, { sendCommand: vi.fn() } as never); - store.set(supervisorsAtom, new Map()); - store.set(supervisorDialogAtom, { - open: false, - sessionId: null, - mode: "enable", - draftObjective: "", - draftEvaluatorProviderId: "claude", - }); + store.set(supervisorsAtom, new Map([["sess-1", createSupervisor()]])); + store.set(supervisorDialogAtom, createDialogState()); render( @@ -147,15 +163,19 @@ describe("MobileSupervisorSheet", () => { ); - fireEvent.click(screen.getByRole("button", { name: "Enable Objective" })); + const rootActions = document.querySelector(".mobile-supervisor-sheet__actions"); + expect(rootActions).not.toBeNull(); + + fireEvent.click( + within(rootActions as HTMLElement).getByRole("button", { name: "Edit Supervisor" }) + ); fireEvent.click(screen.getByRole("button", { name: "Back" })); - expect(screen.getByText("Supervisor is not enabled")).toBeInTheDocument(); + expect(screen.getByText("Reduce mobile regression bugs")).toBeInTheDocument(); expect(screen.queryByLabelText("Objective")).not.toBeInTheDocument(); }); it("renders mobile text actions with shared button compatibility classes", async () => { - const user = userEvent.setup(); const store = createStore(); window.localStorage.setItem("ui.locale", JSON.stringify("en")); @@ -169,13 +189,6 @@ describe("MobileSupervisorSheet", () => { ); - expect(screen.getByRole("button", { name: "Enable Objective" })).toHaveClass( - "btn", - "btn-primary" - ); - - await user.click(screen.getByRole("button", { name: "Enable Objective" })); - expect(screen.getByRole("button", { name: "Cancel" })).toHaveClass("btn", "btn-secondary"); expect(screen.getByRole("button", { name: "Enable" })).toHaveClass("btn", "btn-primary"); }); @@ -195,7 +208,6 @@ describe("MobileSupervisorSheet", () => { ); - await user.click(screen.getByRole("button", { name: "Enable Objective" })); await user.click(screen.getByRole("button", { name: "Evaluator Claude" })); expect(document.querySelector(".mobile-inline-sheet .page-header__title")).toHaveTextContent( @@ -210,7 +222,6 @@ describe("MobileSupervisorSheet", () => { }); it("keeps the migrated evaluator trigger compatibility classes in the mobile detail sheet", async () => { - const user = userEvent.setup(); const store = createStore(); window.localStorage.setItem("ui.locale", JSON.stringify("en")); @@ -224,8 +235,6 @@ describe("MobileSupervisorSheet", () => { ); - await user.click(screen.getByRole("button", { name: "Enable Objective" })); - const trigger = screen.getByRole("button", { name: "Evaluator Claude" }); expect(trigger).toHaveClass("input", "mobile-select-trigger"); expect(trigger.querySelector(".mobile-select-trigger__value")).not.toBeNull(); diff --git a/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx b/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx index 749663ce4..a1e09c9c0 100644 --- a/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx +++ b/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx @@ -1,8 +1,9 @@ import { useSetAtom } from "jotai"; import { useEffect, useState } from "react"; -import { Button, EmptyState, Sheet } from "../../../../components/ui"; +import { Button, Sheet } from "../../../../components/ui"; import { useTranslation } from "../../../../lib/i18n"; import { + formatScheduledAtInput, type ObjectiveDialogEvaluatorProviderId, type ObjectiveDialogMode, useObjectiveDialogState, @@ -20,23 +21,6 @@ interface MobileSupervisorSheetProps { onClose: () => void; } -const mobileSupervisorEmptyStateStyle = { - minHeight: 0, - padding: "var(--sp-4)", - gap: "var(--sp-2)", - alignItems: "stretch", - textAlign: "left", -}; - -const mobileSupervisorEmptySlotStyle = { - width: "auto", - maxWidth: "none", - color: "inherit", - fontSize: "inherit", - fontWeight: "inherit", - lineHeight: "inherit", -}; - export function MobileSupervisorSheet({ sessionId, workspaceId, @@ -52,11 +36,50 @@ export function MobileSupervisorSheet({ copy, isDisable, disableObjective, + isMaxSupervisionCountValid, close, updateDraft, confirm, } = useObjectiveDialogState({ workspaceId, sessionId }); + useEffect(() => { + if (supervisor || detailMode) { + return; + } + + setDialog((current) => { + if (current.sessionId === sessionId && current.mode === "enable" && !current.open) { + return current; + } + + return { + open: false, + sessionId, + mode: "enable", + draftObjective: + current.sessionId === sessionId && current.mode === "enable" + ? current.draftObjective + : "", + draftEvaluatorProviderId: + current.sessionId === sessionId && current.mode === "enable" + ? current.draftEvaluatorProviderId + : "claude", + draftEvaluatorModel: + current.sessionId === sessionId && current.mode === "enable" + ? current.draftEvaluatorModel + : "", + draftMaxSupervisionCount: + current.sessionId === sessionId && current.mode === "enable" + ? current.draftMaxSupervisionCount + : "0", + draftScheduledAt: + current.sessionId === sessionId && current.mode === "enable" + ? current.draftScheduledAt + : "", + }; + }); + }, [detailMode, sessionId, setDialog, supervisor]); + useEffect(() => { if (!dialog.open || dialog.sessionId !== sessionId) { setDetailMode(null); @@ -74,10 +97,76 @@ export function MobileSupervisorSheet({ draftObjective: supervisor?.objective ?? "", draftEvaluatorProviderId: (supervisor?.evaluatorProviderId as ObjectiveDialogEvaluatorProviderId) ?? "claude", + draftEvaluatorModel: supervisor?.evaluatorModel ?? "", + draftMaxSupervisionCount: String(supervisor?.maxSupervisionCount ?? 0), + draftScheduledAt: formatScheduledAtInput(supervisor?.scheduledAt), }); setDetailMode(nextMode); }; + const detailBody = ( +
+
+ +
+

{copy.title}

+

{copy.subtitle}

+
+
+ updateDraft({ draftObjective })} + onDraftEvaluatorProviderChange={(draftEvaluatorProviderId) => + updateDraft({ draftEvaluatorProviderId }) + } + onDraftEvaluatorModelChange={(draftEvaluatorModel) => updateDraft({ draftEvaluatorModel })} + onDraftMaxSupervisionCountChange={(draftMaxSupervisionCount) => + updateDraft({ draftMaxSupervisionCount }) + } + onDraftScheduledAtChange={(draftScheduledAt) => updateDraft({ draftScheduledAt })} + /> +
+ ); + + const detailFooter = ( +
+ + +
+ ); + if (detailMode) { return ( -
- -
-

{copy.title}

-

{copy.subtitle}

-
-
- updateDraft({ draftObjective })} - onDraftEvaluatorProviderChange={(draftEvaluatorProviderId) => - updateDraft({ draftEvaluatorProviderId }) - } - /> -
- } - footer={ -
- - -
- } + fullscreen + body={detailBody} + footer={detailFooter} + /> + ); + } + + if (!supervisor) { + return ( + { + close(); + onClose(); + }} + bodyClassName="mobile-sheet__body--supervisor-detail" + contentClassName="mobile-supervisor-sheet mobile-supervisor-sheet--detail" + fullscreen + body={detailBody} + footer={detailFooter} /> ); } @@ -148,6 +214,7 @@ export function MobileSupervisorSheet({ kicker={t("supervisor.title")} onClose={onClose} contentClassName="mobile-supervisor-sheet mobile-supervisor-sheet--root" + fullscreen body={
{supervisor ? ( @@ -162,29 +229,7 @@ export function MobileSupervisorSheet({
- ) : ( - -

{t("supervisor.title")}

- - } - description={ -
-

{t("supervisor.empty")}

-
- } - action={ -
- -
- } - /> - )} + ) : null} } /> diff --git a/packages/web/src/features/supervisor/views/shared/objective-dialog-content.test.tsx b/packages/web/src/features/supervisor/views/shared/objective-dialog-content.test.tsx index 1bd06d47d..a4d2ed123 100644 --- a/packages/web/src/features/supervisor/views/shared/objective-dialog-content.test.tsx +++ b/packages/web/src/features/supervisor/views/shared/objective-dialog-content.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ObjectiveDialogContent } from "./objective-dialog-content"; vi.mock("../../../../lib/i18n", () => ({ + formatDate: (ts: number) => new Date(ts).toLocaleDateString(), useTranslation: () => (key: string) => key, })); @@ -32,9 +33,15 @@ describe("ObjectiveDialogContent", () => { mode="edit" draftObjective="Investigate regressions" draftEvaluatorProviderId="claude" + draftEvaluatorModel="" + draftMaxSupervisionCount="0" + draftScheduledAt="" disableObjective="" onDraftObjectiveChange={vi.fn()} onDraftEvaluatorProviderChange={vi.fn()} + onDraftEvaluatorModelChange={vi.fn()} + onDraftMaxSupervisionCountChange={vi.fn()} + onDraftScheduledAtChange={vi.fn()} /> ); @@ -67,9 +74,15 @@ describe("ObjectiveDialogContent", () => { mode="enable" draftObjective="" draftEvaluatorProviderId="heuristic" + draftEvaluatorModel="" + draftMaxSupervisionCount="0" + draftScheduledAt="" disableObjective="" onDraftObjectiveChange={onDraftObjectiveChange} onDraftEvaluatorProviderChange={vi.fn()} + onDraftEvaluatorModelChange={vi.fn()} + onDraftMaxSupervisionCountChange={vi.fn()} + onDraftScheduledAtChange={vi.fn()} /> ); @@ -89,9 +102,15 @@ describe("ObjectiveDialogContent", () => { mode="enable" draftObjective="" draftEvaluatorProviderId="claude" + draftEvaluatorModel="" + draftMaxSupervisionCount="0" + draftScheduledAt="" disableObjective="" onDraftObjectiveChange={vi.fn()} onDraftEvaluatorProviderChange={onDraftEvaluatorProviderChange} + onDraftEvaluatorModelChange={vi.fn()} + onDraftMaxSupervisionCountChange={vi.fn()} + onDraftScheduledAtChange={vi.fn()} /> ); @@ -120,9 +139,15 @@ describe("ObjectiveDialogContent", () => { mode="enable" draftObjective="" draftEvaluatorProviderId="codex" + draftEvaluatorModel="" + draftMaxSupervisionCount="0" + draftScheduledAt="" disableObjective="" onDraftObjectiveChange={vi.fn()} onDraftEvaluatorProviderChange={onDraftEvaluatorProviderChange} + onDraftEvaluatorModelChange={vi.fn()} + onDraftMaxSupervisionCountChange={vi.fn()} + onDraftScheduledAtChange={vi.fn()} /> ); @@ -145,4 +170,37 @@ describe("ObjectiveDialogContent", () => { await user.click(screen.getByRole("button", { name: "Codex" })); expect(onDraftEvaluatorProviderChange).toHaveBeenCalledWith("codex"); }); + + it("renders and edits evaluator model, max supervision count, and schedule fields", () => { + const onDraftEvaluatorModelChange = vi.fn(); + const onDraftMaxSupervisionCountChange = vi.fn(); + const onDraftScheduledAtChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByLabelText("supervisor.field.evaluator_model"), { + target: { value: "gpt-5" }, + }); + fireEvent.change(screen.getByLabelText("supervisor.field.max_supervision_count"), { + target: { value: "8" }, + }); + + expect(onDraftEvaluatorModelChange).toHaveBeenCalledWith("gpt-5"); + expect(onDraftMaxSupervisionCountChange).toHaveBeenCalledWith("8"); + }); }); diff --git a/packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx b/packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx index ec4752b22..4348bf261 100644 --- a/packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx +++ b/packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx @@ -1,6 +1,12 @@ import { AlertTriangle, Eye, Pencil, PowerOff } from "lucide-react"; import { useId } from "react"; -import { Select, type SelectOption, Textarea } from "../../../../components/ui"; +import { + DateTimePicker, + Input, + Select, + type SelectOption, + Textarea, +} from "../../../../components/ui"; import { useTranslation } from "../../../../lib/i18n"; import { OBJECTIVE_DIALOG_EVALUATOR_OPTIONS, @@ -18,9 +24,16 @@ interface ObjectiveDialogContentProps { mode: ObjectiveDialogMode; draftObjective: string; draftEvaluatorProviderId: ObjectiveDialogEvaluatorProviderId; + draftEvaluatorModel: string; + draftMaxSupervisionCount: string; + draftScheduledAt: string; + isMaxSupervisionCountValid: boolean; disableObjective: string; onDraftObjectiveChange: (value: string) => void; onDraftEvaluatorProviderChange: (value: ObjectiveDialogEvaluatorProviderId) => void; + onDraftEvaluatorModelChange: (value: string) => void; + onDraftMaxSupervisionCountChange: (value: string) => void; + onDraftScheduledAtChange: (value: string) => void; } export function ObjectiveDialogModeIcon({ mode }: { mode: ObjectiveDialogMode }) { @@ -33,14 +46,24 @@ export function ObjectiveDialogContent({ mode, draftObjective, draftEvaluatorProviderId, + draftEvaluatorModel, + draftMaxSupervisionCount, + draftScheduledAt, + isMaxSupervisionCountValid, disableObjective, onDraftObjectiveChange, onDraftEvaluatorProviderChange, + onDraftEvaluatorModelChange, + onDraftMaxSupervisionCountChange, + onDraftScheduledAtChange, }: ObjectiveDialogContentProps) { const t = useTranslation(); const objectiveHelperId = useId(); const evaluatorLabelId = useId(); const evaluatorHelperId = useId(); + const evaluatorModelHelperId = useId(); + const maxSupervisionCountHelperId = useId(); + const scheduledAtHelperId = useId(); if (mode === "disable") { return ( @@ -98,6 +121,56 @@ export function ObjectiveDialogContent({ {t("supervisor.field.evaluator_helper")} + +
+ + onDraftEvaluatorModelChange(event.target.value)} + aria-describedby={evaluatorModelHelperId} + placeholder={t("supervisor.field.evaluator_model_placeholder")} + /> + + {t("supervisor.field.evaluator_model_helper")} + +
+ +
+ + onDraftMaxSupervisionCountChange(event.target.value)} + invalid={!isMaxSupervisionCountValid} + aria-invalid={!isMaxSupervisionCountValid} + aria-describedby={maxSupervisionCountHelperId} + /> + + {t("supervisor.field.max_supervision_count_helper")} + +
+ +
+ + + + {t("supervisor.field.scheduled_at_helper")} + +
); } diff --git a/packages/web/src/features/supervisor/views/shared/objective-dialog.tsx b/packages/web/src/features/supervisor/views/shared/objective-dialog.tsx index 92d93af50..121d24085 100644 --- a/packages/web/src/features/supervisor/views/shared/objective-dialog.tsx +++ b/packages/web/src/features/supervisor/views/shared/objective-dialog.tsx @@ -28,6 +28,7 @@ export function ObjectiveDialog({ workspaceId, sessionId }: ObjectiveDialogProps copy, isDisable, disableObjective, + isMaxSupervisionCountValid, close, updateDraft, confirm, @@ -62,11 +63,22 @@ export function ObjectiveDialog({ workspaceId, sessionId }: ObjectiveDialogProps mode={mode} draftObjective={dialog.draftObjective} draftEvaluatorProviderId={dialog.draftEvaluatorProviderId} + draftEvaluatorModel={dialog.draftEvaluatorModel} + draftMaxSupervisionCount={dialog.draftMaxSupervisionCount} + draftScheduledAt={dialog.draftScheduledAt} + isMaxSupervisionCountValid={isMaxSupervisionCountValid} disableObjective={disableObjective} onDraftObjectiveChange={(draftObjective) => updateDraft({ draftObjective })} onDraftEvaluatorProviderChange={(draftEvaluatorProviderId) => updateDraft({ draftEvaluatorProviderId }) } + onDraftEvaluatorModelChange={(draftEvaluatorModel) => + updateDraft({ draftEvaluatorModel }) + } + onDraftMaxSupervisionCountChange={(draftMaxSupervisionCount) => + updateDraft({ draftMaxSupervisionCount }) + } + onDraftScheduledAtChange={(draftScheduledAt) => updateDraft({ draftScheduledAt })} /> @@ -77,7 +89,7 @@ export function ObjectiveDialog({ workspaceId, sessionId }: ObjectiveDialogProps onClick={() => { void confirm(); }} - disabled={!isDisable && !dialog.draftObjective.trim()} + disabled={!isDisable && (!dialog.draftObjective.trim() || !isMaxSupervisionCountValid)} > {copy.confirm} diff --git a/packages/web/src/features/supervisor/views/shared/supervisor-card.tsx b/packages/web/src/features/supervisor/views/shared/supervisor-card.tsx index e9055f947..75aed40b0 100644 --- a/packages/web/src/features/supervisor/views/shared/supervisor-card.tsx +++ b/packages/web/src/features/supervisor/views/shared/supervisor-card.tsx @@ -12,6 +12,7 @@ export function SupervisorCard({ sessionId, workspaceId }: SupervisorCardProps) const t = useTranslation(); const { actionError, + executionPolicyItems, handlePause, handleResume, handleTrigger, @@ -19,6 +20,7 @@ export function SupervisorCard({ sessionId, workspaceId }: SupervisorCardProps) latestCycle, latestCycleText, openDialog, + stopReasonLabel, stateClass, stateLabel, supervisor, @@ -79,7 +81,6 @@ export function SupervisorCard({ sessionId, workspaceId }: SupervisorCardProps) } onClick={() => { void handlePause(); @@ -121,19 +122,38 @@ export function SupervisorCard({ sessionId, workspaceId }: SupervisorCardProps) {supervisor.evaluatorProviderId} + {executionPolicyItems.length > 0 ? ( +
+ {executionPolicyItems.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ) : null} + {latestCycle ? (
  1. {latestCycle.trigger === "manual" ? t("supervisor.trigger.manual") - : t("supervisor.trigger.auto")} + : latestCycle.trigger === "scheduled" + ? t("supervisor.trigger.scheduled") + : t("supervisor.trigger.auto")} {latestCycleText}
) : null} + {supervisor.state === "stopped" && stopReasonLabel ? ( +
+ {stopReasonLabel} +
+ ) : null} + {actionError ? (
{actionError} diff --git a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx index d64ed5aec..e25d8b627 100644 --- a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +++ b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx @@ -5,16 +5,17 @@ */ import { Topics } from "@coder-studio/core"; -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createStore, Provider } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { localeAtom, themeAtom } from "../../../atoms/app-ui"; import { wsClientAtom } from "../../../atoms/connection"; import { JotaiProvider } from "../../../test-utils/jotai-provider"; +import { getThemeById } from "../../../theme"; import type { TerminalReplayPayload, TerminalSnapshotPayload } from "../../../ws/client"; import { toastsAtom } from "../../notifications/atoms"; -import { terminalOutputAtomFamily } from "../atoms"; +import { terminalMetaAtomFamily, terminalOutputAtomFamily } from "../atoms"; import type { HydrationRequestHandle, HydrationTier } from "../hydration-coordinator"; import { terminalPreferencesAtom } from "../preferences"; import { TERMINAL_REPLAY_TIMEOUT_MS } from "../replay-state"; @@ -139,6 +140,106 @@ function expectTerminalWriteData(data: Uint8Array | string) { expect(mockTerminal.write.mock.calls.some(([written]) => written === data)).toBe(true); } +interface MockBufferLine { + isWrapped?: boolean; + translateToString(trimRight?: boolean): string; +} + +const mockBufferLines = new Map(); + +function createMockBufferLine(text: string, isWrapped = false): MockBufferLine { + return { + isWrapped, + translateToString(trimRight = false) { + return trimRight ? text.replace(/\s+$/u, "") : text; + }, + }; +} + +function setMockBufferLines(entries: Array<[row: number, text: string, isWrapped?: boolean]>) { + mockBufferLines.clear(); + for (const [row, text, isWrapped = false] of entries) { + mockBufferLines.set(row, createMockBufferLine(text, isWrapped)); + } +} + +function dispatchTouchEvent( + target: EventTarget, + type: string, + touches: Array<{ identifier: number; clientX?: number; clientY: number; target?: EventTarget }>, + changedTouches = touches +) { + const normalizedTouches = touches.map((touch) => ({ ...touch, target: touch.target ?? target })); + const normalizedChangedTouches = changedTouches.map((touch) => ({ + ...touch, + target: touch.target ?? target, + })); + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "touches", { value: normalizedTouches }); + Object.defineProperty(event, "targetTouches", { value: normalizedTouches }); + Object.defineProperty(event, "changedTouches", { value: normalizedChangedTouches }); + target.dispatchEvent(event); +} + +function createMockDomRect({ + x, + y, + width, + height, +}: { + x: number; + y: number; + width: number; + height: number; +}): DOMRect { + return { + x, + y, + width, + height, + top: y, + left: x, + right: x + width, + bottom: y + height, + toJSON: () => ({}), + } as DOMRect; +} + +function stubRowsGeometry( + host: HTMLDivElement, + rowsElement: HTMLDivElement, + rowElements: HTMLDivElement[], + options: { + hostRect?: { x: number; y: number; width: number; height: number }; + rowsRect: { x: number; y: number; width: number; height: number }; + rowHeight: number; + screenRect?: { x: number; y: number; width: number; height: number }; + } +) { + vi.spyOn(host, "getBoundingClientRect").mockReturnValue( + createMockDomRect( + options.hostRect ?? { x: 0, y: 0, width: options.rowsRect.width, height: 240 } + ) + ); + rowsElement.getBoundingClientRect = () => createMockDomRect(options.rowsRect); + rowElements.forEach((rowElement, index) => { + rowElement.getBoundingClientRect = () => + createMockDomRect({ + x: options.rowsRect.x, + y: options.rowsRect.y + index * options.rowHeight, + width: options.rowsRect.width, + height: options.rowHeight, + }); + }); + + if (options.screenRect) { + const screenElement = host.querySelector(".xterm-screen"); + if (screenElement instanceof HTMLDivElement) { + screenElement.getBoundingClientRect = () => createMockDomRect(options.screenRect!); + } + } +} + const mockTerminal = { open: vi.fn(), onData: vi.fn(() => vi.fn()), // Return dispose function @@ -153,10 +254,12 @@ const mockTerminal = { dispose: vi.fn(), focus: vi.fn(), loadAddon: vi.fn(), + cols: 80, buffer: { active: { viewportY: 0, baseY: 0, + getLine: vi.fn((row: number) => mockBufferLines.get(row)), }, }, options: {}, @@ -205,6 +308,10 @@ describe("XtermHost", () => { mockTerminal.rows = undefined; mockTerminal.buffer.active.viewportY = 0; mockTerminal.buffer.active.baseY = 0; + mockBufferLines.clear(); + mockTerminal.buffer.active.getLine.mockImplementation((row: number) => + mockBufferLines.get(row) + ); mockTerminal.write.mockImplementation((_data: Uint8Array | string, callback?: () => void) => { callback?.(); }); @@ -321,6 +428,60 @@ describe("XtermHost", () => { }); }); + it("falls back to document.execCommand when desktop clipboard writeText is rejected", async () => { + const store = createStore(); + const writeText = vi.fn().mockRejectedValue(new Error("clipboard rejected")); + const execCommand = vi.fn().mockReturnValue(true); + + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ status: "ok" }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + } as never); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } satisfies Pick, + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + + const { container } = render( + + + + ); + + mockTerminal.hasSelection.mockReturnValue(true); + mockTerminal.getSelection.mockReturnValue("selected text"); + + const selectionHandler = mockTerminal.onSelectionChange.mock.calls[0]?.[0] as + | (() => void) + | undefined; + + await act(async () => { + selectionHandler?.(); + }); + + fireEvent.pointerDown(container.querySelector(".xterm-host")!, { + pointerType: "mouse", + pointerId: 1, + }); + fireEvent.pointerUp(container.querySelector(".xterm-host")!, { + pointerType: "mouse", + pointerId: 1, + }); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith("selected text"); + expect(execCommand).toHaveBeenCalledWith("copy"); + }); + }); + it("copies the terminal selection when desktop mouse pointerup ends outside the host", async () => { const store = createStore(); const writeText = vi.fn().mockResolvedValue(undefined); @@ -557,6 +718,7 @@ describe("XtermHost", () => { it("pushes only one error toast within the throttle window when clipboard writes fail", async () => { const store = createStore(); const writeText = vi.fn().mockRejectedValue(new Error("clipboard failed")); + const execCommand = vi.fn().mockReturnValue(false); store.set(localeAtom, "zh"); store.set(terminalPreferencesAtom, { copyOnSelect: true }); @@ -570,6 +732,10 @@ describe("XtermHost", () => { configurable: true, value: { writeText } satisfies Pick, }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); const { container } = render( @@ -834,6 +1000,7 @@ describe("XtermHost", () => { hydrationCoordinatorMocks.autoGrant = false; const store = createStore(); store.set(localeAtom, "en"); + store.set(themeAtom, "mint-dark"); store.set(wsClientAtom, { sendCommand: vi.fn().mockImplementation((op: string) => { if (op === "terminal.replay") { @@ -854,7 +1021,7 @@ describe("XtermHost", () => { ); await act(async () => { - store.set(themeAtom, "light"); + store.set(themeAtom, "mint-light"); }); await act(async () => { @@ -866,10 +1033,7 @@ describe("XtermHost", () => { const { Terminal } = await import("@xterm/xterm"); expect(Terminal).toHaveBeenCalledWith( expect.objectContaining({ - theme: expect.objectContaining({ - background: "#fafbfc", - foreground: "#1f2328", - }), + theme: expect.objectContaining(getThemeById("mint-light").terminalTheme), }) ); }); @@ -1091,23 +1255,17 @@ describe("XtermHost", () => { ); - // Terminal should be called with Aurora Mint theme expect(Terminal).toHaveBeenCalledWith( expect.objectContaining({ - theme: expect.objectContaining({ - background: "#0b1218", - foreground: "#e5edf3", - cursor: "#78d7b2", - selectionBackground: "#1e3040", - }), + theme: expect.objectContaining(getThemeById("mint-dark").terminalTheme), }) ); }); - it("creates xterm instance with a light theme when ui theme is light", async () => { + it("creates xterm instance with the mint-light palette when ui theme is mint-light", async () => { const { Terminal } = await import("@xterm/xterm"); const store = createStore(); - store.set(themeAtom, "light"); + store.set(themeAtom, "mint-light"); render( @@ -1117,18 +1275,14 @@ describe("XtermHost", () => { expect(Terminal).toHaveBeenCalledWith( expect.objectContaining({ - theme: expect.objectContaining({ - background: "#fafbfc", - foreground: "#1f2328", - cursor: "#0969da", - selectionBackground: "#dde4ea", - }), + theme: expect.objectContaining(getThemeById("mint-light").terminalTheme), }) ); }); - it("updates the live xterm theme when the ui theme changes", async () => { + it("updates the live xterm theme when the ui theme changes to graphite-light", async () => { const store = createStore(); + store.set(themeAtom, "mint-dark"); render( @@ -1137,21 +1291,36 @@ describe("XtermHost", () => { ); await act(async () => { - store.set(themeAtom, "light"); + store.set(themeAtom, "graphite-light"); }); await waitFor(() => { expect(mockTerminal.options).toEqual( expect.objectContaining({ - theme: expect.objectContaining({ - background: "#fafbfc", - foreground: "#1f2328", - }), + theme: expect.objectContaining(getThemeById("graphite-light").terminalTheme), }) ); }); }); + it("uses the high-contrast dark terminal palette for hc-dark", async () => { + const { Terminal } = await import("@xterm/xterm"); + const store = createStore(); + store.set(themeAtom, "hc-dark"); + + render( + + + + ); + + expect(Terminal).toHaveBeenCalledWith( + expect.objectContaining({ + theme: expect.objectContaining(getThemeById("hc-dark").terminalTheme), + }) + ); + }); + it("uses JetBrains Mono font family", async () => { const { Terminal } = await import("@xterm/xterm"); @@ -1502,7 +1671,7 @@ describe("XtermHost", () => { expect(screen.queryByRole("button", { name: "Escape" })).not.toBeInTheDocument(); }); - it("routes soft-key presses through sendTerminalInput and refocuses the xterm instance", async () => { + it("routes soft-key presses through sendTerminalInput without refocusing the xterm instance", async () => { viewportMocks.viewport = "mobile"; const store = createStore(); const user = userEvent.setup(); @@ -1533,6 +1702,96 @@ describe("XtermHost", () => { undefined ); }); + expect(mockTerminal.focus).not.toHaveBeenCalled(); + }); + + it("routes touch soft-key presses through sendTerminalInput without relying on xterm focus", async () => { + viewportMocks.viewport = "mobile"; + const store = createStore(); + const sendTerminalInput = vi.fn().mockResolvedValue(undefined); + + store.set(localeAtom, "en"); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ status: "ok" }), + sendTerminalInput, + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + const escapeButton = screen.getByRole("button", { name: "Escape" }); + fireEvent.pointerDown(escapeButton, { pointerType: "touch" }); + fireEvent.pointerUp(escapeButton, { pointerType: "touch" }); + + await waitFor(() => { + expect(sendTerminalInput).toHaveBeenLastCalledWith( + "mobile-touch-escape-terminal", + new TextEncoder().encode("\x1b"), + "typing", + undefined + ); + }); + + expect(sendTerminalInput).toHaveBeenCalledTimes(1); + expect(mockTerminal.focus).not.toHaveBeenCalled(); + }); + + it("does not auto-focus a live terminal on mobile", () => { + viewportMocks.viewport = "mobile"; + const store = createStore(); + + store.set(localeAtom, "en"); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ status: "ok" }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + } as never); + store.set(terminalMetaAtomFamily("mobile-alive-terminal"), { + id: "mobile-alive-terminal", + workspaceId: "test-workspace", + kind: "shell", + alive: true, + }); + + render( + + + + ); + + expect(mockTerminal.focus).not.toHaveBeenCalled(); + }); + + it("still auto-focuses a live terminal on desktop", () => { + const store = createStore(); + + store.set(localeAtom, "en"); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ status: "ok" }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + } as never); + store.set(terminalMetaAtomFamily("desktop-alive-terminal"), { + id: "desktop-alive-terminal", + workspaceId: "test-workspace", + kind: "shell", + alive: true, + }); + + render( + + + + ); + expect(mockTerminal.focus).toHaveBeenCalled(); }); @@ -1703,7 +1962,6 @@ describe("XtermHost", () => { it("clears buffered submitted text when the terminal instance changes", async () => { viewportMocks.viewport = "mobile"; const store = createStore(); - const user = userEvent.setup(); const sendTerminalInput = vi.fn().mockResolvedValue(undefined); store.set(localeAtom, "en"); @@ -2028,7 +2286,6 @@ describe("XtermHost", () => { it("locks ctrl from the keyboard shortcut path and keeps applying control input", async () => { viewportMocks.viewport = "mobile"; const store = createStore(); - const user = userEvent.setup(); const sendTerminalInput = vi.fn().mockResolvedValue(undefined); store.set(localeAtom, "en"); @@ -6800,6 +7057,1129 @@ describe("XtermHost", () => { global.cancelAnimationFrame = originalCancelAnimationFrame; }); + it("mobile line copy copies a wrapped logical line after a stable long press when copy-on-select is enabled", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockResolvedValue(undefined); + const vibrate = vi.fn(); + const store = createStore(); + + mockTerminal.cols = 80; + mockTerminal.rows = 3; + mockTerminal.buffer.active.viewportY = 10; + setMockBufferLines([ + [10, "prompt> ", false], + [11, "echo hel", false], + [12, "lo ", true], + ]); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } satisfies Pick, + }); + Object.defineProperty(navigator, "vibrate", { + configurable: true, + value: vibrate, + }); + + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + sendTerminalInput: vi.fn().mockResolvedValue(undefined), + } as never); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const rowsElement = document.createElement("div"); + rowsElement.className = "xterm-rows"; + + const firstRow = document.createElement("div"); + firstRow.innerHTML = "prompt>"; + const secondRow = document.createElement("div"); + secondRow.innerHTML = "echo hel"; + const thirdRow = document.createElement("div"); + thirdRow.innerHTML = "lo"; + + rowsElement.append(firstRow, secondRow, thirdRow); + host!.appendChild(rowsElement); + stubRowsGeometry(host!, rowsElement, [firstRow, secondRow, thirdRow], { + rowsRect: { x: 0, y: 100, width: 320, height: 60 }, + rowHeight: 20, + }); + + dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 6, clientY: 150 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + await Promise.resolve(); + }); + + expect(writeText).not.toHaveBeenCalled(); + + dispatchTouchEvent(host!, "touchend", [], [{ identifier: 1, clientX: 6, clientY: 150 }]); + + await act(async () => { + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith("echo hello"); + expect(store.get(toastsAtom)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "success", + title: "Copied current line", + }), + ]) + ); + expect(vibrate).toHaveBeenCalledWith(10); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("mobile line copy still copies a resolved empty logical line on long press", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockResolvedValue(undefined); + const vibrate = vi.fn(); + const store = createStore(); + + mockTerminal.cols = 80; + mockTerminal.buffer.active.viewportY = 14; + setMockBufferLines([[14, "", false]]); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } satisfies Pick, + }); + Object.defineProperty(navigator, "vibrate", { + configurable: true, + value: vibrate, + }); + + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + sendTerminalInput: vi.fn().mockResolvedValue(undefined), + } as never); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const rowsElement = document.createElement("div"); + rowsElement.className = "xterm-rows"; + rowsElement.innerHTML = "
 
"; + host!.appendChild(rowsElement); + stubRowsGeometry(host!, rowsElement, [rowsElement.firstElementChild as HTMLDivElement], { + rowsRect: { x: 0, y: 100, width: 320, height: 20 }, + rowHeight: 20, + }); + + dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 20, clientY: 110 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + await Promise.resolve(); + }); + + expect(writeText).not.toHaveBeenCalled(); + + dispatchTouchEvent(host!, "touchend", [], [{ identifier: 1, clientX: 20, clientY: 110 }]); + + await act(async () => { + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith(""); + expect(store.get(toastsAtom)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "success", + title: "Copied current line", + }), + ]) + ); + expect(vibrate).toHaveBeenCalledWith(10); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("mobile line copy does nothing when copy-on-select is disabled", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockResolvedValue(undefined); + const vibrate = vi.fn(); + const store = createStore(); + + mockTerminal.cols = 80; + mockTerminal.buffer.active.viewportY = 3; + setMockBufferLines([[3, "disabled line", false]]); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } satisfies Pick, + }); + Object.defineProperty(navigator, "vibrate", { + configurable: true, + value: vibrate, + }); + + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: false }); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + sendTerminalInput: vi.fn().mockResolvedValue(undefined), + } as never); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const rowsElement = document.createElement("div"); + rowsElement.className = "xterm-rows"; + rowsElement.innerHTML = "
disabled line
"; + host!.appendChild(rowsElement); + stubRowsGeometry(host!, rowsElement, [rowsElement.firstElementChild as HTMLDivElement], { + rowsRect: { x: 0, y: 100, width: 320, height: 20 }, + rowHeight: 20, + }); + + dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 20, clientY: 110 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + await Promise.resolve(); + }); + + expect(writeText).not.toHaveBeenCalled(); + expect(vibrate).not.toHaveBeenCalled(); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("mobile line copy does not fire when the gesture becomes a scroll before long press matures", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockResolvedValue(undefined); + mockTerminal.cols = 80; + mockTerminal.rows = 20; + mockTerminal.buffer.active.viewportY = 6; + mockTerminal.buffer.active.baseY = 80; + setMockBufferLines([[6, "scroll line", false]]); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } satisfies Pick, + }); + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host"); + expect(host).toBeTruthy(); + + const rowsElement = document.createElement("div"); + rowsElement.className = "xterm-rows"; + rowsElement.innerHTML = "
scroll line
"; + (host as HTMLDivElement).appendChild(rowsElement); + stubRowsGeometry( + host as HTMLDivElement, + rowsElement, + [rowsElement.firstElementChild as HTMLDivElement], + { + rowsRect: { x: 0, y: 100, width: 320, height: 20 }, + rowHeight: 20, + } + ); + + dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 20, clientY: 110 }]); + dispatchTouchEvent(host!, "touchmove", [{ identifier: 1, clientX: 40, clientY: 88 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + await Promise.resolve(); + }); + + expect(writeText).not.toHaveBeenCalled(); + expect(mockTerminal.scrollLines).toHaveBeenCalled(); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("mobile line copy cancels on horizontal drift beyond tolerance without vibration or scrolling", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockResolvedValue(undefined); + const vibrate = vi.fn(); + mockTerminal.cols = 80; + mockTerminal.rows = 20; + mockTerminal.buffer.active.viewportY = 6; + mockTerminal.buffer.active.baseY = 80; + setMockBufferLines([[6, "drift line", false]]); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } satisfies Pick, + }); + Object.defineProperty(navigator, "vibrate", { + configurable: true, + value: vibrate, + }); + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host"); + expect(host).toBeTruthy(); + + const rowsElement = document.createElement("div"); + rowsElement.className = "xterm-rows"; + rowsElement.innerHTML = "
drift line
"; + (host as HTMLDivElement).appendChild(rowsElement); + stubRowsGeometry( + host as HTMLDivElement, + rowsElement, + [rowsElement.firstElementChild as HTMLDivElement], + { + rowsRect: { x: 0, y: 100, width: 320, height: 20 }, + rowHeight: 20, + } + ); + + dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 20, clientY: 110 }]); + dispatchTouchEvent(host!, "touchmove", [{ identifier: 1, clientX: 56, clientY: 110 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + await Promise.resolve(); + }); + + expect(writeText).not.toHaveBeenCalled(); + expect(mockTerminal.scrollLines).not.toHaveBeenCalled(); + expect(vibrate).not.toHaveBeenCalled(); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("does not start touch momentum after a below-tolerance long-press nudge", () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + const nowSpy = vi.spyOn(performance, "now"); + mockTerminal.rows = 20; + mockTerminal.buffer.active.viewportY = 6; + mockTerminal.buffer.active.baseY = 80; + + let now = 0; + nowSpy.mockImplementation(() => now); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + const rafCallbacks: FrameRequestCallback[] = []; + global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }) as typeof requestAnimationFrame; + global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame; + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host"); + expect(host).toBeTruthy(); + const rafCountBeforeGesture = rafCallbacks.length; + + const dispatchTouchEvent = ( + type: string, + touches: Array<{ identifier: number; clientY: number }>, + changedTouches: Array<{ identifier: number; clientY: number }> = touches + ) => { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "touches", { value: touches }); + Object.defineProperty(event, "changedTouches", { value: changedTouches }); + host?.dispatchEvent(event); + }; + + now = 0; + dispatchTouchEvent("touchstart", [{ identifier: 1, clientY: 120 }]); + + now = 32; + dispatchTouchEvent("touchmove", [{ identifier: 1, clientY: 114 }]); + + now = 48; + dispatchTouchEvent("touchend", [], [{ identifier: 1, clientY: 114 }]); + + expect(rafCallbacks).toHaveLength(rafCountBeforeGesture); + expect(mockTerminal.scrollLines).not.toHaveBeenCalled(); + + window.matchMedia = originalMatchMedia; + global.requestAnimationFrame = originalRequestAnimationFrame; + global.cancelAnimationFrame = originalCancelAnimationFrame; + vi.useRealTimers(); + }); + + it("mobile line copy shows a mobile-specific failure toast when clipboard write fails", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockRejectedValue(new Error("clipboard failed")); + const execCommand = vi.fn().mockReturnValue(false); + const vibrate = vi.fn(); + const store = createStore(); + + mockTerminal.cols = 80; + mockTerminal.buffer.active.viewportY = 7; + setMockBufferLines([[7, "toast line", false]]); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } satisfies Pick, + }); + Object.defineProperty(navigator, "vibrate", { + configurable: true, + value: vibrate, + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + + store.set(localeAtom, "zh"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + sendTerminalInput: vi.fn().mockResolvedValue(undefined), + } as never); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const rowsElement = document.createElement("div"); + rowsElement.className = "xterm-rows"; + rowsElement.innerHTML = "
toast line
"; + host!.appendChild(rowsElement); + stubRowsGeometry(host!, rowsElement, [rowsElement.firstElementChild as HTMLDivElement], { + rowsRect: { x: 0, y: 100, width: 320, height: 20 }, + rowHeight: 20, + }); + + dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 20, clientY: 110 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + await Promise.resolve(); + }); + + expect(writeText).not.toHaveBeenCalled(); + + dispatchTouchEvent(host!, "touchend", [], [{ identifier: 1, clientX: 20, clientY: 110 }]); + + await act(async () => { + await Promise.resolve(); + }); + + expect(store.get(toastsAtom)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "error", + title: "当前行复制失败", + body: "请重试长按当前行", + }), + ]) + ); + expect(vibrate).not.toHaveBeenCalled(); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("mobile line copy falls back to document.execCommand when clipboard writeText is rejected", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockRejectedValue(new Error("clipboard rejected")); + const execCommand = vi.fn().mockReturnValue(true); + const vibrate = vi.fn(); + const store = createStore(); + + mockTerminal.cols = 80; + mockTerminal.buffer.active.viewportY = 8; + setMockBufferLines([[8, "fallback line", false]]); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } satisfies Pick, + }); + Object.defineProperty(navigator, "vibrate", { + configurable: true, + value: vibrate, + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + sendTerminalInput: vi.fn().mockResolvedValue(undefined), + } as never); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const rowsElement = document.createElement("div"); + rowsElement.className = "xterm-rows"; + rowsElement.innerHTML = "
fallback line
"; + host!.appendChild(rowsElement); + stubRowsGeometry(host!, rowsElement, [rowsElement.firstElementChild as HTMLDivElement], { + rowsRect: { x: 0, y: 100, width: 320, height: 20 }, + rowHeight: 20, + }); + + dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 20, clientY: 110 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + await Promise.resolve(); + }); + + expect(writeText).not.toHaveBeenCalled(); + + dispatchTouchEvent(host!, "touchend", [], [{ identifier: 1, clientX: 20, clientY: 110 }]); + + await act(async () => { + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith("fallback line"); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(store.get(toastsAtom)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "success", + title: "Copied current line", + }), + ]) + ); + expect(vibrate).toHaveBeenCalledWith(10); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("mobile line copy still succeeds when the touch target is only the host element", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockResolvedValue(undefined); + const vibrate = vi.fn(); + const store = createStore(); + + mockTerminal.cols = 80; + mockTerminal.buffer.active.viewportY = 9; + setMockBufferLines([[10, "from coords", false]]); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } satisfies Pick, + }); + Object.defineProperty(navigator, "vibrate", { + configurable: true, + value: vibrate, + }); + + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + sendTerminalInput: vi.fn().mockResolvedValue(undefined), + } as never); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const rowsElement = document.createElement("div"); + rowsElement.className = "xterm-rows"; + const firstRow = document.createElement("div"); + firstRow.innerHTML = "ignored"; + const secondRow = document.createElement("div"); + secondRow.innerHTML = "from coords"; + rowsElement.append(firstRow, secondRow); + host!.appendChild(rowsElement); + stubRowsGeometry(host!, rowsElement, [firstRow, secondRow], { + rowsRect: { x: 0, y: 100, width: 320, height: 40 }, + rowHeight: 20, + }); + + dispatchTouchEvent(host!, "touchstart", [ + { identifier: 1, clientX: 40, clientY: 130, target: host }, + ]); + + await act(async () => { + vi.advanceTimersByTime(500); + await Promise.resolve(); + }); + + expect(writeText).not.toHaveBeenCalled(); + + dispatchTouchEvent(host!, "touchend", [], [{ identifier: 1, clientX: 40, clientY: 130 }]); + + await act(async () => { + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith("from coords"); + expect(vibrate).toHaveBeenCalledWith(10); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("mobile line copy does not copy when a long press lands in the blank area to the right of a short row", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockResolvedValue(undefined); + const vibrate = vi.fn(); + const store = createStore(); + + mockTerminal.cols = 80; + mockTerminal.buffer.active.viewportY = 12; + setMockBufferLines([[12, "short", false]]); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } satisfies Pick, + }); + Object.defineProperty(navigator, "vibrate", { + configurable: true, + value: vibrate, + }); + + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + sendTerminalInput: vi.fn().mockResolvedValue(undefined), + } as never); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const rowsElement = document.createElement("div"); + rowsElement.className = "xterm-rows"; + rowsElement.innerHTML = "
short
"; + host!.appendChild(rowsElement); + stubRowsGeometry(host!, rowsElement, [rowsElement.firstElementChild as HTMLDivElement], { + rowsRect: { x: 0, y: 100, width: 320, height: 20 }, + rowHeight: 20, + }); + + dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 220, clientY: 110 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(writeText).not.toHaveBeenCalled(); + expect(vibrate).not.toHaveBeenCalled(); + expect(store.get(toastsAtom)).toEqual([]); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("mobile line copy does not copy when a long press lands in the right gutter outside the xterm screen grid", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockResolvedValue(undefined); + const vibrate = vi.fn(); + const store = createStore(); + + mockTerminal.cols = 80; + mockTerminal.buffer.active.viewportY = 15; + setMockBufferLines([[15, "x".repeat(80), false]]); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } satisfies Pick, + }); + Object.defineProperty(navigator, "vibrate", { + configurable: true, + value: vibrate, + }); + + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + sendTerminalInput: vi.fn().mockResolvedValue(undefined), + } as never); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const screenElement = document.createElement("div"); + screenElement.className = "xterm-screen"; + host!.appendChild(screenElement); + + const rowsElement = document.createElement("div"); + rowsElement.className = "xterm-rows"; + rowsElement.innerHTML = `
${"x".repeat(80)}
`; + host!.appendChild(rowsElement); + stubRowsGeometry(host!, rowsElement, [rowsElement.firstElementChild as HTMLDivElement], { + rowsRect: { x: 0, y: 100, width: 320, height: 20 }, + screenRect: { x: 0, y: 100, width: 280, height: 20 }, + rowHeight: 20, + }); + + dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 300, clientY: 110 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(writeText).not.toHaveBeenCalled(); + expect(vibrate).not.toHaveBeenCalled(); + expect(store.get(toastsAtom)).toEqual([]); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("mobile line copy does not copy an empty row when a long press lands in the right gutter outside the xterm screen grid", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockResolvedValue(undefined); + const vibrate = vi.fn(); + const store = createStore(); + + mockTerminal.cols = 80; + mockTerminal.buffer.active.viewportY = 16; + setMockBufferLines([[16, "", false]]); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } satisfies Pick, + }); + Object.defineProperty(navigator, "vibrate", { + configurable: true, + value: vibrate, + }); + + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + sendTerminalInput: vi.fn().mockResolvedValue(undefined), + } as never); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const screenElement = document.createElement("div"); + screenElement.className = "xterm-screen"; + host!.appendChild(screenElement); + + const rowsElement = document.createElement("div"); + rowsElement.className = "xterm-rows"; + rowsElement.innerHTML = "
 
"; + host!.appendChild(rowsElement); + stubRowsGeometry(host!, rowsElement, [rowsElement.firstElementChild as HTMLDivElement], { + rowsRect: { x: 0, y: 100, width: 320, height: 20 }, + screenRect: { x: 0, y: 100, width: 280, height: 20 }, + rowHeight: 20, + }); + + dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 300, clientY: 110 }]); + + await act(async () => { + vi.advanceTimersByTime(500); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(writeText).not.toHaveBeenCalled(); + expect(vibrate).not.toHaveBeenCalled(); + expect(store.get(toastsAtom)).toEqual([]); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + + it("mobile line copy still succeeds after the row DOM is replaced before long press matures", async () => { + vi.useFakeTimers(); + viewportMocks.viewport = "mobile"; + + const originalMatchMedia = window.matchMedia; + const writeText = vi.fn().mockResolvedValue(undefined); + const store = createStore(); + + mockTerminal.cols = 80; + mockTerminal.buffer.active.viewportY = 4; + setMockBufferLines([[5, "stable row text", false]]); + + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } satisfies Pick, + }); + + store.set(localeAtom, "en"); + store.set(terminalPreferencesAtom, { copyOnSelect: true }); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + sendTerminalInput: vi.fn().mockResolvedValue(undefined), + } as never); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + + const rowsElement = document.createElement("div"); + rowsElement.className = "xterm-rows"; + const firstRow = document.createElement("div"); + firstRow.innerHTML = "ignored"; + const secondRow = document.createElement("div"); + secondRow.innerHTML = "stable row text"; + rowsElement.append(firstRow, secondRow); + host!.appendChild(rowsElement); + stubRowsGeometry(host!, rowsElement, [firstRow, secondRow], { + rowsRect: { x: 0, y: 100, width: 320, height: 40 }, + rowHeight: 20, + }); + + dispatchTouchEvent(host!, "touchstart", [ + { + identifier: 1, + clientX: 40, + clientY: 130, + target: secondRow.querySelector("span span") as HTMLSpanElement, + }, + ]); + + secondRow.replaceChildren(document.createElement("span")); + + await act(async () => { + vi.advanceTimersByTime(500); + await Promise.resolve(); + }); + + expect(writeText).not.toHaveBeenCalled(); + + dispatchTouchEvent(host!, "touchend", [], [{ identifier: 1, clientX: 40, clientY: 130 }]); + + await act(async () => { + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith("stable row text"); + + window.matchMedia = originalMatchMedia; + vi.useRealTimers(); + }); + it("syncs xterm resize events back to the server PTY", async () => { const store = createStore(); const dispatchCommand = vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }); diff --git a/packages/web/src/features/terminal-panel/mobile/long-press-copy-line.test.ts b/packages/web/src/features/terminal-panel/mobile/long-press-copy-line.test.ts new file mode 100644 index 000000000..9f827e5a9 --- /dev/null +++ b/packages/web/src/features/terminal-panel/mobile/long-press-copy-line.test.ts @@ -0,0 +1,728 @@ +import { describe, expect, it } from "vitest"; + +import { getLogicalLineTextFromTouchPoint } from "./long-press-copy-line"; + +interface MockBufferLine { + isWrapped?: boolean; + length: number; + getCell(column: number): { getChars(): string; getWidth(): number } | undefined; + getNoBgTrimmedLength(): number; + getCellWidthTrimmedLength(): number; + translateToString(trimRight?: boolean): string; +} + +function createBufferLine( + text: string, + isWrapped = false, + noBgTrimmedLength = text.replace(/\s+$/u, "").length, + cellWidthTrimmedLength = text.replace(/\s+$/u, "").length +): MockBufferLine { + const trimmedText = text.replace(/\s+$/u, ""); + const columnCells = Array.from({ length: cellWidthTrimmedLength }, () => ({ + chars: "", + width: 1, + })); + if (trimmedText.length > 0 && cellWidthTrimmedLength > 0) { + columnCells[0] = { + chars: trimmedText, + width: cellWidthTrimmedLength, + }; + for (let column = 1; column < cellWidthTrimmedLength; column += 1) { + columnCells[column] = { + chars: "", + width: 0, + }; + } + } + + return { + isWrapped, + length: columnCells.length, + getCell(column: number) { + const cell = columnCells[column]; + if (!cell) { + return undefined; + } + + return { + getChars() { + return cell.chars; + }, + getWidth() { + return cell.width; + }, + }; + }, + getNoBgTrimmedLength() { + return noBgTrimmedLength; + }, + getCellWidthTrimmedLength() { + return cellWidthTrimmedLength; + }, + translateToString(trimRight = false) { + return trimRight ? text.replace(/\s+$/u, "") : text; + }, + }; +} + +function createTerminal( + cols: number, + viewportY: number, + lines: Array<[row: number, line: MockBufferLine]> +): { + cols: number; + buffer: { + active: { + viewportY: number; + getLine(row: number): MockBufferLine | undefined; + }; + }; +} { + const byRow = new Map(lines); + return { + cols, + buffer: { + active: { + viewportY, + getLine(row: number) { + return byRow.get(row); + }, + }, + }, + }; +} + +function createRowsDom() { + const rows = document.createElement("div"); + rows.className = "xterm-rows"; + + const firstRow = document.createElement("div"); + firstRow.innerHTML = "first"; + + const secondRow = document.createElement("div"); + secondRow.innerHTML = "second"; + + const thirdRow = document.createElement("div"); + thirdRow.innerHTML = "third"; + + rows.append(firstRow, secondRow, thirdRow); + + return { + rows, + secondRow, + thirdRow, + }; +} + +describe("getLogicalLineTextFromTouchPoint", () => { + it("maps the touched visual row through viewportY", () => { + const { rows, secondRow } = createRowsDom(); + document.body.appendChild(rows); + rows.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 320, + height: 60, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + secondRow.getBoundingClientRect = () => + ({ + x: 20, + y: 120, + top: 120, + left: 20, + width: 320, + height: 20, + right: 340, + bottom: 140, + toJSON: () => ({}), + }) as DOMRect; + + const terminal = createTerminal(16, 10, [[11, createBufferLine("beta")]]); + + expect( + getLogicalLineTextFromTouchPoint({ + clientX: 40, + clientY: 130, + rowsElement: rows, + terminal, + }) + ).toBe("beta"); + + rows.remove(); + }); + + it("walks upward and downward across wrapped rows and trims only the final segment", () => { + const { rows, secondRow, thirdRow } = createRowsDom(); + document.body.appendChild(rows); + rows.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 320, + height: 60, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + secondRow.getBoundingClientRect = () => + ({ + x: 20, + y: 120, + top: 120, + left: 20, + width: 320, + height: 20, + right: 340, + bottom: 140, + toJSON: () => ({}), + }) as DOMRect; + thirdRow.getBoundingClientRect = () => + ({ + x: 20, + y: 140, + top: 140, + left: 20, + width: 320, + height: 20, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + + const terminal = createTerminal(16, 20, [ + [20, createBufferLine("unrelated line")], + [21, createBufferLine("prefix ", false)], + [22, createBufferLine("middle ", true)], + [23, createBufferLine("suffix ", true)], + ]); + + expect( + getLogicalLineTextFromTouchPoint({ + clientX: 40, + clientY: 150, + rowsElement: rows, + terminal, + }) + ).toBe("prefix middle suffix"); + + rows.remove(); + }); + + it("preserves meaningful internal spaces from wrapped intermediate segments", () => { + const { rows, secondRow } = createRowsDom(); + document.body.appendChild(rows); + rows.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 320, + height: 60, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + secondRow.getBoundingClientRect = () => + ({ + x: 20, + y: 120, + top: 120, + left: 20, + width: 320, + height: 20, + right: 340, + bottom: 140, + toJSON: () => ({}), + }) as DOMRect; + + const terminal = createTerminal(16, 30, [ + [30, createBufferLine("double ", false)], + [31, createBufferLine("space ", true)], + ]); + + expect( + getLogicalLineTextFromTouchPoint({ + clientX: 40, + clientY: 130, + rowsElement: rows, + terminal, + }) + ).toBe("double space"); + + rows.remove(); + }); + + it("returns null when the mapped buffer row is missing", () => { + const { rows, secondRow } = createRowsDom(); + document.body.appendChild(rows); + rows.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 320, + height: 60, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + secondRow.getBoundingClientRect = () => + ({ + x: 20, + y: 120, + top: 120, + left: 20, + width: 320, + height: 20, + right: 340, + bottom: 140, + toJSON: () => ({}), + }) as DOMRect; + + const terminal = createTerminal(16, 40, [[40, createBufferLine("alpha")]]); + + expect( + getLogicalLineTextFromTouchPoint({ + clientX: 40, + clientY: 130, + rowsElement: rows, + terminal, + }) + ).toBeNull(); + + rows.remove(); + }); + + it("returns null when a wrapped row's preceding segment is missing", () => { + const { rows, secondRow } = createRowsDom(); + document.body.appendChild(rows); + rows.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 320, + height: 60, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + secondRow.getBoundingClientRect = () => + ({ + x: 20, + y: 120, + top: 120, + left: 20, + width: 320, + height: 20, + right: 340, + bottom: 140, + toJSON: () => ({}), + }) as DOMRect; + + const terminal = createTerminal(16, 50, [[51, createBufferLine("suffix ", true)]]); + + expect( + getLogicalLineTextFromTouchPoint({ + clientX: 40, + clientY: 130, + rowsElement: rows, + terminal, + }) + ).toBeNull(); + + rows.remove(); + }); + + it("returns null when the touch point is outside the xterm rows bounds", () => { + const { rows, secondRow } = createRowsDom(); + document.body.appendChild(rows); + rows.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 320, + height: 60, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + secondRow.getBoundingClientRect = () => + ({ + x: 20, + y: 120, + top: 120, + left: 20, + width: 320, + height: 20, + right: 340, + bottom: 140, + toJSON: () => ({}), + }) as DOMRect; + + const terminal = createTerminal(16, 0, [[0, createBufferLine("ignored")]]); + + expect( + getLogicalLineTextFromTouchPoint({ + clientX: 40, + clientY: 180, + rowsElement: rows, + terminal, + }) + ).toBeNull(); + + rows.remove(); + }); + + it("maps by row bounds even when the event target is not a row descendant", () => { + const { rows, thirdRow } = createRowsDom(); + document.body.appendChild(rows); + rows.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 320, + height: 60, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + thirdRow.getBoundingClientRect = () => + ({ + x: 20, + y: 140, + top: 140, + left: 20, + width: 320, + height: 20, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + + const terminal = createTerminal(16, 60, [[62, createBufferLine("gamma")]]); + + expect( + getLogicalLineTextFromTouchPoint({ + clientX: 40, + clientY: 150, + rowsElement: rows, + terminal, + }) + ).toBe("gamma"); + + rows.remove(); + }); + + it("returns null when the touch point is inside a row band but outside the rendered text width", () => { + const { rows, secondRow } = createRowsDom(); + document.body.appendChild(rows); + rows.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 320, + height: 60, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + secondRow.getBoundingClientRect = () => + ({ + x: 20, + y: 120, + top: 120, + left: 20, + width: 320, + height: 20, + right: 340, + bottom: 140, + toJSON: () => ({}), + }) as DOMRect; + + const terminal = createTerminal(16, 70, [[71, createBufferLine("beta")]]); + + expect( + getLogicalLineTextFromTouchPoint({ + clientX: 180, + clientY: 130, + rowsElement: rows, + terminal, + }) + ).toBeNull(); + + rows.remove(); + }); + + it("returns null when the touch point lands in bg-only trailing cells with no copyable text", () => { + const { rows, secondRow } = createRowsDom(); + document.body.appendChild(rows); + rows.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 320, + height: 60, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + secondRow.getBoundingClientRect = () => + ({ + x: 20, + y: 120, + top: 120, + left: 20, + width: 320, + height: 20, + right: 340, + bottom: 140, + toJSON: () => ({}), + }) as DOMRect; + + const terminal = createTerminal(16, 90, [[91, createBufferLine("status", false, 10)]]); + + expect( + getLogicalLineTextFromTouchPoint({ + clientX: 185, + clientY: 130, + rowsElement: rows, + terminal, + }) + ).toBeNull(); + + rows.remove(); + }); + + it("returns null when the touch point lands in the right gutter outside the screen cell grid", () => { + const { rows, secondRow } = createRowsDom(); + const screenElement = document.createElement("div"); + document.body.append(rows, screenElement); + rows.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 320, + height: 60, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + screenElement.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 280, + height: 60, + right: 300, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + secondRow.getBoundingClientRect = () => + ({ + x: 20, + y: 120, + top: 120, + left: 20, + width: 320, + height: 20, + right: 340, + bottom: 140, + toJSON: () => ({}), + }) as DOMRect; + + const terminal = createTerminal(16, 100, [[101, createBufferLine("1234567890abcdef")]]); + + expect( + getLogicalLineTextFromTouchPoint({ + clientX: 310, + clientY: 130, + rowsElement: rows, + screenElement, + terminal, + }) + ).toBeNull(); + + rows.remove(); + screenElement.remove(); + }); + + it("accepts touches on the trailing half of a wide character cell", () => { + const { rows, secondRow } = createRowsDom(); + const screenElement = document.createElement("div"); + document.body.append(rows, screenElement); + rows.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 320, + height: 60, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + screenElement.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 320, + height: 60, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + secondRow.getBoundingClientRect = () => + ({ + x: 20, + y: 120, + top: 120, + left: 20, + width: 320, + height: 20, + right: 340, + bottom: 140, + toJSON: () => ({}), + }) as DOMRect; + + const terminal = createTerminal(16, 110, [[111, createBufferLine("中", false, 1, 2)]]); + + expect( + getLogicalLineTextFromTouchPoint({ + clientX: 55, + clientY: 130, + rowsElement: rows, + screenElement, + terminal, + }) + ).toBe("中"); + + rows.remove(); + screenElement.remove(); + }); + + it("returns null for an empty row when the touch point lands in the screen gutter", () => { + const { rows, secondRow } = createRowsDom(); + const screenElement = document.createElement("div"); + document.body.append(rows, screenElement); + rows.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 320, + height: 60, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + screenElement.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 280, + height: 60, + right: 300, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + secondRow.getBoundingClientRect = () => + ({ + x: 20, + y: 120, + top: 120, + left: 20, + width: 320, + height: 20, + right: 340, + bottom: 140, + toJSON: () => ({}), + }) as DOMRect; + + const terminal = createTerminal(16, 120, [[121, createBufferLine("")]]); + + expect( + getLogicalLineTextFromTouchPoint({ + clientX: 310, + clientY: 130, + rowsElement: rows, + screenElement, + terminal, + }) + ).toBeNull(); + + rows.remove(); + screenElement.remove(); + }); + + it("returns null when terminal columns are unavailable for horizontal hit testing", () => { + const { rows, secondRow } = createRowsDom(); + document.body.appendChild(rows); + rows.getBoundingClientRect = () => + ({ + x: 20, + y: 100, + top: 100, + left: 20, + width: 320, + height: 60, + right: 340, + bottom: 160, + toJSON: () => ({}), + }) as DOMRect; + secondRow.getBoundingClientRect = () => + ({ + x: 20, + y: 120, + top: 120, + left: 20, + width: 320, + height: 20, + right: 340, + bottom: 140, + toJSON: () => ({}), + }) as DOMRect; + + const terminal = createTerminal(0, 80, [[81, createBufferLine("beta")]]); + + expect( + getLogicalLineTextFromTouchPoint({ + clientX: 40, + clientY: 130, + rowsElement: rows, + terminal, + }) + ).toBeNull(); + + rows.remove(); + }); +}); diff --git a/packages/web/src/features/terminal-panel/mobile/long-press-copy-line.ts b/packages/web/src/features/terminal-panel/mobile/long-press-copy-line.ts new file mode 100644 index 000000000..4f1c71c03 --- /dev/null +++ b/packages/web/src/features/terminal-panel/mobile/long-press-copy-line.ts @@ -0,0 +1,189 @@ +interface BufferCellLike { + getChars(): string; + getWidth(): number; +} + +interface BufferLineLike { + isWrapped?: boolean; + length?: number; + getCell?(column: number): BufferCellLike | undefined; + translateToString(trimRight?: boolean): string; +} + +interface ActiveBufferLike { + viewportY: number; + getLine(row: number): BufferLineLike | undefined; +} + +export interface TerminalLikeForLongPressCopy { + cols: number; + buffer: { + active: ActiveBufferLike; + }; +} + +export interface GetLogicalLineTextFromTouchPointArgs { + clientX: number; + clientY: number; + rowsElement: HTMLElement; + screenElement?: HTMLElement; + terminal: TerminalLikeForLongPressCopy; +} + +function getVisualRowContentLength(line: BufferLineLike, maxColumns: number): number { + if (typeof line.getCell === "function") { + const scanLimit = + typeof line.length === "number" && Number.isFinite(line.length) + ? Math.min(Math.max(line.length, 0), maxColumns) + : maxColumns; + + for (let column = scanLimit - 1; column >= 0; column -= 1) { + const cell = line.getCell(column); + if (!cell || cell.getChars() === "") { + continue; + } + + return column + Math.max(cell.getWidth(), 1); + } + + return 0; + } + + return line.translateToString(true).length; +} + +function getVisualRowIndexFromTouchPoint(rowsElement: HTMLElement, clientY: number): number | null { + const rowElements = Array.from(rowsElement.children).filter( + (child): child is HTMLElement => child instanceof HTMLElement + ); + for (let index = 0; index < rowElements.length; index += 1) { + const rowElement = rowElements[index]; + const rowRect = rowElement.getBoundingClientRect(); + if (clientY < rowRect.top || clientY > rowRect.bottom) { + continue; + } + + return index; + } + + return null; +} + +function getBufferRowFromTouchPoint( + rowsElement: HTMLElement, + screenElement: HTMLElement | undefined, + terminal: TerminalLikeForLongPressCopy, + clientX: number, + clientY: number +): number | null { + const rowsRect = rowsElement.getBoundingClientRect(); + const horizontalBoundsElement = screenElement ?? rowsElement; + const horizontalRect = horizontalBoundsElement.getBoundingClientRect(); + if ( + clientX < rowsRect.left || + clientX > rowsRect.right || + clientY < rowsRect.top || + clientY > rowsRect.bottom + ) { + return null; + } + + const visualRowIndex = getVisualRowIndexFromTouchPoint(rowsElement, clientY); + if (visualRowIndex === null) { + return null; + } + + const bufferRow = terminal.buffer.active.viewportY + visualRowIndex; + const line = terminal.buffer.active.getLine(bufferRow); + if (!line) { + return null; + } + + if (!Number.isFinite(terminal.cols) || terminal.cols <= 0 || horizontalRect.width <= 0) { + return null; + } + + if (clientX < horizontalRect.left || clientX > horizontalRect.right) { + return null; + } + + const contentLength = getVisualRowContentLength(line, terminal.cols); + if (contentLength === 0) { + return bufferRow; + } + + const cellWidth = horizontalRect.width / terminal.cols; + if (cellWidth <= 0) { + return null; + } + + const columnIndex = Math.floor((clientX - horizontalRect.left) / cellWidth); + if (columnIndex < 0 || columnIndex >= terminal.cols) { + return null; + } + + if (columnIndex >= contentLength) { + return null; + } + + return bufferRow; +} + +function getLogicalLineTextFromBufferRow( + terminal: TerminalLikeForLongPressCopy, + bufferRow: number +): string | null { + const activeBuffer = terminal.buffer.active; + + let startRow = bufferRow; + let currentLine = activeBuffer.getLine(startRow); + if (!currentLine) { + return null; + } + + while (currentLine.isWrapped === true) { + startRow -= 1; + if (startRow < 0) { + return null; + } + + currentLine = activeBuffer.getLine(startRow); + if (!currentLine) { + return null; + } + } + + const segments: BufferLineLike[] = [currentLine]; + let scanRow = startRow; + + while (true) { + const nextLine = activeBuffer.getLine(scanRow + 1); + if (!nextLine || nextLine.isWrapped !== true) { + break; + } + + segments.push(nextLine); + scanRow += 1; + } + + return segments + .map((line, index) => line.translateToString(index === segments.length - 1)) + .join(""); +} + +export function getLogicalLineTextFromTouchPoint( + args: GetLogicalLineTextFromTouchPointArgs +): string | null { + const bufferRow = getBufferRowFromTouchPoint( + args.rowsElement, + args.screenElement, + args.terminal, + args.clientX, + args.clientY + ); + if (bufferRow === null) { + return null; + } + + return getLogicalLineTextFromBufferRow(args.terminal, bufferRow); +} diff --git a/packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.test.tsx b/packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.test.tsx index c1d5aa48a..a6f685588 100644 --- a/packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.test.tsx +++ b/packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.test.tsx @@ -242,6 +242,144 @@ describe("MobileTerminalInputBar", () => { ); }); + it("prevents touch pointer presses from moving focus onto a soft key", () => { + render( + + ); + + const escapeButton = screen.getByRole("button", { name: labels.escape }); + const pointerDownEvent = new Event("pointerdown", { + bubbles: true, + cancelable: true, + }) as Event & { pointerType?: string }; + pointerDownEvent.pointerType = "touch"; + + fireEvent(escapeButton, pointerDownEvent); + + expect(pointerDownEvent.defaultPrevented).toBe(true); + }); + + it("dispatches touch and pen soft-key taps without relying on click synthesis", () => { + const onKeyPress = vi.fn(); + const onShiftTap = vi.fn(); + + render( + + ); + + const escapeButton = screen.getByRole("button", { name: labels.escape }); + fireEvent.pointerDown(escapeButton, { pointerType: "touch" }); + fireEvent.pointerUp(escapeButton, { pointerType: "touch" }); + + expect(onKeyPress).toHaveBeenCalledTimes(1); + expect(onKeyPress).toHaveBeenLastCalledWith("escape"); + + const shiftButton = screen.getByRole("button", { name: labels.shift }); + fireEvent.pointerDown(shiftButton, { pointerType: "pen" }); + fireEvent.pointerUp(shiftButton, { pointerType: "pen" }); + + expect(onShiftTap).toHaveBeenCalledTimes(1); + }); + + it("dispatches touch ctrl taps on pointer release and keeps long press distinct", () => { + const onCtrlTap = vi.fn(); + const onCtrlLongPress = vi.fn(); + + render( + + ); + + const ctrlButton = screen.getByRole("button", { name: labels.ctrl }); + fireEvent.pointerDown(ctrlButton, { pointerType: "touch" }); + act(() => { + vi.advanceTimersByTime(200); + }); + fireEvent.pointerUp(ctrlButton, { pointerType: "touch" }); + + expect(onCtrlTap).toHaveBeenCalledTimes(1); + expect(onCtrlLongPress).not.toHaveBeenCalled(); + + fireEvent.pointerDown(ctrlButton, { pointerType: "touch" }); + act(() => { + vi.advanceTimersByTime(450); + }); + fireEvent.pointerUp(ctrlButton, { pointerType: "touch" }); + + expect(onCtrlTap).toHaveBeenCalledTimes(1); + expect(onCtrlLongPress).toHaveBeenCalledTimes(1); + }); + + it("does not double-dispatch when a touch sequence is followed by a click event", () => { + const onKeyPress = vi.fn(); + + render( + + ); + + const escapeButton = screen.getByRole("button", { name: labels.escape }); + fireEvent.pointerDown(escapeButton, { pointerType: "touch" }); + fireEvent.pointerUp(escapeButton, { pointerType: "touch" }); + fireEvent.click(escapeButton); + + expect(onKeyPress).toHaveBeenCalledTimes(1); + expect(onKeyPress).toHaveBeenCalledWith("escape"); + }); + + it("does not dispatch a touch soft key when the gesture started on a different button", () => { + const onKeyPress = vi.fn(); + + render( + + ); + + const tabButton = screen.getByRole("button", { name: labels.tab }); + const escapeButton = screen.getByRole("button", { name: labels.escape }); + fireEvent.pointerDown(tabButton, { pointerType: "touch", pointerId: 7 }); + fireEvent.pointerUp(escapeButton, { pointerType: "touch", pointerId: 7 }); + + expect(onKeyPress).not.toHaveBeenCalled(); + }); + it("ignores command-key callbacks when disabled", () => { const onCtrlTap = vi.fn(); const onCtrlLongPress = vi.fn(); diff --git a/packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.tsx b/packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.tsx index aed7704fc..892a5e3e5 100644 --- a/packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.tsx +++ b/packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.tsx @@ -1,6 +1,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, useEffect, useRef, } from "react"; @@ -45,6 +46,25 @@ interface MobileTerminalInputBarProps { onShiftTap: () => void; } +type TouchLikeGestureTarget = "ctrl" | "shift" | SoftTerminalKeyId; + +type TouchLikeGestureStatus = "matched" | "mismatched" | "none"; + +interface TouchLikeGestureState { + pointerId: number; + target: TouchLikeGestureTarget; +} + +function preserveExistingInputFocus(event: ReactPointerEvent) { + if (event.pointerType === "touch" || event.pointerType === "pen") { + event.preventDefault(); + } +} + +function isTouchLikePointer(event: ReactPointerEvent) { + return event.pointerType === "touch" || event.pointerType === "pen"; +} + function getKeyAriaLabel(key: SoftTerminalKeyId, labels: MobileTerminalInputBarLabels): string { switch (key) { case "escape": @@ -75,8 +95,11 @@ export function MobileTerminalInputBar({ onShiftTap, }: MobileTerminalInputBarProps) { const longPressTimerRef = useRef | null>(null); + const suppressedClickResetTimerRef = useRef | null>(null); const longPressTriggeredRef = useRef(false); const longPressSourceRef = useRef<"keyboard" | "pointer" | null>(null); + const suppressNextPointerClickRef = useRef(false); + const touchLikeGestureRef = useRef(null); const commandKeysDisabled = disabled; const clearLongPress = () => { @@ -86,17 +109,83 @@ export function MobileTerminalInputBar({ } }; + const clearSuppressedClickReset = () => { + if (suppressedClickResetTimerRef.current !== null) { + clearTimeout(suppressedClickResetTimerRef.current); + suppressedClickResetTimerRef.current = null; + } + }; + + const scheduleSuppressedPointerClick = () => { + suppressNextPointerClickRef.current = true; + clearSuppressedClickReset(); + suppressedClickResetTimerRef.current = setTimeout(() => { + suppressNextPointerClickRef.current = false; + suppressedClickResetTimerRef.current = null; + }, 0); + }; + + const consumeSuppressedPointerClick = () => { + if (!suppressNextPointerClickRef.current) { + return false; + } + + suppressNextPointerClickRef.current = false; + clearSuppressedClickReset(); + return true; + }; + + const clearTouchLikeGesture = () => { + touchLikeGestureRef.current = null; + }; + + const startTouchLikeGesture = ( + event: ReactPointerEvent, + target: TouchLikeGestureTarget + ) => { + if (!isTouchLikePointer(event)) { + return; + } + + touchLikeGestureRef.current = { + pointerId: event.pointerId, + target, + }; + }; + + const finishTouchLikeGesture = ( + event: ReactPointerEvent, + target: TouchLikeGestureTarget + ): TouchLikeGestureStatus => { + if (!isTouchLikePointer(event)) { + return "none"; + } + + const activeGesture = touchLikeGestureRef.current; + if (!activeGesture || activeGesture.pointerId !== event.pointerId) { + return "none"; + } + + touchLikeGestureRef.current = null; + return activeGesture.target === target ? "matched" : "mismatched"; + }; + useEffect(() => { if (commandKeysDisabled) { clearLongPress(); + clearSuppressedClickReset(); + clearTouchLikeGesture(); longPressTriggeredRef.current = false; longPressSourceRef.current = null; + suppressNextPointerClickRef.current = false; } }, [commandKeysDisabled]); useEffect(() => { return () => { clearLongPress(); + clearSuppressedClickReset(); + clearTouchLikeGesture(); }; }, []); @@ -119,6 +208,7 @@ export function MobileTerminalInputBar({ clearLongPress(); longPressTriggeredRef.current = false; longPressSourceRef.current = null; + suppressNextPointerClickRef.current = false; }; const finishCtrlGesture = () => { @@ -134,6 +224,10 @@ export function MobileTerminalInputBar({ return; } + if (consumeSuppressedPointerClick()) { + return; + } + if (longPressTriggeredRef.current) { const shouldSwallowClick = longPressSourceRef.current === "pointer" || @@ -149,6 +243,66 @@ export function MobileTerminalInputBar({ onCtrlTap(); }; + const handleCtrlPointerUp = (event: ReactPointerEvent) => { + const touchGestureStatus = finishTouchLikeGesture(event, "ctrl"); + finishCtrlGesture(); + if (commandKeysDisabled || !isTouchLikePointer(event) || touchGestureStatus === "none") { + return; + } + + scheduleSuppressedPointerClick(); + + if (touchGestureStatus !== "matched") { + return; + } + + if (longPressTriggeredRef.current) { + longPressTriggeredRef.current = false; + longPressSourceRef.current = null; + return; + } + + onCtrlTap(); + }; + + const handleTouchLikePointerUp = ( + event: ReactPointerEvent, + target: TouchLikeGestureTarget, + callback: () => void + ) => { + if (commandKeysDisabled || !isTouchLikePointer(event)) { + return; + } + + const touchGestureStatus = finishTouchLikeGesture(event, target); + if (touchGestureStatus === "none") { + return; + } + + scheduleSuppressedPointerClick(); + if (touchGestureStatus !== "matched") { + return; + } + + callback(); + }; + + const handleShiftClick = () => { + if (commandKeysDisabled || consumeSuppressedPointerClick()) { + return; + } + + onShiftTap(); + }; + + const handleSoftKeyClick = (key: SoftTerminalKeyId) => { + if (commandKeysDisabled || consumeSuppressedPointerClick()) { + return; + } + + onKeyPress(key); + }; + const handleCtrlKeyDown = (event: ReactKeyboardEvent) => { if (commandKeysDisabled) { return; @@ -186,10 +340,20 @@ export function MobileTerminalInputBar({ aria-label={ctrlLabel} aria-keyshortcuts="Alt+Enter Alt+Space" disabled={commandKeysDisabled} - onPointerDown={startCtrlGesture} - onPointerUp={finishCtrlGesture} - onPointerCancel={cancelCtrlGesture} - onPointerLeave={cancelCtrlGesture} + onPointerDown={(event) => { + preserveExistingInputFocus(event); + startTouchLikeGesture(event, "ctrl"); + startCtrlGesture(); + }} + onPointerUp={handleCtrlPointerUp} + onPointerCancel={() => { + clearTouchLikeGesture(); + cancelCtrlGesture(); + }} + onPointerLeave={() => { + clearTouchLikeGesture(); + cancelCtrlGesture(); + }} onClick={handleCtrlClick} onKeyDown={handleCtrlKeyDown} > @@ -203,7 +367,15 @@ export function MobileTerminalInputBar({ aria-pressed={shiftArmed} aria-label={shiftLabel} disabled={commandKeysDisabled} - onClick={onShiftTap} + onPointerDown={(event) => { + preserveExistingInputFocus(event); + startTouchLikeGesture(event, "shift"); + }} + onPointerUp={(event) => { + handleTouchLikePointerUp(event, "shift", onShiftTap); + }} + onPointerCancel={clearTouchLikeGesture} + onClick={handleShiftClick} > Shift @@ -215,7 +387,15 @@ export function MobileTerminalInputBar({ className="mobile-terminal-input-bar__key" aria-label={getKeyAriaLabel(key.id, labels)} disabled={commandKeysDisabled} - onClick={() => onKeyPress(key.id)} + onPointerDown={(event) => { + preserveExistingInputFocus(event); + startTouchLikeGesture(event, key.id); + }} + onPointerUp={(event) => { + handleTouchLikePointerUp(event, key.id, () => onKeyPress(key.id)); + }} + onPointerCancel={clearTouchLikeGesture} + onClick={() => handleSoftKeyClick(key.id)} > {key.text} diff --git a/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx b/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx index 10aeaade0..13671f6ce 100644 --- a/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx +++ b/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx @@ -17,6 +17,7 @@ import { themeAtom } from "../../../../atoms/app-ui"; import { dispatchCommandAtom, wsClientAtom } from "../../../../atoms/connection"; import { useViewport } from "../../../../hooks/use-viewport"; import { useTranslation } from "../../../../lib/i18n"; +import { getThemeById } from "../../../../theme"; import type { ConnectionStatus, TerminalBinaryPayload } from "../../../../ws/client"; import { pushToastAtom } from "../../../notifications/atoms"; import type { OutputBuffer } from "../../atoms"; @@ -26,6 +27,7 @@ import { type HydrationRequestHandle, type HydrationTier, } from "../../hydration-coordinator"; +import { getLogicalLineTextFromTouchPoint } from "../../mobile/long-press-copy-line"; import { MobileTerminalInputBar } from "../../mobile/mobile-terminal-input-bar"; import { applyCtrlModeToInput, @@ -50,6 +52,8 @@ const MOBILE_TOUCH_MOMENTUM_MIN_VELOCITY_PX_PER_MS = 0.12; const MOBILE_TOUCH_MOMENTUM_STOP_VELOCITY_PX_PER_MS = 0.02; const MOBILE_TOUCH_MOMENTUM_FRICTION_PER_FRAME = 0.92; const MOBILE_TOUCH_MOMENTUM_FRAME_MS = 16; +const MOBILE_COPY_MODE_LONG_PRESS_MS = 500; +const MOBILE_COPY_MODE_MOVE_TOLERANCE_PX = 10; const TERMINAL_FOCUS_REPORTING_BYTES = new Set(["\x1b[I", "\x1b[O"]); const TERMINAL_COPY_ON_SELECT_ERROR_THROTTLE_MS = 3_000; @@ -58,7 +62,7 @@ interface TerminalInputDraftState { submittedText?: string; } -type TouchPointLike = Pick; +type TouchPointLike = Pick; interface TouchScrollSample { clientY: number; @@ -67,6 +71,49 @@ interface TouchScrollSample { type TouchScrollDeltaResult = "idle" | "buffered" | "scrolled" | "blocked"; +async function copyTextWithFallback(text: string): Promise { + let clipboardError: unknown; + + try { + await navigator.clipboard.writeText(text); + return; + } catch (error) { + clipboardError = error; + } + + if (typeof document === "undefined" || typeof document.execCommand !== "function") { + throw clipboardError ?? new Error("Clipboard copy unavailable"); + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.setAttribute("aria-hidden", "true"); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.width = "1px"; + textarea.style.height = "1px"; + textarea.style.padding = "0"; + textarea.style.border = "0"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + + document.body.appendChild(textarea); + + try { + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + if (!document.execCommand("copy")) { + throw clipboardError ?? new Error("Clipboard copy unavailable"); + } + } finally { + textarea.remove(); + } +} + function isReplayGeneratedTerminalResponse(data: string): boolean { return /^\x1b\[\d+;\d+R$/.test(data) || /^\x1b\[(?:\?|>)(?:\d+;)*\d*c$/.test(data); } @@ -215,66 +262,6 @@ function getTouchScrollPxPerLine(terminal: Terminal, container: HTMLElement): nu return MOBILE_TOUCH_SCROLL_FALLBACK_PX_PER_LINE; } -/** - * Aurora Mint terminal themes for xterm.js. - * These mirror the light/dark tokens so terminals stay legible when the user - * switches themes without needing a full remount. - */ -const AURORA_MINT_THEMES = { - dark: { - background: "#0b1218", - foreground: "#e5edf3", - cursor: "#78d7b2", - cursorAccent: "#0b1218", - selectionBackground: "#1e3040", - selectionForeground: "#e5edf3", - black: "#0a1014", - red: "#ff9eb0", - green: "#78d7b2", - yellow: "#f1b86a", - blue: "#6cb6ff", - magenta: "#c792ea", - cyan: "#78d7b2", - white: "#9fb0bc", - brightBlack: "#4a5b6a", - brightRed: "#ff9eb0", - brightGreen: "#78d7b2", - brightYellow: "#f1b86a", - brightBlue: "#6cb6ff", - brightMagenta: "#c792ea", - brightCyan: "#78d7b2", - brightWhite: "#e5edf3", - }, - light: { - background: "#fafbfc", - foreground: "#1f2328", - cursor: "#0969da", - cursorAccent: "#fafbfc", - selectionBackground: "#dde4ea", - selectionForeground: "#1f2328", - black: "#24292f", - red: "#cf222e", - green: "#1a7f37", - yellow: "#9a6700", - blue: "#0969da", - magenta: "#8250df", - cyan: "#1b7c83", - white: "#57606a", - brightBlack: "#8b949e", - brightRed: "#cf222e", - brightGreen: "#1a7f37", - brightYellow: "#9a6700", - brightBlue: "#0969da", - brightMagenta: "#8250df", - brightCyan: "#1b7c83", - brightWhite: "#1f2328", - }, -}; - -function getTerminalTheme(theme: "dark" | "light") { - return AURORA_MINT_THEMES[theme]; -} - function shouldBypassPtyForKeyboardPaste(event: KeyboardEvent): boolean { if (event.type !== "keydown") { return false; @@ -475,6 +462,8 @@ export function XtermHost({ const selectedTextRef = useRef(""); const lastCopyOnSelectFailureAtRef = useRef(0); const copyOnSelectPointerIdRef = useRef(null); + const copyMobileLongPressRef = useRef<(lineText: string | null) => void>(() => {}); + const resetTouchStateRef = useRef<() => void>(() => {}); const touchScrollStateRef = useRef<{ activeTouchId: number | null; lastClientY: number; @@ -483,6 +472,7 @@ export function XtermHost({ velocityPxPerMs: number; lastMomentumFrameAt: number; momentumFrameId: number | null; + gestureDidScroll: boolean; samples: TouchScrollSample[]; }>({ activeTouchId: null, @@ -492,6 +482,7 @@ export function XtermHost({ velocityPxPerMs: 0, lastMomentumFrameAt: 0, momentumFrameId: null, + gestureDidScroll: false, samples: [], }); @@ -585,7 +576,7 @@ export function XtermHost({ useEffect(() => { if (terminalRef.current) { - terminalRef.current.options.theme = getTerminalTheme(uiTheme); + terminalRef.current.options.theme = getThemeById(uiTheme).terminalTheme; } }, [uiTheme]); @@ -600,6 +591,25 @@ export function XtermHost({ } const state = touchScrollStateRef.current; + let longPressTimer: ReturnType | null = null; + let longPressTouchId: number | null = null; + let longPressStartClientX = 0; + let longPressStartClientY = 0; + let longPressLineText: string | null = null; + let longPressReady = false; + + const clearLongPressTimer = () => { + if (longPressTimer !== null) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + + longPressTouchId = null; + longPressStartClientX = 0; + longPressStartClientY = 0; + longPressLineText = null; + longPressReady = false; + }; const stopMomentumScroll = () => { if (state.momentumFrameId !== null) { @@ -710,14 +720,18 @@ export function XtermHost({ }; const resetTouchState = () => { + clearLongPressTimer(); stopMomentumScroll(); state.activeTouchId = null; state.lastClientY = 0; state.carryPx = 0; state.pxPerLine = null; + state.gestureDidScroll = false; state.samples = []; }; + resetTouchStateRef.current = resetTouchState; + const handleTouchStart = (event: TouchEvent) => { if (event.touches.length !== 1) { resetTouchState(); @@ -737,8 +751,32 @@ export function XtermHost({ state.pxPerLine = terminalRef.current ? getTouchScrollPxPerLine(terminalRef.current, container) : MOBILE_TOUCH_SCROLL_FALLBACK_PX_PER_LINE; + state.gestureDidScroll = false; state.samples = []; recordTouchSample(touch.clientY, performance.now()); + if (viewport === "mobile" && terminalPreferences.copyOnSelect) { + longPressTouchId = touch.identifier; + longPressStartClientX = touch.clientX; + longPressStartClientY = touch.clientY; + const terminal = terminalRef.current; + const rowsElement = container.querySelector(".xterm-rows"); + const screenElement = container.querySelector(".xterm-screen"); + longPressReady = false; + longPressLineText = + terminal && rowsElement instanceof HTMLElement + ? getLogicalLineTextFromTouchPoint({ + clientX: touch.clientX, + clientY: touch.clientY, + rowsElement, + screenElement: screenElement instanceof HTMLElement ? screenElement : undefined, + terminal, + }) + : null; + longPressTimer = setTimeout(() => { + longPressTimer = null; + longPressReady = true; + }, MOBILE_COPY_MODE_LONG_PRESS_MS); + } }; const handleTouchMove = (event: TouchEvent) => { @@ -748,15 +786,28 @@ export function XtermHost({ return; } + if (longPressTouchId === touch.identifier) { + if ( + Math.abs(touch.clientX - longPressStartClientX) > MOBILE_COPY_MODE_MOVE_TOLERANCE_PX || + Math.abs(touch.clientY - longPressStartClientY) > MOBILE_COPY_MODE_MOVE_TOLERANCE_PX + ) { + clearLongPressTimer(); + } else { + return; + } + } + const base = terminal.buffer.active.baseY; if (base <= 0) { state.lastClientY = touch.clientY; state.carryPx = 0; + state.gestureDidScroll = false; state.samples = []; state.velocityPxPerMs = 0; return; } + state.gestureDidScroll = true; const deltaY = state.lastClientY - touch.clientY; state.lastClientY = touch.clientY; recordTouchSample(touch.clientY, performance.now()); @@ -769,18 +820,42 @@ export function XtermHost({ }; const handleTouchEnd = (event: TouchEvent) => { + const longPressTouch = findTouchByIdentifier(event.changedTouches, longPressTouchId); + if (longPressTouch) { + if (longPressReady) { + const lineText = longPressLineText; + clearLongPressTimer(); + state.activeTouchId = null; + state.lastClientY = 0; + state.carryPx = 0; + state.pxPerLine = null; + state.velocityPxPerMs = 0; + state.gestureDidScroll = false; + state.samples = []; + copyMobileLongPressRef.current(lineText); + return; + } + + clearLongPressTimer(); + } + if (findTouchByIdentifier(event.changedTouches, state.activeTouchId)) { + const canStartMomentum = state.gestureDidScroll; const touch = findTouchByIdentifier(event.changedTouches, state.activeTouchId); - if (touch) { + if (touch && canStartMomentum) { recordTouchSample(touch.clientY, performance.now()); updateVelocityFromSamples(); } state.activeTouchId = null; state.lastClientY = 0; + state.gestureDidScroll = false; state.samples = []; - if (Math.abs(state.velocityPxPerMs) >= MOBILE_TOUCH_MOMENTUM_MIN_VELOCITY_PX_PER_MS) { + if ( + canStartMomentum && + Math.abs(state.velocityPxPerMs) >= MOBILE_TOUCH_MOMENTUM_MIN_VELOCITY_PX_PER_MS + ) { state.lastMomentumFrameAt = 0; state.momentumFrameId = requestAnimationFrame(stepMomentumScroll); return; @@ -806,9 +881,10 @@ export function XtermHost({ container.removeEventListener("touchmove", handleTouchMove); container.removeEventListener("touchend", handleTouchEnd); container.removeEventListener("touchcancel", handleTouchCancel); + resetTouchStateRef.current = () => {}; resetTouchState(); }; - }, []); + }, [terminalPreferences.copyOnSelect, viewport]); const scheduleFit = useCallback(() => { if (fitFrameRef.current !== null) { @@ -855,10 +931,6 @@ export function XtermHost({ setShiftArmed(nextShiftArmed); }, []); - const focusTerminal = useCallback(() => { - terminalRef.current?.focus(); - }, []); - const pushCopyOnSelectFailureToast = useCallback(() => { const now = Date.now(); if (now - lastCopyOnSelectFailureAtRef.current < TERMINAL_COPY_ON_SELECT_ERROR_THROTTLE_MS) { @@ -868,10 +940,53 @@ export function XtermHost({ lastCopyOnSelectFailureAtRef.current = now; pushToast({ kind: "error", - title: t("settings.copy_on_select_failed_title"), - body: t("settings.copy_on_select_failed_body"), + title: + viewport === "mobile" + ? t("terminal.mobile_copy_current_line_failed_title") + : t("settings.copy_on_select_failed_title"), + body: + viewport === "mobile" + ? t("terminal.mobile_copy_current_line_failed_body") + : t("settings.copy_on_select_failed_body"), }); - }, [pushToast, t]); + }, [pushToast, t, viewport]); + + const copyMobileLongPress = useCallback( + async (lineText: string | null) => { + if (viewport !== "mobile" || !terminalPreferences.copyOnSelect) { + return; + } + + resetTouchStateRef.current(); + if (lineText === null) { + return; + } + + try { + await copyTextWithFallback(lineText); + pushToast({ + kind: "success", + title: t("terminal.copied_current_line"), + }); + if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") { + navigator.vibrate(10); + } + } catch { + pushCopyOnSelectFailureToast(); + } + }, + [pushCopyOnSelectFailureToast, pushToast, t, terminalPreferences.copyOnSelect, viewport] + ); + + useEffect(() => { + copyMobileLongPressRef.current = (lineText) => { + void copyMobileLongPress(lineText); + }; + }, [copyMobileLongPress]); + + useLayoutEffect(() => { + resetTouchStateRef.current(); + }, [terminalId, workspaceId]); const copySelectionOnSelect = useCallback(async () => { if (viewport === "mobile" || !terminalPreferences.copyOnSelect) { @@ -889,7 +1004,7 @@ export function XtermHost({ } try { - await navigator.clipboard.writeText(selection); + await copyTextWithFallback(selection); } catch { pushCopyOnSelectFailureToast(); } @@ -1096,7 +1211,7 @@ export function XtermHost({ // characters used by TUIs (claude, codex) render as a continuous frame // with no gaps between rows. const terminal = new Terminal({ - theme: getTerminalTheme(initialThemeRef.current), + theme: getThemeById(initialThemeRef.current).terminalTheme, fontFamily: "JetBrains Mono, Fira Code, SF Mono, monospace", fontSize: 11, scrollback: 5000, @@ -1987,10 +2102,15 @@ export function XtermHost({ * Focus terminal when it becomes active */ useEffect(() => { - if (meta?.alive && terminalRef.current) { + if ( + viewport !== "mobile" && + hydrationState.kind === "granted" && + meta?.alive && + terminalRef.current + ) { terminalRef.current.focus(); } - }, [meta?.alive]); + }, [hydrationState.kind, meta?.alive, viewport]); const showMobileInputBar = viewport === "mobile" && isInteractive; const mobileInputDisabled = !isInteractive || uploadBusy || connectionStatus !== "connected"; @@ -2012,30 +2132,26 @@ export function XtermHost({ const handleSoftKeyPress = useCallback( async (key: SoftTerminalKeyId) => { - focusTerminal(); const nextShiftArmed = shiftArmedRef.current; if (nextShiftArmed) { updateShiftArmed(false); } await handleInput(getSoftTerminalInputBytes(key, { shift: nextShiftArmed })); }, - [focusTerminal, handleInput, updateShiftArmed] + [handleInput, updateShiftArmed] ); const handleCtrlTap = useCallback(() => { - focusTerminal(); updateCtrlMode(toggleCtrlMode(ctrlModeRef.current)); - }, [focusTerminal, updateCtrlMode]); + }, [updateCtrlMode]); const handleCtrlLongPress = useCallback(() => { - focusTerminal(); updateCtrlMode(lockCtrlMode()); - }, [focusTerminal, updateCtrlMode]); + }, [updateCtrlMode]); const handleShiftTap = useCallback(() => { - focusTerminal(); updateShiftArmed(!shiftArmedRef.current); - }, [focusTerminal, updateShiftArmed]); + }, [updateShiftArmed]); const showReplayOverlay = replayUiState.kind !== "ready" && (viewport === "mobile" || hydrationState.kind === "granted"); diff --git a/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts b/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts index a8da86cbd..a5fa0a357 100644 --- a/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts +++ b/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts @@ -149,6 +149,16 @@ export function useWorkspaceScreenModel() { .filter((session): session is NonNullable => Boolean(session)); }, [paneLayout, sessions]); + const mobileAgentSessions = useMemo(() => { + const orderedSessionIds = new Set(orderedSessions.map((session) => session.id)); + return [ + ...orderedSessions, + ...sessions.filter( + (session) => session.state !== "draft" && !orderedSessionIds.has(session.id) + ), + ]; + }, [orderedSessions, sessions]); + const preferredSessionId = workspace?.uiState?.activeSessionId ?? null; useEffect(() => { @@ -190,9 +200,20 @@ export function useWorkspaceScreenModel() { const activeSession = orderedSessions.find((session) => session.id === mobileActiveSessionId) ?? null; - const selectMobileSession = useCallback((sessionId: string | null) => { - setMobileActiveSessionId(sessionId); - }, []); + const selectMobileSession = useCallback( + (sessionId: string | null) => { + if ( + sessionId && + !orderedSessions.some((session) => session.id === sessionId) && + sessions.some((session) => session.id === sessionId && session.state !== "draft") + ) { + paneActions.appendSession(sessionId, mobileActiveSessionId, "vertical"); + } + + setMobileActiveSessionId(sessionId); + }, + [mobileActiveSessionId, orderedSessions, paneActions, sessions] + ); const handleMobileSessionCreated = useCallback( (sessionId: string) => { @@ -254,6 +275,7 @@ export function useWorkspaceScreenModel() { handleMobileSessionCreated, mainAreaMode, mobileActiveSessionId, + mobileAgentSessions, mobileFilesRoute, mobileSheet, openMobileSheet, diff --git a/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx b/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx index bdcf27865..7e60de806 100644 --- a/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx +++ b/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx @@ -40,6 +40,7 @@ export function WorkspaceMobileView() { handleOpenBranchSwitcher, gitState, mobileActiveSessionId, + mobileAgentSessions, mobileFilesRoute, mobileSheet, openMobileSheet, @@ -291,8 +292,8 @@ export function WorkspaceMobileView() { setAgentSheetOpen(false)} onCloseSession={closeMobileSession} onSelectSession={selectMobileSession} diff --git a/packages/web/src/features/workspace/views/shared/workspace-route-gate.test.tsx b/packages/web/src/features/workspace/views/shared/workspace-route-gate.test.tsx index cb41a0c11..15ed5a60f 100644 --- a/packages/web/src/features/workspace/views/shared/workspace-route-gate.test.tsx +++ b/packages/web/src/features/workspace/views/shared/workspace-route-gate.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react"; import { createStore, Provider } from "jotai"; +import { MemoryRouter } from "react-router-dom"; import { describe, expect, it } from "vitest"; import { localeAtom } from "../../../../atoms/app-ui"; import { @@ -11,19 +12,25 @@ import { } from "../../../../atoms/workspaces"; import { WorkspaceRouteGate } from "./workspace-route-gate"; +function renderGate(store: ReturnType, initialEntry = "/") { + return render( + + + +
ready
+
+
+
+ ); +} + describe("WorkspaceRouteGate", () => { it("shows a loading shell while workspaces are unresolved", () => { const store = createStore(); store.set(localeAtom, "en"); store.set(workspacesLoadStateAtom, "loading"); - render( - - -
ready
-
-
- ); + renderGate(store); expect(screen.getByTestId("workspace-resolving-shell")).toBeInTheDocument(); expect(screen.getByText("Workspace")).toBeInTheDocument(); @@ -37,13 +44,7 @@ describe("WorkspaceRouteGate", () => { store.set(workspacesLoadStateAtom, "error"); store.set(workspacesLoadErrorAtom, "Failed to fetch workspace list"); - render( - - -
ready
-
-
- ); + renderGate(store); expect(document.querySelector(".workspace-resolving-shell")).not.toBeNull(); expect(screen.getByText("Workspace")).toBeInTheDocument(); @@ -67,13 +68,7 @@ describe("WorkspaceRouteGate", () => { store.set(activeWorkspaceIdAtom, "ws-1"); store.set(workspacesLoadStateAtom, "ready"); - render( - - -
ready
-
-
- ); + renderGate(store); expect(screen.getByText("ready")).toBeInTheDocument(); }); @@ -84,15 +79,22 @@ describe("WorkspaceRouteGate", () => { store.set(workspaceOrderAtom, []); store.set(workspacesLoadStateAtom, "ready"); - render( - - -
ready
-
-
- ); + renderGate(store); expect(screen.getByText("ready")).toBeInTheDocument(); expect(screen.queryByText("Loading workspaces")).not.toBeInTheDocument(); }); + + it("holds children when MemoryRouter requests /workspace and the list is ready but empty", () => { + const store = createStore(); + store.set(localeAtom, "en"); + store.set(workspacesAtom, {}); + store.set(workspaceOrderAtom, []); + store.set(workspacesLoadStateAtom, "ready"); + + renderGate(store, "/workspace"); + + expect(screen.getByTestId("workspace-resolving-shell")).toBeInTheDocument(); + expect(screen.queryByText("ready")).not.toBeInTheDocument(); + }); }); diff --git a/packages/web/src/features/workspace/views/shared/workspace-route-gate.tsx b/packages/web/src/features/workspace/views/shared/workspace-route-gate.tsx index df00a9848..ce4cb520e 100644 --- a/packages/web/src/features/workspace/views/shared/workspace-route-gate.tsx +++ b/packages/web/src/features/workspace/views/shared/workspace-route-gate.tsx @@ -1,5 +1,6 @@ import { useAtomValue } from "jotai"; import type { ReactNode } from "react"; +import { useLocation } from "react-router-dom"; import { activeWorkspaceAtom, workspacesLoadErrorAtom, @@ -12,7 +13,13 @@ export function WorkspaceRouteGate({ children }: { children: ReactNode }) { const workspace = useAtomValue(activeWorkspaceAtom); const loadState = useAtomValue(workspacesLoadStateAtom); const loadError = useAtomValue(workspacesLoadErrorAtom); - const shouldHoldForResolution = !workspace && (loadState === "idle" || loadState === "loading"); + const location = useLocation(); + const isWorkspaceRoute = location.pathname === "/workspace"; + const shouldHoldForResolution = + !workspace && + (loadState === "idle" || + loadState === "loading" || + (isWorkspaceRoute && loadState === "ready")); if (!workspace && loadState === "error") { return ; diff --git a/packages/web/src/hooks/use-activation.ts b/packages/web/src/hooks/use-activation.ts new file mode 100644 index 000000000..b34a9fca5 --- /dev/null +++ b/packages/web/src/hooks/use-activation.ts @@ -0,0 +1,90 @@ +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect, useRef } from "react"; +import { + activationGenerationAtom, + activationReasonAtom, + activationStatusAtom, + clientInstanceIdAtom, +} from "../atoms/activation"; +import { connectionStatusAtom, wsClientAtom } from "../atoms/connection"; + +interface ActivationClaimPayload { + active: true; + generation: number; + recoveryMode: "fresh" | "grace_recover" | "takeover"; +} + +export function useActivation() { + const wsClient = useAtomValue(wsClientAtom); + const connectionStatus = useAtomValue(connectionStatusAtom); + const clientInstanceId = useAtomValue(clientInstanceIdAtom); + const [status, setStatus] = useAtom(activationStatusAtom); + const [generation, setGeneration] = useAtom(activationGenerationAtom); + const setReason = useSetAtom(activationReasonAtom); + const claimInFlightRef = useRef | null>(null); + + const claim = useCallback(async (): Promise => { + if (!wsClient) { + return false; + } + + if (connectionStatus !== "connected") { + try { + await wsClient.connect(); + } catch { + setStatus("gated"); + setReason("reconnect_failed"); + return false; + } + } + + if (claimInFlightRef.current) { + return claimInFlightRef.current; + } + + setStatus("claiming"); + + const pending = wsClient + .sendCommand("activation.claim", { + clientInstanceId, + }) + .then((result) => { + setGeneration(result.generation); + setReason(null); + setStatus("active"); + return true; + }) + .catch((error) => { + setStatus("gated"); + setReason(error instanceof Error ? error.message : "claim_failed"); + return false; + }) + .finally(() => { + claimInFlightRef.current = null; + }); + + claimInFlightRef.current = pending; + return pending; + }, [clientInstanceId, connectionStatus, setGeneration, setReason, setStatus, wsClient]); + + useEffect(() => { + return () => { + if (!wsClient || generation === null) { + return; + } + + void wsClient + .sendCommand("activation.release", { + clientInstanceId, + generation, + }) + .catch(() => {}); + }; + }, [clientInstanceId, generation, wsClient]); + + return { + status, + generation, + claim, + }; +} diff --git a/packages/web/src/hooks/use-bootstrap.ts b/packages/web/src/hooks/use-bootstrap.ts index 8f4b7d548..74b80cf1a 100644 --- a/packages/web/src/hooks/use-bootstrap.ts +++ b/packages/web/src/hooks/use-bootstrap.ts @@ -3,6 +3,7 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useEffect, useRef } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { authEnabledAtom, connectionStatusAtom, dispatchCommandAtom } from "../atoms"; +import { activationStatusAtom } from "../atoms/activation"; import { authenticatedAtom } from "../atoms/app-ui"; import { orderedWorkspacesAtom, @@ -16,6 +17,7 @@ export function useBootstrap() { const bootstrapRequestIdRef = useRef(0); const navigate = useNavigate(); const location = useLocation(); + const activationStatus = useAtomValue(activationStatusAtom); const connectionStatus = useAtomValue(connectionStatusAtom); const dispatch = useAtomValue(dispatchCommandAtom); const workspaces = useAtomValue(orderedWorkspacesAtom); @@ -49,11 +51,24 @@ export function useBootstrap() { return; } + if (location.pathname === "/session-gate") { + return; + } + + if (activationStatus === "gated") { + navigate("/session-gate", { replace: true }); + return; + } + // Only bootstrap workspaces on "/" and "/workspace" paths if (location.pathname !== "/" && location.pathname !== "/workspace") { return; } + if (activationStatus !== "active") { + return; + } + // Workspace bootstrap logic if (workspacesLoadState === "idle") { if (connectionStatus !== "connected") { @@ -115,6 +130,7 @@ export function useBootstrap() { navigate("/", { replace: true }); } }, [ + activationStatus, authEnabled, authenticated, connectionStatus, diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 649eab8d4..9842296a3 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -1,4 +1,36 @@ { + "datetime": { + "today": "Today", + "tomorrow": "Tomorrow", + "next_week": "Next Week", + "prev_month": "Previous Month", + "next_month": "Next Month", + "clear": "Clear", + "confirm": "Confirm", + "select_date": "Select Date", + "select_time": "Select Time", + "hour": "Hour", + "minute": "Minute", + "january": "January", + "february": "February", + "march": "March", + "april": "April", + "may": "May", + "june": "June", + "july": "July", + "august": "August", + "september": "September", + "october": "October", + "november": "November", + "december": "December", + "sun": "Sun", + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat" + }, "app": { "name": "Coder Studio", "description": "Agent-First Development Environment" @@ -227,6 +259,9 @@ "create_failed_body": "The shell process could not be started. Check the server runtime and shell configuration.", "create_unavailable_title": "No workspace selected", "create_unavailable_body": "Open or switch to a workspace before creating a terminal.", + "copied_current_line": "Copied current line", + "mobile_copy_current_line_failed_title": "Current line copy failed", + "mobile_copy_current_line_failed_body": "Try long-pressing the current line again", "hide": "Hide Terminal", "show": "Show Terminal", "selector": { @@ -470,14 +505,24 @@ "permission_limited_hint": "Even with notification permission allowed, this mobile browser may still fail to show system notifications reliably.", "supervisor": { "title": "Supervisor", - "hint": "Maximum time the supervisor evaluator can run before the server aborts it.", + "hint": "Configure supervisor evaluation timeout and global retry behavior. Retry rules apply to all supervisors.", "evaluation_timeout": "Supervisor Evaluation Timeout (seconds)", - "validation_error": "Enter a whole number between 1 and {max} seconds." + "validation_error": "Enter a whole number between 1 and {max} seconds.", + "retry_enabled": "Enable Supervisor Retries", + "retry_enabled_hint": "Automatically retry when evaluation times out or the evaluator fails.", + "retry_max_count": "Max Retry Count", + "retry_max_count_validation_error": "Enter a whole number between 0 and {max}.", + "retry_delay_sec": "Retry Delay (seconds)", + "retry_delay_validation_error": "Enter a whole number between 1 and {max} seconds.", + "retry_on_timeout": "Retry On Timeout", + "retry_on_timeout_hint": "Retry automatically after evaluation timeout.", + "retry_on_evaluator_error": "Retry On Evaluator Error", + "retry_on_evaluator_error_hint": "Retry automatically when the evaluator process or runtime fails." }, "terminal_renderer": "Terminal Renderer", "terminal_renderer_hint": "Choose terminal rendering mode", "copy_on_select": "Copy on select", - "copy_on_select_hint": "Automatically copy selected text to the system clipboard in desktop terminals", + "copy_on_select_hint": "Automatically copy selected text to the system clipboard", "copy_on_select_failed_title": "Copy on select failed", "copy_on_select_failed_body": "Use Ctrl/Cmd+C to copy manually", "terminal_standard": "Standard", @@ -513,7 +558,23 @@ "dark": "Dark", "light": "Light", "system": "System", - "hint": "Choose app theme" + "hint": "Choose app theme", + "family": "Theme Family", + "variant": "Variant", + "family_mint": "Mint", + "family_graphite": "Graphite", + "family_nord": "Nord", + "family_hc": "High Contrast", + "mint_dark": "Mint Dark", + "mint_light": "Mint Light", + "graphite_dark": "Graphite Dark", + "graphite_light": "Graphite Light", + "nord_dark": "Nord Dark", + "nord_light": "Nord Light", + "hc_dark": "High Contrast Dark", + "hc_light": "High Contrast Light", + "variant_dark": "Dark", + "variant_light": "Light" }, "language": { "title": "Language", @@ -564,7 +625,12 @@ "status_title": "Access verification", "status_loading": "Checking the access state for this deployment.", "status_unavailable": "The authentication service is temporarily unavailable. Please try again shortly.", - "status_not_configured": "Authentication is not configured on this deployment." + "status_not_configured": "Authentication is not configured on this deployment.", + "session_gate_title": "SESSION GATE", + "session_gate_description": "This tab was displaced by another tab and must re-enter before continuing.", + "session_gate_detail": "The websocket connection has been closed. Recovery will reload fresh server state.", + "session_gate_reenter": "Re-enter", + "session_gate_reentering": "Re-entering" }, "connection": { "title": "Connection", @@ -586,16 +652,27 @@ "evaluating": "Evaluating", "injecting": "Injecting", "paused": "Paused", - "error": "Error" + "error": "Error", + "stopped": "Stopped" }, "trigger": { "manual": "MANUAL", - "auto": "AUTO" + "auto": "AUTO", + "scheduled": "SCHEDULED" }, "cycle": { "no_guidance": "No guidance injected this cycle", "evaluating": "Evaluating...", - "waiting": "Waiting for evaluation result" + "waiting": "Waiting for evaluation result", + "cancelled": "Cancelled" + }, + "stop_reason": { + "objective_complete": "Objective complete. Supervisor stopped automatically.", + "max_supervision_count_reached": "Reached the configured max supervision count." + }, + "meta": { + "title": "Execution policy", + "no_cap": "No cap" }, "field": { "objective": "Objective", @@ -603,13 +680,21 @@ "objective_placeholder": "Describe what Supervisor should watch, for example:\n- Finish implementing user authentication\n- Fix all failing unit tests\n- Reduce P95 latency below 100ms", "objective_helper": "The more specific and measurable the target, the better the evaluation. Include completion criteria when possible.", "evaluator": "Evaluator", - "evaluator_helper": "The provider that evaluates progress and suggests the next step. It can differ from the execution provider." + "evaluator_helper": "The provider that evaluates progress and suggests the next step. It can differ from the execution provider.", + "evaluator_model": "Evaluator Model", + "evaluator_model_placeholder": "Optional model override, for example o3 or gpt-5", + "evaluator_model_helper": "Leave blank to use the provider's default supervisor evaluation model behavior.", + "max_supervision_count": "Max Supervision Count", + "max_supervision_count_helper": "Set to 0 for no explicit cap. Evaluation can still stop earlier if the objective is complete.", + "scheduled_at_placeholder": "Choose when to run", + "scheduled_at": "Scheduled Run Time", + "scheduled_at_helper": "Specify when to run the first evaluation. After the scheduled time, evaluations will trigger automatically at the end of each conversation turn. Leave blank to trigger automatically at the end of each turn." }, "action": { "enable": "Enable Supervisor", "enable_objective": "Enable Objective", - "edit_objective": "Edit Objective", - "disable": "Disable Supervisor", + "edit_objective": "Edit Supervisor", + "disable": "Disable", "pause": "Pause", "resume": "Resume", "trigger": "Trigger Evaluation", diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json index 54b996c51..ea5f2f2b6 100644 --- a/packages/web/src/locales/zh.json +++ b/packages/web/src/locales/zh.json @@ -1,4 +1,36 @@ { + "datetime": { + "today": "今天", + "tomorrow": "明天", + "next_week": "下周", + "prev_month": "上一月", + "next_month": "下一月", + "clear": "清空", + "confirm": "确认", + "select_date": "选择日期", + "select_time": "选择时间", + "hour": "小时", + "minute": "分钟", + "january": "一月", + "february": "二月", + "march": "三月", + "april": "四月", + "may": "五月", + "june": "六月", + "july": "七月", + "august": "八月", + "september": "九月", + "october": "十月", + "november": "十一月", + "december": "十二月", + "sun": "日", + "mon": "一", + "tue": "二", + "wed": "三", + "thu": "四", + "fri": "五", + "sat": "六" + }, "app": { "name": "Coder Studio", "description": "Agent-First Development Environment" @@ -227,6 +259,9 @@ "create_failed_body": "Shell 进程启动失败,请检查服务端运行环境和 Shell 配置。", "create_unavailable_title": "当前没有已选中的工作区", "create_unavailable_body": "请先打开或切换到一个工作区,再新建终端。", + "copied_current_line": "已复制当前行", + "mobile_copy_current_line_failed_title": "当前行复制失败", + "mobile_copy_current_line_failed_body": "请重试长按当前行", "hide": "隐藏终端", "show": "显示终端", "selector": { @@ -470,14 +505,24 @@ "permission_limited_hint": "当前移动端浏览器内即使允许通知权限,也可能无法稳定展示系统通知。", "supervisor": { "title": "Supervisor", - "hint": "限制 Supervisor 评估器的最长运行时间,超过后服务端会主动中止本轮评估。", + "hint": "配置 Supervisor 评估超时和全局重试策略。重试规则对所有 Supervisor 生效。", "evaluation_timeout": "Supervisor 超时(秒)", - "validation_error": "请输入 1 到 {max} 秒之间的整数。" + "validation_error": "请输入 1 到 {max} 秒之间的整数。", + "retry_enabled": "启用 Supervisor 重试", + "retry_enabled_hint": "当评估超时或评估器异常时,按全局规则自动重试。", + "retry_max_count": "最大重试次数", + "retry_max_count_validation_error": "请输入 0 到 {max} 之间的整数。", + "retry_delay_sec": "重试间隔(秒)", + "retry_delay_validation_error": "请输入 1 到 {max} 秒之间的整数。", + "retry_on_timeout": "超时后重试", + "retry_on_timeout_hint": "评估超时后按重试次数和间隔自动继续。", + "retry_on_evaluator_error": "评估器异常后重试", + "retry_on_evaluator_error_hint": "评估器进程或运行时报错时按规则自动重试。" }, "terminal_renderer": "终端渲染器", "terminal_renderer_hint": "选择终端渲染模式", "copy_on_select": "选中自动复制", - "copy_on_select_hint": "在桌面端终端中,选中文本后自动复制到系统剪贴板", + "copy_on_select_hint": "选中文本后自动复制到系统剪贴板", "copy_on_select_failed_title": "自动复制失败", "copy_on_select_failed_body": "请使用 Ctrl/Cmd+C 手动复制", "terminal_standard": "标准", @@ -513,7 +558,23 @@ "dark": "深色", "light": "浅色", "system": "跟随系统", - "hint": "选择应用主题" + "hint": "选择应用主题", + "family": "主题系列", + "variant": "明暗模式", + "family_mint": "Mint", + "family_graphite": "Graphite", + "family_nord": "Nord", + "family_hc": "高对比", + "mint_dark": "Mint 深色", + "mint_light": "Mint 浅色", + "graphite_dark": "Graphite 深色", + "graphite_light": "Graphite 浅色", + "nord_dark": "Nord 深色", + "nord_light": "Nord 浅色", + "hc_dark": "高对比深色", + "hc_light": "高对比浅色", + "variant_dark": "深色", + "variant_light": "浅色" }, "language": { "title": "语言", @@ -564,7 +625,12 @@ "status_title": "访问验证", "status_loading": "正在检查当前部署的访问状态。", "status_unavailable": "暂时无法连接鉴权服务,请稍后重试。", - "status_not_configured": "当前部署未配置鉴权。" + "status_not_configured": "当前部署未配置鉴权。", + "session_gate_title": "SESSION GATE", + "session_gate_description": "当前标签页已被其他标签页接管,需要重新进入才能继续操作。", + "session_gate_detail": "已断开当前标签页连接,恢复后会重新同步最新数据。", + "session_gate_reenter": "重新进入", + "session_gate_reentering": "进入中" }, "connection": { "title": "连接", @@ -586,16 +652,27 @@ "evaluating": "评估中", "injecting": "注入中", "paused": "已暂停", - "error": "错误" + "error": "错误", + "stopped": "已停止" }, "trigger": { "manual": "手动", - "auto": "自动" + "auto": "自动", + "scheduled": "定时" }, "cycle": { "no_guidance": "本轮无需注入 guidance", "evaluating": "评估中…", - "waiting": "等待评估结果" + "waiting": "等待评估结果", + "cancelled": "已取消" + }, + "stop_reason": { + "objective_complete": "目标已达成,Supervisor 已自动停止。", + "max_supervision_count_reached": "已达到配置的最大监督次数。" + }, + "meta": { + "title": "执行策略", + "no_cap": "不设上限" }, "field": { "objective": "目标描述", @@ -603,13 +680,21 @@ "objective_placeholder": "描述希望 Supervisor 盯住的目标,例如:\n- 完成用户认证功能的实现\n- 修复所有失败的单元测试\n- 把 P95 响应时间压到 100ms 以内", "objective_helper": "越具体、越可衡量,评估效果越好。建议包含完成条件。", "evaluator": "评估方", - "evaluator_helper": "用于评估进度并生成下一步指引的 provider,与执行方可不相同。" + "evaluator_helper": "用于评估进度并生成下一步指引的 provider,与执行方可不相同。", + "evaluator_model": "评估模型", + "evaluator_model_placeholder": "可选模型覆盖,例如 o3 或 gpt-5", + "evaluator_model_helper": "留空则沿用该 provider 当前默认的 supervisor 评估模型逻辑。", + "max_supervision_count": "最大监督次数", + "max_supervision_count_helper": "填 0 表示不设上限。若目标已达成,评估仍可提前停止。", + "scheduled_at_placeholder": "选择执行时间", + "scheduled_at": "定时执行时间", + "scheduled_at_helper": "指定首次执行时间。到达后开始执行评估,之后每轮对话结束自动触发。留空则每轮对话结束自动触发评估。" }, "action": { "enable": "启用 Supervisor", "enable_objective": "启用目标", - "edit_objective": "编辑目标", - "disable": "禁用 Supervisor", + "edit_objective": "编辑 Supervisor", + "disable": "禁用", "pause": "暂停", "resume": "恢复", "trigger": "触发评估", diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 58b2534f2..921bf35bd 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -2,6 +2,7 @@ * Application Entry Point */ +import "@vitejs/plugin-react/preamble"; import { Provider } from "jotai"; import React from "react"; import ReactDOM from "react-dom/client"; diff --git a/packages/web/src/shells/desktop-shell.test.tsx b/packages/web/src/shells/desktop-shell.test.tsx index f78a9f5a5..828b54e88 100644 --- a/packages/web/src/shells/desktop-shell.test.tsx +++ b/packages/web/src/shells/desktop-shell.test.tsx @@ -2,6 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { BrowserRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { activationStatusAtom } from "../atoms/activation"; import { authenticatedAtom, localeAtom } from "../atoms/app-ui"; import { authEnabledAtom, connectionStatusAtom, wsClientAtom } from "../atoms/connection"; import { @@ -36,6 +37,10 @@ vi.mock("../features/auth", () => ({ LoginPage: () =>
LoginPage
, })); +vi.mock("../features/auth/session-gate", () => ({ + SessionGatePage: () =>
SessionGatePage
, +})); + vi.mock("../features/not-found", () => ({ NotFoundPage: () =>
Page not found
, })); @@ -48,6 +53,10 @@ vi.mock("../features/notifications", () => ({ const originalFetch = globalThis.fetch; function renderShell(store: ReturnType) { + if (store.get(activationStatusAtom) === "idle") { + store.set(activationStatusAtom, "active"); + } + return render( @@ -307,6 +316,34 @@ describe("DesktopShell auth gating", () => { expect(screen.getByText("正在重新连接...")).toBeInTheDocument(); }); + it("renders SessionGatePage on /session-gate", () => { + window.history.replaceState({}, "", "/session-gate"); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(authEnabledAtom, false); + store.set(authenticatedAtom, false); + + renderShell(store); + + expect(screen.getByText("SessionGatePage")).toBeInTheDocument(); + }); + + it("redirects to /session-gate when activation is gated", async () => { + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(authEnabledAtom, false); + store.set(authenticatedAtom, true); + store.set(activationStatusAtom, "gated"); + + renderShell(store); + + await waitFor(() => { + expect(window.location.pathname).toBe("/session-gate"); + expect(screen.getByText("SessionGatePage")).toBeInTheDocument(); + }); + }); + it("redirects / to /workspace after auth resolves and workspace.list is non-empty", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string) => { if (op === "workspace.list") { @@ -382,6 +419,25 @@ describe("DesktopShell auth gating", () => { }); }); + it("does not bootstrap workspaces until activation is active", async () => { + const sendCommand = vi.fn(); + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(authEnabledAtom, false); + store.set(authenticatedAtom, true); + store.set(activationStatusAtom, "gated"); + store.set(workspacesAtom, {}); + store.set(workspaceOrderAtom, []); + store.set(workspacesLoadStateAtom, "idle"); + store.set(wsClientAtom, { sendCommand } as never); + + renderShell(store); + + await waitFor(() => { + expect(sendCommand).not.toHaveBeenCalledWith("workspace.list", {}, undefined); + }); + }); + it("redirects /workspace back to / when auth resolves and workspace.list is empty", async () => { window.history.replaceState({}, "", "/workspace"); const sendCommand = vi.fn().mockResolvedValue([]); diff --git a/packages/web/src/shells/desktop-shell.tsx b/packages/web/src/shells/desktop-shell.tsx index 7d82bcdd0..bce873ada 100644 --- a/packages/web/src/shells/desktop-shell.tsx +++ b/packages/web/src/shells/desktop-shell.tsx @@ -10,6 +10,7 @@ import { Route, Routes, useLocation } from "react-router-dom"; import { authEnabledAtom } from "../atoms"; import { EmptyState } from "../components/ui"; import { LoginPage } from "../features/auth"; +import { SessionGatePage } from "../features/auth/session-gate"; import { CommandPalette } from "../features/command-palette"; import { NotFoundPage } from "../features/not-found"; import { ToastContainer } from "../features/notifications"; @@ -34,7 +35,8 @@ export function DesktopShell() { const authEnabled = useAtomValue(authEnabledAtom); const location = useLocation(); const authUnknown = authEnabled === null; - const shouldBypassAuthLoading = location.pathname.startsWith("/settings"); + const shouldBypassAuthLoading = + location.pathname.startsWith("/settings") || location.pathname === "/session-gate"; return (
@@ -64,6 +66,7 @@ export function DesktopShell() { } /> } /> + } /> { it("shows a loading workspace gate instead of the mobile scaffold while workspaces are still loading", () => { const store = createStore(); + store.set(activationStatusAtom, "active"); store.set(connectionStatusAtom, "connected"); store.set(authEnabledAtom, false); store.set(authenticatedAtom, true); @@ -1067,6 +1090,7 @@ describe("MobileShell Phase 2 workspace", () => { it("shows a workspace load error gate instead of the mobile scaffold when bootstrap fails", () => { const store = createStore(); + store.set(activationStatusAtom, "active"); store.set(connectionStatusAtom, "connected"); store.set(authEnabledAtom, false); store.set(authenticatedAtom, true); @@ -1088,6 +1112,7 @@ describe("MobileShell Phase 2 workspace", () => { it("does not render the mobile scaffold before redirecting home when /workspace resolves to an empty workspace list", async () => { const store = createStore(); + store.set(activationStatusAtom, "active"); store.set(connectionStatusAtom, "connected"); store.set(authEnabledAtom, false); store.set(authenticatedAtom, true); @@ -1111,6 +1136,7 @@ describe("MobileShell Phase 2 workspace", () => { it("redirects /workspace home on mobile when the workspace list is ready but empty while reconnecting", async () => { const store = createStore(); + store.set(activationStatusAtom, "active"); store.set(connectionStatusAtom, "reconnecting"); store.set(authEnabledAtom, false); store.set(authenticatedAtom, true); @@ -1552,6 +1578,76 @@ describe("MobileShell Phase 2 workspace", () => { expect(screen.getByTestId("mobile-session-card")).toHaveTextContent("sess_2"); }); + it("lists and can close a workspace session even when it is missing from the current pane layout", async () => { + const user = userEvent.setup(); + const existingSession = createSession({ + id: "sess_1", + terminalId: "term-1", + providerId: "claude", + state: "idle", + lastActiveAt: Date.now() - 5_000, + title: "Claude", + }); + const hiddenSession = createSession({ + id: "sess_hidden", + terminalId: "term-hidden", + providerId: "codex", + state: "running", + lastActiveAt: Date.now() - 500, + title: "Remote Codex", + }); + const sendCommand = vi.fn(async (op: string) => { + if (op === "session.list") { + return [existingSession]; + } + + return undefined; + }); + + const { store } = renderMobileShell({ + sendCommand, + sessions: [existingSession], + paneLayout: { + id: "root", + type: "leaf", + sessionId: "sess_1", + }, + }); + + await waitFor(() => { + expect(screen.getByTestId("mobile-session-card")).toHaveTextContent("sess_1"); + }); + + await act(async () => { + store.set(sessionsAtom, { + sess_1: existingSession, + sess_hidden: hiddenSession, + }); + }); + + await user.click(await screen.findByRole("button", { name: "Open Agent sheet" })); + + const hiddenRow = screen + .getByRole("button", { + name: "Remote Codex", + description: "Switch to agent Remote Codex CODEX", + }) + .closest(".mobile-select-sheet__item-row"); + expect(hiddenRow).not.toBeNull(); + + await user.click( + within(hiddenRow as HTMLElement).getByRole("button", { name: "Close Current Session" }) + ); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "session.stop", + { sessionId: "sess_hidden" }, + undefined + ); + }); + }); + it("switches to the target session when a pending focus marker points at a non-active mobile session", async () => { const { store } = renderMobileShell({ initialEntry: "/workspace" }); @@ -1629,6 +1725,86 @@ describe("MobileShell Phase 2 workspace", () => { }); }); + it("restores a newly created mobile session after reload even when workspace uiState paneLayout is stale", async () => { + const sessions = [ + createSession({ + id: "sess_existing", + terminalId: "term-existing", + providerId: "claude", + state: "idle", + lastActiveAt: Date.now() - 5_000, + title: "Existing Claude", + }), + createSession({ + id: "sess_new_mobile", + terminalId: "term-new-mobile", + providerId: "codex", + state: "idle", + lastActiveAt: Date.now() - 500, + title: "New Mobile Codex", + }), + ]; + const sendCommand = vi.fn(async (op: string, payload?: Record) => { + if (op === "session.list") { + return sessions; + } + + if (op === "workspace.uiState.set") { + return { + id: "ws-1", + name: "Alpha", + path: "/tmp/alpha", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: payload?.uiState, + }; + } + + return undefined; + }); + + renderMobileShell({ + initialEntry: "/workspace", + sessions: [], + paneLayout: { + id: "root", + type: "leaf", + sessionId: "sess_existing", + }, + sendCommand, + }); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith("session.list", { workspaceId: "ws-1" }, undefined); + }); + + await waitFor(() => { + expect(screen.getByTestId("mobile-session-card")).toHaveTextContent("sess_new_mobile"); + }); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "workspace.uiState.set", + expect.objectContaining({ + workspaceId: "ws-1", + uiState: expect.objectContaining({ + activeSessionId: "sess_new_mobile", + paneLayout: expect.objectContaining({ + type: "split", + direction: "horizontal", + children: [ + expect.objectContaining({ sessionId: "sess_existing" }), + expect.objectContaining({ sessionId: "sess_new_mobile" }), + ], + }), + }), + }), + undefined + ); + }); + }); + it("does not clear a server-backed active mobile session before session hydration completes", async () => { let resolveSessions: ((value: Session[]) => void) | null = null; const sessionListPromise = new Promise((resolve) => { @@ -1995,9 +2171,11 @@ describe("MobileShell Phase 2 workspace", () => { await user.click(badge); - expect(screen.getByRole("region", { name: "Supervisor sheet" })).toBeInTheDocument(); - expect(screen.getByText("Supervisor is not enabled")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Enable Objective" })).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Enable Supervisor", level: 2 }) + ).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Objective" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Enable" })).toBeInTheDocument(); }); it("renders a reconnecting banner inside the mobile workspace scaffold", async () => { diff --git a/packages/web/src/shells/mobile-shell/index.tsx b/packages/web/src/shells/mobile-shell/index.tsx index b4e98701f..898b3eb53 100644 --- a/packages/web/src/shells/mobile-shell/index.tsx +++ b/packages/web/src/shells/mobile-shell/index.tsx @@ -3,6 +3,7 @@ import { Route, Routes, useLocation } from "react-router-dom"; import { authEnabledAtom } from "../../atoms/connection"; import { EmptyState } from "../../components/ui"; import { LoginPage } from "../../features/auth"; +import { SessionGatePage } from "../../features/auth/session-gate"; import { CommandPalette } from "../../features/command-palette"; import { NotFoundPage } from "../../features/not-found"; import { ToastContainer } from "../../features/notifications"; @@ -28,7 +29,8 @@ export function MobileShell() { const authEnabled = useAtomValue(authEnabledAtom); const location = useLocation(); const authUnknown = authEnabled === null; - const shouldBypassAuthLoading = location.pathname.startsWith("/settings"); + const shouldBypassAuthLoading = + location.pathname.startsWith("/settings") || location.pathname === "/session-gate"; return (
@@ -58,6 +60,7 @@ export function MobileShell() { } /> } /> + } /> entry.replace(/\s+/g, " ").trim()); + + if (selectors.includes(normalizedSelector)) { + return match[2]; + } + } + + throw new Error(`expected CSS rule for ${selector}`); +} + +describe("base.css theme-sensitive shells", () => { + it("keeps the app loading shell on theme tokens instead of dark-only gradients", () => { + const shell = getRuleBlock(".app-loading-shell"); + const card = getRuleBlock(".app-loading-card"); + + expect(shell).toContain("var(--bg-page)"); + expect(shell).not.toContain("rgba(17, 24, 31, 0.92)"); + expect(card).toContain("var(--bg-surface)"); + expect(card).not.toContain("rgba(17, 24, 31, 0.96)"); + }); + + it("defines shared icon tone and surface utilities", () => { + const tone = getRuleBlock(".icon-tone-secondary"); + const surface = getRuleBlock(".icon-surface-warning"); + const chip = getRuleBlock(".icon-chip"); + + expect(tone).toContain("color: var(--icon-secondary)"); + expect(surface).toContain("background: var(--icon-surface-warning)"); + expect(chip).toContain("display: inline-flex"); + expect(chip).toContain("align-items: center"); + expect(chip).toContain("justify-content: center"); + expect(chip).toContain("border-radius: var(--radius-md)"); + }); +}); diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index 0ae9651f7..14d3f0d96 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -269,6 +269,10 @@ background: color-mix(in srgb, var(--accent-blue) 10%, var(--bg-surface)); } +.mobile-select-sheet__list > [data-selected="true"] > .mobile-select-sheet__item { + background: color-mix(in srgb, var(--accent-blue) 10%, var(--bg-surface)); +} + .mobile-select-sheet__item-row:last-child { border-bottom: none; } @@ -728,6 +732,7 @@ .settings-nav-icon { flex-shrink: 0; + color: var(--icon-secondary); } .settings-nav-label { @@ -1584,6 +1589,7 @@ } .terminal-panel-empty-icon { + color: var(--icon-muted); opacity: 0.5; } @@ -2226,8 +2232,8 @@ body.is-resizing-panels * { width: 32px; height: 32px; border-radius: var(--radius-md); - background: var(--bg-hover); - color: var(--accent-green); + background: var(--icon-surface-accent); + color: var(--icon-accent); } .welcome-feature-text { @@ -2251,9 +2257,21 @@ body.is-resizing-panels * { .auth-screen { padding: var(--sp-8); background: - radial-gradient(circle at top, rgba(120, 215, 178, 0.08), transparent 34%), - radial-gradient(circle at bottom right, rgba(108, 182, 255, 0.1), transparent 30%), - linear-gradient(180deg, rgba(17, 24, 31, 0.96), rgba(10, 16, 20, 0.99)); + radial-gradient( + circle at top, + color-mix(in srgb, var(--accent-green) 12%, transparent), + transparent 34% + ), + radial-gradient( + circle at bottom right, + color-mix(in srgb, var(--accent-blue) 14%, transparent), + transparent 30% + ), + linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-page) 92%, var(--bg-surface) 8%), + var(--bg-page) + ); } .auth-card-shell { @@ -2262,9 +2280,13 @@ body.is-resizing-panels * { text-align: left; gap: var(--sp-4); padding: var(--sp-10); - background: linear-gradient(180deg, rgba(17, 24, 31, 0.96), rgba(13, 20, 26, 0.94)); - border-color: color-mix(in srgb, var(--border) 88%, rgba(108, 182, 255, 0.18)); - box-shadow: 0 24px 72px rgba(0, 0, 0, 0.42); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-surface) 98%, white 2%), + color-mix(in srgb, var(--bg-surface) 88%, var(--bg-page) 12%) + ); + border-color: color-mix(in srgb, var(--border) 88%, var(--accent-blue) 12%); + box-shadow: var(--shadow-xl); } .auth-card-shell .welcome-kicker, @@ -3233,6 +3255,41 @@ body.is-resizing-panels * { text-transform: lowercase; } +.supervisor-meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--sp-2); + margin: 0; +} + +.supervisor-meta-item { + min-width: 0; + padding: var(--sp-1) var(--sp-2); + background: color-mix(in srgb, var(--bg-surface) 82%, var(--bg-page) 18%); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.supervisor-meta-label { + margin: 0; + font-size: 10px; + line-height: 1.4; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.supervisor-meta-value { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: var(--leading-normal); + color: var(--text-secondary); +} + /* Latest-cycle banner */ .supervisor-history-list { display: flex; @@ -3341,18 +3398,18 @@ body.is-resizing-panels * { width: 28px; height: 28px; border-radius: var(--radius-md); - background: rgba(120, 215, 178, 0.12); - color: var(--accent-green); + background: var(--icon-surface-success); + color: var(--icon-success); } .supervisor-dialog[data-mode="edit"] .supervisor-dialog-header-icon { - background: rgba(108, 182, 255, 0.12); - color: var(--accent-blue); + background: var(--icon-surface-info); + color: var(--icon-info); } .supervisor-dialog[data-mode="disable"] .supervisor-dialog-header-icon { - background: rgba(255, 158, 176, 0.12); - color: var(--accent-pink); + background: var(--icon-surface-error); + color: var(--icon-error); } .supervisor-dialog-subtitle { @@ -3380,15 +3437,15 @@ body.is-resizing-panels * { display: flex; gap: var(--sp-3); padding: var(--sp-3); - background: rgba(255, 158, 176, 0.08); - border: 1px solid rgba(255, 158, 176, 0.35); + background: var(--icon-surface-error); + border: 1px solid color-mix(in srgb, var(--icon-error) 36%, var(--border)); border-left-width: 2px; border-radius: var(--radius-md); } .supervisor-danger-callout-icon { flex-shrink: 0; - color: var(--accent-pink); + color: var(--icon-error); margin-top: 1px; } @@ -4865,27 +4922,27 @@ textarea.input { } .tree-icon.folder { - color: var(--accent-amber); + color: var(--icon-file-folder); } .tree-icon.code { - color: var(--accent-blue); + color: var(--icon-file-code); } .tree-icon.data { - color: var(--accent-purple); + color: var(--icon-file-data); } .tree-icon.doc { - color: var(--text-secondary); + color: var(--icon-file-doc); } .tree-icon.media { - color: var(--accent-green); + color: var(--icon-file-media); } .tree-icon.file { - color: var(--text-tertiary); + color: var(--icon-file-default); } .tree-label { @@ -5311,6 +5368,7 @@ textarea.input { } .bottom-terminal-empty-icon { + color: var(--icon-muted); opacity: 0.45; } @@ -5462,19 +5520,19 @@ textarea.input { } .git-row-icon-staged { - color: var(--accent-blue); + color: var(--icon-git-staged); } .git-row-icon-modified { - color: var(--accent-amber); + color: var(--icon-git-modified); } .git-row-icon-deleted { - color: var(--accent-pink); + color: var(--icon-git-deleted); } .git-row-icon-untracked { - color: var(--text-tertiary); + color: var(--icon-git-untracked); } .git-row-name { @@ -5875,7 +5933,11 @@ textarea.input { border-radius: 14px; box-shadow: var(--shadow-lg); overflow: hidden; - background: linear-gradient(180deg, rgba(17, 24, 31, 0.96), rgba(11, 18, 24, 0.99)); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-terminal) 96%, white 4%), + color-mix(in srgb, var(--bg-terminal) 88%, var(--bg-page) 12%) + ); } .terminal-toolbar { @@ -6245,7 +6307,11 @@ textarea.input { } .session-terminal { - background: linear-gradient(180deg, rgba(11, 18, 24, 0.98), rgba(9, 15, 20, 1)); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-terminal) 98%, white 2%), + color-mix(in srgb, var(--bg-terminal) 90%, var(--bg-page) 10%) + ); } .workspace-empty-content, @@ -7081,16 +7147,16 @@ textarea.input { } .toast--success .toast__icon { - color: var(--color-success); + color: var(--icon-success); } .toast--error .toast__icon { - color: var(--color-error); + color: var(--icon-error); } .toast--warning .toast__icon { - color: var(--color-warning); + color: var(--icon-warning); } .toast--info .toast__icon { - color: var(--color-info); + color: var(--icon-info); } .toast__icon { @@ -7463,20 +7529,25 @@ textarea.input { white-space: nowrap; } +.config-status__icon { + display: inline-flex; + align-items: center; +} + .config-status--success { - color: var(--color-success); + color: var(--icon-success); } .config-status--warning { - color: var(--color-warning, #f59e0b); + color: var(--icon-warning); } .config-status--info { - color: var(--color-info, #3b82f6); + color: var(--icon-info); } .config-status--error { - color: var(--color-error); + color: var(--icon-error); } /* Body */ @@ -7561,7 +7632,9 @@ textarea.input { margin-bottom: var(--sp-2); font-size: var(--text-sm); opacity: 0.8; - color: var(--text-tertiary); + background: var(--icon-surface-subtle); + border-radius: var(--radius-md); + color: var(--icon-muted); } .config-empty-title { @@ -8346,6 +8419,7 @@ textarea.input { justify-content: center; flex-shrink: 0; line-height: 1; + color: var(--icon-accent); } .mobile-supervisor-badge__icon svg { @@ -8476,6 +8550,7 @@ textarea.input { display: inline-flex; align-items: center; justify-content: center; + color: currentColor; } .mobile-dock__icon svg { @@ -9277,6 +9352,8 @@ textarea.input { min-height: 0; flex-direction: column; gap: var(--sp-4); + padding: var(--sp-4); + padding-bottom: var(--sp-5); } .mobile-supervisor-sheet__actions, @@ -9286,6 +9363,34 @@ textarea.input { gap: var(--sp-2); } + .mobile-supervisor-sheet__footer { + width: 100%; + padding: var(--sp-2); + border: 1px solid color-mix(in srgb, var(--border) 86%, transparent); + border-radius: 16px; + background: color-mix(in srgb, var(--bg-surface) 96%, var(--bg-page)); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--fg-white) 6%, transparent); + } + + .mobile-supervisor-sheet.mobile-sheet--fullscreen .mobile-sheet__footer { + padding: var(--sp-2) var(--sp-4) calc(var(--mobile-safe-bottom) + var(--sp-4)); + } + + .mobile-supervisor-sheet__actions > .btn, + .mobile-supervisor-sheet__footer > .btn { + min-height: 44px; + box-shadow: none; + } + + .mobile-supervisor-sheet__footer > * { + width: 100%; + } + + .mobile-supervisor-sheet__footer > .btn:hover:not(:disabled) { + transform: none; + box-shadow: none; + } + .mobile-supervisor-sheet__detail-header { display: flex; align-items: flex-start; @@ -9807,7 +9912,7 @@ textarea.input { .settings-mobile-item__icon { display: inline-flex; - color: var(--text-tertiary); + color: var(--icon-secondary); } .settings-mobile-item__label { @@ -10284,7 +10389,7 @@ textarea.input { } .file-tree-shell .tree-icon.file { - color: var(--text-secondary); + color: var(--icon-file-default); } .file-tree-shell .tree-label { diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts index 2613a8140..9218936df 100644 --- a/packages/web/src/styles/components.theme.test.ts +++ b/packages/web/src/styles/components.theme.test.ts @@ -20,6 +20,14 @@ const noticeStylesheet = readFileSync( `${process.cwd()}/src/components/ui/notice/index.module.css`, "utf8" ); +const toastStyles = readFileSync( + `${process.cwd()}/src/components/ui/toast/index.module.css`, + "utf8" +); +const confirmDialogStyles = readFileSync( + `${process.cwd()}/src/components/ui/confirm-dialog/index.module.css`, + "utf8" +); function getLastGroupedRuleBlockFrom(source: string, pattern: RegExp) { const matches = Array.from(source.matchAll(pattern)); @@ -63,6 +71,85 @@ function getLastRuleBlock(selector: string) { } describe("components.css theme-sensitive surfaces", () => { + it("routes file tree and git status icons through icon theme tokens", () => { + expect(getLastRuleBlock(".tree-icon.folder")).toContain("var(--icon-file-folder)"); + expect(getLastRuleBlock(".tree-icon.code")).toContain("var(--icon-file-code)"); + expect(getLastRuleBlock(".tree-icon.data")).toContain("var(--icon-file-data)"); + expect(getLastRuleBlock(".tree-icon.doc")).toContain("var(--icon-file-doc)"); + expect(getLastRuleBlock(".tree-icon.media")).toContain("var(--icon-file-media)"); + expect(getLastRuleBlock(".tree-icon.file")).toContain("var(--icon-file-default)"); + expect(getLastRuleBlock(".file-tree-shell .tree-icon.file")).toContain( + "var(--icon-file-default)" + ); + expect(getLastRuleBlock(".git-row-icon-staged")).toContain("var(--icon-git-staged)"); + expect(getLastRuleBlock(".git-row-icon-modified")).toContain("var(--icon-git-modified)"); + expect(getLastRuleBlock(".git-row-icon-deleted")).toContain("var(--icon-git-deleted)"); + expect(getLastRuleBlock(".git-row-icon-untracked")).toContain("var(--icon-git-untracked)"); + }); + + it("routes semantic icon classes through icon tokens", () => { + expect(getLastRuleBlock(".settings-mobile-item__icon")).toContain("var(--icon-secondary)"); + expect(getLastRuleBlock(".settings-nav-icon")).toContain("var(--icon-secondary)"); + expect(getLastRuleBlock(".terminal-panel-empty-icon")).toContain("var(--icon-muted)"); + expect(getLastRuleBlock(".bottom-terminal-empty-icon")).toContain("var(--icon-muted)"); + expect(getLastRuleBlock(".config-empty-icon")).toContain("var(--icon-muted)"); + }); + + it("keeps icon surfaces on dedicated icon surface tokens", () => { + expect(getLastRuleBlock(".welcome-feature-icon")).toContain( + "background: var(--icon-surface-accent)" + ); + expect(getLastRuleBlock(".welcome-feature-icon")).toContain("color: var(--icon-accent)"); + expect(getLastRuleBlock(".config-empty-icon")).toContain( + "background: var(--icon-surface-subtle)" + ); + expect(getLastRuleBlock(".supervisor-danger-callout")).toContain("var(--icon-surface-error)"); + expect(getLastRuleBlock(".supervisor-danger-callout-icon")).toContain("var(--icon-error)"); + }); + + it("keeps mobile icon intent scoped correctly", () => { + expect(getLastRuleBlock(".mobile-supervisor-badge__icon")).toContain( + "color: var(--icon-accent)" + ); + expect(getLastRuleBlock(".mobile-dock__icon")).toContain("color: currentColor"); + }); + + it("keeps toast icons on icon semantic tokens instead of raw status colors", () => { + expect(getLastRuleBlock(".toast--success .toast__icon")).toContain("var(--icon-success)"); + expect(getLastRuleBlock(".toast--error .toast__icon")).toContain("var(--icon-error)"); + expect(getLastRuleBlock(".toast--warning .toast__icon")).toContain("var(--icon-warning)"); + expect(getLastRuleBlock(".toast--info .toast__icon")).toContain("var(--icon-info)"); + expect(getLastRuleBlockFrom(toastStyles, ".success .icon")).toContain( + "background: var(--icon-surface-success)" + ); + expect(getLastRuleBlockFrom(toastStyles, ".error .icon")).toContain( + "background: var(--icon-surface-error)" + ); + expect(getLastRuleBlockFrom(toastStyles, ".warning .icon")).toContain( + "background: var(--icon-surface-warning)" + ); + expect(getLastRuleBlockFrom(toastStyles, ".info .icon")).toContain( + "background: var(--icon-surface-info)" + ); + expect(getLastRuleBlockFrom(toastStyles, ".success .icon")).toContain("var(--icon-success)"); + }); + + it("keeps confirm dialog danger icons on icon tokens", () => { + expect(getLastRuleBlockFrom(confirmDialogStyles, ".titleDanger")).toContain( + "var(--icon-warning)" + ); + expect(getLastRuleBlockFrom(confirmDialogStyles, ".iconDanger")).toContain( + "color: var(--icon-warning)" + ); + }); + + it("keeps config status colors on icon tokens", () => { + expect(getLastRuleBlock(".config-status--success")).toContain("var(--icon-success)"); + expect(getLastRuleBlock(".config-status--warning")).toContain("var(--icon-warning)"); + expect(getLastRuleBlock(".config-status--info")).toContain("var(--icon-info)"); + expect(getLastRuleBlock(".config-status--error")).toContain("var(--icon-error)"); + }); + it("exposes global mobile safe-area tokens so standalone mobile views keep their padding", () => { expect(tokensStylesheet).toContain("--mobile-safe-top: env(safe-area-inset-top, 0px);"); expect(tokensStylesheet).toContain("--mobile-safe-right: env(safe-area-inset-right, 0px);"); @@ -84,12 +171,34 @@ describe("components.css theme-sensitive surfaces", () => { const activeTab = getLastRuleBlock(".topbar-tab.active"); const emptyCard = getLastRuleBlock(".workspace-empty-inner"); const resolvingCard = getLastRuleBlock(".workspace-resolving-card"); + const sessionTerminal = getLastRuleBlock(".session-terminal"); + const bottomTerminalShell = getLastRuleBlock( + ".workspace-main-area > .bottom-terminal > .bottom-terminal" + ); expect(topbar).toContain("var(--bg-surface)"); expect(activeTab).toContain("var(--bg-active)"); expect(activeTab).not.toContain("rgba(45, 63, 79, 0.92)"); expect(emptyCard).toContain("var(--bg-surface)"); expect(resolvingCard).toContain("var(--bg-surface)"); + expect(sessionTerminal).toContain("var(--bg-terminal)"); + expect(sessionTerminal).not.toContain("rgba(11, 18, 24, 0.98)"); + expect(bottomTerminalShell).toContain("var(--bg-terminal)"); + expect(bottomTerminalShell).not.toContain("rgba(17, 24, 31, 0.96)"); + }); + + it("keeps auth shells theme-aware instead of forcing dark gradients", () => { + const authScreen = getLastRuleBlock(".auth-screen"); + const authCard = getLastRuleBlock(".auth-card-shell"); + + expect(authScreen).toContain("var(--bg-page)"); + expect(authScreen).toContain("var(--accent-green)"); + expect(authScreen).toContain("var(--accent-blue)"); + expect(authScreen).not.toContain("rgba(17, 24, 31, 0.96)"); + expect(authCard).toContain("var(--bg-surface)"); + expect(authCard).toContain("var(--accent-blue)"); + expect(authCard).toContain("var(--shadow-xl)"); + expect(authCard).not.toContain("rgba(13, 20, 26, 0.94)"); }); it("keeps quick actions sized to its label instead of icon-button width", () => { @@ -180,6 +289,10 @@ describe("components.css theme-sensitive surfaces", () => { expect(xtermViewport).not.toContain("touch-action: none"); }); + it("does not ship removed mobile terminal copy mode overlay CSS", () => { + expect(stylesheet).not.toContain(".mobile-terminal-copy-mode"); + }); + it("keeps code editor header actions docked to the right edge", () => { expect(stylesheet).toMatch( /\.code-mode-toggle\s*\{[^}]*display:\s*inline-flex;[^}]*margin-left:\s*auto;[^}]*flex-shrink:\s*0;[^}]*\}/ @@ -298,6 +411,9 @@ describe("components.css theme-sensitive surfaces", () => { it("keeps mobile select row-side actions lightweight and token-driven", () => { const row = getLastRuleBlock(".mobile-select-sheet__item-row"); const rowSelected = getLastRuleBlock('.mobile-select-sheet__item-row[data-selected="true"]'); + const plainSelected = getLastRuleBlock( + '.mobile-select-sheet__list > [data-selected="true"] > .mobile-select-sheet__item' + ); const commandSelected = getLastRuleBlock( '.mobile-select-sheet--command .mobile-select-sheet__list > [data-selected="true"] > .mobile-select-sheet__item' ); @@ -307,6 +423,7 @@ describe("components.css theme-sensitive surfaces", () => { expect(row).toContain("display: flex"); expect(row).toContain("padding: var(--sp-1) var(--sp-2)"); expect(rowSelected).toContain("var(--accent-blue)"); + expect(plainSelected).toContain("var(--accent-blue)"); expect(commandSelected).toContain("var(--accent-blue) 12%"); expect(commandSelected).toContain("inset 2px 0 0"); expect(sideAction).toContain("width: 40px"); @@ -399,7 +516,7 @@ describe("components.css theme-sensitive surfaces", () => { expect(mobileItem).toContain("border-bottom: 1px solid var(--border)"); expect(mobileItem).toContain("border-radius: 0"); expect(mobileItem).toContain("background: transparent"); - expect(mobileItemIcon).toContain("color: var(--text-tertiary)"); + expect(mobileItemIcon).toContain("color: var(--icon-secondary)"); expect(mobileItemArrow).toContain("color: var(--text-tertiary)"); }); @@ -470,6 +587,39 @@ describe("components.css theme-sensitive surfaces", () => { expect(launchActionButton).toContain("box-shadow: none"); }); + it("keeps mobile supervisor sheets aligned with the shared fullscreen page spacing and action sizing", () => { + const supervisorRoot = getLastRuleBlock(".mobile-supervisor-sheet__root").replace(/\s+/g, " "); + const supervisorDetail = getLastRuleBlock(".mobile-supervisor-sheet__detail").replace( + /\s+/g, + " " + ); + const supervisorFullscreenFooter = getLastRuleBlock( + ".mobile-supervisor-sheet.mobile-sheet--fullscreen .mobile-sheet__footer" + ).replace(/\s+/g, " "); + const supervisorActionButton = getLastRuleBlock( + ".mobile-supervisor-sheet__actions > .btn" + ).replace(/\s+/g, " "); + const supervisorFooterButton = getLastRuleBlock( + ".mobile-supervisor-sheet__footer > .btn" + ).replace(/\s+/g, " "); + + expect(supervisorRoot).toContain("padding: var(--sp-4)"); + expect(supervisorRoot).toContain("padding-bottom: var(--sp-5)"); + expect(supervisorDetail).toContain("padding: var(--sp-4)"); + expect(supervisorDetail).toContain("padding-bottom: var(--sp-5)"); + expect(supervisorFullscreenFooter).toContain( + "padding: var(--sp-2) var(--sp-4) calc(var(--mobile-safe-bottom) + var(--sp-4))" + ); + expect(supervisorActionButton).toContain("min-height: 44px"); + expect(supervisorFooterButton).toContain("min-height: 44px"); + expect(supervisorFooterButton).toContain("box-shadow: none"); + expect(getLastRuleBlock(".mobile-supervisor-sheet__footer")).toContain("padding: var(--sp-2)"); + expect(getLastRuleBlock(".mobile-supervisor-sheet__footer")).toContain("border-radius: 16px"); + expect(getLastRuleBlock(".mobile-supervisor-sheet__footer")).toContain( + "background: color-mix(" + ); + }); + it("keeps the mobile workspace home screen aligned to settings chrome and editor-pane empty states", () => { const topbar = getLastRuleBlock(".mobile-topbar").replace(/\s+/g, " "); const emptyStage = getLastRuleBlock(".mobile-shell__agent-empty").replace(/\s+/g, " "); diff --git a/packages/web/src/styles/tokens-touch.test.ts b/packages/web/src/styles/tokens-touch.test.ts index d4c38b072..0bbc84af1 100644 --- a/packages/web/src/styles/tokens-touch.test.ts +++ b/packages/web/src/styles/tokens-touch.test.ts @@ -10,9 +10,17 @@ function getRuleBlock(selector: string): string { let match: RegExpExecArray | null = null; while ((match = matcher.exec(stylesheet)) !== null) { - const currentSelector = match[1].replace(/\/\*[\s\S]*?\*\//g, "").trim(); + const selectors = match[1] + .replace(/\/\*[\s\S]*?\*\//g, "") + .split(",") + .map((part) => part.trim()); - if (currentSelector === selector) { + if (selectors.length === 1 && selectors[0] === selector) { + block = match[2]; + continue; + } + + if (!block && selectors.includes(selector)) { block = match[2]; } } @@ -20,7 +28,49 @@ function getRuleBlock(selector: string): string { return block; } +function getCustomProperty(block: string, name: string): string | null { + const match = new RegExp(`${name}:\\s*([^;]+);`).exec(block); + return match?.[1]?.trim() ?? null; +} + describe("tokens.css touch tokens", () => { + const builtInThemes = [ + "mint-dark", + "mint-light", + "graphite-dark", + "graphite-light", + "nord-dark", + "nord-light", + "hc-dark", + "hc-light", + ] as const; + + const requiredIconTokens = [ + "--icon-primary", + "--icon-secondary", + "--icon-muted", + "--icon-accent", + "--icon-success", + "--icon-warning", + "--icon-error", + "--icon-info", + "--icon-file-folder", + "--icon-git-deleted", + "--icon-surface-subtle", + "--icon-surface-error", + ] as const; + + it("defines named theme blocks for all built-in themes", () => { + expect(stylesheet).toContain(':root,\n[data-theme="mint-dark"]'); + expect(stylesheet).toContain('[data-theme="mint-light"]'); + expect(stylesheet).toContain('[data-theme="graphite-dark"]'); + expect(stylesheet).toContain('[data-theme="graphite-light"]'); + expect(stylesheet).toContain('[data-theme="nord-dark"]'); + expect(stylesheet).toContain('[data-theme="nord-light"]'); + expect(stylesheet).toContain('[data-theme="hc-dark"]'); + expect(stylesheet).toContain('[data-theme="hc-light"]'); + }); + it("defines desktop-default touch target tokens on :root", () => { const root = getRuleBlock(":root"); @@ -44,4 +94,65 @@ describe("tokens.css touch tokens", () => { expect(body).toContain("--touch-spacing-min: 12px"); expect(body).toContain("--touch-hit-slop: 8px"); }); + + it("keeps the light theme families visually separated through structure tokens", () => { + const mintLight = getRuleBlock('[data-theme="mint-light"]'); + const graphiteLight = getRuleBlock('[data-theme="graphite-light"]'); + const nordLight = getRuleBlock('[data-theme="nord-light"]'); + + expect(getCustomProperty(mintLight, "--bg-page")).toBe("#f3fbf7"); + expect(getCustomProperty(mintLight, "--bg-sidebar")).toBe("#edf7f2"); + expect(getCustomProperty(mintLight, "--border-focus")).toBe("#158f77"); + + expect(getCustomProperty(graphiteLight, "--bg-page")).toBe("#e7edf3"); + expect(getCustomProperty(graphiteLight, "--bg-sidebar")).toBe("#dfe6ee"); + expect(getCustomProperty(graphiteLight, "--border-focus")).toBe("#315fdd"); + + expect(getCustomProperty(nordLight, "--bg-page")).toBe("#e3ebf4"); + expect(getCustomProperty(nordLight, "--bg-sidebar")).toBe("#dbe4ef"); + expect(getCustomProperty(nordLight, "--border-focus")).toBe("#5b7fa8"); + }); + + it("separates the light theme interaction palette across families", () => { + const mintLight = getRuleBlock('[data-theme="mint-light"]'); + const graphiteLight = getRuleBlock('[data-theme="graphite-light"]'); + const nordLight = getRuleBlock('[data-theme="nord-light"]'); + + expect(getCustomProperty(mintLight, "--text-secondary")).toBe("#557067"); + expect(getCustomProperty(mintLight, "--accent-blue")).toBe("#148a7a"); + expect(getCustomProperty(mintLight, "--shadow-glow")).toBe("0 0 12px rgba(21, 143, 119, 0.18)"); + + expect(getCustomProperty(graphiteLight, "--text-secondary")).toBe("#4d5b6a"); + expect(getCustomProperty(graphiteLight, "--accent-blue")).toBe("#315fdd"); + expect(getCustomProperty(graphiteLight, "--shadow-glow")).toBe( + "0 0 12px rgba(49, 95, 221, 0.16)" + ); + + expect(getCustomProperty(nordLight, "--text-secondary")).toBe("#4d5a6f"); + expect(getCustomProperty(nordLight, "--accent-blue")).toBe("#5b7fa8"); + expect(getCustomProperty(nordLight, "--shadow-glow")).toBe("0 0 12px rgba(91, 127, 168, 0.18)"); + }); + + it("defines required icon tokens for every built-in theme", () => { + for (const theme of builtInThemes) { + const block = getRuleBlock(`[data-theme="${theme}"]`); + + for (const token of requiredIconTokens) { + expect(getCustomProperty(block, token), `${theme} should define ${token}`).not.toBeNull(); + } + } + }); + + it("keeps light-theme icon tokens visually distinct across families", () => { + const mintLight = getRuleBlock('[data-theme="mint-light"]'); + const graphiteLight = getRuleBlock('[data-theme="graphite-light"]'); + const nordLight = getRuleBlock('[data-theme="nord-light"]'); + + expect(getCustomProperty(mintLight, "--icon-file-folder")).not.toBe( + getCustomProperty(graphiteLight, "--icon-file-folder") + ); + expect(getCustomProperty(graphiteLight, "--icon-accent")).not.toBe( + getCustomProperty(nordLight, "--icon-accent") + ); + }); }); diff --git a/packages/web/src/styles/tokens.css b/packages/web/src/styles/tokens.css index 34ad91ed8..23d701b20 100644 --- a/packages/web/src/styles/tokens.css +++ b/packages/web/src/styles/tokens.css @@ -2,46 +2,10 @@ * Aurora Mint Design System - Design Tokens * * All UI code MUST use these tokens. Never hardcode colors, spacing, or other values. - * Phase 4 light theme will add [data-theme="light"] overrides. + * Theme-specific colors live in named [data-theme] blocks. */ :root { - /* ========== Backgrounds ========== */ - --bg-page: #0a1014; - --bg-surface: #11181f; - --bg-sidebar: #0d141a; - --bg-terminal: #0b1218; - --bg-hover: #1a2632; - --bg-active: #1e3040; - --bg-disabled: #151f28; - --bg-input: #0d141a; - - /* ========== Borders ========== */ - --border: #1e2a35; - --border-light: #263545; - --border-focus: #6cb6ff; - --border-error: #ff9eb0; - - /* ========== Text ========== */ - --text-primary: #e5edf3; - --text-secondary: #9fb0bc; - --text-tertiary: #728492; - --text-disabled: #4a5b6a; - --text-inverse: #0a1014; - - /* ========== Accents ========== */ - --accent-blue: #6cb6ff; - --accent-green: #78d7b2; - --accent-amber: #f1b86a; - --accent-pink: #ff9eb0; - --accent-purple: #c792ea; - - /* ========== Semantic Colors ========== */ - --color-success: #78d7b2; - --color-warning: #f1b86a; - --color-error: #ff9eb0; - --color-info: #6cb6ff; - /* ========== Spacing (4px grid system) ========== */ --sp-1: 4px; --sp-2: 8px; @@ -87,13 +51,6 @@ --font-semibold: 600; --font-bold: 700; - /* ========== Shadows ========== */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); - --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6); - --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.7); - --shadow-glow: 0 0 12px rgba(120, 215, 178, 0.3); - /* ========== Transitions ========== */ --ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-in-out: cubic-bezier(0.45, 0, 0.55, 1); @@ -163,7 +120,6 @@ /* Scrollbar */ --scrollbar-width: 8px; - --scrollbar-thumb: var(--border-light); --scrollbar-track: transparent; /* ========== Touch / Pointer (Mobile-Friendly Phase 0) ========== */ @@ -175,55 +131,615 @@ --touch-hit-slop: 0px; } -/* ========== Phase 4: Light Theme ========== */ -[data-theme="light"] { - /* Backgrounds - inverted for light mode */ - --bg-page: #f8fafb; - --bg-surface: #ffffff; - --bg-sidebar: #f4f6f8; +:root, +[data-theme="mint-dark"] { + /* ========== Backgrounds ========== */ + --bg-page: #0a1014; + --bg-surface: #11181f; + --bg-sidebar: #0d141a; + --bg-terminal: #0b1218; + --bg-hover: #1a2632; + --bg-active: #1e3040; + --bg-disabled: #151f28; + --bg-input: #0d141a; + + /* ========== Borders ========== */ + --border: #1e2a35; + --border-light: #263545; + --border-focus: #6cb6ff; + --border-error: #ff9eb0; + + /* ========== Text ========== */ + --text-primary: #e5edf3; + --text-secondary: #9fb0bc; + --text-tertiary: #728492; + --text-disabled: #4a5b6a; + --text-inverse: #0a1014; + + /* ========== Accents ========== */ + --accent-blue: #6cb6ff; + --accent-green: #78d7b2; + --accent-amber: #f1b86a; + --accent-pink: #ff9eb0; + --accent-purple: #c792ea; + + /* ========== Semantic Colors ========== */ + --color-success: #78d7b2; + --color-warning: #f1b86a; + --color-error: #ff9eb0; + --color-info: #6cb6ff; + + /* ========== Icon Tokens ========== */ + --icon-primary: var(--text-primary); + --icon-secondary: var(--text-secondary); + --icon-muted: var(--text-tertiary); + --icon-accent: var(--accent-green); + --icon-success: var(--color-success); + --icon-warning: var(--color-warning); + --icon-error: var(--color-error); + --icon-info: var(--color-info); + + --icon-file-folder: #8adfc0; + --icon-file-code: var(--accent-blue); + --icon-file-data: var(--accent-purple); + --icon-file-doc: #d7e2ec; + --icon-file-media: #ffb7c5; + --icon-file-default: var(--text-secondary); + --icon-git-staged: var(--color-success); + --icon-git-modified: var(--color-warning); + --icon-git-deleted: var(--color-error); + --icon-git-untracked: var(--color-info); + + --icon-surface-subtle: color-mix(in srgb, var(--text-secondary) 18%, var(--bg-surface)); + --icon-surface-accent: color-mix(in srgb, var(--accent-green) 24%, var(--bg-surface)); + --icon-surface-success: color-mix(in srgb, var(--color-success) 24%, var(--bg-surface)); + --icon-surface-warning: color-mix(in srgb, var(--color-warning) 24%, var(--bg-surface)); + --icon-surface-error: color-mix(in srgb, var(--color-error) 24%, var(--bg-surface)); + --icon-surface-info: color-mix(in srgb, var(--color-info) 24%, var(--bg-surface)); + + /* ========== Shadows ========== */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6); + --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.7); + --shadow-glow: 0 0 12px rgba(120, 215, 178, 0.3); + + /* Scrollbar */ + --scrollbar-thumb: #263545; +} + +[data-theme="mint-light"] { + /* ========== Backgrounds ========== */ + --bg-page: #f3fbf7; + --bg-surface: #fcfffd; + --bg-sidebar: #edf7f2; --bg-terminal: #fafbfc; - --bg-hover: #e8ecf0; - --bg-active: #dde4ea; + --bg-hover: #ddefe5; + --bg-active: #d1e8dc; --bg-disabled: #f0f3f6; --bg-input: #ffffff; - /* Borders */ - --border: #d0d7de; - --border-light: #e1e4e8; - --border-focus: #0969da; + /* ========== Borders ========== */ + --border: #c7d8cf; + --border-light: #d8e7df; + --border-focus: #158f77; --border-error: #cf222e; - /* Text */ + /* ========== Text ========== */ --text-primary: #1f2328; - --text-secondary: #57606a; + --text-secondary: #557067; --text-tertiary: #8b949e; --text-disabled: #6e7681; --text-inverse: #ffffff; - /* Accents - adjusted for light mode visibility */ - --accent-blue: #0969da; + /* ========== Accents ========== */ + --accent-blue: #148a7a; --accent-green: #1a7f37; --accent-amber: #9a6700; --accent-pink: #cf222e; --accent-purple: #8250df; - /* Semantic Colors */ + /* ========== Semantic Colors ========== */ --color-success: #1a7f37; --color-warning: #9a6700; --color-error: #cf222e; - --color-info: #0969da; + --color-info: #148a7a; + + /* ========== Icon Tokens ========== */ + --icon-primary: var(--text-primary); + --icon-secondary: var(--text-secondary); + --icon-muted: var(--text-tertiary); + --icon-accent: var(--accent-blue); + --icon-success: var(--color-success); + --icon-warning: var(--color-warning); + --icon-error: var(--color-error); + --icon-info: var(--color-info); + + --icon-file-folder: #1f9d7f; + --icon-file-code: #1373a5; + --icon-file-data: var(--accent-purple); + --icon-file-doc: #567065; + --icon-file-media: #c65f80; + --icon-file-default: var(--text-secondary); + --icon-git-staged: var(--color-success); + --icon-git-modified: var(--color-warning); + --icon-git-deleted: var(--color-error); + --icon-git-untracked: var(--color-info); + + --icon-surface-subtle: color-mix(in srgb, var(--text-secondary) 16%, var(--bg-surface)); + --icon-surface-accent: color-mix(in srgb, var(--accent-blue) 18%, var(--bg-surface)); + --icon-surface-success: color-mix(in srgb, var(--color-success) 18%, var(--bg-surface)); + --icon-surface-warning: color-mix(in srgb, var(--color-warning) 18%, var(--bg-surface)); + --icon-surface-error: color-mix(in srgb, var(--color-error) 18%, var(--bg-surface)); + --icon-surface-info: color-mix(in srgb, var(--color-info) 18%, var(--bg-surface)); - /* Shadows - lighter for light mode */ + /* ========== Shadows ========== */ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1); --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12); --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.15); - --shadow-glow: 0 0 12px rgba(9, 105, 218, 0.2); + --shadow-glow: 0 0 12px rgba(21, 143, 119, 0.18); /* Scrollbar */ --scrollbar-thumb: #d0d7de; } +[data-theme="graphite-dark"] { + /* ========== Backgrounds ========== */ + --bg-page: #0d1014; + --bg-surface: #14171c; + --bg-sidebar: #101318; + --bg-terminal: #111317; + --bg-hover: #1c2026; + --bg-active: #252b33; + --bg-disabled: #161a20; + --bg-input: #101318; + + /* ========== Borders ========== */ + --border: #2a3038; + --border-light: #353c46; + --border-focus: #9bb8ff; + --border-error: #ffa198; + + /* ========== Text ========== */ + --text-primary: #e6e6e6; + --text-secondary: #a8b0ba; + --text-tertiary: #7d8590; + --text-disabled: #5d6672; + --text-inverse: #111317; + + /* ========== Accents ========== */ + --accent-blue: #7aa2f7; + --accent-green: #8ddb8c; + --accent-amber: #dcb86a; + --accent-pink: #ff7b72; + --accent-purple: #c099ff; + + /* ========== Semantic Colors ========== */ + --color-success: #8ddb8c; + --color-warning: #dcb86a; + --color-error: #ff7b72; + --color-info: #7aa2f7; + + /* ========== Icon Tokens ========== */ + --icon-primary: var(--text-primary); + --icon-secondary: var(--text-secondary); + --icon-muted: var(--text-tertiary); + --icon-accent: #8fb1ff; + --icon-success: var(--color-success); + --icon-warning: var(--color-warning); + --icon-error: var(--color-error); + --icon-info: var(--color-info); + + --icon-file-folder: #e1b86b; + --icon-file-code: var(--accent-blue); + --icon-file-data: var(--accent-purple); + --icon-file-doc: #c5ccd6; + --icon-file-media: #ff9f99; + --icon-file-default: var(--text-secondary); + --icon-git-staged: var(--color-success); + --icon-git-modified: var(--color-warning); + --icon-git-deleted: var(--color-error); + --icon-git-untracked: var(--color-info); + + --icon-surface-subtle: color-mix(in srgb, var(--text-secondary) 18%, var(--bg-surface)); + --icon-surface-accent: color-mix(in srgb, #8fb1ff 24%, var(--bg-surface)); + --icon-surface-success: color-mix(in srgb, var(--color-success) 24%, var(--bg-surface)); + --icon-surface-warning: color-mix(in srgb, var(--color-warning) 24%, var(--bg-surface)); + --icon-surface-error: color-mix(in srgb, var(--color-error) 24%, var(--bg-surface)); + --icon-surface-info: color-mix(in srgb, var(--color-info) 24%, var(--bg-surface)); + + /* ========== Shadows ========== */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.42); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.54); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.64); + --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.74); + --shadow-glow: 0 0 12px rgba(122, 162, 247, 0.24); + + /* Scrollbar */ + --scrollbar-thumb: #353c46; +} + +[data-theme="graphite-light"] { + /* ========== Backgrounds ========== */ + --bg-page: #e7edf3; + --bg-surface: #ffffff; + --bg-sidebar: #dfe6ee; + --bg-terminal: #f3f4f6; + --bg-hover: #d4dce5; + --bg-active: #c8d1dc; + --bg-disabled: #eef2f6; + --bg-input: #ffffff; + + /* ========== Borders ========== */ + --border: #b8c3cf; + --border-light: #ccd5de; + --border-focus: #315fdd; + --border-error: #d14343; + + /* ========== Text ========== */ + --text-primary: #1f2933; + --text-secondary: #4d5b6a; + --text-tertiary: #7b8794; + --text-disabled: #9aa5b1; + --text-inverse: #ffffff; + + /* ========== Accents ========== */ + --accent-blue: #315fdd; + --accent-green: #15803d; + --accent-amber: #a16207; + --accent-pink: #d14343; + --accent-purple: #7c3aed; + + /* ========== Semantic Colors ========== */ + --color-success: #15803d; + --color-warning: #a16207; + --color-error: #d14343; + --color-info: #315fdd; + + /* ========== Icon Tokens ========== */ + --icon-primary: var(--text-primary); + --icon-secondary: var(--text-secondary); + --icon-muted: var(--text-tertiary); + --icon-accent: #2f4fb8; + --icon-success: var(--color-success); + --icon-warning: var(--color-warning); + --icon-error: var(--color-error); + --icon-info: var(--color-info); + + --icon-file-folder: #8a6324; + --icon-file-code: var(--accent-blue); + --icon-file-data: var(--accent-purple); + --icon-file-doc: #516171; + --icon-file-media: #bf5a5a; + --icon-file-default: var(--text-secondary); + --icon-git-staged: var(--color-success); + --icon-git-modified: var(--color-warning); + --icon-git-deleted: var(--color-error); + --icon-git-untracked: var(--color-info); + + --icon-surface-subtle: color-mix(in srgb, var(--text-secondary) 16%, var(--bg-surface)); + --icon-surface-accent: color-mix(in srgb, #2f4fb8 18%, var(--bg-surface)); + --icon-surface-success: color-mix(in srgb, var(--color-success) 18%, var(--bg-surface)); + --icon-surface-warning: color-mix(in srgb, var(--color-warning) 18%, var(--bg-surface)); + --icon-surface-error: color-mix(in srgb, var(--color-error) 18%, var(--bg-surface)); + --icon-surface-info: color-mix(in srgb, var(--color-info) 18%, var(--bg-surface)); + + /* ========== Shadows ========== */ + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.08); + --shadow-md: 0 4px 12px rgba(15, 23, 42, 0.1); + --shadow-lg: 0 8px 32px rgba(15, 23, 42, 0.12); + --shadow-xl: 0 16px 48px rgba(15, 23, 42, 0.16); + --shadow-glow: 0 0 12px rgba(49, 95, 221, 0.16); + + /* Scrollbar */ + --scrollbar-thumb: #c7d0da; +} + +[data-theme="nord-dark"] { + /* ========== Backgrounds ========== */ + --bg-page: #242933; + --bg-surface: #2e3440; + --bg-sidebar: #282e39; + --bg-terminal: #2e3440; + --bg-hover: #384152; + --bg-active: #434c5e; + --bg-disabled: #2b313c; + --bg-input: #272d38; + + /* ========== Borders ========== */ + --border: #4c566a; + --border-light: #616e88; + --border-focus: #88c0d0; + --border-error: #d08770; + + /* ========== Text ========== */ + --text-primary: #d8dee9; + --text-secondary: #b5c1d2; + --text-tertiary: #7b88a1; + --text-disabled: #616e88; + --text-inverse: #2e3440; + + /* ========== Accents ========== */ + --accent-blue: #81a1c1; + --accent-green: #a3be8c; + --accent-amber: #ebcb8b; + --accent-pink: #bf616a; + --accent-purple: #b48ead; + + /* ========== Semantic Colors ========== */ + --color-success: #a3be8c; + --color-warning: #ebcb8b; + --color-error: #bf616a; + --color-info: #88c0d0; + + /* ========== Icon Tokens ========== */ + --icon-primary: var(--text-primary); + --icon-secondary: var(--text-secondary); + --icon-muted: var(--text-tertiary); + --icon-accent: #8fbcbb; + --icon-success: var(--color-success); + --icon-warning: var(--color-warning); + --icon-error: var(--color-error); + --icon-info: var(--color-info); + + --icon-file-folder: #d8b76e; + --icon-file-code: var(--accent-blue); + --icon-file-data: var(--accent-purple); + --icon-file-doc: #c7d3e0; + --icon-file-media: #cf8c95; + --icon-file-default: var(--text-secondary); + --icon-git-staged: var(--color-success); + --icon-git-modified: var(--color-warning); + --icon-git-deleted: var(--color-error); + --icon-git-untracked: var(--color-info); + + --icon-surface-subtle: color-mix(in srgb, var(--text-secondary) 20%, var(--bg-surface)); + --icon-surface-accent: color-mix(in srgb, #8fbcbb 24%, var(--bg-surface)); + --icon-surface-success: color-mix(in srgb, var(--color-success) 24%, var(--bg-surface)); + --icon-surface-warning: color-mix(in srgb, var(--color-warning) 24%, var(--bg-surface)); + --icon-surface-error: color-mix(in srgb, var(--color-error) 24%, var(--bg-surface)); + --icon-surface-info: color-mix(in srgb, var(--color-info) 24%, var(--bg-surface)); + + /* ========== Shadows ========== */ + --shadow-sm: 0 1px 2px rgba(17, 24, 39, 0.36); + --shadow-md: 0 4px 12px rgba(17, 24, 39, 0.46); + --shadow-lg: 0 8px 32px rgba(17, 24, 39, 0.58); + --shadow-xl: 0 16px 48px rgba(17, 24, 39, 0.68); + --shadow-glow: 0 0 12px rgba(136, 192, 208, 0.26); + + /* Scrollbar */ + --scrollbar-thumb: #4c566a; +} + +[data-theme="nord-light"] { + /* ========== Backgrounds ========== */ + --bg-page: #e3ebf4; + --bg-surface: #f1f5fa; + --bg-sidebar: #dbe4ef; + --bg-terminal: #eceff4; + --bg-hover: #d2ddea; + --bg-active: #c8d4e2; + --bg-disabled: #edf1f6; + --bg-input: #f7f9fc; + + /* ========== Borders ========== */ + --border: #b8c6d7; + --border-light: #cad5e2; + --border-focus: #5b7fa8; + --border-error: #bf616a; + + /* ========== Text ========== */ + --text-primary: #2e3440; + --text-secondary: #4d5a6f; + --text-tertiary: #7b88a1; + --text-disabled: #93a0b7; + --text-inverse: #ffffff; + + /* ========== Accents ========== */ + --accent-blue: #5b7fa8; + --accent-green: #4c7a5b; + --accent-amber: #a77f2f; + --accent-pink: #bf616a; + --accent-purple: #8f5b9c; + + /* ========== Semantic Colors ========== */ + --color-success: #4c7a5b; + --color-warning: #a77f2f; + --color-error: #bf616a; + --color-info: #5b7fa8; + + /* ========== Icon Tokens ========== */ + --icon-primary: var(--text-primary); + --icon-secondary: var(--text-secondary); + --icon-muted: var(--text-tertiary); + --icon-accent: #6b8fb9; + --icon-success: var(--color-success); + --icon-warning: var(--color-warning); + --icon-error: var(--color-error); + --icon-info: var(--color-info); + + --icon-file-folder: #b28a49; + --icon-file-code: var(--accent-blue); + --icon-file-data: var(--accent-purple); + --icon-file-doc: #5d6c82; + --icon-file-media: #b96f77; + --icon-file-default: var(--text-secondary); + --icon-git-staged: var(--color-success); + --icon-git-modified: var(--color-warning); + --icon-git-deleted: var(--color-error); + --icon-git-untracked: var(--color-info); + + --icon-surface-subtle: color-mix(in srgb, var(--text-secondary) 16%, var(--bg-surface)); + --icon-surface-accent: color-mix(in srgb, #6b8fb9 18%, var(--bg-surface)); + --icon-surface-success: color-mix(in srgb, var(--color-success) 18%, var(--bg-surface)); + --icon-surface-warning: color-mix(in srgb, var(--color-warning) 18%, var(--bg-surface)); + --icon-surface-error: color-mix(in srgb, var(--color-error) 18%, var(--bg-surface)); + --icon-surface-info: color-mix(in srgb, var(--color-info) 18%, var(--bg-surface)); + + /* ========== Shadows ========== */ + --shadow-sm: 0 1px 2px rgba(46, 52, 64, 0.08); + --shadow-md: 0 4px 12px rgba(46, 52, 64, 0.1); + --shadow-lg: 0 8px 32px rgba(46, 52, 64, 0.12); + --shadow-xl: 0 16px 48px rgba(46, 52, 64, 0.15); + --shadow-glow: 0 0 12px rgba(91, 127, 168, 0.18); + + /* Scrollbar */ + --scrollbar-thumb: #c6ceda; +} + +[data-theme="hc-dark"] { + /* ========== Backgrounds ========== */ + --bg-page: #000000; + --bg-surface: #000000; + --bg-sidebar: #000000; + --bg-terminal: #000000; + --bg-hover: #141414; + --bg-active: #264f78; + --bg-disabled: #0d0d0d; + --bg-input: #000000; + + /* ========== Borders ========== */ + --border: #ffffff; + --border-light: #ffff00; + --border-focus: #ffff00; + --border-error: #ff4d4d; + + /* ========== Text ========== */ + --text-primary: #ffffff; + --text-secondary: #f0f0f0; + --text-tertiary: #d0d0d0; + --text-disabled: #8f8f8f; + --text-inverse: #000000; + + /* ========== Accents ========== */ + --accent-blue: #66b3ff; + --accent-green: #00ff7f; + --accent-amber: #ffff66; + --accent-pink: #ff4d4d; + --accent-purple: #ff7fff; + + /* ========== Semantic Colors ========== */ + --color-success: #00ff7f; + --color-warning: #ffff66; + --color-error: #ff4d4d; + --color-info: #66b3ff; + + /* ========== Icon Tokens ========== */ + --icon-primary: #ffffff; + --icon-secondary: #ffff00; + --icon-muted: #d0d0d0; + --icon-accent: #66b3ff; + --icon-success: #00ff7f; + --icon-warning: #ffff66; + --icon-error: #ff4d4d; + --icon-info: #66b3ff; + + --icon-file-folder: #ffff00; + --icon-file-code: #66b3ff; + --icon-file-data: #ff7fff; + --icon-file-doc: #ffffff; + --icon-file-media: #ff4d4d; + --icon-file-default: #ffffff; + --icon-git-staged: #00ff7f; + --icon-git-modified: #ffff66; + --icon-git-deleted: #ff4d4d; + --icon-git-untracked: #66b3ff; + + --icon-surface-subtle: color-mix(in srgb, #ffffff 20%, var(--bg-surface)); + --icon-surface-accent: color-mix(in srgb, #66b3ff 28%, var(--bg-surface)); + --icon-surface-success: color-mix(in srgb, #00ff7f 28%, var(--bg-surface)); + --icon-surface-warning: color-mix(in srgb, #ffff66 28%, var(--bg-surface)); + --icon-surface-error: color-mix(in srgb, #ff4d4d 28%, var(--bg-surface)); + --icon-surface-info: color-mix(in srgb, #66b3ff 28%, var(--bg-surface)); + + /* ========== Shadows ========== */ + --shadow-sm: 0 0 0 1px rgba(255, 255, 255, 0.55); + --shadow-md: 0 0 0 1px rgba(255, 255, 255, 0.72); + --shadow-lg: 0 0 0 2px rgba(255, 255, 255, 0.72); + --shadow-xl: 0 0 0 3px rgba(255, 255, 255, 0.8); + --shadow-glow: 0 0 0 2px rgba(255, 255, 0, 0.52); + + /* Scrollbar */ + --scrollbar-thumb: #ffffff; +} + +[data-theme="hc-light"] { + /* ========== Backgrounds ========== */ + --bg-page: #ffffff; + --bg-surface: #ffffff; + --bg-sidebar: #ffffff; + --bg-terminal: #ffffff; + --bg-hover: #eef4ff; + --bg-active: #add6ff; + --bg-disabled: #f3f3f3; + --bg-input: #ffffff; + + /* ========== Borders ========== */ + --border: #000000; + --border-light: #5c5c5c; + --border-focus: #0037da; + --border-error: #b00020; + + /* ========== Text ========== */ + --text-primary: #000000; + --text-secondary: #111111; + --text-tertiary: #333333; + --text-disabled: #5c5c5c; + --text-inverse: #ffffff; + + /* ========== Accents ========== */ + --accent-blue: #0037da; + --accent-green: #006b3c; + --accent-amber: #7a5c00; + --accent-pink: #b00020; + --accent-purple: #7a00cc; + + /* ========== Semantic Colors ========== */ + --color-success: #006b3c; + --color-warning: #7a5c00; + --color-error: #b00020; + --color-info: #0037da; + + /* ========== Icon Tokens ========== */ + --icon-primary: #000000; + --icon-secondary: #111111; + --icon-muted: #333333; + --icon-accent: #0037da; + --icon-success: #006b3c; + --icon-warning: #7a5c00; + --icon-error: #b00020; + --icon-info: #0037da; + + --icon-file-folder: #7a5c00; + --icon-file-code: #0037da; + --icon-file-data: #7a00cc; + --icon-file-doc: #000000; + --icon-file-media: #b00020; + --icon-file-default: #111111; + --icon-git-staged: #006b3c; + --icon-git-modified: #7a5c00; + --icon-git-deleted: #b00020; + --icon-git-untracked: #0037da; + + --icon-surface-subtle: color-mix(in srgb, #000000 12%, var(--bg-surface)); + --icon-surface-accent: color-mix(in srgb, #0037da 18%, var(--bg-surface)); + --icon-surface-success: color-mix(in srgb, #006b3c 18%, var(--bg-surface)); + --icon-surface-warning: color-mix(in srgb, #7a5c00 18%, var(--bg-surface)); + --icon-surface-error: color-mix(in srgb, #b00020 18%, var(--bg-surface)); + --icon-surface-info: color-mix(in srgb, #0037da 18%, var(--bg-surface)); + + /* ========== Shadows ========== */ + --shadow-sm: 0 0 0 1px rgba(0, 0, 0, 0.18); + --shadow-md: 0 0 0 1px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 0 0 2px rgba(0, 0, 0, 0.32); + --shadow-xl: 0 0 0 3px rgba(0, 0, 0, 0.36); + --shadow-glow: 0 0 0 2px rgba(0, 55, 218, 0.3); + + /* Scrollbar */ + --scrollbar-thumb: #5c5c5c; +} + /* ========== Mobile / Touch Override (Phase 0) ========== */ @media (max-width: 899px) { :root { diff --git a/packages/web/src/theme/index.ts b/packages/web/src/theme/index.ts new file mode 100644 index 000000000..929c3f965 --- /dev/null +++ b/packages/web/src/theme/index.ts @@ -0,0 +1,16 @@ +export { + type AppThemeDefinition, + type MonacoThemeDefinition, + type TerminalThemeDefinition, + THEME_IDS, + THEMES, + type ThemeFamily, + type ThemeKind, +} from "./registry"; +export { + getThemeById, + getThemeFamily, + getThemeIdForFamilyVariant, + getThemeVariant, + resolveStoredThemeId, +} from "./resolve"; diff --git a/packages/web/src/theme/registry.test.ts b/packages/web/src/theme/registry.test.ts new file mode 100644 index 000000000..3fd4166a3 --- /dev/null +++ b/packages/web/src/theme/registry.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import en from "../locales/en.json"; +import zh from "../locales/zh.json"; +import { THEME_IDS, THEMES } from "./index"; + +function getTranslationValue(messages: Record, key: string): unknown { + return key.split(".").reduce((current, segment) => { + if (current && typeof current === "object" && segment in current) { + return (current as Record)[segment]; + } + + return undefined; + }, messages); +} + +describe("theme registry", () => { + it("contains the first-phase theme ids", () => { + expect(THEMES).toHaveLength(8); + expect(THEME_IDS).toEqual( + expect.arrayContaining([ + "mint-dark", + "mint-light", + "graphite-dark", + "graphite-light", + "nord-dark", + "nord-light", + "hc-dark", + "hc-light", + ]) + ); + }); + + it("uses unique theme ids", () => { + expect(new Set(THEME_IDS).size).toBe(THEME_IDS.length); + }); + + it("defines the required fields for every theme", () => { + for (const theme of THEMES) { + expect(theme).toEqual( + expect.objectContaining({ + family: expect.any(String), + kind: expect.any(String), + documentThemeAttr: expect.any(String), + terminalTheme: expect.any(Object), + monaco: expect.any(Object), + }) + ); + } + }); + + it("only references real paired themes", () => { + const ids = new Set(THEME_IDS); + + for (const theme of THEMES) { + expect(ids.has(theme.pairedThemeId)).toBe(true); + expect(THEMES.find((candidate) => candidate.id === theme.pairedThemeId)?.pairedThemeId).toBe( + theme.id + ); + } + }); + + it("defines one dark and one light theme for each family", () => { + expect( + THEMES.reduce>((families, theme) => { + const variants = families[theme.family] ?? []; + variants.push(theme.kind); + families[theme.family] = variants; + return families; + }, {}) + ).toEqual({ + mint: ["dark", "light"], + graphite: ["dark", "light"], + nord: ["dark", "light"], + hc: ["dark", "light"], + }); + }); + + it("keeps derived attributes aligned with ids and families", () => { + for (const theme of THEMES) { + expect(theme.documentThemeAttr).toBe(theme.id); + expect(theme.isHighContrast).toBe(theme.family === "hc"); + } + }); + + it("uses translation keys that exist in every bundled locale", () => { + for (const theme of THEMES) { + expect(getTranslationValue(en as Record, theme.labelKey)).toEqual( + expect.any(String) + ); + expect(getTranslationValue(zh as Record, theme.labelKey)).toEqual( + expect.any(String) + ); + } + }); + + it("keeps light terminal palettes separated by theme family", () => { + const mintLight = THEMES.find((theme) => theme.id === "mint-light"); + const graphiteLight = THEMES.find((theme) => theme.id === "graphite-light"); + const nordLight = THEMES.find((theme) => theme.id === "nord-light"); + + expect(mintLight?.terminalTheme).toEqual( + expect.objectContaining({ + background: "#fcfffd", + cursor: "#148a7a", + blue: "#148a7a", + cyan: "#0f766e", + selectionBackground: "#ddefe5", + }) + ); + + expect(graphiteLight?.terminalTheme).toEqual( + expect.objectContaining({ + background: "#f5f7fa", + cursor: "#315fdd", + blue: "#315fdd", + cyan: "#1f6f8b", + selectionBackground: "#d4dce5", + }) + ); + + expect(nordLight?.terminalTheme).toEqual( + expect.objectContaining({ + background: "#f1f5fa", + cursor: "#5b7fa8", + blue: "#5b7fa8", + cyan: "#4c7f99", + selectionBackground: "#d2ddea", + }) + ); + }); + + it("keeps light monaco palettes separated by theme family", () => { + const mintLight = THEMES.find((theme) => theme.id === "mint-light"); + const graphiteLight = THEMES.find((theme) => theme.id === "graphite-light"); + const nordLight = THEMES.find((theme) => theme.id === "nord-light"); + + expect(mintLight?.monaco.colors).toEqual( + expect.objectContaining({ + "editor.background": "#fcfffd", + "editorCursor.foreground": "#148a7a", + "editor.selectionBackground": "#ddefe5", + }) + ); + expect(mintLight?.monaco.rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ token: "string", foreground: "18794e" }), + expect.objectContaining({ token: "keyword", foreground: "148a7a" }), + ]) + ); + + expect(graphiteLight?.monaco.colors).toEqual( + expect.objectContaining({ + "editor.background": "#f5f7fa", + "editorCursor.foreground": "#315fdd", + "editor.selectionBackground": "#d4dce5", + }) + ); + expect(graphiteLight?.monaco.rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ token: "string", foreground: "2f6f44" }), + expect.objectContaining({ token: "keyword", foreground: "315fdd" }), + ]) + ); + + expect(nordLight?.monaco.colors).toEqual( + expect.objectContaining({ + "editor.background": "#f1f5fa", + "editorCursor.foreground": "#5b7fa8", + "editor.selectionBackground": "#d2ddea", + }) + ); + expect(nordLight?.monaco.rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ token: "string", foreground: "5d7a66" }), + expect.objectContaining({ token: "keyword", foreground: "5b7fa8" }), + ]) + ); + }); +}); diff --git a/packages/web/src/theme/registry.ts b/packages/web/src/theme/registry.ts new file mode 100644 index 000000000..b6d68ca05 --- /dev/null +++ b/packages/web/src/theme/registry.ts @@ -0,0 +1,453 @@ +export type ThemeFamily = "mint" | "graphite" | "nord" | "hc"; +export type ThemeKind = "dark" | "light"; + +export interface TerminalThemeDefinition { + background: string; + foreground: string; + cursor: string; + cursorAccent: string; + selectionBackground: string; + selectionForeground: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; +} + +export interface MonacoThemeDefinition { + base: "vs" | "vs-dark" | "hc-black" | "hc-light"; + inherit: boolean; + rules: ReadonlyArray<{ + token: string; + foreground?: string; + background?: string; + fontStyle?: string; + }>; + colors: Readonly>; +} + +export interface AppThemeDefinition { + id: string; + family: ThemeFamily; + kind: ThemeKind; + labelKey: string; + pairedThemeId: string; + isHighContrast: boolean; + documentThemeAttr: string; + terminalTheme: TerminalThemeDefinition; + monaco: MonacoThemeDefinition; +} + +const mintDarkTerminal: TerminalThemeDefinition = { + background: "#0b1218", + foreground: "#e5edf3", + cursor: "#78d7b2", + cursorAccent: "#0b1218", + selectionBackground: "#1e3040", + selectionForeground: "#e5edf3", + black: "#22303c", + red: "#ff8f9f", + green: "#5fd7a3", + yellow: "#f1b86a", + blue: "#6cb6ff", + magenta: "#c792ea", + cyan: "#78d7b2", + white: "#cdd9e5", + brightBlack: "#4a5b6a", + brightRed: "#ff9eb0", + brightGreen: "#78d7b2", + brightYellow: "#f1b86a", + brightBlue: "#6cb6ff", + brightMagenta: "#c792ea", + brightCyan: "#78d7b2", + brightWhite: "#e5edf3", +}; + +const mintLightTerminal: TerminalThemeDefinition = { + background: "#fcfffd", + foreground: "#1f2328", + cursor: "#148a7a", + cursorAccent: "#fcfffd", + selectionBackground: "#ddefe5", + selectionForeground: "#1f2328", + black: "#24292f", + red: "#cf222e", + green: "#18794e", + yellow: "#9a6700", + blue: "#148a7a", + magenta: "#8250df", + cyan: "#0f766e", + white: "#57606a", + brightBlack: "#8b949e", + brightRed: "#cf222e", + brightGreen: "#1f9360", + brightYellow: "#9a6700", + brightBlue: "#148a7a", + brightMagenta: "#8250df", + brightCyan: "#14877d", + brightWhite: "#1f2328", +}; + +const THEMES_REGISTRY: ReadonlyArray = [ + { + id: "mint-dark", + family: "mint", + kind: "dark", + labelKey: "settings.theme.mint_dark", + pairedThemeId: "mint-light", + isHighContrast: false, + documentThemeAttr: "mint-dark", + terminalTheme: mintDarkTerminal, + monaco: { + base: "vs-dark", + inherit: true, + rules: [ + { token: "comment", foreground: "8b9bab" }, + { token: "string", foreground: "78d7b2" }, + { token: "keyword", foreground: "6cb6ff" }, + ], + colors: { + "editor.background": "#0b1218", + "editor.foreground": "#e5edf3", + "editorLineNumber.foreground": "#4a5b6a", + "editorCursor.foreground": "#78d7b2", + "editor.selectionBackground": "#1e3040", + }, + }, + }, + { + id: "mint-light", + family: "mint", + kind: "light", + labelKey: "settings.theme.mint_light", + pairedThemeId: "mint-dark", + isHighContrast: false, + documentThemeAttr: "mint-light", + terminalTheme: mintLightTerminal, + monaco: { + base: "vs", + inherit: true, + rules: [ + { token: "comment", foreground: "6e7781" }, + { token: "string", foreground: "18794e" }, + { token: "keyword", foreground: "148a7a" }, + ], + colors: { + "editor.background": "#fcfffd", + "editor.foreground": "#1f2328", + "editorLineNumber.foreground": "#8b949e", + "editorCursor.foreground": "#148a7a", + "editor.selectionBackground": "#ddefe5", + }, + }, + }, + { + id: "graphite-dark", + family: "graphite", + kind: "dark", + labelKey: "settings.theme.graphite_dark", + pairedThemeId: "graphite-light", + isHighContrast: false, + documentThemeAttr: "graphite-dark", + terminalTheme: { + background: "#111317", + foreground: "#e6e6e6", + cursor: "#9aa4b2", + cursorAccent: "#111317", + selectionBackground: "#2b3038", + selectionForeground: "#f5f7fa", + black: "#1d2127", + red: "#ff7b72", + green: "#8ddb8c", + yellow: "#dcb86a", + blue: "#7aa2f7", + magenta: "#c099ff", + cyan: "#76c7c0", + white: "#c9d1d9", + brightBlack: "#6e7681", + brightRed: "#ffa198", + brightGreen: "#a5e39a", + brightYellow: "#eacb91", + brightBlue: "#9bb8ff", + brightMagenta: "#d2b5ff", + brightCyan: "#93ddd8", + brightWhite: "#f0f3f6", + }, + monaco: { + base: "vs-dark", + inherit: true, + rules: [ + { token: "comment", foreground: "7d8590" }, + { token: "string", foreground: "8ddb8c" }, + { token: "keyword", foreground: "7aa2f7" }, + ], + colors: { + "editor.background": "#111317", + "editor.foreground": "#e6e6e6", + "editorLineNumber.foreground": "#6e7681", + "editorCursor.foreground": "#9aa4b2", + "editor.selectionBackground": "#2b3038", + }, + }, + }, + { + id: "graphite-light", + family: "graphite", + kind: "light", + labelKey: "settings.theme.graphite_light", + pairedThemeId: "graphite-dark", + isHighContrast: false, + documentThemeAttr: "graphite-light", + terminalTheme: { + background: "#f5f7fa", + foreground: "#1f2933", + cursor: "#315fdd", + cursorAccent: "#f5f7fa", + selectionBackground: "#d4dce5", + selectionForeground: "#111317", + black: "#111827", + red: "#c2410c", + green: "#2f6f44", + yellow: "#a16207", + blue: "#315fdd", + magenta: "#7c3aed", + cyan: "#1f6f8b", + white: "#6b7280", + brightBlack: "#9ca3af", + brightRed: "#ea580c", + brightGreen: "#3f8457", + brightYellow: "#ca8a04", + brightBlue: "#4672e7", + brightMagenta: "#8b5cf6", + brightCyan: "#2e86a5", + brightWhite: "#111827", + }, + monaco: { + base: "vs", + inherit: true, + rules: [ + { token: "comment", foreground: "6b7280" }, + { token: "string", foreground: "2f6f44" }, + { token: "keyword", foreground: "315fdd" }, + ], + colors: { + "editor.background": "#f5f7fa", + "editor.foreground": "#1f2933", + "editorLineNumber.foreground": "#9ca3af", + "editorCursor.foreground": "#315fdd", + "editor.selectionBackground": "#d4dce5", + }, + }, + }, + { + id: "nord-dark", + family: "nord", + kind: "dark", + labelKey: "settings.theme.nord_dark", + pairedThemeId: "nord-light", + isHighContrast: false, + documentThemeAttr: "nord-dark", + terminalTheme: { + background: "#2e3440", + foreground: "#d8dee9", + cursor: "#88c0d0", + cursorAccent: "#2e3440", + selectionBackground: "#434c5e", + selectionForeground: "#eceff4", + black: "#3b4252", + red: "#bf616a", + green: "#a3be8c", + yellow: "#ebcb8b", + blue: "#81a1c1", + magenta: "#b48ead", + cyan: "#88c0d0", + white: "#e5e9f0", + brightBlack: "#4c566a", + brightRed: "#d08770", + brightGreen: "#b1d196", + brightYellow: "#f0d399", + brightBlue: "#8fbcbb", + brightMagenta: "#c895bf", + brightCyan: "#93ccdc", + brightWhite: "#eceff4", + }, + monaco: { + base: "vs-dark", + inherit: true, + rules: [ + { token: "comment", foreground: "616e88" }, + { token: "string", foreground: "a3be8c" }, + { token: "keyword", foreground: "81a1c1" }, + ], + colors: { + "editor.background": "#2e3440", + "editor.foreground": "#d8dee9", + "editorLineNumber.foreground": "#616e88", + "editorCursor.foreground": "#88c0d0", + "editor.selectionBackground": "#434c5e", + }, + }, + }, + { + id: "nord-light", + family: "nord", + kind: "light", + labelKey: "settings.theme.nord_light", + pairedThemeId: "nord-dark", + isHighContrast: false, + documentThemeAttr: "nord-light", + terminalTheme: { + background: "#f1f5fa", + foreground: "#2e3440", + cursor: "#5b7fa8", + cursorAccent: "#f1f5fa", + selectionBackground: "#d2ddea", + selectionForeground: "#2e3440", + black: "#3b4252", + red: "#bf616a", + green: "#5d7a66", + yellow: "#a77f2f", + blue: "#5b7fa8", + magenta: "#8f5b9c", + cyan: "#4c7f99", + white: "#4c566a", + brightBlack: "#7b88a1", + brightRed: "#d08770", + brightGreen: "#6a8d76", + brightYellow: "#b08d49", + brightBlue: "#6c90b7", + brightMagenta: "#9b6aa8", + brightCyan: "#5b90a6", + brightWhite: "#2e3440", + }, + monaco: { + base: "vs", + inherit: true, + rules: [ + { token: "comment", foreground: "7b88a1" }, + { token: "string", foreground: "5d7a66" }, + { token: "keyword", foreground: "5b7fa8" }, + ], + colors: { + "editor.background": "#f1f5fa", + "editor.foreground": "#2e3440", + "editorLineNumber.foreground": "#7b88a1", + "editorCursor.foreground": "#5b7fa8", + "editor.selectionBackground": "#d2ddea", + }, + }, + }, + { + id: "hc-dark", + family: "hc", + kind: "dark", + labelKey: "settings.theme.hc_dark", + pairedThemeId: "hc-light", + isHighContrast: true, + documentThemeAttr: "hc-dark", + terminalTheme: { + background: "#000000", + foreground: "#ffffff", + cursor: "#ffff00", + cursorAccent: "#000000", + selectionBackground: "#264f78", + selectionForeground: "#ffffff", + black: "#000000", + red: "#ff4d4d", + green: "#00ff7f", + yellow: "#ffff66", + blue: "#66b3ff", + magenta: "#ff7fff", + cyan: "#66ffff", + white: "#ffffff", + brightBlack: "#808080", + brightRed: "#ff8080", + brightGreen: "#66ffb3", + brightYellow: "#ffff99", + brightBlue: "#99ccff", + brightMagenta: "#ffb3ff", + brightCyan: "#99ffff", + brightWhite: "#ffffff", + }, + monaco: { + base: "hc-black", + inherit: true, + rules: [ + { token: "comment", foreground: "c0c0c0" }, + { token: "string", foreground: "00ff7f" }, + { token: "keyword", foreground: "66b3ff" }, + ], + colors: { + "editor.background": "#000000", + "editor.foreground": "#ffffff", + "editorLineNumber.foreground": "#c0c0c0", + "editorCursor.foreground": "#ffff00", + "editor.selectionBackground": "#264f78", + }, + }, + }, + { + id: "hc-light", + family: "hc", + kind: "light", + labelKey: "settings.theme.hc_light", + pairedThemeId: "hc-dark", + isHighContrast: true, + documentThemeAttr: "hc-light", + terminalTheme: { + background: "#ffffff", + foreground: "#000000", + cursor: "#0000ff", + cursorAccent: "#ffffff", + selectionBackground: "#add6ff", + selectionForeground: "#000000", + black: "#000000", + red: "#b00020", + green: "#006b3c", + yellow: "#7a5c00", + blue: "#0037da", + magenta: "#7a00cc", + cyan: "#006f8f", + white: "#5c5c5c", + brightBlack: "#767676", + brightRed: "#d00032", + brightGreen: "#008f50", + brightYellow: "#997500", + brightBlue: "#2455ff", + brightMagenta: "#9900ff", + brightCyan: "#0088aa", + brightWhite: "#000000", + }, + monaco: { + base: "hc-light", + inherit: true, + rules: [ + { token: "comment", foreground: "5c5c5c" }, + { token: "string", foreground: "006b3c" }, + { token: "keyword", foreground: "0037da" }, + ], + colors: { + "editor.background": "#ffffff", + "editor.foreground": "#000000", + "editorLineNumber.foreground": "#5c5c5c", + "editorCursor.foreground": "#0037da", + "editor.selectionBackground": "#add6ff", + }, + }, + }, +]; + +export const THEMES = THEMES_REGISTRY; +export const THEME_IDS = THEMES_REGISTRY.map((theme) => theme.id) as readonly string[]; diff --git a/packages/web/src/theme/resolve.test.ts b/packages/web/src/theme/resolve.test.ts new file mode 100644 index 000000000..fbdeecd9d --- /dev/null +++ b/packages/web/src/theme/resolve.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + getThemeById, + getThemeFamily, + getThemeIdForFamilyVariant, + getThemeVariant, + resolveStoredThemeId, +} from "./index"; + +describe("resolveStoredThemeId", () => { + it("maps legacy and invalid values to supported ids", () => { + expect(resolveStoredThemeId("dark")).toBe("mint-dark"); + expect(resolveStoredThemeId("light")).toBe("mint-light"); + expect(resolveStoredThemeId("mint-dark")).toBe("mint-dark"); + expect(resolveStoredThemeId("missing-theme")).toBe("mint-dark"); + expect(resolveStoredThemeId("__proto__")).toBe("mint-dark"); + expect(resolveStoredThemeId("toString")).toBe("mint-dark"); + }); +}); + +describe("theme resolvers", () => { + it("returns the theme definition for known ids and falls back for unknown ids", () => { + expect(getThemeById("mint-light").id).toBe("mint-light"); + expect(getThemeById("missing-theme").id).toBe("mint-dark"); + }); + + it("reads the family and variant from a theme id", () => { + expect(getThemeFamily("graphite-light")).toBe("graphite"); + expect(getThemeVariant("graphite-light")).toBe("light"); + }); + + it("finds theme ids by family and variant", () => { + expect(getThemeIdForFamilyVariant("nord", "dark")).toBe("nord-dark"); + expect(getThemeIdForFamilyVariant("hc", "light")).toBe("hc-light"); + }); +}); diff --git a/packages/web/src/theme/resolve.ts b/packages/web/src/theme/resolve.ts new file mode 100644 index 000000000..48f53ba70 --- /dev/null +++ b/packages/web/src/theme/resolve.ts @@ -0,0 +1,40 @@ +import { type AppThemeDefinition, THEME_IDS, THEMES, type ThemeFamily } from "./registry"; + +const DEFAULT_THEME_ID = "mint-dark"; +const LEGACY_THEME_ID_MAP = { + dark: "mint-dark", + light: "mint-light", +} as const; + +const themeById = new Map(THEMES.map((theme) => [theme.id, theme])); + +export function getThemeById(themeId: string): AppThemeDefinition { + return themeById.get(resolveStoredThemeId(themeId)) ?? themeById.get(DEFAULT_THEME_ID)!; +} + +export function resolveStoredThemeId(value: unknown): string { + if (typeof value !== "string") { + return DEFAULT_THEME_ID; + } + + if (Object.hasOwn(LEGACY_THEME_ID_MAP, value)) { + return LEGACY_THEME_ID_MAP[value as keyof typeof LEGACY_THEME_ID_MAP]; + } + + return THEME_IDS.includes(value) ? value : DEFAULT_THEME_ID; +} + +export function getThemeFamily(themeId: string): ThemeFamily { + return getThemeById(themeId).family; +} + +export function getThemeVariant(themeId: string): "dark" | "light" { + return getThemeById(themeId).kind; +} + +export function getThemeIdForFamilyVariant( + family: ThemeFamily, + variant: "dark" | "light" +): string | null { + return THEMES.find((theme) => theme.family === family && theme.kind === variant)?.id ?? null; +} diff --git a/packages/web/src/ui-preview/app.test.tsx b/packages/web/src/ui-preview/app.test.tsx index 6945e5f63..43bccb084 100644 --- a/packages/web/src/ui-preview/app.test.tsx +++ b/packages/web/src/ui-preview/app.test.tsx @@ -26,10 +26,10 @@ describe("UiPreviewApp", () => { }); it("renders the welcome scene and applies theme/lang to the document", async () => { - renderPreview("?scene=welcome&theme=light&locale=en&device=desktop"); + renderPreview("?scene=welcome&theme=mint-light&locale=en&device=desktop"); expect(await screen.findByRole("button", { name: /open workspace/i })).toBeInTheDocument(); - expect(document.documentElement).toHaveAttribute("data-theme", "light"); + expect(document.documentElement).toHaveAttribute("data-theme", "mint-light"); expect(document.documentElement).toHaveAttribute("lang", "en"); expect(document.body.dataset.uiPreviewDevice).toBe("desktop"); }); diff --git a/packages/web/src/ui-preview/app.tsx b/packages/web/src/ui-preview/app.tsx index ac7b0eecb..4dd673db8 100644 --- a/packages/web/src/ui-preview/app.tsx +++ b/packages/web/src/ui-preview/app.tsx @@ -1,5 +1,6 @@ import { MemoryRouter, Route, Routes } from "react-router-dom"; import { EmptyState } from "../components/ui"; +import { getThemeById, resolveStoredThemeId } from "../theme"; import { getUiPreviewScene, type UiPreviewSceneDefinition } from "./catalog"; import type { UiPreviewDevice, UiPreviewLocale, UiPreviewTheme } from "./preview-store"; @@ -19,7 +20,7 @@ export interface UiPreviewRequest { export function resolvePreviewRequest(search: string): UiPreviewRequest { const params = new URLSearchParams(search); const sceneId = params.get("scene") ?? "welcome"; - const theme = params.get("theme") === "light" ? "light" : "dark"; + const theme = resolveStoredThemeId(params.get("theme")); const locale = params.get("locale") === "en" ? "en" : "zh"; const device = params.get("device") === "mobile" ? "mobile" : "desktop"; const scene = getUiPreviewScene(sceneId); @@ -45,7 +46,10 @@ function UnknownScene({ sceneId }: { sceneId: string }) { } export function UiPreviewApp({ request }: { request: UiPreviewRequest }) { - document.documentElement.setAttribute("data-theme", request.theme); + document.documentElement.setAttribute( + "data-theme", + getThemeById(request.theme).documentThemeAttr + ); document.documentElement.setAttribute("lang", request.locale === "zh" ? "zh" : "en"); document.body.dataset.uiPreviewDevice = request.device; diff --git a/packages/web/src/ui-preview/catalog.test.tsx b/packages/web/src/ui-preview/catalog.test.tsx index b61fa7cce..740beef12 100644 --- a/packages/web/src/ui-preview/catalog.test.tsx +++ b/packages/web/src/ui-preview/catalog.test.tsx @@ -31,7 +31,7 @@ function renderScene(sceneId: string, device: "desktop" | "mobile" = "desktop") } installMatchMedia(device); - const context = { theme: "dark" as const, locale: "en" as const, device }; + const context = { theme: "mint-dark" as const, locale: "en" as const, device }; const store = buildUiPreviewStore(scene.seed(context)); const router = scene.router(context); @@ -74,6 +74,7 @@ describe("UI preview catalog", () => { "workspace-desktop", "workspace-mobile", "auth-preview", + "session-gate", "not-found", ]) ); @@ -82,7 +83,7 @@ describe("UI preview catalog", () => { it("marks settings section scenes for capture-time navigation", () => { const scene = getUiPreviewScene("settings-appearance"); expect( - scene?.router({ theme: "dark", locale: "en", device: "desktop" }).initialEntries + scene?.router({ theme: "mint-dark", locale: "en", device: "desktop" }).initialEntries ).toEqual(["/settings"]); expect(scene?.capture?.settingsSection).toBe("appearance"); }); @@ -168,10 +169,33 @@ describe("UI preview catalog", () => { expect(document.querySelector(".workspace-resolving-card")).toBeTruthy(); }); + it("renders the session gate scene through the shared auth shell", async () => { + renderScene("session-gate"); + + expect(await screen.findByRole("button", { name: /re-enter|重新进入/i })).toBeInTheDocument(); + expect(document.querySelector(".auth-card-shell")).toBeTruthy(); + }); + it("renders the file-tree delete confirm scene with the shared danger dialog", async () => { renderScene("file-tree-delete-confirm"); expect(await screen.findByText(/delete preview-file.ts/i)).toBeInTheDocument(); expect(document.querySelector(".modal-card")).toBeTruthy(); }); + + it("renders the workspace icon review scene with file tree and git status content", async () => { + renderScene("workspace-icon-review"); + + expect(await screen.findByText("packages")).toBeInTheDocument(); + expect(document.querySelector(".file-tree-shell")).toBeTruthy(); + expect(document.querySelector(".git-panel, .git-row")).toBeTruthy(); + expect(document.querySelector(".bottom-terminal-empty")).toBeTruthy(); + }); + + it("renders the toast icon review scene with four status tones", async () => { + renderScene("toast-icon-review"); + + expect(await screen.findByText("Workspace opened")).toBeInTheDocument(); + expect(document.querySelectorAll(".toast").length).toBeGreaterThanOrEqual(4); + }); }); diff --git a/packages/web/src/ui-preview/preview-store.ts b/packages/web/src/ui-preview/preview-store.ts index 501a751d9..4b0a0fbcd 100644 --- a/packages/web/src/ui-preview/preview-store.ts +++ b/packages/web/src/ui-preview/preview-store.ts @@ -43,8 +43,9 @@ import { terminalPanelVisibleAtom, worktreeListAtomFamily, } from "../features/workspace/atoms"; +import { resolveStoredThemeId } from "../theme"; -export type UiPreviewTheme = "dark" | "light"; +export type UiPreviewTheme = string; export type UiPreviewLocale = "zh" | "en"; export type UiPreviewDevice = "desktop" | "mobile"; @@ -132,6 +133,9 @@ export interface UiPreviewSeed { mode: "enable" | "edit" | "disable"; draftObjective: string; draftEvaluatorProviderId: "claude" | "codex"; + draftEvaluatorModel?: string; + draftMaxSupervisionCount?: string; + draftScheduledAt?: string; }; commands?: UiPreviewCommands; } @@ -331,7 +335,7 @@ export function buildUiPreviewStore(seed: UiPreviewSeed): Store { const dispatch = createPreviewDispatcher(seed); const workspaces = seed.workspaces ?? []; - store.set(themeAtom, seed.theme); + store.set(themeAtom, resolveStoredThemeId(seed.theme)); store.set(localeAtom, seed.locale); store.set(authEnabledAtom, seed.authEnabled === undefined ? false : seed.authEnabled); store.set(authenticatedAtom, seed.authenticated ?? true); @@ -360,13 +364,23 @@ export function buildUiPreviewStore(seed: UiPreviewSeed): Store { store.set(toastsAtom, seed.toasts ?? []); store.set( supervisorDialogAtom, - seed.supervisorDialog ?? { - open: false, - sessionId: null, - mode: "enable", - draftObjective: "", - draftEvaluatorProviderId: "claude", - } + seed.supervisorDialog + ? { + draftEvaluatorModel: "", + draftMaxSupervisionCount: "0", + draftScheduledAt: "", + ...seed.supervisorDialog, + } + : { + open: false, + sessionId: null, + mode: "enable", + draftObjective: "", + draftEvaluatorProviderId: "claude", + draftEvaluatorModel: "", + draftMaxSupervisionCount: "0", + draftScheduledAt: "", + } ); store.set(supervisorsAtom, new Map(Object.entries(seed.supervisorBySessionId ?? {}))); store.set(supervisorCyclesAtom, new Map()); diff --git a/packages/web/src/ui-preview/scene-metadata.test.ts b/packages/web/src/ui-preview/scene-metadata.test.ts new file mode 100644 index 000000000..ec3a73116 --- /dev/null +++ b/packages/web/src/ui-preview/scene-metadata.test.ts @@ -0,0 +1,33 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { THEME_IDS } from "../theme"; +import { UI_PREVIEW_SCENE_METADATA } from "./scene-metadata"; + +const source = readFileSync(`${process.cwd()}/src/ui-preview/scene-metadata.ts`, "utf8"); + +describe("ui preview scene metadata", () => { + it("registers icon-focused scenes for theme review", () => { + expect(UI_PREVIEW_SCENE_METADATA.map((scene) => scene.id)).toEqual( + expect.arrayContaining([ + "workspace-icon-review", + "toast-icon-review", + "supervisor-icon-review", + ]) + ); + }); + + it("covers every built-in theme for route-backed workspace scenes", () => { + expect( + UI_PREVIEW_SCENE_METADATA.filter( + (scene) => + scene.source === "real-route" && + (scene.id === "workspace-desktop" || scene.id === "workspace-mobile") + ).map((scene) => scene.themes) + ).toEqual([[...THEME_IDS], [...THEME_IDS]]); + }); + + it("enumerates concrete theme ids instead of dark/light buckets", () => { + expect(source).not.toContain('themeIdsForKinds("dark", "light")'); + }); +}); diff --git a/packages/web/src/ui-preview/scene-metadata.ts b/packages/web/src/ui-preview/scene-metadata.ts index 775fa8fb5..076cd6576 100644 --- a/packages/web/src/ui-preview/scene-metadata.ts +++ b/packages/web/src/ui-preview/scene-metadata.ts @@ -1,5 +1,7 @@ +import { THEME_IDS } from "../theme"; + export type UiPreviewSceneDevice = "desktop" | "mobile"; -export type UiPreviewSceneTheme = "dark" | "light"; +export type UiPreviewSceneTheme = (typeof THEME_IDS)[number]; export type UiPreviewSceneLocale = "zh" | "en"; export type UiPreviewCategory = | "page" @@ -28,6 +30,10 @@ export interface UiPreviewSceneMetadata { }; } +function allThemeIds(): UiPreviewSceneTheme[] { + return [...THEME_IDS] as UiPreviewSceneTheme[]; +} + export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ { id: "welcome", @@ -36,7 +42,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "real-route", description: "Welcome page on the real / route under the preview harness.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".welcome-card" }, }, @@ -47,7 +53,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "real-route", description: "Settings page at /settings with deterministic settings.get data.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".settings-page", settingsSection: "general" }, }, @@ -58,7 +64,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "real-route", description: "Settings appearance section using route-backed production UI.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".settings-page", settingsSection: "appearance" }, }, @@ -69,7 +75,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "real-route", description: "Settings providers section with fixed provider args.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".settings-page", settingsSection: "providers" }, }, @@ -80,7 +86,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "real-route", description: "Settings shortcuts section with the keyboard shortcut list and category tabs.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".settings-page", settingsSection: "shortcuts" }, }, @@ -91,7 +97,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "real-route", description: "Mobile settings root list before drilling into any subsection.", devices: ["mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".settings-mobile-list" }, }, @@ -102,7 +108,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "real-route", description: "Top-level shell shown while auth state is still unresolved before routes render.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".app-loading-shell" }, }, @@ -113,7 +119,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "real-route", description: "Desktop workspace shell with seeded workspace, git status, and file tree.", devices: ["desktop"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".workspace-page" }, }, @@ -124,7 +130,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "real-route", description: "Mobile workspace shell with seeded workspace and no active sessions.", devices: ["mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: "[data-testid='mobile-shell']" }, }, @@ -135,7 +141,19 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "real-route", description: "Login/auth page component on the real /login route under preview harness.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), + locales: ["zh", "en"], + capture: { selector: ".auth-card-shell" }, + }, + { + id: "session-gate", + title: "Session Gate", + category: "error", + source: "real-route", + description: + "Session gate shell shown when activation is displaced and the app requires re-entry.", + devices: ["desktop", "mobile"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".auth-card-shell" }, }, @@ -146,7 +164,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "real-route", description: "Not found page for an unknown route path.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".welcome-card" }, }, @@ -157,7 +175,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "real-route", description: "Shared workspace route error shell when workspace list loading fails.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".workspace-resolving-card" }, }, @@ -168,7 +186,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "showcase", description: "Workspace open modal with fixed browse/open responses.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".launch-modal, .mobile-sheet--launch" }, }, @@ -179,7 +197,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "showcase", description: "Command palette forced open with a seeded active workspace.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".command-palette, .command-palette-sheet" }, }, @@ -190,7 +208,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "showcase", description: "Branch picker opened via seeded branchQuickPick atom and fake git.branches data.", devices: ["mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".mobile-select-sheet--command" }, }, @@ -201,10 +219,45 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "showcase", description: "Success and error toasts for visual review.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), + locales: ["zh", "en"], + capture: { selector: ".toast-container" }, + }, + { + id: "workspace-icon-review", + title: "Workspace Icon Review", + category: "page", + source: "showcase", + description: + "File tree, git states, terminal empty state, and mobile dock/supervisor icon surfaces for theme review.", + devices: ["desktop", "mobile"], + themes: allThemeIds(), + locales: ["zh", "en"], + capture: { selector: ".workspace-icon-review" }, + }, + { + id: "toast-icon-review", + title: "Toast Icon Review", + category: "toast", + source: "showcase", + description: + "Success, warning, error, and info toast icons rendered together for theme review.", + devices: ["desktop", "mobile"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".toast-container" }, }, + { + id: "supervisor-icon-review", + title: "Supervisor Icon Review", + category: "modal", + source: "showcase", + description: "Supervisor dialog header icon and destructive callout surface review.", + devices: ["desktop", "mobile"], + themes: allThemeIds(), + locales: ["zh", "en"], + capture: { selector: ".supervisor-dialog, .mobile-supervisor-sheet" }, + }, { id: "mobile-workspace-drawer", title: "Mobile Workspace Drawer", @@ -212,7 +265,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "showcase", description: "Opened mobile workspace drawer with two example workspaces.", devices: ["mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".mobile-workspace-drawer" }, }, @@ -223,7 +276,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "showcase", description: "Static mobile files sheet chrome for screenshot comparison.", devices: ["mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".mobile-sheet--files" }, }, @@ -235,7 +288,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ description: "Mobile terminal fullscreen sheet using xterm placeholder chrome instead of live ws runtime.", devices: ["mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".mobile-sheet--terminal" }, }, @@ -246,7 +299,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "showcase", description: "Mobile supervisor sheet with a seeded supervisor state.", devices: ["mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".mobile-supervisor-sheet" }, }, @@ -257,7 +310,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "showcase", description: "Desktop supervisor objective dialog opened by atom seed.", devices: ["desktop"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".supervisor-dialog" }, }, @@ -268,7 +321,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "showcase", description: "Worktree manager surface with seeded worktree list, status, diff, and tree.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".worktree-manager-surface, .mobile-sheet--worktree" }, }, @@ -279,7 +332,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "showcase", description: "Generic destructive confirm dialog for screenshot review.", devices: ["desktop"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".modal-card" }, }, @@ -290,7 +343,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "showcase", description: "Inline settings error state for provider/config failures.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".settings-page__notice" }, }, @@ -301,7 +354,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "showcase", description: "Shared destructive confirm dialog used by the file tree when deleting a file.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".modal-card" }, }, @@ -312,7 +365,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "showcase", description: "Shared empty-state shell.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".welcome-card" }, }, @@ -323,7 +376,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ source: "showcase", description: "Workspace resolving/loading shell.", devices: ["desktop", "mobile"], - themes: ["dark", "light"], + themes: allThemeIds(), locales: ["zh", "en"], capture: { selector: ".workspace-resolving-card" }, }, diff --git a/packages/web/src/ui-preview/scenes/page-scenes.tsx b/packages/web/src/ui-preview/scenes/page-scenes.tsx index 8fbd72375..77998349d 100644 --- a/packages/web/src/ui-preview/scenes/page-scenes.tsx +++ b/packages/web/src/ui-preview/scenes/page-scenes.tsx @@ -1,5 +1,6 @@ import type { FileNode, GitStatus, Session, Workspace } from "@coder-studio/core"; import { LoginPage } from "../../features/auth"; +import { SessionGatePage } from "../../features/auth/session-gate"; import { NotFoundPage } from "../../features/not-found"; import { SettingsPage } from "../../features/settings"; import { WelcomePage } from "../../features/welcome"; @@ -55,7 +56,7 @@ function buildSettingsSeed(context: UiPreviewSceneContext) { "notifications.soundEnabled": true, "supervisor.evaluationTimeoutSec": 600, "appearance.locale": context.locale, - "appearance.theme": context.theme, + "appearance.themeId": context.theme, "appearance.terminalRenderer": "standard", "providers.claude.additionalArgs": ["--verbose"], "providers.codex.additionalArgs": ["--sandbox", "workspace-write"], @@ -211,6 +212,15 @@ export function createPageScenes(): UiPreviewSceneDefinition[] { }), render: () => , }), + scene("session-gate", { + router: () => ({ initialEntries: ["/session-gate"], path: "/session-gate" }), + seed: (context) => ({ + ...context, + authEnabled: false, + authenticated: false, + }), + render: () => , + }), scene("not-found", { router: () => ({ initialEntries: ["/preview-missing"], path: "*" }), seed: (context) => ({ ...context }), diff --git a/packages/web/src/ui-preview/scenes/showcase-scenes.tsx b/packages/web/src/ui-preview/scenes/showcase-scenes.tsx index aff45701c..4ac2ef79a 100644 --- a/packages/web/src/ui-preview/scenes/showcase-scenes.tsx +++ b/packages/web/src/ui-preview/scenes/showcase-scenes.tsx @@ -1,10 +1,24 @@ import type { FileNode, GitStatus, Supervisor, Workspace, WorktreeInfo } from "@coder-studio/core"; +import { + CircleAlert, + CircleCheckBig, + CircleDot, + File, + FileCode2, + FileJson2, + FileText, + Folder, + Image as ImageIcon, + Terminal, +} from "lucide-react"; import { ConfirmDialog, EmptyState, Notice, Sheet } from "../../components/ui"; import { CommandPalette } from "../../features/command-palette"; import { ToastContainer } from "../../features/notifications"; +import { MobileSupervisorBadge } from "../../features/supervisor/views/mobile/mobile-supervisor-badge"; import { MobileSupervisorSheet } from "../../features/supervisor/views/mobile/mobile-supervisor-sheet"; import { ObjectiveDialog } from "../../features/supervisor/views/shared/objective-dialog"; import { XtermPlaceholder } from "../../features/terminal-panel/views/shared/xterm-placeholder"; +import { MobileDock } from "../../features/workspace/views/mobile/mobile-dock"; import { MobileWorkspaceDrawer } from "../../features/workspace/views/mobile/mobile-workspace-drawer"; import { BranchQuickPick } from "../../features/workspace/views/shared/branch-quick-pick"; import { WorkspaceLaunchModal } from "../../features/workspace/views/shared/workspace-launch-modal"; @@ -35,6 +49,8 @@ const supervisor: Supervisor = { state: "idle", objective: "Review UI regressions before shipping", evaluatorProviderId: "claude", + maxSupervisionCount: 0, + completedSupervisionCount: 0, cycles: [], createdAt: 1, updatedAt: 1, @@ -164,6 +180,209 @@ export function createShowcaseScenes(): UiPreviewSceneDefinition[] { }), render: () => , }), + scene("workspace-icon-review", { + router: () => ({ initialEntries: ["/workspace"], path: "/workspace" }), + seed: (context) => ({ + ...context, + supervisorBySessionId: { + "session-preview-1": supervisor, + }, + }), + render: (context) => + context.device === "mobile" ? ( +
+
+
+ {}} /> +
+
+
+ + packages +
+
+ + app.tsx +
+
+ + theme.json +
+
+ + README.md +
+
+ + Review the terminal empty-state icon and surface treatment. +

+ } + icon={} + title={

No terminal session

} + /> + {}} /> +
+
+ ) : ( +
+
+
+ + packages +
+
+ + app.tsx +
+
+ + theme.json +
+
+ + README.md +
+
+ + logo.png +
+
+ + LICENSE +
+
+
+
+ + staged.ts +
+
+ + modified.ts +
+
+ + deleted.ts +
+
+ + untracked.ts +
+
+ + Review the terminal empty-state icon and surface treatment. +

+ } + icon={} + title={

No terminal session

} + /> +
+ ), + }), + scene("toast-icon-review", { + router: () => ({ initialEntries: ["/"], path: "/" }), + seed: (context) => ({ + ...context, + toasts: [ + { + id: "toast-success", + kind: "success", + title: "Workspace opened", + body: "coder-studio is ready.", + createdAt: 1, + duration: 0, + }, + { + id: "toast-warning", + kind: "warning", + title: "Unsaved config", + body: "Review pending changes before continuing.", + createdAt: 2, + duration: 0, + }, + { + id: "toast-error", + kind: "error", + title: "Failed to refresh provider config", + body: "Retry after checking the provider settings.", + createdAt: 3, + duration: 0, + }, + { + id: "toast-info", + kind: "info", + title: "Theme preview active", + body: "Comparing icon palettes across themes.", + createdAt: 4, + duration: 0, + }, + ], + }), + render: () => , + }), + scene("supervisor-icon-review", { + router: () => ({ initialEntries: ["/workspace"], path: "/workspace" }), + seed: (context) => ({ + ...context, + supervisorBySessionId: { + "session-preview-1": supervisor, + }, + supervisorDialog: { + open: true, + sessionId: "session-preview-1", + mode: "disable", + draftObjective: supervisor.objective, + draftEvaluatorProviderId: "claude", + draftEvaluatorModel: "", + draftMaxSupervisionCount: "0", + draftScheduledAt: "", + }, + }), + render: (context) => + context.device === "mobile" ? ( + {}} + /> + ) : ( + + ), + }), scene("mobile-workspace-drawer", { router: () => ({ initialEntries: ["/workspace"], path: "/workspace" }), seed: (context) => ({ diff --git a/packages/web/src/ws/__tests__/client.test.ts b/packages/web/src/ws/__tests__/client.test.ts index 4b1096cca..7911d18eb 100644 --- a/packages/web/src/ws/__tests__/client.test.ts +++ b/packages/web/src/ws/__tests__/client.test.ts @@ -1214,6 +1214,26 @@ describe("web WsClient", () => { } }); + it("marks the client rejected without reconnecting after single-active displacement", async () => { + vi.useFakeTimers(); + + try { + const client = new WsClient("ws://127.0.0.1:4173/ws"); + const connectPromise = client.connect(); + const socket = MockWebSocket.instances[0]!; + socket.triggerOpen(); + await connectPromise; + + socket.triggerClose(4001, "single_active_displaced"); + + expect(client.getStatus()).toBe("rejected"); + await vi.advanceTimersByTimeAsync(5_000); + expect(MockWebSocket.instances).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + it("can bypass reconnect backoff and reconnect immediately when recovery is requested", async () => { vi.useFakeTimers(); diff --git a/packages/web/src/ws/client.ts b/packages/web/src/ws/client.ts index 33d8a16eb..8624606d9 100644 --- a/packages/web/src/ws/client.ts +++ b/packages/web/src/ws/client.ts @@ -230,6 +230,14 @@ export class WsClient { return; } + if ( + event.reason === "single_active_displaced" || + event.reason === "activation_required" || + event.reason === "activation_revoked" + ) { + this.isManualClose = true; + } + this.handleClose(event.code, event.reason); this.rejectConnectDeferred(new Error(`WebSocket closed: ${event.reason || event.code}`)); }; @@ -652,7 +660,6 @@ export class WsClient { // Check for rejection codes if (code === 4001 || code === 4002) { - // 4001: another_tab_active, 4002: takeover this.setStatus("rejected"); return; } diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 533945486..6dff89a35 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -1,9 +1,10 @@ import react from "@vitejs/plugin-react"; import path from "path"; -import { defineConfig } from "vitest/config"; +import { defineConfig } from "vite"; const backendHttpTarget = process.env.VITE_BACKEND_HTTP_URL || "http://127.0.0.1:4173"; const backendWsTarget = process.env.VITE_BACKEND_WS_URL || "ws://127.0.0.1:4173"; +const disableHmr = process.env.CODER_STUDIO_DISABLE_HMR === "true"; export default defineConfig({ plugins: [react()], @@ -15,6 +16,7 @@ export default defineConfig({ server: { port: 5173, host: true, + hmr: disableHmr ? false : undefined, proxy: { "/ws": { target: backendWsTarget, diff --git a/scripts/demo/record-demo.ts b/scripts/demo/record-demo.ts new file mode 100644 index 000000000..0d8601773 --- /dev/null +++ b/scripts/demo/record-demo.ts @@ -0,0 +1,184 @@ +import { spawn } from "node:child_process"; +import { constants } from "node:fs"; +import { access, mkdir, readdir, rename, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { chromium, type Page } from "playwright"; + +const OUTPUT_DIR = join(process.cwd(), "docs", "assets"); +const OUTPUT_VIDEO = join(OUTPUT_DIR, "demo.mp4"); +const OUTPUT_POSTER = join(OUTPUT_DIR, "demo-poster.png"); +const BASE_URL = process.env.DEMO_BASE_URL ?? "http://127.0.0.1:5173"; + +const SCENES = [ + "ui-preview.html?scene=workspace-desktop&theme=mint-dark&locale=en&device=desktop", + "ui-preview.html?scene=command-palette&theme=mint-dark&locale=en&device=desktop", + "ui-preview.html?scene=workspace-launch-modal&theme=mint-dark&locale=en&device=desktop", + "ui-preview.html?scene=workspace-mobile&theme=mint-dark&locale=en&device=mobile", +] as const; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function pathExists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +function runCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: "inherit" }); + child.once("error", reject); + child.once("close", (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`${command} exited with code ${code ?? -1}`)); + }); + }); +} + +async function waitForUrl(page: Page, url: string): Promise { + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 5_000 }); + if (response?.ok()) { + await page.waitForLoadState("networkidle"); + return; + } + } catch {} + await sleep(500); + } + + throw new Error(`Unable to reach ${url}`); +} + +async function cleanupOldArtifacts(): Promise { + await mkdir(OUTPUT_DIR, { recursive: true }); + await rm(OUTPUT_VIDEO, { force: true }); + await rm(OUTPUT_POSTER, { force: true }); + + const files = await readdir(OUTPUT_DIR); + await Promise.all( + files + .filter((file) => file.endsWith(".webm")) + .map((file) => rm(join(OUTPUT_DIR, file), { force: true })) + ); +} + +async function captureScene(page: Page, scenePath: string, index: number): Promise { + const url = `${BASE_URL}/${scenePath}`; + await waitForUrl(page, url); + await sleep(1200); + + if (index === 0) { + await page.screenshot({ path: OUTPUT_POSTER, fullPage: false }); + } + + if (scenePath.includes("workspace-desktop")) { + await page.mouse.move(180, 150); + await sleep(500); + await page.mouse.move(620, 310, { steps: 12 }); + await sleep(1500); + return; + } + + if (scenePath.includes("command-palette")) { + await page.mouse.move(610, 240); + await sleep(500); + await page.keyboard.press("ArrowDown"); + await sleep(400); + await page.keyboard.press("ArrowDown"); + await sleep(1200); + return; + } + + if (scenePath.includes("workspace-launch-modal")) { + await page.mouse.move(530, 410); + await sleep(500); + await page.mouse.wheel(0, 320); + await sleep(1200); + return; + } + + if (scenePath.includes("workspace-mobile")) { + await page.mouse.move(250, 720); + await sleep(600); + await page.mouse.move(250, 120, { steps: 18 }); + await sleep(1200); + } +} + +async function record(): Promise { + await cleanupOldArtifacts(); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + recordVideo: { + dir: OUTPUT_DIR, + size: { width: 1440, height: 900 }, + }, + }); + const page = await context.newPage(); + + for (const [index, scenePath] of SCENES.entries()) { + await captureScene(page, scenePath, index); + } + + const video = page.video(); + await context.close(); + await browser.close(); + + const recordedPath = await video?.path(); + if (!recordedPath || !(await pathExists(recordedPath))) { + throw new Error("Playwright did not produce a video file"); + } + + return recordedPath; +} + +async function transcodeToMp4(inputPath: string): Promise { + const tempPath = join(OUTPUT_DIR, "demo-source.webm"); + if (inputPath !== tempPath) { + await rename(inputPath, tempPath); + } + + await runCommand("ffmpeg", [ + "-y", + "-i", + tempPath, + "-vf", + "scale=1280:-2", + "-c:v", + "libx264", + "-preset", + "slow", + "-crf", + "28", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + OUTPUT_VIDEO, + ]); + + await rm(tempPath, { force: true }); +} + +async function main(): Promise { + const recordedPath = await record(); + await transcodeToMp4(recordedPath); + console.log(`Saved video to ${OUTPUT_VIDEO}`); + console.log(`Saved poster to ${OUTPUT_POSTER}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +});