From a4af2d737731154b71066e54fc2f6f634adccc5d Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 07:31:12 +0000
Subject: [PATCH 01/95] docs: add supervisor execution policy design
---
...5-11-supervisor-execution-policy-design.md | 489 ++++++++++++++++++
1 file changed, 489 insertions(+)
create mode 100644 docs/superpowers/specs/2026-05-11-supervisor-execution-policy-design.md
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
From 1015a65e1d4da3a24d7f88b18785c20add11cc49 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 07:35:41 +0000
Subject: [PATCH 02/95] docs: add theme skins design spec
---
.../specs/2026-05-11-theme-skins-design.md | 565 ++++++++++++++++++
1 file changed, 565 insertions(+)
create mode 100644 docs/superpowers/specs/2026-05-11-theme-skins-design.md
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 和相关测试矩阵
+
+这样可以先把主题架构站稳,再逐步增加实际皮肤数量,避免在第一步就把改动面拉到最大。
From bbf01ee5e0eef985b56f506d75684d56cb4a9756 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 07:55:07 +0000
Subject: [PATCH 03/95] docs: add theme skins implementation plan
---
.../plans/2026-05-11-theme-skins-system.md | 733 ++++++++++++++++++
1 file changed, 733 insertions(+)
create mode 100644 docs/superpowers/plans/2026-05-11-theme-skins-system.md
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.
From 5b3ff8bc821b033300c2194b52d90e439b83e284 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 08:16:25 +0000
Subject: [PATCH 04/95] feat: add supervisor schema v2 upgrade
---
packages/server/src/__tests__/db.test.ts | 181 ++++++++++-
packages/server/src/storage/db.ts | 180 ++++-------
.../src/storage/migrations/001_init.sql | 16 +-
packages/server/src/storage/schema-version.ts | 296 ++++++++++++++++++
4 files changed, 556 insertions(+), 117 deletions(-)
create mode 100644 packages/server/src/storage/schema-version.ts
diff --git a/packages/server/src/__tests__/db.test.ts b/packages/server/src/__tests__/db.test.ts
index f322b3881..88289c96e 100644
--- a/packages/server/src/__tests__/db.test.ts
+++ b/packages/server/src/__tests__/db.test.ts
@@ -1,10 +1,138 @@
-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";
+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);
+`;
+
describe("Database", () => {
let db: DatabaseSync;
let tempDir: string;
@@ -85,6 +213,55 @@ 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 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();
+
+ expect(() => openDatabase(dbPath)).toThrowError(/db_incompatible_schema/);
+ });
+
it("should create all required tables", () => {
const dbPath = join(tempDir, "test.db");
db = openDatabase(dbPath);
@@ -104,6 +281,7 @@ describe("Database", () => {
"auth_sessions",
"supervisors",
"supervisor_cycles",
+ "supervisor_cycle_attempts",
"auth_login_blocks",
"auth_login_failures",
])
@@ -134,6 +312,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/storage/db.ts b/packages/server/src/storage/db.ts
index 2e9f653b1..1eb4f4b96 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,14 +58,6 @@ 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 {
const reasons = detectLegacySchema(db);
if (reasons.length === 0) {
@@ -127,66 +70,73 @@ function assertNoLegacySchema(db: Database, dbPath: string): void {
);
}
-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 {
+ assertNoLegacySchema(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 +160,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 +181,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/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/schema-version.ts b/packages/server/src/storage/schema-version.ts
new file mode 100644
index 000000000..0cd90090a
--- /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");
+
+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";
+ }
+}
From 5b17689ae98c308eb06e8a63f17a31c12b4b70c8 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 08:23:33 +0000
Subject: [PATCH 05/95] Fix mobile terminal soft key focus behavior
---
.../__tests__/xterm-host.test.tsx | 94 ++++++++-
.../mobile/mobile-terminal-input-bar.test.tsx | 138 +++++++++++++
.../mobile/mobile-terminal-input-bar.tsx | 192 +++++++++++++++++-
.../views/shared/xterm-host.tsx | 25 +--
4 files changed, 427 insertions(+), 22 deletions(-)
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..fc3454c21 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
@@ -14,7 +14,7 @@ import { wsClientAtom } from "../../../atoms/connection";
import { JotaiProvider } from "../../../test-utils/jotai-provider";
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";
@@ -1502,7 +1502,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 +1533,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();
});
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..43456b815 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
@@ -855,10 +855,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) {
@@ -1987,10 +1983,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 +2013,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");
From a35d16fa834cbc8f0cc26a3220e452a39240fe5c Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 08:25:08 +0000
Subject: [PATCH 06/95] fix: type legacy schema startup failures
---
packages/server/src/storage/db.test.ts | 16 ++++++++--------
packages/server/src/storage/db.ts | 9 +++------
2 files changed, 11 insertions(+), 14 deletions(-)
diff --git a/packages/server/src/storage/db.test.ts b/packages/server/src/storage/db.test.ts
index bbe590734..18b16b985 100644
--- a/packages/server/src/storage/db.test.ts
+++ b/packages/server/src/storage/db.test.ts
@@ -79,7 +79,7 @@ describe("database schema baseline", () => {
closeDatabase(db);
});
- it("rejects a legacy database schema that still contains resume_id", async () => {
+ it("rejects a legacy database schema that still contains resume_id with a typed incompatible-schema error", async () => {
const { openDatabase } = await import("./db");
const db = new DatabaseSync(dbPath);
@@ -102,10 +102,10 @@ describe("database schema baseline", () => {
`);
db.close();
- expect(() => openDatabase(dbPath)).toThrow(/Legacy database schema detected/);
+ expect(() => openDatabase(dbPath)).toThrow(/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 db = new DatabaseSync(dbPath);
@@ -117,10 +117,10 @@ describe("database schema baseline", () => {
`);
db.close();
- expect(() => openDatabase(dbPath)).toThrow(/Legacy database schema detected/);
+ expect(() => openDatabase(dbPath)).toThrow(/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 db = new DatabaseSync(dbPath);
@@ -143,10 +143,10 @@ describe("database schema baseline", () => {
`);
db.close();
- expect(() => openDatabase(dbPath)).toThrow(/Legacy database schema detected/);
+ expect(() => openDatabase(dbPath)).toThrow(/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 db = new DatabaseSync(dbPath);
@@ -159,7 +159,7 @@ describe("database schema baseline", () => {
`);
db.close();
- expect(() => openDatabase(dbPath)).toThrow(/Legacy database schema detected/);
+ expect(() => openDatabase(dbPath)).toThrow(/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 1eb4f4b96..e1754e228 100644
--- a/packages/server/src/storage/db.ts
+++ b/packages/server/src/storage/db.ts
@@ -58,16 +58,13 @@ function detectLegacySchema(db: Database): string[] {
return reasons;
}
-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 initializeSchema(db: Database): void {
@@ -112,7 +109,7 @@ function assertCurrentSchema(db: Database, dbPath: string): void {
}
function initializeOrUpgradeSchema(db: Database, dbPath: string): void {
- assertNoLegacySchema(db, dbPath);
+ throwIfLegacySchema(db, dbPath);
const detection = detectSchema(db);
From 01fa95fd37b692ca8635931b9be673c40fc1f212 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 08:38:40 +0000
Subject: [PATCH 07/95] test: strengthen schema version coverage
---
packages/server/src/__tests__/db.test.ts | 167 ++++--------------
packages/server/src/storage/db.test.ts | 69 +++++++-
packages/server/src/storage/schema-version.ts | 2 +-
3 files changed, 104 insertions(+), 134 deletions(-)
diff --git a/packages/server/src/__tests__/db.test.ts b/packages/server/src/__tests__/db.test.ts
index 88289c96e..45a5b07b5 100644
--- a/packages/server/src/__tests__/db.test.ts
+++ b/packages/server/src/__tests__/db.test.ts
@@ -4,134 +4,11 @@ import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { closeDatabase, openDatabase } from "../storage/index.js";
-
-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);
-`;
+import {
+ CURRENT_SCHEMA_VERSION,
+ IncompatibleSchemaError,
+ V1_SCHEMA_SQL,
+} from "../storage/schema-version.js";
describe("Database", () => {
let db: DatabaseSync;
@@ -185,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");
@@ -253,13 +138,37 @@ describe("Database", () => {
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();
- expect(() => openDatabase(dbPath)).toThrowError(/db_incompatible_schema/);
+ 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", () => {
diff --git a/packages/server/src/storage/db.test.ts b/packages/server/src/storage/db.test.ts
index 18b16b985..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("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(/db_incompatible_schema/);
+ 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 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(/db_incompatible_schema/);
+ 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 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(/db_incompatible_schema/);
+ 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 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(/db_incompatible_schema/);
+ 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/schema-version.ts b/packages/server/src/storage/schema-version.ts
index 0cd90090a..df83c8bb7 100644
--- a/packages/server/src/storage/schema-version.ts
+++ b/packages/server/src/storage/schema-version.ts
@@ -35,7 +35,7 @@ const CURRENT_SCHEMA_PATH = join(import.meta.dirname, "migrations", "001_init.sq
export const CURRENT_SCHEMA_SQL = readFileSync(CURRENT_SCHEMA_PATH, "utf-8");
-const V1_SCHEMA_SQL = `
+export const V1_SCHEMA_SQL = `
CREATE TABLE workspaces (
id TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
From b340ddb79dfe002f6670c98eb2ff98c34988f7ee Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 08:51:46 +0000
Subject: [PATCH 08/95] feat: prompt to rebuild incompatible databases
---
packages/cli/src/bin.test.ts | 66 ++++++++++++++++++++++++
packages/cli/src/cli.ts | 70 ++++++++++++++++++++++++--
packages/cli/src/server-runner.test.ts | 47 ++++++++++++++++-
packages/cli/src/server-runner.ts | 13 +++++
4 files changed, 191 insertions(+), 5 deletions(-)
diff --git a/packages/cli/src/bin.test.ts b/packages/cli/src/bin.test.ts
index 5db2ff8dc..7efa5cf5f 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,41 @@ 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);
+ expect(verifyLocalDatabaseCompatibility.mock.invocationCallOrder[0]).toBeLessThan(
+ startManagedServer.mock.invocationCallOrder[0]
+ );
+ 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);
From a2d25d9c520b9f10828bb28e0de992415a0de715 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 08:55:15 +0000
Subject: [PATCH 09/95] feat: persist supervisor execution policy fields
---
packages/core/src/domain/supervisor.ts | 51 ++++-
.../src/__tests__/supervisor-repo.test.ts | 193 ++++++++++++++++++
packages/server/src/storage/index.ts | 5 +
.../supervisor-cycle-attempt-repo.ts | 118 +++++++++++
.../storage/repositories/supervisor-repo.ts | 51 ++++-
5 files changed, 412 insertions(+), 6 deletions(-)
create mode 100644 packages/server/src/storage/repositories/supervisor-cycle-attempt-repo.ts
diff --git a/packages/core/src/domain/supervisor.ts b/packages/core/src/domain/supervisor.ts
index 248295123..4182fb638 100644
--- a/packages/core/src/domain/supervisor.ts
+++ b/packages/core/src/domain/supervisor.ts
@@ -1,10 +1,32 @@
// 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 =
+ | "queued"
+ | "running"
+ | "completed"
+ | "failed"
+ | "cancelled";
export type EvidenceSource = "headless_snapshot" | "transcript" | "terminal_fallback";
@@ -26,6 +48,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 +73,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;
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/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/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..361d4f0fd 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,
From 3b70a33ac28cbec3cef7480b7828249cf395bc2c Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 09:53:01 +0000
Subject: [PATCH 10/95] Fix mobile session restore and visibility
---
.../actions/use-workspace-sessions.ts | 58 +++++--
.../actions/use-workspace-screen-model.ts | 28 +++-
.../views/mobile/workspace-mobile-view.tsx | 5 +-
.../src/shells/mobile-shell/index.test.tsx | 150 ++++++++++++++++++
4 files changed, 221 insertions(+), 20 deletions(-)
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/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/shells/mobile-shell/index.test.tsx b/packages/web/src/shells/mobile-shell/index.test.tsx
index 4fb1bcd50..b85564533 100644
--- a/packages/web/src/shells/mobile-shell/index.test.tsx
+++ b/packages/web/src/shells/mobile-shell/index.test.tsx
@@ -1552,6 +1552,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 +1699,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) => {
From d4db73ecbd61fa3802b6a310f7d6407deb94c7b9 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 11:25:06 +0000
Subject: [PATCH 11/95] feat: complete supervisor execution policy workflow
---
packages/cli/src/bin.test.ts | 8 +-
packages/core/src/domain/supervisor.ts | 54 ++-
packages/providers/src/codex/config-schema.ts | 1 +
.../providers/src/codex/definition.test.ts | 18 +
.../providers/src/codex/supervisor-eval.ts | 1 +
.../src/__tests__/supervisor-commands.test.ts | 66 +++
.../__tests__/supervisor-integration.test.ts | 1 +
.../src/__tests__/supervisor-manager.test.ts | 368 +++++++++++++-
packages/server/src/commands/settings.test.ts | 71 +++
packages/server/src/commands/settings.ts | 49 +-
packages/server/src/commands/supervisor.ts | 21 +-
packages/server/src/server.ts | 3 +
.../storage/repositories/supervisor-repo.ts | 4 +-
.../server/src/supervisor/evaluator.test.ts | 38 ++
packages/server/src/supervisor/evaluator.ts | 23 +-
.../server/src/supervisor/injector.test.ts | 15 +
packages/server/src/supervisor/injector.ts | 17 +-
.../server/src/supervisor/manager.test.ts | 22 +
packages/server/src/supervisor/manager.ts | 454 ++++++++++++++----
.../server/src/supervisor/scheduler.test.ts | 112 +++++
packages/server/src/supervisor/scheduler.ts | 74 +++
packages/server/src/supervisor/settings.ts | 77 +++
packages/web/src/app/providers.test.tsx | 2 +
.../components/session-card.test.tsx | 2 +
.../components/settings-page.test.tsx | 114 +++++
.../settings/components/settings-page.tsx | 337 +++++++++++++
.../actions/use-objective-dialog-state.ts | 68 ++-
.../actions/use-supervisor-actions.ts | 48 +-
.../supervisor/actions/use-supervisor.ts | 4 +
packages/web/src/features/supervisor/atoms.ts | 6 +
.../components/objective-dialog.test.tsx | 201 +++++---
.../components/supervisor-card.test.tsx | 124 +++++
.../mobile/mobile-supervisor-sheet.test.tsx | 68 ++-
.../views/mobile/mobile-supervisor-sheet.tsx | 14 +
.../shared/objective-dialog-content.test.tsx | 61 +++
.../views/shared/objective-dialog-content.tsx | 68 ++-
.../views/shared/objective-dialog.tsx | 14 +-
.../views/shared/supervisor-card.tsx | 24 +-
packages/web/src/locales/en.json | 40 +-
packages/web/src/locales/zh.json | 40 +-
.../src/shells/mobile-shell/index.test.tsx | 20 +
packages/web/src/styles/components.css | 35 ++
packages/web/src/ui-preview/preview-store.ts | 27 +-
.../src/ui-preview/scenes/showcase-scenes.tsx | 2 +
44 files changed, 2569 insertions(+), 247 deletions(-)
diff --git a/packages/cli/src/bin.test.ts b/packages/cli/src/bin.test.ts
index 7efa5cf5f..d7fc1af32 100644
--- a/packages/cli/src/bin.test.ts
+++ b/packages/cli/src/bin.test.ts
@@ -216,8 +216,12 @@ describe("main", () => {
);
expect(verifyLocalDatabaseCompatibility).toHaveBeenCalledTimes(2);
expect(startManagedServer).toHaveBeenCalledTimes(1);
- expect(verifyLocalDatabaseCompatibility.mock.invocationCallOrder[0]).toBeLessThan(
- startManagedServer.mock.invocationCallOrder[0]
+ 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 {
diff --git a/packages/core/src/domain/supervisor.ts b/packages/core/src/domain/supervisor.ts
index 4182fb638..bbe0cc39f 100644
--- a/packages/core/src/domain/supervisor.ts
+++ b/packages/core/src/domain/supervisor.ts
@@ -21,12 +21,7 @@ export type CycleTrigger = "turn_completed" | "manual" | "scheduled";
export type SupervisorStopReason = "objective_complete" | "max_supervision_count_reached";
-export type SupervisorCycleAttemptStatus =
- | "queued"
- | "running"
- | "completed"
- | "failed"
- | "cancelled";
+export type SupervisorCycleAttemptStatus = "evaluating" | "completed" | "failed" | "cancelled";
export type EvidenceSource = "headless_snapshot" | "transcript" | "terminal_fallback";
@@ -74,8 +69,8 @@ export interface Supervisor {
objective: string;
evaluatorProviderId: string;
evaluatorModel?: string;
- maxSupervisionCount?: number;
- completedSupervisionCount?: number;
+ maxSupervisionCount: number;
+ completedSupervisionCount: number;
scheduledAt?: number;
stopReason?: SupervisorStopReason;
cycles: SupervisorCycle[];
@@ -95,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)) {
@@ -108,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/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__/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..190857aeb 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,203 @@ 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("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 +791,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/commands/settings.test.ts b/packages/server/src/commands/settings.test.ts
index 7216cccdc..73de96e2a 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 () => {
@@ -144,6 +166,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 +322,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 +358,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,
});
});
diff --git a/packages/server/src/commands/settings.ts b/packages/server/src/commands/settings.ts
index c434a868e..795920f00 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,6 +56,11 @@ 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
@@ -93,6 +112,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/server.ts b/packages/server/src/server.ts
index 6aca0542c..f71c24511 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";
@@ -168,6 +169,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 +181,7 @@ export async function createServer(
settingsRepo,
supervisorRepo,
cycleRepo,
+ cycleAttemptRepo,
logger: app.log,
});
await sessionMgr.hydrate();
diff --git a/packages/server/src/storage/repositories/supervisor-repo.ts b/packages/server/src/storage/repositories/supervisor-repo.ts
index 361d4f0fd..e46f7bc2a 100644
--- a/packages/server/src/storage/repositories/supervisor-repo.ts
+++ b/packages/server/src/storage/repositories/supervisor-repo.ts
@@ -28,8 +28,8 @@ export interface NewSupervisor {
objective: string;
evaluatorProviderId: string;
evaluatorModel?: string;
- maxSupervisionCount?: number;
- completedSupervisionCount?: number;
+ maxSupervisionCount: number;
+ completedSupervisionCount: number;
scheduledAt?: number;
stopReason?: SupervisorStopReason;
lastCycleAt?: number;
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/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..e67a7e049 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,46 @@ 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 (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 +596,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" &&
@@ -511,12 +610,15 @@ export class SupervisorManager {
const evaluatingSupervisor = this.attachCycles(
this.deps.supervisorRepo.update(supervisor.id, {
state: "evaluating",
+ scheduledAt: trigger === "scheduled" ? 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 +634,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 +668,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 +698,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 +713,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 +734,7 @@ export class SupervisorManager {
const failedSupervisor = this.attachCycles(
this.deps.supervisorRepo.update(supervisorId, {
state: "error",
+ stopReason: null,
errorReason: reason,
updatedAt: Date.now(),
})
@@ -711,10 +751,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 +983,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 +1007,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 +1055,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/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/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/settings/components/settings-page.test.tsx b/packages/web/src/features/settings/components/settings-page.test.tsx
index c62de0ce7..746dfde65 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") {
diff --git a/packages/web/src/features/settings/components/settings-page.tsx b/packages/web/src/features/settings/components/settings-page.tsx
index 152d0f1a8..b19995911 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";
@@ -148,6 +160,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"
);
@@ -219,6 +246,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"
@@ -327,6 +367,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}
@@ -480,6 +530,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 +564,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 +600,16 @@ function GeneralSettings({
setSoundEnabled,
supervisorEvaluationTimeoutSec,
setSupervisorEvaluationTimeoutSec,
+ supervisorRetryEnabled,
+ setSupervisorRetryEnabled,
+ supervisorRetryMaxCount,
+ setSupervisorRetryMaxCount,
+ supervisorRetryDelaySec,
+ setSupervisorRetryDelaySec,
+ supervisorRetryOnTimeout,
+ setSupervisorRetryOnTimeout,
+ supervisorRetryOnEvaluatorError,
+ setSupervisorRetryOnEvaluatorError,
terminalRenderer,
setTerminalRenderer,
terminalCopyOnSelect,
@@ -536,6 +634,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 +668,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 +730,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 (
@@ -823,6 +1019,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 } });
+ }}
+ />
+
);
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..bae5834f4 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,
@@ -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-sheet.test.tsx b/packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx
index 585a1541c..7eae0a057 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(
@@ -121,6 +140,9 @@ describe("MobileSupervisorSheet", () => {
workspaceId: "ws-1",
objective: "Reduce mobile regression bugs",
evaluatorProviderId: "claude",
+ evaluatorModel: undefined,
+ maxSupervisionCount: 0,
+ scheduledAt: undefined,
},
undefined
);
@@ -133,13 +155,7 @@ describe("MobileSupervisorSheet", () => {
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(supervisorDialogAtom, createDialogState());
render(
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..dbc222379 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
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { Button, EmptyState, Sheet } from "../../../../components/ui";
import { useTranslation } from "../../../../lib/i18n";
import {
+ formatScheduledAtInput,
type ObjectiveDialogEvaluatorProviderId,
type ObjectiveDialogMode,
useObjectiveDialogState,
@@ -74,6 +75,9 @@ 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);
};
@@ -109,11 +113,21 @@ export function MobileSupervisorSheet({
mode={mode}
draftObjective={dialog.draftObjective}
draftEvaluatorProviderId={dialog.draftEvaluatorProviderId}
+ draftEvaluatorModel={dialog.draftEvaluatorModel}
+ draftMaxSupervisionCount={dialog.draftMaxSupervisionCount}
+ draftScheduledAt={dialog.draftScheduledAt}
disableObjective={disableObjective}
onDraftObjectiveChange={(draftObjective) => updateDraft({ draftObjective })}
onDraftEvaluatorProviderChange={(draftEvaluatorProviderId) =>
updateDraft({ draftEvaluatorProviderId })
}
+ onDraftEvaluatorModelChange={(draftEvaluatorModel) =>
+ updateDraft({ draftEvaluatorModel })
+ }
+ onDraftMaxSupervisionCountChange={(draftMaxSupervisionCount) =>
+ updateDraft({ draftMaxSupervisionCount })
+ }
+ onDraftScheduledAtChange={(draftScheduledAt) => updateDraft({ draftScheduledAt })}
/>
}
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..01caeed42 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
@@ -32,9 +32,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 +73,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 +101,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 +138,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 +169,41 @@ 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" },
+ });
+ fireEvent.change(screen.getByLabelText("supervisor.field.scheduled_at"), {
+ target: { value: "2026-05-15T00:00" },
+ });
+
+ expect(onDraftEvaluatorModelChange).toHaveBeenCalledWith("gpt-5");
+ expect(onDraftMaxSupervisionCountChange).toHaveBeenCalledWith("8");
+ expect(onDraftScheduledAtChange).toHaveBeenCalledWith("2026-05-15T00:00");
+ });
});
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..e5d880729 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,6 @@
import { AlertTriangle, Eye, Pencil, PowerOff } from "lucide-react";
import { useId } from "react";
-import { Select, type SelectOption, Textarea } from "../../../../components/ui";
+import { Input, Select, type SelectOption, Textarea } from "../../../../components/ui";
import { useTranslation } from "../../../../lib/i18n";
import {
OBJECTIVE_DIALOG_EVALUATOR_OPTIONS,
@@ -18,9 +18,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 +40,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 +115,55 @@ 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")}
+
+
+
+
+
+ onDraftScheduledAtChange(event.target.value)}
+ aria-describedby={scheduledAtHelperId}
+ />
+
+ {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 ? (
-
{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/locales/en.json b/packages/web/src/locales/en.json
index 649eab8d4..f8110143f 100644
--- a/packages/web/src/locales/en.json
+++ b/packages/web/src/locales/en.json
@@ -470,9 +470,19 @@
"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",
@@ -586,16 +596,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,7 +624,14 @@
"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": "Scheduled Run Time",
+ "scheduled_at_helper": "Optional one-shot automatic run time. Leave blank to rely only on turn-completed supervision."
},
"action": {
"enable": "Enable Supervisor",
diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json
index 54b996c51..9467975a3 100644
--- a/packages/web/src/locales/zh.json
+++ b/packages/web/src/locales/zh.json
@@ -470,9 +470,19 @@
"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": "选择终端渲染模式",
@@ -586,16 +596,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,7 +624,14 @@
"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": "定时执行时间",
+ "scheduled_at_helper": "可选的一次性自动执行时间。留空则仅依赖 turn-completed 自动监督。"
},
"action": {
"enable": "启用 Supervisor",
diff --git a/packages/web/src/shells/mobile-shell/index.test.tsx b/packages/web/src/shells/mobile-shell/index.test.tsx
index 4fb1bcd50..d6664288b 100644
--- a/packages/web/src/shells/mobile-shell/index.test.tsx
+++ b/packages/web/src/shells/mobile-shell/index.test.tsx
@@ -409,17 +409,25 @@ function renderMobileShell({
objective: "Ship mobile phase 3",
evaluatorProviderId: "claude",
state: "idle",
+ maxSupervisionCount: 0,
+ completedSupervisionCount: 0,
cycles: [
{
id: "cycle-1",
supervisorId: "sup-1",
+ sessionId: "sess_2",
trigger: "manual",
status: "completed",
+ evidenceSource: "transcript",
+ objective: "Ship mobile phase 3",
+ evaluatorProviderId: "claude",
result: "cycle 1/1",
createdAt: Date.now() - 5_000,
completedAt: Date.now() - 1_000,
},
],
+ createdAt: Date.now() - 5_000,
+ updatedAt: Date.now() - 1_000,
},
};
}
@@ -502,17 +510,25 @@ function renderMobileShell({
objective: "Ship mobile phase 3",
evaluatorProviderId: "claude",
state: "idle",
+ maxSupervisionCount: 0,
+ completedSupervisionCount: 0,
cycles: [
{
id: "cycle-1",
supervisorId: "sup-1",
+ sessionId: "sess_2",
trigger: "manual",
status: "completed",
+ evidenceSource: "transcript",
+ objective: "Ship mobile phase 3",
+ evaluatorProviderId: "claude",
result: "cycle 1/1",
createdAt: Date.now() - 5_000,
completedAt: Date.now() - 1_000,
},
],
+ createdAt: Date.now() - 5_000,
+ updatedAt: Date.now() - 1_000,
},
],
])
@@ -528,8 +544,12 @@ function renderMobileShell({
{
id: "cycle-1",
supervisorId: "sup-1",
+ sessionId: "sess_2",
trigger: "manual",
status: "completed",
+ evidenceSource: "transcript",
+ objective: "Ship mobile phase 3",
+ evaluatorProviderId: "claude",
result: "cycle 1/1",
createdAt: Date.now() - 5_000,
completedAt: Date.now() - 1_000,
diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css
index 0ae9651f7..e2e57a365 100644
--- a/packages/web/src/styles/components.css
+++ b/packages/web/src/styles/components.css
@@ -3233,6 +3233,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;
diff --git a/packages/web/src/ui-preview/preview-store.ts b/packages/web/src/ui-preview/preview-store.ts
index 501a751d9..e923df959 100644
--- a/packages/web/src/ui-preview/preview-store.ts
+++ b/packages/web/src/ui-preview/preview-store.ts
@@ -132,6 +132,9 @@ export interface UiPreviewSeed {
mode: "enable" | "edit" | "disable";
draftObjective: string;
draftEvaluatorProviderId: "claude" | "codex";
+ draftEvaluatorModel?: string;
+ draftMaxSupervisionCount?: string;
+ draftScheduledAt?: string;
};
commands?: UiPreviewCommands;
}
@@ -360,13 +363,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/scenes/showcase-scenes.tsx b/packages/web/src/ui-preview/scenes/showcase-scenes.tsx
index aff45701c..897110c5f 100644
--- a/packages/web/src/ui-preview/scenes/showcase-scenes.tsx
+++ b/packages/web/src/ui-preview/scenes/showcase-scenes.tsx
@@ -35,6 +35,8 @@ const supervisor: Supervisor = {
state: "idle",
objective: "Review UI regressions before shipping",
evaluatorProviderId: "claude",
+ maxSupervisionCount: 0,
+ completedSupervisionCount: 0,
cycles: [],
createdAt: 1,
updatedAt: 1,
From 168eaf95d79103e486121b1b16b1e2b25b21d8a4 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 11:33:19 +0000
Subject: [PATCH 12/95] fix: refresh worktree list after external changes
---
.../src/__tests__/fs/watcher.worktree.test.ts | 97 +++++++++++++++++++
packages/server/src/fs/watcher.ts | 16 +++
2 files changed, 113 insertions(+)
create mode 100644 packages/server/src/__tests__/fs/watcher.worktree.test.ts
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/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.
*/
From 4300a2eeff3913a02e6b3ee9fee6abfb9d2d8e92 Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Mon, 11 May 2026 21:02:52 +0800
Subject: [PATCH 13/95] fix(web): coalesce foreground recovery signals
---
.../web/src/app/providers.lifecycle.test.tsx | 70 ++++++++++++++++++
packages/web/src/app/providers.tsx | 74 +++++++++++++------
2 files changed, 122 insertions(+), 22 deletions(-)
diff --git a/packages/web/src/app/providers.lifecycle.test.tsx b/packages/web/src/app/providers.lifecycle.test.tsx
index 757ab7759..658b24628 100644
--- a/packages/web/src/app/providers.lifecycle.test.tsx
+++ b/packages/web/src/app/providers.lifecycle.test.tsx
@@ -135,6 +135,7 @@ describe("AppProviders lifecycle recovery", () => {
afterEach(() => {
resetAppProvidersSingletonsForTests();
globalThis.fetch = originalFetch;
+ vi.useRealTimers();
vi.restoreAllMocks();
if (originalTerminalPreferences === null) {
localStorage.removeItem("ui.terminalPreferences");
@@ -321,6 +322,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 }),
diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx
index 9c169a94c..c83b18d35 100644
--- a/packages/web/src/app/providers.tsx
+++ b/packages/web/src/app/providers.tsx
@@ -80,6 +80,7 @@ const DEFAULT_REFRESH_HINT: WorkspaceRefreshHint = {
markTreeStale: false,
refreshEditorBuffers: false,
};
+const FOREGROUND_RECOVERY_COOLDOWN_MS = 250;
function shouldMarkTreeStaleForFsReason(reason?: string): boolean {
return reason === "fs_change";
@@ -192,6 +193,7 @@ export function AppProviders({ children }: AppProvidersProps) {
const refreshHintsRef = useRef
- }
- 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}
/>
);
}
@@ -162,6 +214,7 @@ export function MobileSupervisorSheet({
kicker={t("supervisor.title")}
onClose={onClose}
contentClassName="mobile-supervisor-sheet mobile-supervisor-sheet--root"
+ fullscreen
body={
{supervisor ? (
@@ -176,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 01caeed42..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,
}));
@@ -198,12 +199,8 @@ describe("ObjectiveDialogContent", () => {
fireEvent.change(screen.getByLabelText("supervisor.field.max_supervision_count"), {
target: { value: "8" },
});
- fireEvent.change(screen.getByLabelText("supervisor.field.scheduled_at"), {
- target: { value: "2026-05-15T00:00" },
- });
expect(onDraftEvaluatorModelChange).toHaveBeenCalledWith("gpt-5");
expect(onDraftMaxSupervisionCountChange).toHaveBeenCalledWith("8");
- expect(onDraftScheduledAtChange).toHaveBeenCalledWith("2026-05-15T00:00");
});
});
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 e5d880729..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 { Input, 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,
@@ -152,12 +158,13 @@ export function ObjectiveDialogContent({
-
onDraftScheduledAtChange(event.target.value)}
+ onValueChange={onDraftScheduledAtChange}
+ placeholder={t("supervisor.field.scheduled_at_placeholder")}
+ clearable
+ minDate={new Date()}
aria-describedby={scheduledAtHelperId}
/>
diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json
index f8110143f..3bfcd907d 100644
--- a/packages/web/src/locales/en.json
+++ b/packages/web/src/locales/en.json
@@ -1,4 +1,32 @@
{
+ "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",
+ "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"
@@ -630,6 +658,7 @@
"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": "Optional one-shot automatic run time. Leave blank to rely only on turn-completed supervision."
},
diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json
index 9467975a3..1a157b67b 100644
--- a/packages/web/src/locales/zh.json
+++ b/packages/web/src/locales/zh.json
@@ -1,4 +1,32 @@
{
+ "datetime": {
+ "today": "今天",
+ "tomorrow": "明天",
+ "next_week": "下周",
+ "clear": "清空",
+ "confirm": "确认",
+ "select_date": "选择日期",
+ "select_time": "选择时间",
+ "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"
@@ -630,6 +658,7 @@
"evaluator_model_helper": "留空则沿用该 provider 当前默认的 supervisor 评估模型逻辑。",
"max_supervision_count": "最大监督次数",
"max_supervision_count_helper": "填 0 表示不设上限。若目标已达成,评估仍可提前停止。",
+ "scheduled_at_placeholder": "选择执行时间",
"scheduled_at": "定时执行时间",
"scheduled_at_helper": "可选的一次性自动执行时间。留空则仅依赖 turn-completed 自动监督。"
},
From cc3b13e25300bb94cb71db3f6059e0edf3c5d4cc Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Mon, 11 May 2026 22:38:32 +0800
Subject: [PATCH 16/95] feat(web): implement calendar grid and time selector
for DateTimePicker
- Add CalendarGrid component with month navigation and date selection
- Add TimeSelector component with hour/minute dropdowns
- Integrate calendar and time selector into main DateTimePicker component
- Support minDate/maxDate constraints for date selection
- Add i18n keys for month navigation in both en and zh locales
- All tests passing for both datetime-picker and supervisor features
---
.../ui/datetime-picker/calendar-grid.tsx | 158 ++++++++++++++++++
.../ui/datetime-picker/index.module.css | 142 ++++++++++++++++
.../components/ui/datetime-picker/index.tsx | 97 ++++++++---
.../ui/datetime-picker/time-selector.tsx | 72 ++++++++
packages/web/src/locales/en.json | 2 +
packages/web/src/locales/zh.json | 2 +
6 files changed, 451 insertions(+), 22 deletions(-)
create mode 100644 packages/web/src/components/ui/datetime-picker/calendar-grid.tsx
create mode 100644 packages/web/src/components/ui/datetime-picker/time-selector.tsx
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
index 92407c169..06a5d9e41 100644
--- a/packages/web/src/components/ui/datetime-picker/index.module.css
+++ b/packages/web/src/components/ui/datetime-picker/index.module.css
@@ -113,3 +113,145 @@
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 */
diff --git a/packages/web/src/components/ui/datetime-picker/index.tsx b/packages/web/src/components/ui/datetime-picker/index.tsx
index 34dedf0fc..d813524a4 100644
--- a/packages/web/src/components/ui/datetime-picker/index.tsx
+++ b/packages/web/src/components/ui/datetime-picker/index.tsx
@@ -5,7 +5,9 @@ import { formatDate, 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";
@@ -64,25 +66,65 @@ export function DateTimePicker({
const t = useTranslation();
const viewport = useViewport();
const triggerId = useId();
- const contentId = useId();
const [open, setOpen] = useState(false);
- const [draftDate, setDraftDate] = useState(() => parseLocalDateTime(value));
- useEffect(() => {
- setDraftDate(parseLocalDateTime(value));
+ // Initialize draft from value
+ const getInitialDraft = useCallback(() => {
+ const parsed = parseLocalDateTime(value);
+ if (parsed) {
+ return {
+ year: parsed.getFullYear(),
+ month: parsed.getMonth(),
+ day: parsed.getDate(),
+ hour: parsed.getHours(),
+ minute: parsed.getMinutes(),
+ };
+ }
+ const now = new Date();
+ return {
+ year: now.getFullYear(),
+ month: now.getMonth(),
+ day: now.getDate(),
+ hour: now.getHours(),
+ minute: 0,
+ };
}, [value]);
+ const [draft, setDraft] = useState(getInitialDraft);
+
+ // Update draft when value changes externally
+ useEffect(() => {
+ setDraft(getInitialDraft());
+ }, [value, getInitialDraft]);
+
+ 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 }));
+ }, []);
+
+ const handleHourChange = useCallback((hour: number) => {
+ setDraft((prev) => ({ ...prev, hour }));
+ }, []);
+
+ const handleMinuteChange = useCallback((minute: number) => {
+ setDraft((prev) => ({ ...prev, minute }));
+ }, []);
+
const handleConfirm = useCallback(() => {
- if (draftDate) {
- onValueChange(formatLocalDateTime(draftDate));
- } else {
- onValueChange("");
- }
+ const date = new Date(draft.year, draft.month, draft.day, draft.hour, draft.minute);
+ onValueChange(formatLocalDateTime(date));
setOpen(false);
- }, [draftDate, onValueChange]);
+ }, [draft, onValueChange]);
const handleClear = useCallback(() => {
- setDraftDate(null);
onValueChange("");
setOpen(false);
}, [onValueChange]);
@@ -108,7 +150,6 @@ export function DateTimePicker({
disabled={disabled}
aria-haspopup={isMobile ? "dialog" : "menu"}
aria-expanded={open}
- aria-controls={open ? contentId : undefined}
aria-label={label}
aria-describedby={ariaDescribedBy}
className={triggerClasses}
@@ -119,19 +160,31 @@ export function DateTimePicker({
);
+ const selectedDate = value ? parseLocalDateTime(value) : null;
+ const calendarMinDate = minDate
+ ? new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())
+ : undefined;
+
const content: ReactNode = (
-
- {label}
-
- {/* Calendar grid placeholder */}
-
- Calendar Grid
-
- {/* Time selector placeholder */}
-
-
Time Selector
+
+
+ {t("datetime.select_time")}
+
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..6d80968c9
--- /dev/null
+++ b/packages/web/src/components/ui/datetime-picker/time-selector.tsx
@@ -0,0 +1,72 @@
+import clsx from "clsx";
+import { useId } from "react";
+import { Select, type SelectOption } from "../select";
+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;
+}
+
+function generateHourOptions(): ReadonlyArray
> {
+ return Array.from({ length: 24 }, (_, i) => ({
+ value: String(i),
+ label: String(i).padStart(2, "0"),
+ }));
+}
+
+function generateMinuteOptions(): ReadonlyArray> {
+ return Array.from({ length: 60 }, (_, i) => ({
+ value: String(i),
+ label: String(i).padStart(2, "0"),
+ }));
+}
+
+const HOUR_OPTIONS = generateHourOptions();
+const MINUTE_OPTIONS = generateMinuteOptions();
+
+export function TimeSelector({
+ hour,
+ minute,
+ onHourChange,
+ onMinuteChange,
+ disabled = false,
+}: TimeSelectorProps) {
+ const hourId = useId();
+ const minuteId = useId();
+
+ return (
+
+
+
+
+
:
+
+
+
+
+ );
+}
diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json
index 3bfcd907d..df2294a24 100644
--- a/packages/web/src/locales/en.json
+++ b/packages/web/src/locales/en.json
@@ -3,6 +3,8 @@
"today": "Today",
"tomorrow": "Tomorrow",
"next_week": "Next Week",
+ "prev_month": "Previous Month",
+ "next_month": "Next Month",
"clear": "Clear",
"confirm": "Confirm",
"select_date": "Select Date",
diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json
index 1a157b67b..34eba6b0d 100644
--- a/packages/web/src/locales/zh.json
+++ b/packages/web/src/locales/zh.json
@@ -3,6 +3,8 @@
"today": "今天",
"tomorrow": "明天",
"next_week": "下周",
+ "prev_month": "上一月",
+ "next_month": "下一月",
"clear": "清空",
"confirm": "确认",
"select_date": "选择日期",
From c9f8caa56b1670374de7ab4ddff538e67ce0fbf0 Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Mon, 11 May 2026 23:03:16 +0800
Subject: [PATCH 17/95] fix(server): respect scheduled execution time for
turn_completed triggers
- If scheduledAt is set and hasn't arrived yet, skip evaluation on turn_completed
- Only after the scheduled time passes will turn_completed triggers proceed
- Update helper text to clarify the behavior: first evaluation at scheduled time, then auto-trigger on each turn completion
- Fixes behavior where scheduled time was only checked for 'scheduled' trigger but not for 'turn_completed'
---
packages/server/src/supervisor/manager.ts | 13 +++++++++++++
packages/web/src/locales/en.json | 2 +-
packages/web/src/locales/zh.json | 2 +-
3 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/packages/server/src/supervisor/manager.ts b/packages/server/src/supervisor/manager.ts
index e67a7e049..8dd34d9fa 100644
--- a/packages/server/src/supervisor/manager.ts
+++ b/packages/server/src/supervisor/manager.ts
@@ -574,6 +574,19 @@ export class SupervisorManager {
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;
diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json
index df2294a24..e2c923114 100644
--- a/packages/web/src/locales/en.json
+++ b/packages/web/src/locales/en.json
@@ -662,7 +662,7 @@
"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": "Optional one-shot automatic run time. Leave blank to rely only on turn-completed supervision."
+ "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",
diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json
index 34eba6b0d..43ae76fa5 100644
--- a/packages/web/src/locales/zh.json
+++ b/packages/web/src/locales/zh.json
@@ -662,7 +662,7 @@
"max_supervision_count_helper": "填 0 表示不设上限。若目标已达成,评估仍可提前停止。",
"scheduled_at_placeholder": "选择执行时间",
"scheduled_at": "定时执行时间",
- "scheduled_at_helper": "可选的一次性自动执行时间。留空则仅依赖 turn-completed 自动监督。"
+ "scheduled_at_helper": "指定首次执行时间。到达后开始执行评估,之后每轮对话结束自动触发。留空则每轮对话结束自动触发评估。"
},
"action": {
"enable": "启用 Supervisor",
From 91f8c606aeb3e49f1022e346dd075c07919f6080 Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Mon, 11 May 2026 23:10:05 +0800
Subject: [PATCH 18/95] fix(web): normalize supervisor action button labels
- Change 'Edit Objective' to 'Edit Supervisor' for consistency
- Change 'Disable Supervisor' to 'Disable' to keep button text short
- Aligns with other buttons that use short labels without repeating context
---
packages/web/src/locales/en.json | 4 ++--
packages/web/src/locales/zh.json | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json
index e2c923114..e54b56f71 100644
--- a/packages/web/src/locales/en.json
+++ b/packages/web/src/locales/en.json
@@ -667,8 +667,8 @@
"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 43ae76fa5..b4807b083 100644
--- a/packages/web/src/locales/zh.json
+++ b/packages/web/src/locales/zh.json
@@ -667,8 +667,8 @@
"action": {
"enable": "启用 Supervisor",
"enable_objective": "启用目标",
- "edit_objective": "编辑目标",
- "disable": "禁用 Supervisor",
+ "edit_objective": "编辑 Supervisor",
+ "disable": "禁用",
"pause": "暂停",
"resume": "恢复",
"trigger": "触发评估",
From dfecdc0748923eef1ec04897960b7e3d207082a2 Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Mon, 11 May 2026 23:18:34 +0800
Subject: [PATCH 19/95] fix(web): use native number inputs for time selector in
DateTimePicker
- Replace Select dropdowns with native number inputs for hour/minute
- Fixes PC/desktop popover not opening Select dropdowns properly
- Add max-width constraints for popover and sheet body to prevent overflow
- Inputs support direct typing and validation (hour: 0-23, minute: 0-59)
---
.../ui/datetime-picker/index.module.css | 13 ++++
.../ui/datetime-picker/time-selector.tsx | 70 +++++++++----------
2 files changed, 46 insertions(+), 37 deletions(-)
diff --git a/packages/web/src/components/ui/datetime-picker/index.module.css b/packages/web/src/components/ui/datetime-picker/index.module.css
index 06a5d9e41..337bcff36 100644
--- a/packages/web/src/components/ui/datetime-picker/index.module.css
+++ b/packages/web/src/components/ui/datetime-picker/index.module.css
@@ -255,3 +255,16 @@
}
/* 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/time-selector.tsx b/packages/web/src/components/ui/datetime-picker/time-selector.tsx
index 6d80968c9..5d0ce45fb 100644
--- a/packages/web/src/components/ui/datetime-picker/time-selector.tsx
+++ b/packages/web/src/components/ui/datetime-picker/time-selector.tsx
@@ -1,6 +1,5 @@
import clsx from "clsx";
-import { useId } from "react";
-import { Select, type SelectOption } from "../select";
+import { Input } from "../input";
import styles from "./index.module.css";
interface TimeSelectorProps {
@@ -11,23 +10,6 @@ interface TimeSelectorProps {
readonly disabled?: boolean;
}
-function generateHourOptions(): ReadonlyArray> {
- return Array.from({ length: 24 }, (_, i) => ({
- value: String(i),
- label: String(i).padStart(2, "0"),
- }));
-}
-
-function generateMinuteOptions(): ReadonlyArray> {
- return Array.from({ length: 60 }, (_, i) => ({
- value: String(i),
- label: String(i).padStart(2, "0"),
- }));
-}
-
-const HOUR_OPTIONS = generateHourOptions();
-const MINUTE_OPTIONS = generateMinuteOptions();
-
export function TimeSelector({
hour,
minute,
@@ -35,36 +17,50 @@ export function TimeSelector({
onMinuteChange,
disabled = false,
}: TimeSelectorProps) {
- 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 (
From caf1d41853a1e27e47a3905fd10bfa0e23e571c4 Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Mon, 11 May 2026 23:21:37 +0800
Subject: [PATCH 20/95] feat(server): add optional CSP header relaxation for
browser extension compatibility
xterm.js and Monaco Editor use eval/new Function internally for performance.
When browser extensions (uBlock Origin, AdGuard) inject strict CSP, these
libraries fail silently:
- xterm.js: terminal shows only [Process exited with code 0]
- Monaco Editor: code editor renders blank
Add config option (env: RELAX_CSP=true) to inject a permissive
CSP header allowing 'unsafe-eval', 'unsafe-inline', and WebSocket connections.
---
packages/server/src/app.ts | 28 ++++++++++++++++++++++++++++
packages/server/src/config.ts | 2 ++
2 files changed, 30 insertions(+)
diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts
index 42511f3ac..35c62ec47 100644
--- a/packages/server/src/app.ts
+++ b/packages/server/src/app.ts
@@ -87,6 +87,34 @@ export async function buildFastifyApp(deps: AppDeps): Promise {
})
);
+ // CSP Header Injection (optional, controlled by `relaxCsp` config)
+ //
+ // xterm.js and Monaco Editor internally use `eval` and `new Function` for
+ // performance optimizations (e.g., JIT-compiled rendering loops). When a
+ // browser extension (e.g., uBlock Origin, AdGuard) injects a strict CSP,
+ // these libraries silently fail:
+ // - xterm.js: terminal shows only "[Process exited with code 0]"
+ // - Monaco Editor: code editor panel renders blank
+ //
+ // When `relaxCsp: true`, we inject a permissive CSP header that allows
+ // 'unsafe-eval' and 'unsafe-inline', plus WebSocket connections (ws:/wss:).
+ // This is intended for local development scenarios where browser extensions
+ // interfere. For production, leave this disabled and configure CSP at the
+ // reverse proxy level.
+ if (deps.config.relaxCsp) {
+ app.addHook("onRequest", async (_request, reply) => {
+ reply.header(
+ "Content-Security-Policy",
+ "default-src 'self'; " +
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
+ "style-src 'self' 'unsafe-inline'; " +
+ "connect-src 'self' ws: wss:; " +
+ "img-src 'self' data: blob:; " +
+ "font-src 'self' data:;"
+ );
+ });
+ }
+
await app.register(compress);
await app.register(multipart, {
diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts
index 1c2c42c8c..aac3f648b 100644
--- a/packages/server/src/config.ts
+++ b/packages/server/src/config.ts
@@ -18,6 +18,7 @@ export interface ServerConfig {
dataDir: string;
uploadsDir: string;
logLevel: "trace" | "debug" | "info" | "warn" | "error";
+ relaxCsp: boolean;
webRoot?: string;
appVersion?: string;
auth: {
@@ -121,6 +122,7 @@ export function parseServerConfig(overrides?: Partial): ServerConf
dataDir,
uploadsDir,
logLevel: overrides?.logLevel ?? parseLogLevel(process.env.LOG_LEVEL) ?? "info",
+ relaxCsp: overrides?.relaxCsp ?? process.env.RELAX_CSP === "true",
webRoot: overrides?.webRoot,
appVersion:
overrides?.appVersion ?? process.env.CODER_STUDIO_APP_VERSION ?? resolveDefaultAppVersion(),
From 0088df5605742536798b203d2b0df530297a6fc2 Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Mon, 11 May 2026 23:28:21 +0800
Subject: [PATCH 21/95] test(web): add desktop popover rendering test for
DateTimePicker
---
.../ui/datetime-picker/index.test.tsx | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/packages/web/src/components/ui/datetime-picker/index.test.tsx b/packages/web/src/components/ui/datetime-picker/index.test.tsx
index e7b2a98c2..7a5c4c395 100644
--- a/packages/web/src/components/ui/datetime-picker/index.test.tsx
+++ b/packages/web/src/components/ui/datetime-picker/index.test.tsx
@@ -161,3 +161,22 @@ describe("DateTimePicker", () => {
expect(trigger).toHaveAttribute("aria-describedby", "helper");
});
});
+
+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");
+ });
+});
From 75d388ad680ce27cb189431fc5864fc148e3cc9b Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Mon, 11 May 2026 23:31:18 +0800
Subject: [PATCH 22/95] fix(web): increase popover z-index to modal level for
visibility
- Change popover z-index from --z-dropdown (100) to --z-modal (400)
- Fixes desktop popover being hidden behind other UI elements
---
packages/web/src/components/ui/popover/index.module.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/web/src/components/ui/popover/index.module.css b/packages/web/src/components/ui/popover/index.module.css
index 516e64dfb..4d17185ec 100644
--- a/packages/web/src/components/ui/popover/index.module.css
+++ b/packages/web/src/components/ui/popover/index.module.css
@@ -11,5 +11,5 @@
margin-top: 0;
overflow: auto;
outline: none;
- z-index: var(--z-dropdown);
+ z-index: var(--z-modal);
}
From b3560283a03714148754f8c23d2b742c650b73db Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Mon, 11 May 2026 23:34:29 +0800
Subject: [PATCH 23/95] fix(web): add background and border to popover content
- Add padding, border, border-radius, and background to popover .content
- Fixes popover appearing invisible without background styling
---
packages/web/src/components/ui/popover/index.module.css | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/packages/web/src/components/ui/popover/index.module.css b/packages/web/src/components/ui/popover/index.module.css
index 4d17185ec..45184224b 100644
--- a/packages/web/src/components/ui/popover/index.module.css
+++ b/packages/web/src/components/ui/popover/index.module.css
@@ -12,4 +12,8 @@
overflow: auto;
outline: none;
z-index: var(--z-modal);
+ padding: var(--sp-3);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: var(--bg-surface);
}
From ed5bbac732ce5efc0f9de27dc1569f80746cf88b Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Mon, 11 May 2026 23:51:55 +0800
Subject: [PATCH 24/95] fix: mobile supervisor button label should always show
'Supervisor'
---
.../views/mobile/mobile-supervisor-badge.tsx | 15 +++------------
1 file changed, 3 insertions(+), 12 deletions(-)
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;
From fa807170479296d28474f7a738aa6549336db9ae Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 16:14:15 +0000
Subject: [PATCH 25/95] Add shared theme registry and resolver
---
packages/core/src/domain/types.ts | 2 +-
packages/web/src/theme/index.ts | 16 +
packages/web/src/theme/registry.test.ts | 55 +++
packages/web/src/theme/registry.ts | 453 ++++++++++++++++++++++++
packages/web/src/theme/resolve.test.ts | 34 ++
packages/web/src/theme/resolve.ts | 40 +++
6 files changed, 599 insertions(+), 1 deletion(-)
create mode 100644 packages/web/src/theme/index.ts
create mode 100644 packages/web/src/theme/registry.test.ts
create mode 100644 packages/web/src/theme/registry.ts
create mode 100644 packages/web/src/theme/resolve.test.ts
create mode 100644 packages/web/src/theme/resolve.ts
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/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..8ddef586c
--- /dev/null
+++ b/packages/web/src/theme/registry.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { THEME_IDS, THEMES } from "./index";
+
+describe("theme registry", () => {
+ it("contains the first-phase theme ids", () => {
+ 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);
+ }
+ });
+
+ it("flags high contrast themes explicitly", () => {
+ const highContrastThemes = THEMES.filter((theme) => theme.family === "hc");
+
+ expect(highContrastThemes).toHaveLength(2);
+
+ for (const theme of highContrastThemes) {
+ expect(theme.isHighContrast).toBe(true);
+ }
+ });
+});
diff --git a/packages/web/src/theme/registry.ts b/packages/web/src/theme/registry.ts
new file mode 100644
index 000000000..67604a433
--- /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: Array<{
+ token: string;
+ foreground?: string;
+ background?: string;
+ fontStyle?: string;
+ }>;
+ colors: Record;
+}
+
+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: "#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",
+};
+
+const THEMES_REGISTRY: AppThemeDefinition[] = [
+ {
+ id: "mint-dark",
+ family: "mint",
+ kind: "dark",
+ labelKey: "settings.appearance.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.appearance.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: "1a7f37" },
+ { token: "keyword", foreground: "0969da" },
+ ],
+ colors: {
+ "editor.background": "#fafbfc",
+ "editor.foreground": "#1f2328",
+ "editorLineNumber.foreground": "#8b949e",
+ "editorCursor.foreground": "#0969da",
+ "editor.selectionBackground": "#dde4ea",
+ },
+ },
+ },
+ {
+ id: "graphite-dark",
+ family: "graphite",
+ kind: "dark",
+ labelKey: "settings.appearance.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.appearance.theme.graphite_light",
+ pairedThemeId: "graphite-dark",
+ isHighContrast: false,
+ documentThemeAttr: "graphite-light",
+ terminalTheme: {
+ background: "#f3f4f6",
+ foreground: "#1f2933",
+ cursor: "#4b5563",
+ cursorAccent: "#f3f4f6",
+ selectionBackground: "#d6d9df",
+ selectionForeground: "#111317",
+ black: "#111827",
+ red: "#c2410c",
+ green: "#15803d",
+ yellow: "#a16207",
+ blue: "#1d4ed8",
+ magenta: "#7c3aed",
+ cyan: "#0f766e",
+ white: "#6b7280",
+ brightBlack: "#9ca3af",
+ brightRed: "#ea580c",
+ brightGreen: "#16a34a",
+ brightYellow: "#ca8a04",
+ brightBlue: "#2563eb",
+ brightMagenta: "#8b5cf6",
+ brightCyan: "#14b8a6",
+ brightWhite: "#111827",
+ },
+ monaco: {
+ base: "vs",
+ inherit: true,
+ rules: [
+ { token: "comment", foreground: "6b7280" },
+ { token: "string", foreground: "15803d" },
+ { token: "keyword", foreground: "1d4ed8" },
+ ],
+ colors: {
+ "editor.background": "#f3f4f6",
+ "editor.foreground": "#1f2933",
+ "editorLineNumber.foreground": "#9ca3af",
+ "editorCursor.foreground": "#4b5563",
+ "editor.selectionBackground": "#d6d9df",
+ },
+ },
+ },
+ {
+ id: "nord-dark",
+ family: "nord",
+ kind: "dark",
+ labelKey: "settings.appearance.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.appearance.theme.nord_light",
+ pairedThemeId: "nord-dark",
+ isHighContrast: false,
+ documentThemeAttr: "nord-light",
+ terminalTheme: {
+ background: "#eceff4",
+ foreground: "#2e3440",
+ cursor: "#5e81ac",
+ cursorAccent: "#eceff4",
+ selectionBackground: "#d8dee9",
+ selectionForeground: "#2e3440",
+ black: "#3b4252",
+ red: "#bf616a",
+ green: "#4c7a5b",
+ yellow: "#a77f2f",
+ blue: "#5e81ac",
+ magenta: "#8f5b9c",
+ cyan: "#3f7c8b",
+ white: "#4c566a",
+ brightBlack: "#7b88a1",
+ brightRed: "#d08770",
+ brightGreen: "#5f8f70",
+ brightYellow: "#b08d49",
+ brightBlue: "#6b8fb8",
+ brightMagenta: "#9b6aa8",
+ brightCyan: "#4c8e9f",
+ brightWhite: "#2e3440",
+ },
+ monaco: {
+ base: "vs",
+ inherit: true,
+ rules: [
+ { token: "comment", foreground: "7b88a1" },
+ { token: "string", foreground: "4c7a5b" },
+ { token: "keyword", foreground: "5e81ac" },
+ ],
+ colors: {
+ "editor.background": "#eceff4",
+ "editor.foreground": "#2e3440",
+ "editorLineNumber.foreground": "#7b88a1",
+ "editorCursor.foreground": "#5e81ac",
+ "editor.selectionBackground": "#d8dee9",
+ },
+ },
+ },
+ {
+ id: "hc-dark",
+ family: "hc",
+ kind: "dark",
+ labelKey: "settings.appearance.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.appearance.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);
diff --git a/packages/web/src/theme/resolve.test.ts b/packages/web/src/theme/resolve.test.ts
new file mode 100644
index 000000000..1471e28f3
--- /dev/null
+++ b/packages/web/src/theme/resolve.test.ts
@@ -0,0 +1,34 @@
+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");
+ });
+});
+
+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..627735339
--- /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 (value in LEGACY_THEME_ID_MAP) {
+ 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;
+}
From 1b05f57a3e5d5c69af6d2c6a14ba2f094421ace0 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 16:23:22 +0000
Subject: [PATCH 26/95] Harden theme alias resolution
---
packages/web/src/theme/registry.test.ts | 29 ++++++++++++++++++++-----
packages/web/src/theme/registry.ts | 8 +++----
packages/web/src/theme/resolve.test.ts | 2 ++
packages/web/src/theme/resolve.ts | 2 +-
4 files changed, 30 insertions(+), 11 deletions(-)
diff --git a/packages/web/src/theme/registry.test.ts b/packages/web/src/theme/registry.test.ts
index 8ddef586c..e76e013a1 100644
--- a/packages/web/src/theme/registry.test.ts
+++ b/packages/web/src/theme/registry.test.ts
@@ -3,6 +3,7 @@ import { THEME_IDS, THEMES } from "./index";
describe("theme registry", () => {
it("contains the first-phase theme ids", () => {
+ expect(THEMES).toHaveLength(8);
expect(THEME_IDS).toEqual(
expect.arrayContaining([
"mint-dark",
@@ -40,16 +41,32 @@ describe("theme registry", () => {
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("flags high contrast themes explicitly", () => {
- const highContrastThemes = THEMES.filter((theme) => theme.family === "hc");
-
- expect(highContrastThemes).toHaveLength(2);
+ 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"],
+ });
+ });
- for (const theme of highContrastThemes) {
- expect(theme.isHighContrast).toBe(true);
+ 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");
}
});
});
diff --git a/packages/web/src/theme/registry.ts b/packages/web/src/theme/registry.ts
index 67604a433..b8cde97fa 100644
--- a/packages/web/src/theme/registry.ts
+++ b/packages/web/src/theme/registry.ts
@@ -29,13 +29,13 @@ export interface TerminalThemeDefinition {
export interface MonacoThemeDefinition {
base: "vs" | "vs-dark" | "hc-black" | "hc-light";
inherit: boolean;
- rules: Array<{
+ rules: ReadonlyArray<{
token: string;
foreground?: string;
background?: string;
fontStyle?: string;
}>;
- colors: Record;
+ colors: Readonly>;
}
export interface AppThemeDefinition {
@@ -100,7 +100,7 @@ const mintLightTerminal: TerminalThemeDefinition = {
brightWhite: "#1f2328",
};
-const THEMES_REGISTRY: AppThemeDefinition[] = [
+const THEMES_REGISTRY: ReadonlyArray = [
{
id: "mint-dark",
family: "mint",
@@ -450,4 +450,4 @@ const THEMES_REGISTRY: AppThemeDefinition[] = [
];
export const THEMES = THEMES_REGISTRY;
-export const THEME_IDS = THEMES_REGISTRY.map((theme) => theme.id);
+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
index 1471e28f3..fbdeecd9d 100644
--- a/packages/web/src/theme/resolve.test.ts
+++ b/packages/web/src/theme/resolve.test.ts
@@ -13,6 +13,8 @@ describe("resolveStoredThemeId", () => {
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");
});
});
diff --git a/packages/web/src/theme/resolve.ts b/packages/web/src/theme/resolve.ts
index 627735339..48f53ba70 100644
--- a/packages/web/src/theme/resolve.ts
+++ b/packages/web/src/theme/resolve.ts
@@ -17,7 +17,7 @@ export function resolveStoredThemeId(value: unknown): string {
return DEFAULT_THEME_ID;
}
- if (value in LEGACY_THEME_ID_MAP) {
+ if (Object.hasOwn(LEGACY_THEME_ID_MAP, value)) {
return LEGACY_THEME_ID_MAP[value as keyof typeof LEGACY_THEME_ID_MAP];
}
From 164327e4f19075c1cf7d3352d50e613899f27fa1 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 16:36:53 +0000
Subject: [PATCH 27/95] Migrate theme settings bootstrap to themeId
---
packages/server/src/commands/settings.test.ts | 45 ++++++++
packages/server/src/commands/settings.ts | 1 +
.../web/src/app/providers.lifecycle.test.tsx | 101 +++++++++++++++++-
packages/web/src/app/providers.tsx | 77 ++++++++++---
packages/web/src/atoms/app-ui.ts | 51 ++++++++-
5 files changed, 259 insertions(+), 16 deletions(-)
diff --git a/packages/server/src/commands/settings.test.ts b/packages/server/src/commands/settings.test.ts
index 73de96e2a..9ddd4dd63 100644
--- a/packages/server/src/commands/settings.test.ts
+++ b/packages/server/src/commands/settings.test.ts
@@ -114,6 +114,29 @@ 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 rejects fractional supervisor timeout values", async () => {
const result = await dispatch(
{
@@ -386,6 +409,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 795920f00..c599bb834 100644
--- a/packages/server/src/commands/settings.ts
+++ b/packages/server/src/commands/settings.ts
@@ -66,6 +66,7 @@ const SettingsSchema = z.object({
appearance: z
.object({
theme: z.enum(["dark"]).optional(),
+ themeId: z.string().optional(),
terminalRenderer: z.enum(["standard", "compatibility"]).optional(),
terminalCopyOnSelect: z.boolean().optional(),
locale: z.enum(["zh", "en"]).optional(),
diff --git a/packages/web/src/app/providers.lifecycle.test.tsx b/packages/web/src/app/providers.lifecycle.test.tsx
index 658b24628..4eaed9da3 100644
--- a/packages/web/src/app/providers.lifecycle.test.tsx
+++ b/packages/web/src/app/providers.lifecycle.test.tsx
@@ -2,7 +2,7 @@ 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 { authenticatedAtom, themeAtom } from "../atoms/app-ui";
import { authEnabledAtom, connectionStatusAtom } from "../atoms/connection";
import {
activeWorkspaceIdAtom,
@@ -98,10 +98,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 }),
@@ -142,6 +148,21 @@ describe("AppProviders lifecycle recovery", () => {
} 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 {
@@ -484,6 +505,84 @@ 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");
+ });
+ });
+
+ 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("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.tsx b/packages/web/src/app/providers.tsx
index c83b18d35..0db1c017c 100644
--- a/packages/web/src/app/providers.tsx
+++ b/packages/web/src/app/providers.tsx
@@ -33,7 +33,7 @@ import {
workspacesLoadStateAtom,
wsClientAtom,
} from "../atoms";
-import { authenticatedAtom } from "../atoms/app-ui";
+import { authenticatedAtom, themeAtom } from "../atoms/app-ui";
import type { DispatchCommand } from "../atoms/connection";
import { activeWorkspaceIdAtom } from "../atoms/workspaces";
import { useSessionNotifications } from "../features/notifications";
@@ -50,6 +50,7 @@ import {
gitStateAtomFamily,
worktreeListAtomFamily,
} from "../features/workspace/atoms";
+import { getThemeById, resolveStoredThemeId } from "../theme";
import type { ConnectionStatus, EventListener } from "../ws";
import { resolveWsUrl, WsClient } from "../ws";
@@ -81,11 +82,41 @@ const DEFAULT_REFRESH_HINT: WorkspaceRefreshHint = {
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);
@@ -160,6 +191,7 @@ interface AppProvidersProps {
export function AppProviders({ children }: AppProvidersProps) {
const [, setWsClient] = useAtom(wsClientAtom);
+ const setTheme = useSetAtom(themeAtom);
const authEnabled = useAtomValue(authEnabledAtom);
const authenticated = useAtomValue(authenticatedAtom);
const connectionStatus = useAtomValue(connectionStatusAtom);
@@ -248,19 +280,40 @@ export function AppProviders({ children }: AppProvidersProps) {
// 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
- }
- }
+ applyResolvedTheme(readStoredThemePreference());
}, []);
+ useEffect(() => {
+ if (connectionStatus !== "connected") {
+ return;
+ }
+
+ let cancelled = false;
+
+ const hydrateTheme = async () => {
+ const result = await dispatch>("settings.get", {});
+ if (cancelled || !result.ok || !result.data) {
+ return;
+ }
+
+ const settings = result.data;
+ const resolvedThemeId = applyResolvedTheme(
+ settings["appearance.themeId"] ??
+ settings["appearance.theme"] ??
+ readStoredThemePreference()
+ );
+
+ setTheme(resolvedThemeId);
+ localStorage.setItem(THEME_ID_STORAGE_KEY, JSON.stringify(resolvedThemeId));
+ };
+
+ void hydrateTheme();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [connectionStatus, dispatch, setTheme]);
+
useEffect(() => {
const loadAuthStatus = async () => {
try {
diff --git a/packages/web/src/atoms/app-ui.ts b/packages/web/src/atoms/app-ui.ts
index fef4add73..02eb3e001 100644
--- a/packages/web/src/atoms/app-ui.ts
+++ b/packages/web/src/atoms/app-ui.ts
@@ -5,13 +5,58 @@
*/
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,
+});
+
+export function resolveStoredThemeAtomValue(value: unknown): string {
+ return resolveStoredThemeId(value);
+}
/**
* Locale preference
From 8c08d8b7d15ff3f6f958fbf985aa7c2948871576 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 16:40:43 +0000
Subject: [PATCH 28/95] Migrate theme bootstrap to theme IDs
---
packages/web/src/atoms/app-ui.ts | 4 ----
1 file changed, 4 deletions(-)
diff --git a/packages/web/src/atoms/app-ui.ts b/packages/web/src/atoms/app-ui.ts
index 02eb3e001..9146e6f11 100644
--- a/packages/web/src/atoms/app-ui.ts
+++ b/packages/web/src/atoms/app-ui.ts
@@ -54,10 +54,6 @@ export const themeAtom = atomWithStorage(THEME_ID_STORAGE_KEY, "mint-dar
getOnInit: true,
});
-export function resolveStoredThemeAtomValue(value: unknown): string {
- return resolveStoredThemeId(value);
-}
-
/**
* Locale preference
* Persisted: ui.locale
From 2b2961b63f93ab6bf1dd0ba0f003e216eff65875 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 16:56:37 +0000
Subject: [PATCH 29/95] Backfill legacy theme cache migration
---
.../web/src/app/providers.lifecycle.test.tsx | 33 +++++++++++++++++++
packages/web/src/app/providers.tsx | 6 ++--
2 files changed, 37 insertions(+), 2 deletions(-)
diff --git a/packages/web/src/app/providers.lifecycle.test.tsx b/packages/web/src/app/providers.lifecycle.test.tsx
index 4eaed9da3..d527f29cc 100644
--- a/packages/web/src/app/providers.lifecycle.test.tsx
+++ b/packages/web/src/app/providers.lifecycle.test.tsx
@@ -512,6 +512,7 @@ describe("AppProviders lifecycle recovery", () => {
await vi.waitFor(() => {
expect(document.documentElement.getAttribute("data-theme")).toBe("mint-light");
+ expect(localStorage.getItem("ui.themeId")).toBe(JSON.stringify("mint-light"));
});
});
@@ -583,6 +584,38 @@ describe("AppProviders lifecycle recovery", () => {
});
});
+ 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 terminal copy-on-select update when startup hydration resolves later", async () => {
const store = createStore();
setVisibilityState("visible");
diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx
index 0db1c017c..683909208 100644
--- a/packages/web/src/app/providers.tsx
+++ b/packages/web/src/app/providers.tsx
@@ -280,8 +280,10 @@ export function AppProviders({ children }: AppProvidersProps) {
// Initialize theme from localStorage
useEffect(() => {
- applyResolvedTheme(readStoredThemePreference());
- }, []);
+ const resolvedThemeId = applyResolvedTheme(readStoredThemePreference());
+ setTheme(resolvedThemeId);
+ localStorage.setItem(THEME_ID_STORAGE_KEY, JSON.stringify(resolvedThemeId));
+ }, [setTheme]);
useEffect(() => {
if (connectionStatus !== "connected") {
From 0d1e4cfd0d9e84111ea2af0c9e1751cf6a88575d Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 17:01:21 +0000
Subject: [PATCH 30/95] Refactor theme settings to family and variant
---
.../components/settings-page.test.tsx | 86 ++++++++++++---
.../settings/components/settings-page.tsx | 101 ++++++++++++++----
packages/web/src/locales/en.json | 10 +-
packages/web/src/locales/zh.json | 10 +-
4 files changed, 170 insertions(+), 37 deletions(-)
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 746dfde65..40ac9e8c0 100644
--- a/packages/web/src/features/settings/components/settings-page.test.tsx
+++ b/packages/web/src/features/settings/components/settings-page.test.tsx
@@ -1022,13 +1022,19 @@ describe("SettingsPage", () => {
renderSettingsPage(store);
fireEvent.click(screen.getByRole("button", { name: "外观" }));
- const darkThemePill = await screen.findByRole("button", { name: "深色" });
- const lightThemePill = screen.getByRole("button", { name: "浅色" });
+ const mintThemePill = await screen.findByRole("button", { name: "Mint" });
+ const darkVariantPill = screen.getByRole("button", { name: "深色" });
+ const lightVariantPill = screen.getByRole("button", { name: "浅色" });
const chineseLanguagePill = screen.getByRole("button", { name: "中文" });
expect(
screen.getByRole("group", {
- name: "主题",
+ name: "主题系列",
+ })
+ ).toHaveAccessibleDescription("选择应用主题");
+ expect(
+ screen.getByRole("group", {
+ name: "明暗模式",
})
).toHaveAccessibleDescription("选择应用主题");
expect(
@@ -1038,10 +1044,9 @@ 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(mintThemePill).toHaveAttribute("aria-pressed", "true");
+ expect(darkVariantPill).toHaveAttribute("aria-pressed", "true");
+ expect(lightVariantPill).toHaveAttribute("aria-pressed", "false");
expect(chineseLanguagePill).toHaveAttribute("aria-pressed", "true");
});
@@ -1107,7 +1112,8 @@ describe("SettingsPage", () => {
expect(screen.queryByText("选中自动复制")).not.toBeInTheDocument();
});
- it("updates theme selection through the shared appearance pills", async () => {
+ it("updates theme family and variant through the shared appearance pills", async () => {
+ window.localStorage.setItem("ui.locale", JSON.stringify("en"));
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
if (op === "settings.get") {
return {};
@@ -1117,8 +1123,34 @@ 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" }));
+
+ 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();
+
+ fireEvent.click(screen.getByRole("button", { name: "Graphite" }));
+
+ await waitFor(() => {
+ expect(sendCommand).toHaveBeenCalledWith(
+ "settings.update",
+ {
+ settings: {
+ appearance: {
+ themeId: "graphite-dark",
+ },
+ },
+ },
+ undefined
+ );
+ });
+
+ expect(document.documentElement).toHaveAttribute("data-theme", "graphite-dark");
+
+ fireEvent.click(screen.getByRole("button", { name: "Light" }));
await waitFor(() => {
expect(sendCommand).toHaveBeenCalledWith(
@@ -1126,7 +1158,7 @@ describe("SettingsPage", () => {
{
settings: {
appearance: {
- theme: "light",
+ themeId: "graphite-light",
},
},
},
@@ -1134,9 +1166,35 @@ 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");
+ expect(document.documentElement).toHaveAttribute("data-theme", "graphite-light");
+ expect(screen.getByRole("button", { name: "Graphite" })).toHaveAttribute(
+ "aria-pressed",
+ "true"
+ );
+ expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "false");
+ });
+
+ it("hydrates theme family and variant 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: "Nord" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "false");
+ });
});
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 b19995911..255574125 100644
--- a/packages/web/src/features/settings/components/settings-page.tsx
+++ b/packages/web/src/features/settings/components/settings-page.tsx
@@ -35,6 +35,14 @@ import { resolvedActiveWorkspaceIdAtom } from "../../../atoms/workspaces";
import { Input, Notice, Pill, Switch } from "../../../components/ui";
import { useViewport } from "../../../hooks/use-viewport";
import { useTranslation } from "../../../lib/i18n";
+import {
+ getThemeById,
+ getThemeFamily,
+ getThemeIdForFamilyVariant,
+ getThemeVariant,
+ resolveStoredThemeId,
+ type ThemeFamily,
+} from "../../../theme";
import { notificationPreferencesAtom } from "../../notifications/atoms";
import { MobilePageHeader } from "../../shared/components/mobile-page-header";
import {
@@ -65,6 +73,8 @@ type SettingsNavigationState =
type SettingsContentLayoutMode = "default" | "fill-height";
const DEFAULT_SETTINGS_SECTION: SettingsSection = SETTINGS_SECTIONS[0].id;
+const THEME_FAMILIES: ReadonlyArray = ["mint", "graphite", "nord", "hc"];
+const THEME_VARIANTS = ["dark", "light"] as const;
function isStandaloneWebApp(): boolean {
if (typeof window === "undefined") {
@@ -297,6 +307,14 @@ export function SettingsPage() {
setLocaleState(settings["appearance.locale"]);
}
}
+ const resolvedThemeId = resolveStoredThemeId(
+ settings["appearance.themeId"] ?? settings["appearance.theme"]
+ );
+ setTheme(resolvedThemeId);
+ document.documentElement.setAttribute(
+ "data-theme",
+ getThemeById(resolvedThemeId).documentThemeAttr
+ );
setProviderAdditionalArgsById(loadProviderAdditionalArgs(settings, providers));
};
@@ -310,6 +328,7 @@ export function SettingsPage() {
setLocaleState,
setNotificationPreferences,
setTerminalPreferences,
+ setTheme,
settingsRefreshKey,
]);
@@ -1168,26 +1187,46 @@ 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 themeFamilyTitleId = useId();
+ const themeVariantTitleId = useId();
const languageTitleId = useId();
const languageDescId = useId();
const dispatch = useAtomValue(dispatchCommandAtom);
+ const currentThemeId = resolveStoredThemeId(theme);
+ const currentThemeFamily = getThemeFamily(currentThemeId);
+ const currentThemeVariant = getThemeVariant(currentThemeId);
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);
+ setTheme(resolvedTheme.id);
+ document.documentElement.setAttribute("data-theme", resolvedTheme.documentThemeAttr);
+ void saveSettings({ appearance: { themeId: resolvedTheme.id } });
+ };
+
+ const handleThemeFamilyChange = (family: ThemeFamily) => {
+ const nextThemeId = getThemeIdForFamilyVariant(family, currentThemeVariant);
+ if (nextThemeId) {
+ handleThemeChange(nextThemeId);
+ }
+ };
+
+ const handleThemeVariantChange = (variant: (typeof THEME_VARIANTS)[number]) => {
+ const nextThemeId = getThemeIdForFamilyVariant(currentThemeFamily, variant);
+ if (nextThemeId) {
+ handleThemeChange(nextThemeId);
+ }
};
return (
@@ -1200,26 +1239,46 @@ function AppearanceSettings({ locale, setLocale, theme, setTheme }: AppearanceSe
{t("settings.theme.hint")}
+
+ {t("settings.theme.family")}
+
-
: undefined}
- onClick={() => handleThemeChange("dark")}
- active={theme === "dark"}
- >
- {t("settings.theme.dark")}
-
-
: undefined}
- onClick={() => handleThemeChange("light")}
- active={theme === "light"}
- >
- {t("settings.theme.light")}
-
+ {THEME_FAMILIES.map((family) => (
+
: undefined}
+ onClick={() => handleThemeFamilyChange(family)}
+ active={currentThemeFamily === family}
+ >
+ {t(`settings.theme.family_${family}`)}
+
+ ))}
+
+
+
+ {t("settings.theme.variant")}
+
+
+ {THEME_VARIANTS.map((variant) => (
+
: undefined}
+ onClick={() => handleThemeVariantChange(variant)}
+ active={currentThemeVariant === variant}
+ >
+ {t(`settings.theme.variant_${variant}`)}
+
+ ))}
diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json
index e54b56f71..2f31c2573 100644
--- a/packages/web/src/locales/en.json
+++ b/packages/web/src/locales/en.json
@@ -553,7 +553,15 @@
"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",
+ "variant_dark": "Dark",
+ "variant_light": "Light"
},
"language": {
"title": "Language",
diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json
index b4807b083..1af41c8f3 100644
--- a/packages/web/src/locales/zh.json
+++ b/packages/web/src/locales/zh.json
@@ -553,7 +553,15 @@
"dark": "深色",
"light": "浅色",
"system": "跟随系统",
- "hint": "选择应用主题"
+ "hint": "选择应用主题",
+ "family": "主题系列",
+ "variant": "明暗模式",
+ "family_mint": "Mint",
+ "family_graphite": "Graphite",
+ "family_nord": "Nord",
+ "family_hc": "高对比",
+ "variant_dark": "深色",
+ "variant_light": "浅色"
},
"language": {
"title": "语言",
From 59536f81a50212ffc6c7cd0350612db7f2da61ba Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 17:12:19 +0000
Subject: [PATCH 31/95] Harden theme settings hydration
---
.../components/settings-page.test.tsx | 94 +++++++++++++++++++
.../settings/components/settings-page.tsx | 37 ++++++--
2 files changed, 122 insertions(+), 9 deletions(-)
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 40ac9e8c0..e14dc2c15 100644
--- a/packages/web/src/features/settings/components/settings-page.test.tsx
+++ b/packages/web/src/features/settings/components/settings-page.test.tsx
@@ -1197,6 +1197,100 @@ describe("SettingsPage", () => {
});
});
+ 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: "Mint" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "true");
+ 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 {};
+ }
+ return {};
+ });
+ const store = createConnectedStore(sendCommand);
+
+ renderSettingsPage(store);
+ fireEvent.click(screen.getByRole("button", { name: "Appearance" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Graphite" })).toHaveAttribute(
+ "aria-pressed",
+ "true"
+ );
+ expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "true");
+ 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: "Graphite" }));
+
+ await waitFor(() => {
+ expect(sendCommand).toHaveBeenCalledWith(
+ "settings.update",
+ {
+ settings: {
+ appearance: {
+ themeId: "graphite-dark",
+ },
+ },
+ },
+ undefined
+ );
+ });
+
+ await act(async () => {
+ resolveSettingsGet?.({
+ "appearance.themeId": "nord-light",
+ });
+ await settingsGetPromise;
+ });
+
+ expect(screen.getByRole("button", { name: "Graphite" })).toHaveAttribute(
+ "aria-pressed",
+ "true"
+ );
+ expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "true");
+ expect(document.documentElement).toHaveAttribute("data-theme", "graphite-dark");
+ });
+
it("updates terminal renderer selection through the shared general pills", async () => {
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
if (op === "settings.get") {
diff --git a/packages/web/src/features/settings/components/settings-page.tsx b/packages/web/src/features/settings/components/settings-page.tsx
index 255574125..975db6c8b 100644
--- a/packages/web/src/features/settings/components/settings-page.tsx
+++ b/packages/web/src/features/settings/components/settings-page.tsx
@@ -201,6 +201,7 @@ export function SettingsPage() {
const setTerminalPreferences = useSetAtom(terminalPreferencesAtom);
const settingsLoadFailedUnknownRef = useRef(settingsLoadFailedUnknown);
const appearanceSelectionVersionRef = useRef({
+ theme: 0,
locale: 0,
terminalRenderer: 0,
terminalCopyOnSelect: 0,
@@ -307,14 +308,23 @@ export function SettingsPage() {
setLocaleState(settings["appearance.locale"]);
}
}
- const resolvedThemeId = resolveStoredThemeId(
- settings["appearance.themeId"] ?? settings["appearance.theme"]
- );
- setTheme(resolvedThemeId);
- document.documentElement.setAttribute(
- "data-theme",
- getThemeById(resolvedThemeId).documentThemeAttr
- );
+ 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));
};
@@ -337,6 +347,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);
@@ -408,7 +423,7 @@ export function SettingsPage() {
locale={locale}
setLocale={handleLocaleSelection}
theme={theme}
- setTheme={setTheme}
+ setTheme={handleThemeSelection}
/>
);
case "providers":
@@ -1210,6 +1225,10 @@ function AppearanceSettings({ locale, setLocale, theme, setTheme }: AppearanceSe
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 } });
From e69883fb19bf7909f25356bf8d6328d5dc3f2d50 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 17:16:37 +0000
Subject: [PATCH 32/95] Add named CSS theme token blocks
---
packages/web/src/styles/tokens-touch.test.ts | 11 +
packages/web/src/styles/tokens.css | 398 ++++++++++++++++---
2 files changed, 356 insertions(+), 53 deletions(-)
diff --git a/packages/web/src/styles/tokens-touch.test.ts b/packages/web/src/styles/tokens-touch.test.ts
index d4c38b072..f0fbdfef3 100644
--- a/packages/web/src/styles/tokens-touch.test.ts
+++ b/packages/web/src/styles/tokens-touch.test.ts
@@ -21,6 +21,17 @@ function getRuleBlock(selector: string): string {
}
describe("tokens.css touch tokens", () => {
+ 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");
diff --git a/packages/web/src/styles/tokens.css b/packages/web/src/styles/tokens.css
index 34ad91ed8..08d856c6e 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,9 +131,57 @@
--touch-hit-slop: 0px;
}
-/* ========== Phase 4: Light Theme ========== */
-[data-theme="light"] {
- /* Backgrounds - inverted for light mode */
+: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;
+
+ /* ========== 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: #f8fafb;
--bg-surface: #ffffff;
--bg-sidebar: #f4f6f8;
@@ -187,33 +191,33 @@
--bg-disabled: #f0f3f6;
--bg-input: #ffffff;
- /* Borders */
+ /* ========== Borders ========== */
--border: #d0d7de;
--border-light: #e1e4e8;
--border-focus: #0969da;
--border-error: #cf222e;
- /* Text */
+ /* ========== Text ========== */
--text-primary: #1f2328;
--text-secondary: #57606a;
--text-tertiary: #8b949e;
--text-disabled: #6e7681;
--text-inverse: #ffffff;
- /* Accents - adjusted for light mode visibility */
+ /* ========== Accents ========== */
--accent-blue: #0969da;
--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;
- /* 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);
@@ -224,6 +228,294 @@
--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;
+
+ /* ========== 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: #edf1f5;
+ --bg-surface: #ffffff;
+ --bg-sidebar: #e7ebf0;
+ --bg-terminal: #f3f4f6;
+ --bg-hover: #dde3ea;
+ --bg-active: #d2d9e2;
+ --bg-disabled: #eef2f6;
+ --bg-input: #ffffff;
+
+ /* ========== Borders ========== */
+ --border: #c7d0da;
+ --border-light: #d8dee6;
+ --border-focus: #2563eb;
+ --border-error: #d14343;
+
+ /* ========== Text ========== */
+ --text-primary: #1f2933;
+ --text-secondary: #52606d;
+ --text-tertiary: #7b8794;
+ --text-disabled: #9aa5b1;
+ --text-inverse: #ffffff;
+
+ /* ========== Accents ========== */
+ --accent-blue: #1d4ed8;
+ --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: #1d4ed8;
+
+ /* ========== 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(29, 78, 216, 0.18);
+
+ /* 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;
+
+ /* ========== 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: #e8ecf2;
+ --bg-surface: #eceff4;
+ --bg-sidebar: #e1e7ef;
+ --bg-terminal: #eceff4;
+ --bg-hover: #dde3eb;
+ --bg-active: #d8dee9;
+ --bg-disabled: #edf1f6;
+ --bg-input: #f7f9fc;
+
+ /* ========== Borders ========== */
+ --border: #c6ceda;
+ --border-light: #d8dee9;
+ --border-focus: #5e81ac;
+ --border-error: #bf616a;
+
+ /* ========== Text ========== */
+ --text-primary: #2e3440;
+ --text-secondary: #4c566a;
+ --text-tertiary: #7b88a1;
+ --text-disabled: #93a0b7;
+ --text-inverse: #ffffff;
+
+ /* ========== Accents ========== */
+ --accent-blue: #5e81ac;
+ --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: #5e81ac;
+
+ /* ========== 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(94, 129, 172, 0.2);
+
+ /* 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;
+
+ /* ========== 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;
+
+ /* ========== 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 {
From 99ad351746c3280821f426087c35625c5b440b3a Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 17:19:59 +0000
Subject: [PATCH 33/95] Route editor and terminal through theme registry
---
.../components/monaco-host.test.tsx | 21 ++++--
.../code-editor/components/monaco-host.tsx | 13 +++-
.../__tests__/xterm-host.test.tsx | 56 ++++++++--------
.../views/shared/xterm-host.tsx | 65 +------------------
4 files changed, 61 insertions(+), 94 deletions(-)
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/terminal-panel/__tests__/xterm-host.test.tsx b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx
index fc3454c21..9365b7187 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
@@ -12,6 +12,7 @@ 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 { terminalMetaAtomFamily, terminalOutputAtomFamily } from "../atoms";
@@ -834,6 +835,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 +856,7 @@ describe("XtermHost", () => {
);
await act(async () => {
- store.set(themeAtom, "light");
+ store.set(themeAtom, "mint-light");
});
await act(async () => {
@@ -866,10 +868,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 +1090,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 +1110,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 +1126,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");
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 43456b815..69f3ea1b8 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";
@@ -215,66 +216,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;
@@ -585,7 +526,7 @@ export function XtermHost({
useEffect(() => {
if (terminalRef.current) {
- terminalRef.current.options.theme = getTerminalTheme(uiTheme);
+ terminalRef.current.options.theme = getThemeById(uiTheme).terminalTheme;
}
}, [uiTheme]);
@@ -1092,7 +1033,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,
From 8425e32d4a85a26d909f4bd2102cea3c945d7a6c Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 17:20:20 +0000
Subject: [PATCH 34/95] Route editor and terminal through theme registry
---
.../src/features/terminal-panel/__tests__/xterm-host.test.tsx | 1 -
1 file changed, 1 deletion(-)
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 9365b7187..9a7adfd17 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
@@ -1797,7 +1797,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");
From 66c0854cb5a226817039d89c4062527a16f911c0 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Mon, 11 May 2026 17:28:51 +0000
Subject: [PATCH 35/95] Upgrade preview and e2e theme ids
---
e2e-ui/fixtures/prefs.ts | 4 +-
e2e-ui/report/build-report.test.ts | 14 ++--
e2e-ui/report/build-report.ts | 2 +-
e2e-ui/scenes/index.ts | 2 +-
e2e/specs/quality/general.spec.ts | 18 ++---
e2e/specs/settings/general.spec.ts | 4 ++
packages/web/src/ui-preview/app.test.tsx | 4 +-
packages/web/src/ui-preview/app.tsx | 8 ++-
packages/web/src/ui-preview/catalog.test.tsx | 4 +-
packages/web/src/ui-preview/preview-store.ts | 5 +-
packages/web/src/ui-preview/scene-metadata.ts | 71 +++++++++++--------
.../web/src/ui-preview/scenes/page-scenes.tsx | 2 +-
12 files changed, 81 insertions(+), 57 deletions(-)
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..d328ebfbb 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,9 +22,9 @@ 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",
});
});
@@ -37,15 +37,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/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..a99815158 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);
@@ -82,7 +82,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");
});
diff --git a/packages/web/src/ui-preview/preview-store.ts b/packages/web/src/ui-preview/preview-store.ts
index e923df959..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";
@@ -334,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);
diff --git a/packages/web/src/ui-preview/scene-metadata.ts b/packages/web/src/ui-preview/scene-metadata.ts
index 775fa8fb5..70b3f5b12 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, type ThemeKind } 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,19 @@ export interface UiPreviewSceneMetadata {
};
}
+const THEME_IDS_BY_KIND = {
+ dark: THEME_IDS.filter((themeId) => themeId.endsWith("-dark")),
+ light: THEME_IDS.filter((themeId) => themeId.endsWith("-light")),
+} satisfies Record;
+
+function allThemeIds(): UiPreviewSceneTheme[] {
+ return [...THEME_IDS] as UiPreviewSceneTheme[];
+}
+
+function themeIdsForKinds(...kinds: ThemeKind[]): UiPreviewSceneTheme[] {
+ return kinds.flatMap((kind) => THEME_IDS_BY_KIND[kind]) as UiPreviewSceneTheme[];
+}
+
export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [
{
id: "welcome",
@@ -36,7 +51,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 +62,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 +73,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 +84,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 +95,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 +106,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 +117,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 +128,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: themeIdsForKinds("dark", "light"),
locales: ["zh", "en"],
capture: { selector: ".workspace-page" },
},
@@ -124,7 +139,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: themeIdsForKinds("dark", "light"),
locales: ["zh", "en"],
capture: { selector: "[data-testid='mobile-shell']" },
},
@@ -135,7 +150,7 @@ 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" },
},
@@ -146,7 +161,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 +172,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 +183,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 +194,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 +205,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,7 +216,7 @@ 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" },
},
@@ -212,7 +227,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 +238,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 +250,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 +261,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 +272,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 +283,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 +294,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 +305,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 +316,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 +327,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 +338,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..48a2dd7ba 100644
--- a/packages/web/src/ui-preview/scenes/page-scenes.tsx
+++ b/packages/web/src/ui-preview/scenes/page-scenes.tsx
@@ -55,7 +55,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"],
From b6e9108e94de71159816050151901c24fb4b2d86 Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Tue, 12 May 2026 10:40:39 +0800
Subject: [PATCH 36/95] Fix datetime picker and supervisor review regressions
---
.../src/__tests__/supervisor-manager.test.ts | 22 ++
packages/server/src/app-routing.test.ts | 20 +-
packages/server/src/app.ts | 28 ---
packages/server/src/config.ts | 2 -
packages/server/src/supervisor/manager.ts | 9 +-
.../ui/datetime-picker/index.test.tsx | 212 ++++++++++++++++--
.../components/ui/datetime-picker/index.tsx | 158 ++++++++++---
.../ui/datetime-picker/time-selector.tsx | 18 +-
.../components/supervisor-card.test.tsx | 4 +-
.../mobile/mobile-supervisor-sheet.test.tsx | 6 +-
packages/web/src/locales/en.json | 2 +
packages/web/src/locales/zh.json | 2 +
.../src/shells/mobile-shell/index.test.tsx | 8 +-
13 files changed, 397 insertions(+), 94 deletions(-)
diff --git a/packages/server/src/__tests__/supervisor-manager.test.ts b/packages/server/src/__tests__/supervisor-manager.test.ts
index 190857aeb..61a5a9da7 100644
--- a/packages/server/src/__tests__/supervisor-manager.test.ts
+++ b/packages/server/src/__tests__/supervisor-manager.test.ts
@@ -540,6 +540,28 @@ describe("SupervisorManager cycle triggers", () => {
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";
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/app.ts b/packages/server/src/app.ts
index 35c62ec47..42511f3ac 100644
--- a/packages/server/src/app.ts
+++ b/packages/server/src/app.ts
@@ -87,34 +87,6 @@ export async function buildFastifyApp(deps: AppDeps): Promise {
})
);
- // CSP Header Injection (optional, controlled by `relaxCsp` config)
- //
- // xterm.js and Monaco Editor internally use `eval` and `new Function` for
- // performance optimizations (e.g., JIT-compiled rendering loops). When a
- // browser extension (e.g., uBlock Origin, AdGuard) injects a strict CSP,
- // these libraries silently fail:
- // - xterm.js: terminal shows only "[Process exited with code 0]"
- // - Monaco Editor: code editor panel renders blank
- //
- // When `relaxCsp: true`, we inject a permissive CSP header that allows
- // 'unsafe-eval' and 'unsafe-inline', plus WebSocket connections (ws:/wss:).
- // This is intended for local development scenarios where browser extensions
- // interfere. For production, leave this disabled and configure CSP at the
- // reverse proxy level.
- if (deps.config.relaxCsp) {
- app.addHook("onRequest", async (_request, reply) => {
- reply.header(
- "Content-Security-Policy",
- "default-src 'self'; " +
- "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
- "style-src 'self' 'unsafe-inline'; " +
- "connect-src 'self' ws: wss:; " +
- "img-src 'self' data: blob:; " +
- "font-src 'self' data:;"
- );
- });
- }
-
await app.register(compress);
await app.register(multipart, {
diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts
index aac3f648b..1c2c42c8c 100644
--- a/packages/server/src/config.ts
+++ b/packages/server/src/config.ts
@@ -18,7 +18,6 @@ export interface ServerConfig {
dataDir: string;
uploadsDir: string;
logLevel: "trace" | "debug" | "info" | "warn" | "error";
- relaxCsp: boolean;
webRoot?: string;
appVersion?: string;
auth: {
@@ -122,7 +121,6 @@ export function parseServerConfig(overrides?: Partial): ServerConf
dataDir,
uploadsDir,
logLevel: overrides?.logLevel ?? parseLogLevel(process.env.LOG_LEVEL) ?? "info",
- relaxCsp: overrides?.relaxCsp ?? process.env.RELAX_CSP === "true",
webRoot: overrides?.webRoot,
appVersion:
overrides?.appVersion ?? process.env.CODER_STUDIO_APP_VERSION ?? resolveDefaultAppVersion(),
diff --git a/packages/server/src/supervisor/manager.ts b/packages/server/src/supervisor/manager.ts
index 8dd34d9fa..eec14873d 100644
--- a/packages/server/src/supervisor/manager.ts
+++ b/packages/server/src/supervisor/manager.ts
@@ -620,10 +620,17 @@ 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: trigger === "scheduled" ? null : (supervisor.scheduledAt ?? undefined),
+ scheduledAt: shouldConsumeScheduledAt ? null : (supervisor.scheduledAt ?? undefined),
stopReason: null,
errorReason: null,
updatedAt: Date.now(),
diff --git a/packages/web/src/components/ui/datetime-picker/index.test.tsx b/packages/web/src/components/ui/datetime-picker/index.test.tsx
index 7a5c4c395..9193d3687 100644
--- a/packages/web/src/components/ui/datetime-picker/index.test.tsx
+++ b/packages/web/src/components/ui/datetime-picker/index.test.tsx
@@ -1,5 +1,9 @@
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) {
@@ -16,6 +20,18 @@ function setMatchMediaMock(predicate: (query: string) => boolean) {
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;
@@ -27,18 +43,20 @@ describe("DateTimePicker", () => {
afterEach(() => {
window.matchMedia = originalMatchMedia;
window.localStorage.removeItem("ui.locale");
+ vi.useRealTimers();
});
it("renders the trigger button with label and placeholder", () => {
setMatchMediaMock(() => false);
- render(
+ renderWithLocale(
+ />,
+ "en"
);
const trigger = screen.getByRole("button", { name: "Scheduled At" });
@@ -49,8 +67,9 @@ describe("DateTimePicker", () => {
it("renders the current value formatted according to locale", () => {
setMatchMediaMock(() => false);
- render(
-
+ renderWithLocale(
+ ,
+ "en"
);
const trigger = screen.getByRole("button", { name: "Scheduled At" });
@@ -60,7 +79,10 @@ describe("DateTimePicker", () => {
it("opens desktop popover on trigger click", () => {
setMatchMediaMock(() => false);
- render();
+ renderWithLocale(
+ ,
+ "en"
+ );
const trigger = screen.getByRole("button", { name: "Scheduled At" });
fireEvent.click(trigger);
@@ -74,8 +96,13 @@ describe("DateTimePicker", () => {
const onValueChange = vi.fn();
- render(
-
+ renderWithLocale(
+ ,
+ "en"
);
const trigger = screen.getByRole("button", { name: "Scheduled At" });
@@ -92,13 +119,14 @@ describe("DateTimePicker", () => {
const onValueChange = vi.fn();
- render(
+ renderWithLocale(
+ />,
+ "en"
);
const trigger = screen.getByRole("button", { name: "Scheduled At" });
@@ -113,7 +141,10 @@ describe("DateTimePicker", () => {
it("applies invalid styling when invalid prop is true", () => {
setMatchMediaMock(() => false);
- render();
+ renderWithLocale(
+ ,
+ "en"
+ );
const trigger = screen.getByRole("button", { name: "Scheduled At" });
expect(trigger).toHaveClass("input-invalid");
@@ -122,7 +153,10 @@ describe("DateTimePicker", () => {
it("supports disabled state", () => {
setMatchMediaMock(() => false);
- render();
+ renderWithLocale(
+ ,
+ "en"
+ );
const trigger = screen.getByRole("button", { name: "Scheduled At" });
expect(trigger).toBeDisabled();
@@ -133,7 +167,10 @@ describe("DateTimePicker", () => {
(query) => query.includes("max-width: 899px") || query.includes("pointer: coarse")
);
- render();
+ renderWithLocale(
+ ,
+ "en"
+ );
const trigger = screen.getByRole("button", { name: "Scheduled At" });
fireEvent.click(trigger);
@@ -145,7 +182,7 @@ describe("DateTimePicker", () => {
it("links aria-describedby when provided", () => {
setMatchMediaMock(() => false);
- render(
+ renderWithLocale(
<>
{
aria-describedby="helper"
/>
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", () => {
diff --git a/packages/web/src/components/ui/datetime-picker/index.tsx b/packages/web/src/components/ui/datetime-picker/index.tsx
index d813524a4..69db36cfd 100644
--- a/packages/web/src/components/ui/datetime-picker/index.tsx
+++ b/packages/web/src/components/ui/datetime-picker/index.tsx
@@ -1,7 +1,9 @@
import clsx from "clsx";
+import { useAtomValue } from "jotai";
import { Calendar } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useId, useState } from "react";
-import { formatDate, useTranslation } from "../../../lib/i18n";
+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";
@@ -26,11 +28,29 @@ export interface DateTimePickerProps {
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, month, day, hour, minute] = match.map(Number);
+
+ 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);
}
@@ -43,6 +63,64 @@ function formatLocalDateTime(date: Date): string {
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,
@@ -64,38 +142,28 @@ export function DateTimePicker({
"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;
- // Initialize draft from value
- const getInitialDraft = useCallback(() => {
- const parsed = parseLocalDateTime(value);
+ const createInitialDraft = (currentValue: string) => {
+ const parsed = parseLocalDateTime(currentValue);
if (parsed) {
- return {
- year: parsed.getFullYear(),
- month: parsed.getMonth(),
- day: parsed.getDate(),
- hour: parsed.getHours(),
- minute: parsed.getMinutes(),
- };
+ return createDraft(parsed);
}
- const now = new Date();
- return {
- year: now.getFullYear(),
- month: now.getMonth(),
- day: now.getDate(),
- hour: now.getHours(),
- minute: 0,
- };
- }, [value]);
- const [draft, setDraft] = useState(getInitialDraft);
+ const now = truncateToMinute(new Date());
+ return createDraft(clampDateToBounds(now, effectiveMinTime, effectiveMaxTime));
+ };
+
+ const [draft, setDraft] = useState(() => createInitialDraft(value));
- // Update draft when value changes externally
useEffect(() => {
- setDraft(getInitialDraft());
- }, [value, getInitialDraft]);
+ setDraft(createInitialDraft(value));
+ }, [value]);
const handleDateSelect = useCallback((date: Date) => {
setDraft((prev) => ({
@@ -107,7 +175,12 @@ export function DateTimePicker({
}, []);
const handleMonthChange = useCallback((year: number, month: number) => {
- setDraft((prev) => ({ ...prev, year, month }));
+ setDraft((prev) => ({
+ ...prev,
+ year,
+ month,
+ day: clampDay(year, month, prev.day),
+ }));
}, []);
const handleHourChange = useCallback((hour: number) => {
@@ -118,19 +191,26 @@ export function DateTimePicker({
setDraft((prev) => ({ ...prev, minute }));
}, []);
+ const selectedDate = createDateFromDraft(draft);
+ const isConfirmDisabled = isDateTimeDisabled(selectedDate, effectiveMinTime, effectiveMaxTime);
+
const handleConfirm = useCallback(() => {
- const date = new Date(draft.year, draft.month, draft.day, draft.hour, draft.minute);
+ const date = createDateFromDraft(draft);
+ if (isDateTimeDisabled(date, effectiveMinTime, effectiveMaxTime)) {
+ return;
+ }
onValueChange(formatLocalDateTime(date));
setOpen(false);
- }, [draft, onValueChange]);
+ }, [draft, effectiveMaxTime, effectiveMinTime, onValueChange]);
const handleClear = useCallback(() => {
onValueChange("");
setOpen(false);
}, [onValueChange]);
- const displayValue = value
- ? formatDate(parseLocalDateTime(value)?.getTime() ?? Date.now(), "en")
+ const parsedValue = parseLocalDateTime(value);
+ const displayValue = parsedValue
+ ? formatDate(parsedValue.getTime(), locale)
: (placeholder ?? t("datetime.select_date"));
const isMobile = viewport === "mobile";
@@ -160,10 +240,9 @@ export function DateTimePicker({
);
- const selectedDate = value ? parseLocalDateTime(value) : null;
- const calendarMinDate = minDate
- ? new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())
- : undefined;
+ 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 = (
@@ -171,9 +250,9 @@ export function DateTimePicker({
@@ -193,7 +272,12 @@ export function DateTimePicker({
{t("datetime.clear")}
) : null}
-
diff --git a/packages/web/src/components/ui/datetime-picker/time-selector.tsx b/packages/web/src/components/ui/datetime-picker/time-selector.tsx
index 5d0ce45fb..c6e03abc7 100644
--- a/packages/web/src/components/ui/datetime-picker/time-selector.tsx
+++ b/packages/web/src/components/ui/datetime-picker/time-selector.tsx
@@ -1,4 +1,5 @@
-import clsx from "clsx";
+import { useId } from "react";
+import { useTranslation } from "../../../lib/i18n";
import { Input } from "../input";
import styles from "./index.module.css";
@@ -17,6 +18,9 @@ export function TimeSelector({
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");
@@ -37,11 +41,15 @@ export function TimeSelector({
return (
-
+
handleHourChange(e.target.value)}
@@ -51,11 +59,15 @@ export function TimeSelector({
:
-
+
handleMinuteChange(e.target.value)}
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 bae5834f4..1cd364614 100644
--- a/packages/web/src/features/supervisor/components/supervisor-card.test.tsx
+++ b/packages/web/src/features/supervisor/components/supervisor-card.test.tsx
@@ -104,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",
@@ -122,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",
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 70052f554..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
@@ -91,10 +91,10 @@ 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(
@@ -167,7 +167,7 @@ describe("MobileSupervisorSheet", () => {
expect(rootActions).not.toBeNull();
fireEvent.click(
- within(rootActions as HTMLElement).getByRole("button", { name: "Edit Objective" })
+ within(rootActions as HTMLElement).getByRole("button", { name: "Edit Supervisor" })
);
fireEvent.click(screen.getByRole("button", { name: "Back" }));
diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json
index e54b56f71..cf69bc579 100644
--- a/packages/web/src/locales/en.json
+++ b/packages/web/src/locales/en.json
@@ -9,6 +9,8 @@
"confirm": "Confirm",
"select_date": "Select Date",
"select_time": "Select Time",
+ "hour": "Hour",
+ "minute": "Minute",
"january": "January",
"february": "February",
"march": "March",
diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json
index b4807b083..8dca7fcc6 100644
--- a/packages/web/src/locales/zh.json
+++ b/packages/web/src/locales/zh.json
@@ -9,6 +9,8 @@
"confirm": "确认",
"select_date": "选择日期",
"select_time": "选择时间",
+ "hour": "小时",
+ "minute": "分钟",
"january": "一月",
"february": "二月",
"march": "三月",
diff --git a/packages/web/src/shells/mobile-shell/index.test.tsx b/packages/web/src/shells/mobile-shell/index.test.tsx
index b446b91e0..1b0decc59 100644
--- a/packages/web/src/shells/mobile-shell/index.test.tsx
+++ b/packages/web/src/shells/mobile-shell/index.test.tsx
@@ -2165,9 +2165,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 () => {
From c8f2a82d436be22a95add7cce32f06b890998b8e Mon Sep 17 00:00:00 2001
From: Spencer
Date: Tue, 12 May 2026 03:31:55 +0000
Subject: [PATCH 37/95] Fix theme hydration regressions
---
packages/server/src/commands/settings.test.ts | 25 +++++
packages/server/src/commands/settings.ts | 2 +-
.../web/src/app/providers.lifecycle.test.tsx | 96 +++++++++++++++++++
packages/web/src/app/providers.tsx | 46 ++++++++-
packages/web/src/locales/en.json | 8 ++
packages/web/src/locales/zh.json | 8 ++
packages/web/src/theme/registry.test.ts | 23 +++++
packages/web/src/theme/registry.ts | 16 ++--
8 files changed, 212 insertions(+), 12 deletions(-)
diff --git a/packages/server/src/commands/settings.test.ts b/packages/server/src/commands/settings.test.ts
index 9ddd4dd63..9bf53b28e 100644
--- a/packages/server/src/commands/settings.test.ts
+++ b/packages/server/src/commands/settings.test.ts
@@ -137,6 +137,31 @@ describe("settings commands", () => {
).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(
{
diff --git a/packages/server/src/commands/settings.ts b/packages/server/src/commands/settings.ts
index c599bb834..d330adb7f 100644
--- a/packages/server/src/commands/settings.ts
+++ b/packages/server/src/commands/settings.ts
@@ -65,7 +65,7 @@ const SettingsSchema = z.object({
.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(),
diff --git a/packages/web/src/app/providers.lifecycle.test.tsx b/packages/web/src/app/providers.lifecycle.test.tsx
index d527f29cc..47dc84d3e 100644
--- a/packages/web/src/app/providers.lifecycle.test.tsx
+++ b/packages/web/src/app/providers.lifecycle.test.tsx
@@ -616,6 +616,102 @@ describe("AppProviders lifecycle recovery", () => {
});
});
+ 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.tsx b/packages/web/src/app/providers.tsx
index 683909208..863bbacaf 100644
--- a/packages/web/src/app/providers.tsx
+++ b/packages/web/src/app/providers.tsx
@@ -74,6 +74,10 @@ interface WorkspaceActivityState {
workspaceId: string | null;
}
+interface AppearanceSelectionVersion {
+ theme: number;
+}
+
const DEFAULT_REFRESH_HINT: WorkspaceRefreshHint = {
refreshGit: false,
refreshBranches: false,
@@ -191,7 +195,7 @@ interface AppProvidersProps {
export function AppProviders({ children }: AppProvidersProps) {
const [, setWsClient] = useAtom(wsClientAtom);
- const setTheme = useSetAtom(themeAtom);
+ const [theme, setTheme] = useAtom(themeAtom);
const authEnabled = useAtomValue(authEnabledAtom);
const authenticated = useAtomValue(authenticatedAtom);
const connectionStatus = useAtomValue(connectionStatusAtom);
@@ -230,6 +234,10 @@ export function AppProviders({ children }: AppProvidersProps) {
mode: "inactive",
workspaceId: null,
});
+ const appearanceSelectionVersionRef = useRef({
+ theme: 0,
+ });
+ const preferPersistedThemeOnFirstHydrationRef = useRef(false);
// Keep dispatchRef in sync
useEffect(() => {
@@ -280,11 +288,19 @@ export function AppProviders({ children }: AppProvidersProps) {
// Initialize theme from localStorage
useEffect(() => {
+ 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;
@@ -293,20 +309,34 @@ export function AppProviders({ children }: AppProvidersProps) {
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 = applyResolvedTheme(
+ const resolvedThemeId = resolveStoredThemeId(
settings["appearance.themeId"] ??
settings["appearance.theme"] ??
readStoredThemePreference()
);
setTheme(resolvedThemeId);
- localStorage.setItem(THEME_ID_STORAGE_KEY, JSON.stringify(resolvedThemeId));
};
void hydrateTheme();
@@ -316,6 +346,16 @@ export function AppProviders({ children }: AppProvidersProps) {
};
}, [connectionStatus, dispatch, setTheme]);
+ useEffect(() => {
+ const unsubscribeTheme = store.sub(themeAtom, () => {
+ appearanceSelectionVersionRef.current.theme += 1;
+ });
+
+ return () => {
+ unsubscribeTheme();
+ };
+ }, [store]);
+
useEffect(() => {
const loadAuthStatus = async () => {
try {
diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json
index 2f31c2573..61da18fdc 100644
--- a/packages/web/src/locales/en.json
+++ b/packages/web/src/locales/en.json
@@ -560,6 +560,14 @@
"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"
},
diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json
index 1af41c8f3..386e0eace 100644
--- a/packages/web/src/locales/zh.json
+++ b/packages/web/src/locales/zh.json
@@ -560,6 +560,14 @@
"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": "浅色"
},
diff --git a/packages/web/src/theme/registry.test.ts b/packages/web/src/theme/registry.test.ts
index e76e013a1..2622e8663 100644
--- a/packages/web/src/theme/registry.test.ts
+++ b/packages/web/src/theme/registry.test.ts
@@ -1,6 +1,18 @@
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);
@@ -69,4 +81,15 @@ describe("theme registry", () => {
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)
+ );
+ }
+ });
});
diff --git a/packages/web/src/theme/registry.ts b/packages/web/src/theme/registry.ts
index b8cde97fa..f5620692e 100644
--- a/packages/web/src/theme/registry.ts
+++ b/packages/web/src/theme/registry.ts
@@ -105,7 +105,7 @@ const THEMES_REGISTRY: ReadonlyArray = [
id: "mint-dark",
family: "mint",
kind: "dark",
- labelKey: "settings.appearance.theme.mint_dark",
+ labelKey: "settings.theme.mint_dark",
pairedThemeId: "mint-light",
isHighContrast: false,
documentThemeAttr: "mint-dark",
@@ -131,7 +131,7 @@ const THEMES_REGISTRY: ReadonlyArray = [
id: "mint-light",
family: "mint",
kind: "light",
- labelKey: "settings.appearance.theme.mint_light",
+ labelKey: "settings.theme.mint_light",
pairedThemeId: "mint-dark",
isHighContrast: false,
documentThemeAttr: "mint-light",
@@ -157,7 +157,7 @@ const THEMES_REGISTRY: ReadonlyArray = [
id: "graphite-dark",
family: "graphite",
kind: "dark",
- labelKey: "settings.appearance.theme.graphite_dark",
+ labelKey: "settings.theme.graphite_dark",
pairedThemeId: "graphite-light",
isHighContrast: false,
documentThemeAttr: "graphite-dark",
@@ -206,7 +206,7 @@ const THEMES_REGISTRY: ReadonlyArray = [
id: "graphite-light",
family: "graphite",
kind: "light",
- labelKey: "settings.appearance.theme.graphite_light",
+ labelKey: "settings.theme.graphite_light",
pairedThemeId: "graphite-dark",
isHighContrast: false,
documentThemeAttr: "graphite-light",
@@ -255,7 +255,7 @@ const THEMES_REGISTRY: ReadonlyArray = [
id: "nord-dark",
family: "nord",
kind: "dark",
- labelKey: "settings.appearance.theme.nord_dark",
+ labelKey: "settings.theme.nord_dark",
pairedThemeId: "nord-light",
isHighContrast: false,
documentThemeAttr: "nord-dark",
@@ -304,7 +304,7 @@ const THEMES_REGISTRY: ReadonlyArray = [
id: "nord-light",
family: "nord",
kind: "light",
- labelKey: "settings.appearance.theme.nord_light",
+ labelKey: "settings.theme.nord_light",
pairedThemeId: "nord-dark",
isHighContrast: false,
documentThemeAttr: "nord-light",
@@ -353,7 +353,7 @@ const THEMES_REGISTRY: ReadonlyArray = [
id: "hc-dark",
family: "hc",
kind: "dark",
- labelKey: "settings.appearance.theme.hc_dark",
+ labelKey: "settings.theme.hc_dark",
pairedThemeId: "hc-light",
isHighContrast: true,
documentThemeAttr: "hc-dark",
@@ -402,7 +402,7 @@ const THEMES_REGISTRY: ReadonlyArray = [
id: "hc-light",
family: "hc",
kind: "light",
- labelKey: "settings.appearance.theme.hc_light",
+ labelKey: "settings.theme.hc_light",
pairedThemeId: "hc-dark",
isHighContrast: true,
documentThemeAttr: "hc-light",
From eea04311dae14e04cfc3c1ade1e3592cb178c38c Mon Sep 17 00:00:00 2001
From: Spencer
Date: Tue, 12 May 2026 04:28:29 +0000
Subject: [PATCH 38/95] Refine theme settings picker
---
.../components/settings-page.test.tsx | 73 ++++++----------
.../settings/components/settings-page.tsx | 85 ++++---------------
2 files changed, 39 insertions(+), 119 deletions(-)
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 e14dc2c15..676724014 100644
--- a/packages/web/src/features/settings/components/settings-page.test.tsx
+++ b/packages/web/src/features/settings/components/settings-page.test.tsx
@@ -1008,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 {
@@ -1022,21 +1022,13 @@ describe("SettingsPage", () => {
renderSettingsPage(store);
fireEvent.click(screen.getByRole("button", { name: "外观" }));
- const mintThemePill = await screen.findByRole("button", { name: "Mint" });
- const darkVariantPill = screen.getByRole("button", { name: "深色" });
- const lightVariantPill = screen.getByRole("button", { name: "浅色" });
+ const themePicker = await screen.findByRole("combobox", { name: "主题" });
const chineseLanguagePill = screen.getByRole("button", { name: "中文" });
- expect(
- screen.getByRole("group", {
- name: "主题系列",
- })
- ).toHaveAccessibleDescription("选择应用主题");
- expect(
- screen.getByRole("group", {
- name: "明暗模式",
- })
- ).toHaveAccessibleDescription("选择应用主题");
+ expect(themePicker).toHaveAccessibleDescription("选择应用主题");
+ expect(screen.getByRole("option", { name: "Mint 深色" })).toBeInTheDocument();
+ expect(screen.getByRole("option", { name: "Mint 浅色" })).toBeInTheDocument();
+ expect(screen.getByRole("option", { name: "高对比深色" })).toBeInTheDocument();
expect(
screen.getByRole("group", {
name: "语言",
@@ -1044,9 +1036,7 @@ describe("SettingsPage", () => {
).toHaveAccessibleDescription("选择界面语言");
expect(screen.queryByRole("group", { name: "终端渲染器" })).not.toBeInTheDocument();
expect(screen.queryByRole("switch", { name: "选中自动复制" })).not.toBeInTheDocument();
- expect(mintThemePill).toHaveAttribute("aria-pressed", "true");
- expect(darkVariantPill).toHaveAttribute("aria-pressed", "true");
- expect(lightVariantPill).toHaveAttribute("aria-pressed", "false");
+ expect(themePicker).toHaveValue("mint-dark");
expect(chineseLanguagePill).toHaveAttribute("aria-pressed", "true");
});
@@ -1112,7 +1102,7 @@ describe("SettingsPage", () => {
expect(screen.queryByText("选中自动复制")).not.toBeInTheDocument();
});
- it("updates theme family and variant through the shared appearance pills", async () => {
+ 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") {
@@ -1125,14 +1115,13 @@ describe("SettingsPage", () => {
renderSettingsPage(store);
fireEvent.click(screen.getByRole("button", { name: "Appearance" }));
- 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();
+ const picker = await screen.findByRole("combobox", { name: "Theme" });
+ expect(screen.getByRole("option", { name: "Mint Dark" })).toBeInTheDocument();
+ expect(screen.getByRole("option", { name: "Graphite Dark" })).toBeInTheDocument();
+ expect(screen.getByRole("option", { name: "Graphite Light" })).toBeInTheDocument();
+ expect(screen.getByRole("option", { name: "Nord Light" })).toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "Graphite" }));
+ fireEvent.change(picker, { target: { value: "graphite-dark" } });
await waitFor(() => {
expect(sendCommand).toHaveBeenCalledWith(
@@ -1150,7 +1139,7 @@ describe("SettingsPage", () => {
expect(document.documentElement).toHaveAttribute("data-theme", "graphite-dark");
- fireEvent.click(screen.getByRole("button", { name: "Light" }));
+ fireEvent.change(picker, { target: { value: "graphite-light" } });
await waitFor(() => {
expect(sendCommand).toHaveBeenCalledWith(
@@ -1167,15 +1156,10 @@ describe("SettingsPage", () => {
});
expect(document.documentElement).toHaveAttribute("data-theme", "graphite-light");
- expect(screen.getByRole("button", { name: "Graphite" })).toHaveAttribute(
- "aria-pressed",
- "true"
- );
- expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "true");
- expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "false");
+ expect(picker).toHaveValue("graphite-light");
});
- it("hydrates theme family and variant from settings.get themeId", 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") {
@@ -1191,9 +1175,7 @@ describe("SettingsPage", () => {
fireEvent.click(screen.getByRole("button", { name: "Appearance" }));
await waitFor(() => {
- expect(screen.getByRole("button", { name: "Nord" })).toHaveAttribute("aria-pressed", "true");
- expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "true");
- expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "false");
+ expect(screen.getByRole("combobox", { name: "Theme" })).toHaveValue("nord-light");
});
});
@@ -1213,8 +1195,7 @@ describe("SettingsPage", () => {
fireEvent.click(screen.getByRole("button", { name: "Appearance" }));
await waitFor(() => {
- expect(screen.getByRole("button", { name: "Mint" })).toHaveAttribute("aria-pressed", "true");
- expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByRole("combobox", { name: "Theme" })).toHaveValue("mint-light");
expect(document.documentElement).toHaveAttribute("data-theme", "mint-light");
});
});
@@ -1235,11 +1216,7 @@ describe("SettingsPage", () => {
fireEvent.click(screen.getByRole("button", { name: "Appearance" }));
await waitFor(() => {
- expect(screen.getByRole("button", { name: "Graphite" })).toHaveAttribute(
- "aria-pressed",
- "true"
- );
- expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByRole("combobox", { name: "Theme" })).toHaveValue("graphite-light");
expect(document.documentElement).toHaveAttribute("data-theme", "graphite-light");
});
});
@@ -1260,7 +1237,9 @@ describe("SettingsPage", () => {
renderSettingsPage(store);
fireEvent.click(screen.getByRole("button", { name: "Appearance" }));
- fireEvent.click(await screen.findByRole("button", { name: "Graphite" }));
+ fireEvent.change(await screen.findByRole("combobox", { name: "Theme" }), {
+ target: { value: "graphite-dark" },
+ });
await waitFor(() => {
expect(sendCommand).toHaveBeenCalledWith(
@@ -1283,11 +1262,7 @@ describe("SettingsPage", () => {
await settingsGetPromise;
});
- expect(screen.getByRole("button", { name: "Graphite" })).toHaveAttribute(
- "aria-pressed",
- "true"
- );
- expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByRole("combobox", { name: "Theme" })).toHaveValue("graphite-dark");
expect(document.documentElement).toHaveAttribute("data-theme", "graphite-dark");
});
diff --git a/packages/web/src/features/settings/components/settings-page.tsx b/packages/web/src/features/settings/components/settings-page.tsx
index 975db6c8b..a357a820d 100644
--- a/packages/web/src/features/settings/components/settings-page.tsx
+++ b/packages/web/src/features/settings/components/settings-page.tsx
@@ -32,17 +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,
- getThemeFamily,
- getThemeIdForFamilyVariant,
- getThemeVariant,
- resolveStoredThemeId,
- type ThemeFamily,
-} from "../../../theme";
+import { getThemeById, resolveStoredThemeId, THEMES } from "../../../theme";
import { notificationPreferencesAtom } from "../../notifications/atoms";
import { MobilePageHeader } from "../../shared/components/mobile-page-header";
import {
@@ -73,8 +66,6 @@ type SettingsNavigationState =
type SettingsContentLayoutMode = "default" | "fill-height";
const DEFAULT_SETTINGS_SECTION: SettingsSection = SETTINGS_SECTIONS[0].id;
-const THEME_FAMILIES: ReadonlyArray = ["mint", "graphite", "nord", "hc"];
-const THEME_VARIANTS = ["dark", "light"] as const;
function isStandaloneWebApp(): boolean {
if (typeof window === "undefined") {
@@ -1210,14 +1201,15 @@ function AppearanceSettings({ locale, setLocale, theme, setTheme }: AppearanceSe
const t = useTranslation();
const themeTitleId = useId();
const themeDescId = useId();
- const themeFamilyTitleId = useId();
- const themeVariantTitleId = useId();
+ const themeSelectId = useId();
const languageTitleId = useId();
const languageDescId = useId();
const dispatch = useAtomValue(dispatchCommandAtom);
const currentThemeId = resolveStoredThemeId(theme);
- const currentThemeFamily = getThemeFamily(currentThemeId);
- const currentThemeVariant = getThemeVariant(currentThemeId);
+ const themeOptions = THEMES.map((registeredTheme) => ({
+ value: registeredTheme.id,
+ label: t(registeredTheme.labelKey),
+ }));
const saveSettings = async (settings: Record) => {
await dispatch("settings.update", { settings });
@@ -1234,20 +1226,6 @@ function AppearanceSettings({ locale, setLocale, theme, setTheme }: AppearanceSe
void saveSettings({ appearance: { themeId: resolvedTheme.id } });
};
- const handleThemeFamilyChange = (family: ThemeFamily) => {
- const nextThemeId = getThemeIdForFamilyVariant(family, currentThemeVariant);
- if (nextThemeId) {
- handleThemeChange(nextThemeId);
- }
- };
-
- const handleThemeVariantChange = (variant: (typeof THEME_VARIANTS)[number]) => {
- const nextThemeId = getThemeIdForFamilyVariant(currentThemeFamily, variant);
- if (nextThemeId) {
- handleThemeChange(nextThemeId);
- }
- };
-
return (
@@ -1257,48 +1235,15 @@ function AppearanceSettings({ locale, setLocale, theme, setTheme }: AppearanceSe
{t("settings.theme.hint")}
-
-
- {t("settings.theme.family")}
-
-
- {THEME_FAMILIES.map((family) => (
-
: undefined}
- onClick={() => handleThemeFamilyChange(family)}
- active={currentThemeFamily === family}
- >
- {t(`settings.theme.family_${family}`)}
-
- ))}
-
-
-
- {t("settings.theme.variant")}
-
-
- {THEME_VARIANTS.map((variant) => (
-
: undefined}
- onClick={() => handleThemeVariantChange(variant)}
- active={currentThemeVariant === variant}
- >
- {t(`settings.theme.variant_${variant}`)}
-
- ))}
-
+ aria-label={t("settings.theme.title")}
+ className="settings-input-compact"
+ options={themeOptions}
+ value={currentThemeId}
+ onValueChange={handleThemeChange}
+ />
From e925843c41df586f4beb126bc5260dfd22d02dfd Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Tue, 12 May 2026 13:36:14 +0800
Subject: [PATCH 39/95] docs: add mobile terminal copy mode design
---
...-05-12-mobile-terminal-copy-mode-design.md | 275 ++++++++++++++++++
1 file changed, 275 insertions(+)
create mode 100644 docs/superpowers/specs/2026-05-12-mobile-terminal-copy-mode-design.md
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
+
+无。
From d8e99386e58ed954eef61c5c4983502fe431b59a Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Tue, 12 May 2026 14:20:14 +0800
Subject: [PATCH 40/95] feat(server): add activation lease manager
---
.../src/__tests__/activation-manager.test.ts | 61 ++++++++
packages/server/src/ws/activation.ts | 142 ++++++++++++++++++
2 files changed, 203 insertions(+)
create mode 100644 packages/server/src/__tests__/activation-manager.test.ts
create mode 100644 packages/server/src/ws/activation.ts
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..12fe7d07e
--- /dev/null
+++ b/packages/server/src/__tests__/activation-manager.test.ts
@@ -0,0 +1,61 @@
+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({
+ heartbeatMs: 10_000,
+ leaseExpirationMs: 30_000,
+ 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");
+ });
+
+ 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(manager.getLease()?.clientInstanceId).toBe("client-b");
+ });
+
+ it("allows the same client instance to recover during grace", () => {
+ 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.recoveryMode).toBe("grace_recover");
+ });
+
+ it("rejects heartbeat for stale generations", () => {
+ const first = manager.claim("client-a", "ws-a", request);
+ manager.claim("client-b", "ws-b", request);
+
+ const ok = manager.heartbeat("client-a", first.generation);
+
+ expect(ok).toBe(false);
+ });
+});
diff --git a/packages/server/src/ws/activation.ts b/packages/server/src/ws/activation.ts
new file mode 100644
index 000000000..ba4cf6a52
--- /dev/null
+++ b/packages/server/src/ws/activation.ts
@@ -0,0 +1,142 @@
+import type { FastifyRequest } from "fastify";
+
+export interface ActivationLease {
+ clientInstanceId: string;
+ wsClientId: string;
+ generation: number;
+ issuedAt: number;
+ expiresAt: 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 {
+ heartbeatMs: number;
+ leaseExpirationMs: number;
+ graceMs: number;
+}
+
+const DEFAULT_OPTIONS: ActivationManagerOptions = {
+ heartbeatMs: 10_000,
+ leaseExpirationMs: 30_000,
+ 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 &&
+ activeLease.graceUntil !== null &&
+ now <= activeLease.graceUntil
+ ) {
+ activeLease.wsClientId = wsClientId;
+ activeLease.graceUntil = null;
+ activeLease.expiresAt = now + this.options.leaseExpirationMs;
+
+ return {
+ active: true,
+ generation: activeLease.generation,
+ recoveryMode: "grace_recover",
+ displacedWsClientId: null,
+ };
+ }
+
+ 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,
+ expiresAt: now + this.options.leaseExpirationMs,
+ graceUntil: null,
+ ip: request.ip,
+ userAgent: request.headers["user-agent"] ?? "",
+ };
+
+ return {
+ active: true,
+ generation: this.lease.generation,
+ recoveryMode,
+ displacedWsClientId,
+ };
+ }
+
+ heartbeat(clientInstanceId: string, generation: number): boolean {
+ const lease = this.getLease();
+ if (!lease) {
+ return false;
+ }
+
+ if (lease.clientInstanceId !== clientInstanceId || lease.generation !== generation) {
+ return false;
+ }
+
+ lease.expiresAt = Date.now() + this.options.leaseExpirationMs;
+ return true;
+ }
+
+ 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;
+ }
+
+ if (Date.now() > this.lease.expiresAt) {
+ this.lease = null;
+ return null;
+ }
+
+ return this.lease;
+ }
+}
From 07bb1bdb76287752962ee461c91847fef3a86d98 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Tue, 12 May 2026 06:23:05 +0000
Subject: [PATCH 41/95] Refine light theme differentiation
---
packages/web/src/styles/tokens-touch.test.ts | 43 ++++++++++
packages/web/src/styles/tokens.css | 70 +++++++--------
packages/web/src/theme/registry.test.ts | 84 ++++++++++++++++++
packages/web/src/theme/registry.ts | 90 ++++++++++----------
4 files changed, 207 insertions(+), 80 deletions(-)
diff --git a/packages/web/src/styles/tokens-touch.test.ts b/packages/web/src/styles/tokens-touch.test.ts
index f0fbdfef3..069502c54 100644
--- a/packages/web/src/styles/tokens-touch.test.ts
+++ b/packages/web/src/styles/tokens-touch.test.ts
@@ -20,6 +20,11 @@ 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", () => {
it("defines named theme blocks for all built-in themes", () => {
expect(stylesheet).toContain(':root,\n[data-theme="mint-dark"]');
@@ -55,4 +60,42 @@ 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)");
+ });
});
diff --git a/packages/web/src/styles/tokens.css b/packages/web/src/styles/tokens.css
index 08d856c6e..68e07abe6 100644
--- a/packages/web/src/styles/tokens.css
+++ b/packages/web/src/styles/tokens.css
@@ -182,30 +182,30 @@
[data-theme="mint-light"] {
/* ========== Backgrounds ========== */
- --bg-page: #f8fafb;
- --bg-surface: #ffffff;
- --bg-sidebar: #f4f6f8;
+ --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;
+ --border: #c7d8cf;
+ --border-light: #d8e7df;
+ --border-focus: #158f77;
--border-error: #cf222e;
/* ========== Text ========== */
--text-primary: #1f2328;
- --text-secondary: #57606a;
+ --text-secondary: #557067;
--text-tertiary: #8b949e;
--text-disabled: #6e7681;
--text-inverse: #ffffff;
/* ========== Accents ========== */
- --accent-blue: #0969da;
+ --accent-blue: #148a7a;
--accent-green: #1a7f37;
--accent-amber: #9a6700;
--accent-pink: #cf222e;
@@ -215,14 +215,14 @@
--color-success: #1a7f37;
--color-warning: #9a6700;
--color-error: #cf222e;
- --color-info: #0969da;
+ --color-info: #148a7a;
/* ========== 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;
@@ -278,30 +278,30 @@
[data-theme="graphite-light"] {
/* ========== Backgrounds ========== */
- --bg-page: #edf1f5;
+ --bg-page: #e7edf3;
--bg-surface: #ffffff;
- --bg-sidebar: #e7ebf0;
+ --bg-sidebar: #dfe6ee;
--bg-terminal: #f3f4f6;
- --bg-hover: #dde3ea;
- --bg-active: #d2d9e2;
+ --bg-hover: #d4dce5;
+ --bg-active: #c8d1dc;
--bg-disabled: #eef2f6;
--bg-input: #ffffff;
/* ========== Borders ========== */
- --border: #c7d0da;
- --border-light: #d8dee6;
- --border-focus: #2563eb;
+ --border: #b8c3cf;
+ --border-light: #ccd5de;
+ --border-focus: #315fdd;
--border-error: #d14343;
/* ========== Text ========== */
--text-primary: #1f2933;
- --text-secondary: #52606d;
+ --text-secondary: #4d5b6a;
--text-tertiary: #7b8794;
--text-disabled: #9aa5b1;
--text-inverse: #ffffff;
/* ========== Accents ========== */
- --accent-blue: #1d4ed8;
+ --accent-blue: #315fdd;
--accent-green: #15803d;
--accent-amber: #a16207;
--accent-pink: #d14343;
@@ -311,14 +311,14 @@
--color-success: #15803d;
--color-warning: #a16207;
--color-error: #d14343;
- --color-info: #1d4ed8;
+ --color-info: #315fdd;
/* ========== 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(29, 78, 216, 0.18);
+ --shadow-glow: 0 0 12px rgba(49, 95, 221, 0.16);
/* Scrollbar */
--scrollbar-thumb: #c7d0da;
@@ -374,30 +374,30 @@
[data-theme="nord-light"] {
/* ========== Backgrounds ========== */
- --bg-page: #e8ecf2;
- --bg-surface: #eceff4;
- --bg-sidebar: #e1e7ef;
+ --bg-page: #e3ebf4;
+ --bg-surface: #f1f5fa;
+ --bg-sidebar: #dbe4ef;
--bg-terminal: #eceff4;
- --bg-hover: #dde3eb;
- --bg-active: #d8dee9;
+ --bg-hover: #d2ddea;
+ --bg-active: #c8d4e2;
--bg-disabled: #edf1f6;
--bg-input: #f7f9fc;
/* ========== Borders ========== */
- --border: #c6ceda;
- --border-light: #d8dee9;
- --border-focus: #5e81ac;
+ --border: #b8c6d7;
+ --border-light: #cad5e2;
+ --border-focus: #5b7fa8;
--border-error: #bf616a;
/* ========== Text ========== */
--text-primary: #2e3440;
- --text-secondary: #4c566a;
+ --text-secondary: #4d5a6f;
--text-tertiary: #7b88a1;
--text-disabled: #93a0b7;
--text-inverse: #ffffff;
/* ========== Accents ========== */
- --accent-blue: #5e81ac;
+ --accent-blue: #5b7fa8;
--accent-green: #4c7a5b;
--accent-amber: #a77f2f;
--accent-pink: #bf616a;
@@ -407,14 +407,14 @@
--color-success: #4c7a5b;
--color-warning: #a77f2f;
--color-error: #bf616a;
- --color-info: #5e81ac;
+ --color-info: #5b7fa8;
/* ========== 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(94, 129, 172, 0.2);
+ --shadow-glow: 0 0 12px rgba(91, 127, 168, 0.18);
/* Scrollbar */
--scrollbar-thumb: #c6ceda;
diff --git a/packages/web/src/theme/registry.test.ts b/packages/web/src/theme/registry.test.ts
index 2622e8663..3fd4166a3 100644
--- a/packages/web/src/theme/registry.test.ts
+++ b/packages/web/src/theme/registry.test.ts
@@ -92,4 +92,88 @@ describe("theme registry", () => {
);
}
});
+
+ 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
index f5620692e..b6d68ca05 100644
--- a/packages/web/src/theme/registry.ts
+++ b/packages/web/src/theme/registry.ts
@@ -76,27 +76,27 @@ const mintDarkTerminal: TerminalThemeDefinition = {
};
const mintLightTerminal: TerminalThemeDefinition = {
- background: "#fafbfc",
+ background: "#fcfffd",
foreground: "#1f2328",
- cursor: "#0969da",
- cursorAccent: "#fafbfc",
- selectionBackground: "#dde4ea",
+ cursor: "#148a7a",
+ cursorAccent: "#fcfffd",
+ selectionBackground: "#ddefe5",
selectionForeground: "#1f2328",
black: "#24292f",
red: "#cf222e",
- green: "#1a7f37",
+ green: "#18794e",
yellow: "#9a6700",
- blue: "#0969da",
+ blue: "#148a7a",
magenta: "#8250df",
- cyan: "#1b7c83",
+ cyan: "#0f766e",
white: "#57606a",
brightBlack: "#8b949e",
brightRed: "#cf222e",
- brightGreen: "#1a7f37",
+ brightGreen: "#1f9360",
brightYellow: "#9a6700",
- brightBlue: "#0969da",
+ brightBlue: "#148a7a",
brightMagenta: "#8250df",
- brightCyan: "#1b7c83",
+ brightCyan: "#14877d",
brightWhite: "#1f2328",
};
@@ -141,15 +141,15 @@ const THEMES_REGISTRY: ReadonlyArray = [
inherit: true,
rules: [
{ token: "comment", foreground: "6e7781" },
- { token: "string", foreground: "1a7f37" },
- { token: "keyword", foreground: "0969da" },
+ { token: "string", foreground: "18794e" },
+ { token: "keyword", foreground: "148a7a" },
],
colors: {
- "editor.background": "#fafbfc",
+ "editor.background": "#fcfffd",
"editor.foreground": "#1f2328",
"editorLineNumber.foreground": "#8b949e",
- "editorCursor.foreground": "#0969da",
- "editor.selectionBackground": "#dde4ea",
+ "editorCursor.foreground": "#148a7a",
+ "editor.selectionBackground": "#ddefe5",
},
},
},
@@ -211,27 +211,27 @@ const THEMES_REGISTRY: ReadonlyArray = [
isHighContrast: false,
documentThemeAttr: "graphite-light",
terminalTheme: {
- background: "#f3f4f6",
+ background: "#f5f7fa",
foreground: "#1f2933",
- cursor: "#4b5563",
- cursorAccent: "#f3f4f6",
- selectionBackground: "#d6d9df",
+ cursor: "#315fdd",
+ cursorAccent: "#f5f7fa",
+ selectionBackground: "#d4dce5",
selectionForeground: "#111317",
black: "#111827",
red: "#c2410c",
- green: "#15803d",
+ green: "#2f6f44",
yellow: "#a16207",
- blue: "#1d4ed8",
+ blue: "#315fdd",
magenta: "#7c3aed",
- cyan: "#0f766e",
+ cyan: "#1f6f8b",
white: "#6b7280",
brightBlack: "#9ca3af",
brightRed: "#ea580c",
- brightGreen: "#16a34a",
+ brightGreen: "#3f8457",
brightYellow: "#ca8a04",
- brightBlue: "#2563eb",
+ brightBlue: "#4672e7",
brightMagenta: "#8b5cf6",
- brightCyan: "#14b8a6",
+ brightCyan: "#2e86a5",
brightWhite: "#111827",
},
monaco: {
@@ -239,15 +239,15 @@ const THEMES_REGISTRY: ReadonlyArray = [
inherit: true,
rules: [
{ token: "comment", foreground: "6b7280" },
- { token: "string", foreground: "15803d" },
- { token: "keyword", foreground: "1d4ed8" },
+ { token: "string", foreground: "2f6f44" },
+ { token: "keyword", foreground: "315fdd" },
],
colors: {
- "editor.background": "#f3f4f6",
+ "editor.background": "#f5f7fa",
"editor.foreground": "#1f2933",
"editorLineNumber.foreground": "#9ca3af",
- "editorCursor.foreground": "#4b5563",
- "editor.selectionBackground": "#d6d9df",
+ "editorCursor.foreground": "#315fdd",
+ "editor.selectionBackground": "#d4dce5",
},
},
},
@@ -309,27 +309,27 @@ const THEMES_REGISTRY: ReadonlyArray = [
isHighContrast: false,
documentThemeAttr: "nord-light",
terminalTheme: {
- background: "#eceff4",
+ background: "#f1f5fa",
foreground: "#2e3440",
- cursor: "#5e81ac",
- cursorAccent: "#eceff4",
- selectionBackground: "#d8dee9",
+ cursor: "#5b7fa8",
+ cursorAccent: "#f1f5fa",
+ selectionBackground: "#d2ddea",
selectionForeground: "#2e3440",
black: "#3b4252",
red: "#bf616a",
- green: "#4c7a5b",
+ green: "#5d7a66",
yellow: "#a77f2f",
- blue: "#5e81ac",
+ blue: "#5b7fa8",
magenta: "#8f5b9c",
- cyan: "#3f7c8b",
+ cyan: "#4c7f99",
white: "#4c566a",
brightBlack: "#7b88a1",
brightRed: "#d08770",
- brightGreen: "#5f8f70",
+ brightGreen: "#6a8d76",
brightYellow: "#b08d49",
- brightBlue: "#6b8fb8",
+ brightBlue: "#6c90b7",
brightMagenta: "#9b6aa8",
- brightCyan: "#4c8e9f",
+ brightCyan: "#5b90a6",
brightWhite: "#2e3440",
},
monaco: {
@@ -337,15 +337,15 @@ const THEMES_REGISTRY: ReadonlyArray = [
inherit: true,
rules: [
{ token: "comment", foreground: "7b88a1" },
- { token: "string", foreground: "4c7a5b" },
- { token: "keyword", foreground: "5e81ac" },
+ { token: "string", foreground: "5d7a66" },
+ { token: "keyword", foreground: "5b7fa8" },
],
colors: {
- "editor.background": "#eceff4",
+ "editor.background": "#f1f5fa",
"editor.foreground": "#2e3440",
"editorLineNumber.foreground": "#7b88a1",
- "editorCursor.foreground": "#5e81ac",
- "editor.selectionBackground": "#d8dee9",
+ "editorCursor.foreground": "#5b7fa8",
+ "editor.selectionBackground": "#d2ddea",
},
},
},
From 1a9d41e4805f52a84b265e494ef41c33c0feda78 Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Tue, 12 May 2026 14:29:00 +0800
Subject: [PATCH 42/95] fix(server): preserve activation lease generation on
reconnect
---
.../src/__tests__/activation-manager.test.ts | 65 ++++++++++++++++++-
packages/server/src/ws/activation.ts | 13 ++--
2 files changed, 70 insertions(+), 8 deletions(-)
diff --git a/packages/server/src/__tests__/activation-manager.test.ts b/packages/server/src/__tests__/activation-manager.test.ts
index 12fe7d07e..55509b5f0 100644
--- a/packages/server/src/__tests__/activation-manager.test.ts
+++ b/packages/server/src/__tests__/activation-manager.test.ts
@@ -28,6 +28,7 @@ describe("ActivationManager", () => {
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", () => {
@@ -37,17 +38,37 @@ describe("ActivationManager", () => {
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", () => {
- manager.claim("client-a", "ws-a", request);
+ 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("rejects heartbeat for stale generations", () => {
@@ -58,4 +79,46 @@ describe("ActivationManager", () => {
expect(ok).toBe(false);
});
+
+ 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("nulls expired state when getLease is called after expiry", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-05-12T00:00:00.000Z"));
+
+ manager.claim("client-a", "ws-a", request);
+
+ vi.advanceTimersByTime(30_001);
+
+ expect(manager.getLease()).toBeNull();
+ expect(manager.getLease()).toBeNull();
+ });
+
+ it("refreshes expiry on heartbeat for the active generation", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-05-12T00:00:00.000Z"));
+
+ const claim = manager.claim("client-a", "ws-a", request);
+ const beforeExpiresAt = manager.getLease()?.expiresAt;
+
+ expect(beforeExpiresAt).toBe(Date.now() + 30_000);
+
+ vi.advanceTimersByTime(5_000);
+
+ const ok = manager.heartbeat("client-a", claim.generation);
+ const after = manager.getLease();
+
+ expect(ok).toBe(true);
+ expect(after?.expiresAt).toBe(Date.now() + 30_000);
+ expect(after?.expiresAt).toBeGreaterThan(beforeExpiresAt ?? 0);
+ });
});
diff --git a/packages/server/src/ws/activation.ts b/packages/server/src/ws/activation.ts
index ba4cf6a52..79762f9f8 100644
--- a/packages/server/src/ws/activation.ts
+++ b/packages/server/src/ws/activation.ts
@@ -47,12 +47,11 @@ export class ActivationManager {
const now = Date.now();
const activeLease = this.getLease();
- if (
- activeLease &&
- activeLease.clientInstanceId === clientInstanceId &&
- activeLease.graceUntil !== null &&
- now <= activeLease.graceUntil
- ) {
+ 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;
activeLease.expiresAt = now + this.options.leaseExpirationMs;
@@ -61,7 +60,7 @@ export class ActivationManager {
active: true,
generation: activeLease.generation,
recoveryMode: "grace_recover",
- displacedWsClientId: null,
+ displacedWsClientId,
};
}
From 49ba29e9c66140ece27ec57626e4ddae6e30498d Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Tue, 12 May 2026 14:33:13 +0800
Subject: [PATCH 43/95] feat(server): add activation commands and dispatch
guard
---
.../src/__tests__/activation-commands.test.ts | 50 ++++++++++++++++
packages/server/src/commands/activation.ts | 27 +++++++++
packages/server/src/commands/index.ts | 1 +
packages/server/src/server.ts | 3 +
packages/server/src/ws/activation.ts | 60 +++++++++++++++++++
packages/server/src/ws/dispatch.ts | 23 +++++++
6 files changed, 164 insertions(+)
create mode 100644 packages/server/src/__tests__/activation-commands.test.ts
create mode 100644 packages/server/src/commands/activation.ts
create mode 100644 packages/server/src/ws/activation.ts
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..77b65eeb0
--- /dev/null
+++ b/packages/server/src/__tests__/activation-commands.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import { ActivationManager } from "../ws/activation.js";
+import { type CommandContext, dispatch } from "../ws/dispatch.js";
+import "../commands/activation.js";
+
+describe("activation commands", () => {
+ it("returns generation data from activation.claim", async () => {
+ const ctx = {
+ activationMgr: new ActivationManager(),
+ } as unknown as CommandContext;
+
+ 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",
+ });
+ });
+
+ it("rejects non-activation commands when activation is missing", async () => {
+ const ctx = {
+ activationMgr: new ActivationManager(),
+ } as unknown as CommandContext;
+
+ 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?.code).toBe("activation_required");
+ });
+});
diff --git a/packages/server/src/commands/activation.ts b/packages/server/src/commands/activation.ts
new file mode 100644
index 000000000..27c186e8a
--- /dev/null
+++ b/packages/server/src/commands/activation.ts
@@ -0,0 +1,27 @@
+import { z } from "zod";
+import { registerCommand } from "../ws/dispatch.js";
+
+registerCommand(
+ "activation.claim",
+ z.object({ clientInstanceId: z.string().min(1) }),
+ async (args, ctx, clientId) => {
+ return ctx.activationMgr.claim(args.clientInstanceId, clientId!);
+ }
+);
+
+registerCommand(
+ "activation.heartbeat",
+ z.object({ clientInstanceId: z.string(), generation: z.number().int().positive() }),
+ async (args, ctx) => {
+ return { ok: ctx.activationMgr.heartbeat(args.clientInstanceId, args.generation) };
+ }
+);
+
+registerCommand(
+ "activation.release",
+ z.object({ clientInstanceId: z.string(), generation: z.number().int().positive() }),
+ async (args, ctx) => {
+ 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/server.ts b/packages/server/src/server.ts
index f71c24511..ca3c1c0a8 100644
--- a/packages/server/src/server.ts
+++ b/packages/server/src/server.ts
@@ -38,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";
@@ -66,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;
@@ -211,6 +213,7 @@ export async function createServer(
autoFetch,
providerRuntimeDeps,
providerInstallMgr,
+ activationMgr,
};
wsHub.setCommandContext(commandContext);
diff --git a/packages/server/src/ws/activation.ts b/packages/server/src/ws/activation.ts
new file mode 100644
index 000000000..c171d78ff
--- /dev/null
+++ b/packages/server/src/ws/activation.ts
@@ -0,0 +1,60 @@
+export interface ActivationLease {
+ clientInstanceId: string;
+ wsClientId: string;
+ generation: number;
+}
+
+export interface ActivationClaimResult {
+ active: true;
+ generation: number;
+ recoveryMode: "fresh" | "grace_recover" | "takeover";
+}
+
+export class ActivationManager {
+ private lease: ActivationLease | null = null;
+ private generation = 0;
+
+ claim(clientInstanceId: string, wsClientId: string): ActivationClaimResult {
+ const current = this.lease;
+
+ if (current && current.clientInstanceId === clientInstanceId) {
+ current.wsClientId = wsClientId;
+ return {
+ active: true,
+ generation: current.generation,
+ recoveryMode: "grace_recover",
+ };
+ }
+
+ this.generation += 1;
+ this.lease = {
+ clientInstanceId,
+ wsClientId,
+ generation: this.generation,
+ };
+
+ return {
+ active: true,
+ generation: this.lease.generation,
+ recoveryMode: current ? "takeover" : "fresh",
+ };
+ }
+
+ heartbeat(clientInstanceId: string, generation: number): boolean {
+ return (
+ this.lease?.clientInstanceId === clientInstanceId && this.lease.generation === generation
+ );
+ }
+
+ release(clientInstanceId: string, generation: number): void {
+ if (this.lease?.clientInstanceId !== clientInstanceId || this.lease.generation !== generation) {
+ return;
+ }
+
+ this.lease = null;
+ }
+
+ getLease(): ActivationLease | null {
+ return this.lease;
+ }
+}
diff --git a/packages/server/src/ws/dispatch.ts b/packages/server/src/ws/dispatch.ts
index ed63c7629..d95f6996b 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,12 @@ const handlers = new Map();
* Registry of all command schemas
*/
const schemas = new Map();
+const ACTIVATION_ALLOWLIST = new Set([
+ "activation.claim",
+ "activation.heartbeat",
+ "activation.release",
+ "connection.probe",
+]);
/**
* Register a command handler
@@ -77,6 +85,21 @@ export async function dispatch(
ctx: CommandContext,
clientId?: string
): Promise {
+ if (!ACTIVATION_ALLOWLIST.has(msg.op)) {
+ const lease = ctx.activationMgr.getLease();
+ if (!clientId || !lease || lease.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) {
From 2b8d1acb9708e3e5704675a105173e5d31371021 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Tue, 12 May 2026 06:33:49 +0000
Subject: [PATCH 44/95] Use shared select for theme settings
---
.../components/settings-page.test.tsx | 52 ++++++++++++-------
.../settings/components/settings-page.tsx | 2 +
2 files changed, 34 insertions(+), 20 deletions(-)
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 676724014..a83e1769a 100644
--- a/packages/web/src/features/settings/components/settings-page.test.tsx
+++ b/packages/web/src/features/settings/components/settings-page.test.tsx
@@ -1022,13 +1022,10 @@ describe("SettingsPage", () => {
renderSettingsPage(store);
fireEvent.click(screen.getByRole("button", { name: "外观" }));
- const themePicker = await screen.findByRole("combobox", { name: "主题" });
+ const themePicker = await screen.findByRole("button", { name: "主题 Mint 深色" });
const chineseLanguagePill = screen.getByRole("button", { name: "中文" });
expect(themePicker).toHaveAccessibleDescription("选择应用主题");
- expect(screen.getByRole("option", { name: "Mint 深色" })).toBeInTheDocument();
- expect(screen.getByRole("option", { name: "Mint 浅色" })).toBeInTheDocument();
- expect(screen.getByRole("option", { name: "高对比深色" })).toBeInTheDocument();
expect(
screen.getByRole("group", {
name: "语言",
@@ -1036,7 +1033,7 @@ describe("SettingsPage", () => {
).toHaveAccessibleDescription("选择界面语言");
expect(screen.queryByRole("group", { name: "终端渲染器" })).not.toBeInTheDocument();
expect(screen.queryByRole("switch", { name: "选中自动复制" })).not.toBeInTheDocument();
- expect(themePicker).toHaveValue("mint-dark");
+ expect(themePicker).toHaveAttribute("aria-haspopup", "listbox");
expect(chineseLanguagePill).toHaveAttribute("aria-pressed", "true");
});
@@ -1115,13 +1112,21 @@ describe("SettingsPage", () => {
renderSettingsPage(store);
fireEvent.click(screen.getByRole("button", { name: "Appearance" }));
- const picker = await screen.findByRole("combobox", { name: "Theme" });
- expect(screen.getByRole("option", { name: "Mint Dark" })).toBeInTheDocument();
- expect(screen.getByRole("option", { name: "Graphite Dark" })).toBeInTheDocument();
- expect(screen.getByRole("option", { name: "Graphite Light" })).toBeInTheDocument();
- expect(screen.getByRole("option", { name: "Nord Light" })).toBeInTheDocument();
+ const picker = await screen.findByRole("button", { name: "Theme Mint Dark" });
+ expect(picker).toHaveAttribute("aria-haspopup", "listbox");
- fireEvent.change(picker, { target: { value: "graphite-dark" } });
+ 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(
@@ -1139,7 +1144,11 @@ describe("SettingsPage", () => {
expect(document.documentElement).toHaveAttribute("data-theme", "graphite-dark");
- fireEvent.change(picker, { target: { value: "graphite-light" } });
+ 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(
@@ -1156,7 +1165,7 @@ describe("SettingsPage", () => {
});
expect(document.documentElement).toHaveAttribute("data-theme", "graphite-light");
- expect(picker).toHaveValue("graphite-light");
+ expect(screen.getByRole("button", { name: "Theme Graphite Light" })).toBeInTheDocument();
});
it("hydrates the single theme picker from settings.get themeId", async () => {
@@ -1175,7 +1184,7 @@ describe("SettingsPage", () => {
fireEvent.click(screen.getByRole("button", { name: "Appearance" }));
await waitFor(() => {
- expect(screen.getByRole("combobox", { name: "Theme" })).toHaveValue("nord-light");
+ expect(screen.getByRole("button", { name: "Theme Nord Light" })).toBeInTheDocument();
});
});
@@ -1195,7 +1204,7 @@ describe("SettingsPage", () => {
fireEvent.click(screen.getByRole("button", { name: "Appearance" }));
await waitFor(() => {
- expect(screen.getByRole("combobox", { name: "Theme" })).toHaveValue("mint-light");
+ expect(screen.getByRole("button", { name: "Theme Mint Light" })).toBeInTheDocument();
expect(document.documentElement).toHaveAttribute("data-theme", "mint-light");
});
});
@@ -1216,7 +1225,7 @@ describe("SettingsPage", () => {
fireEvent.click(screen.getByRole("button", { name: "Appearance" }));
await waitFor(() => {
- expect(screen.getByRole("combobox", { name: "Theme" })).toHaveValue("graphite-light");
+ expect(screen.getByRole("button", { name: "Theme Graphite Light" })).toBeInTheDocument();
expect(document.documentElement).toHaveAttribute("data-theme", "graphite-light");
});
});
@@ -1237,9 +1246,12 @@ describe("SettingsPage", () => {
renderSettingsPage(store);
fireEvent.click(screen.getByRole("button", { name: "Appearance" }));
- fireEvent.change(await screen.findByRole("combobox", { name: "Theme" }), {
- target: { value: "graphite-dark" },
- });
+ 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(
@@ -1262,7 +1274,7 @@ describe("SettingsPage", () => {
await settingsGetPromise;
});
- expect(screen.getByRole("combobox", { name: "Theme" })).toHaveValue("graphite-dark");
+ expect(screen.getByRole("button", { name: "Theme Graphite Dark" })).toBeInTheDocument();
expect(document.documentElement).toHaveAttribute("data-theme", "graphite-dark");
});
diff --git a/packages/web/src/features/settings/components/settings-page.tsx b/packages/web/src/features/settings/components/settings-page.tsx
index a357a820d..057a7b4f2 100644
--- a/packages/web/src/features/settings/components/settings-page.tsx
+++ b/packages/web/src/features/settings/components/settings-page.tsx
@@ -1236,10 +1236,12 @@ function AppearanceSettings({ locale, setLocale, theme, setTheme }: AppearanceSe
{t("settings.theme.hint")}
) : null}
+ {viewport === "mobile" && mobileCopyModeSnapshot ? (
+
+
+
{t("terminal.copy_mode_title")}
+
{t("terminal.copy_mode_hint")}
+
+ {t("terminal.copy_mode_done")}
+
+
+
+
+ ) : null}
);
}
diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json
index cf69bc579..ea9f0978a 100644
--- a/packages/web/src/locales/en.json
+++ b/packages/web/src/locales/en.json
@@ -259,6 +259,11 @@
"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.",
+ "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",
"hide": "Hide Terminal",
"show": "Show Terminal",
"selector": {
diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json
index 8dca7fcc6..39a04b092 100644
--- a/packages/web/src/locales/zh.json
+++ b/packages/web/src/locales/zh.json
@@ -259,6 +259,11 @@
"create_failed_body": "Shell 进程启动失败,请检查服务端运行环境和 Shell 配置。",
"create_unavailable_title": "当前没有已选中的工作区",
"create_unavailable_body": "请先打开或切换到一个工作区,再新建终端。",
+ "copy_mode_title": "复制模式",
+ "copy_mode_hint": "拖动选择文本",
+ "copy_mode_done": "完成",
+ "copy_mode_failed_title": "无法进入复制模式",
+ "copy_mode_failed_body": "请重试,或先滚动终端后再长按",
"hide": "隐藏终端",
"show": "显示终端",
"selector": {
diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css
index c0e0a33a4..31ee50262 100644
--- a/packages/web/src/styles/components.css
+++ b/packages/web/src/styles/components.css
@@ -9072,6 +9072,61 @@ textarea.input {
border-top-right-radius: 18px;
}
+ .mobile-terminal-copy-mode {
+ position: absolute;
+ inset: 0;
+ z-index: 6;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ padding: var(--sp-3);
+ 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-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;
+ overflow: auto;
+ user-select: text;
+ -webkit-user-select: text;
+ -webkit-touch-callout: default;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ .mobile-terminal-copy-mode__text {
+ display: inline-block;
+ margin: 0;
+ white-space: pre;
+ user-select: text;
+ -webkit-user-select: text;
+ }
+
.mobile-sheet--files,
.mobile-sheet--terminal,
.mobile-sheet--launch {
diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts
index 99efbe3d6..adb5375f4 100644
--- a/packages/web/src/styles/components.theme.test.ts
+++ b/packages/web/src/styles/components.theme.test.ts
@@ -180,6 +180,33 @@ describe("components.css theme-sensitive surfaces", () => {
expect(xtermViewport).not.toContain("touch-action: none");
});
+ it("mobile terminal copy mode overlay styles", () => {
+ const overlay = getLastRuleBlock(".mobile-terminal-copy-mode");
+ const toolbar = getLastRuleBlock(".mobile-terminal-copy-mode__toolbar");
+ const done = getLastRuleBlock(".mobile-terminal-copy-mode__done");
+ const content = getLastRuleBlock(".mobile-terminal-copy-mode__content");
+ const text = getLastRuleBlock(".mobile-terminal-copy-mode__text");
+
+ expect(overlay).toContain("position: absolute");
+ expect(overlay).toContain("inset: 0");
+ expect(overlay).toContain("z-index: 6");
+ expect(overlay).toContain("user-select: text");
+ expect(overlay).toContain("-webkit-user-select: text");
+ expect(toolbar).toContain("display: flex");
+ expect(toolbar).toContain("align-items: center");
+ expect(done).toContain("margin-left: auto");
+ expect(content).toContain("overflow: auto");
+ expect(content).toContain("-webkit-overflow-scrolling: touch");
+ expect(content).toContain("user-select: text");
+ expect(content).toContain("-webkit-user-select: text");
+ expect(content).toContain("-webkit-touch-callout: default");
+ expect(text).toContain("white-space: pre");
+ expect(text).toContain("user-select: text");
+ expect(text).toContain("-webkit-user-select: text");
+ expect(text).not.toContain("white-space: pre-wrap");
+ expect(text).not.toContain("word-break: break-word");
+ });
+
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;[^}]*\}/
From afc4a91761d0b55ca480bddfa3cd75a35c2c45ed Mon Sep 17 00:00:00 2001
From: Spencer
Date: Tue, 12 May 2026 08:59:45 +0000
Subject: [PATCH 54/95] docs: add icon theme system design
---
.../specs/2026-05-12-icon-theme-design.md | 448 ++++++++++++++++++
1 file changed, 448 insertions(+)
create mode 100644 docs/superpowers/specs/2026-05-12-icon-theme-design.md
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 把浅色主题作为重点检查对象。
From e022e68503af59fba589aa81faaf345392537d1d Mon Sep 17 00:00:00 2001
From: Spencer
Date: Tue, 12 May 2026 10:12:35 +0000
Subject: [PATCH 55/95] feat: add theme icon tokens
---
packages/web/src/styles/tokens-touch.test.ts | 61 ++++-
packages/web/src/styles/tokens.css | 224 +++++++++++++++++++
2 files changed, 283 insertions(+), 2 deletions(-)
diff --git a/packages/web/src/styles/tokens-touch.test.ts b/packages/web/src/styles/tokens-touch.test.ts
index 069502c54..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];
}
}
@@ -26,6 +34,32 @@ function getCustomProperty(block: string, name: string): string | 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"]');
@@ -98,4 +132,27 @@ describe("tokens.css touch tokens", () => {
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 68e07abe6..23d701b20 100644
--- a/packages/web/src/styles/tokens.css
+++ b/packages/web/src/styles/tokens.css
@@ -169,6 +169,34 @@
--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);
@@ -217,6 +245,34 @@
--color-error: #cf222e;
--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 ========== */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
@@ -265,6 +321,34 @@
--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);
@@ -313,6 +397,34 @@
--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);
@@ -361,6 +473,34 @@
--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);
@@ -409,6 +549,34 @@
--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);
@@ -457,6 +625,34 @@
--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);
@@ -505,6 +701,34 @@
--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);
From 707b144aa5e5bfd8605541b2ccd8930377801f63 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Tue, 12 May 2026 10:20:20 +0000
Subject: [PATCH 56/95] feat: add shared icon style utilities
---
packages/web/src/styles/base.css | 52 ++++++++++++++++++++++
packages/web/src/styles/base.theme.test.ts | 13 ++++++
2 files changed, 65 insertions(+)
diff --git a/packages/web/src/styles/base.css b/packages/web/src/styles/base.css
index 459ea208d..793c6eae0 100644
--- a/packages/web/src/styles/base.css
+++ b/packages/web/src/styles/base.css
@@ -251,6 +251,58 @@ textarea::placeholder {
color: var(--color-error);
}
+/* Icon utilities */
+.icon-tone-primary {
+ color: var(--icon-primary);
+}
+.icon-tone-secondary {
+ color: var(--icon-secondary);
+}
+.icon-tone-muted {
+ color: var(--icon-muted);
+}
+.icon-tone-accent {
+ color: var(--icon-accent);
+}
+.icon-tone-success {
+ color: var(--icon-success);
+}
+.icon-tone-warning {
+ color: var(--icon-warning);
+}
+.icon-tone-error {
+ color: var(--icon-error);
+}
+.icon-tone-info {
+ color: var(--icon-info);
+}
+
+.icon-surface-subtle {
+ background: var(--icon-surface-subtle);
+}
+.icon-surface-accent {
+ background: var(--icon-surface-accent);
+}
+.icon-surface-success {
+ background: var(--icon-surface-success);
+}
+.icon-surface-warning {
+ background: var(--icon-surface-warning);
+}
+.icon-surface-error {
+ background: var(--icon-surface-error);
+}
+.icon-surface-info {
+ background: var(--icon-surface-info);
+}
+
+.icon-chip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-md);
+}
+
/* Spacing utilities (using 4px grid) */
.p-1 {
padding: var(--sp-1);
diff --git a/packages/web/src/styles/base.theme.test.ts b/packages/web/src/styles/base.theme.test.ts
index 99dea4d60..b5cccbc9a 100644
--- a/packages/web/src/styles/base.theme.test.ts
+++ b/packages/web/src/styles/base.theme.test.ts
@@ -33,4 +33,17 @@ describe("base.css theme-sensitive shells", () => {
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)");
+ });
});
From e17f3a9abe2dddb92870a545c99facdd2679ce2b Mon Sep 17 00:00:00 2001
From: Spencer
Date: Tue, 12 May 2026 10:26:31 +0000
Subject: [PATCH 57/95] feat: theme feature icon styles
---
packages/web/src/styles/components.css | 53 +++++++++++--------
.../web/src/styles/components.theme.test.ts | 42 ++++++++++++++-
2 files changed, 71 insertions(+), 24 deletions(-)
diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css
index 77e8203aa..8e2d0ff7a 100644
--- a/packages/web/src/styles/components.css
+++ b/packages/web/src/styles/components.css
@@ -732,6 +732,7 @@
.settings-nav-icon {
flex-shrink: 0;
+ color: var(--icon-secondary);
}
.settings-nav-label {
@@ -1588,6 +1589,7 @@
}
.terminal-panel-empty-icon {
+ color: var(--icon-muted);
opacity: 0.5;
}
@@ -2230,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 {
@@ -3380,18 +3382,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 {
@@ -3419,15 +3421,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;
}
@@ -4904,27 +4906,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 {
@@ -5350,6 +5352,7 @@ textarea.input {
}
.bottom-terminal-empty-icon {
+ color: var(--icon-muted);
opacity: 0.45;
}
@@ -5501,19 +5504,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 {
@@ -7608,7 +7611,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 {
@@ -8393,6 +8398,7 @@ textarea.input {
justify-content: center;
flex-shrink: 0;
line-height: 1;
+ color: var(--icon-accent);
}
.mobile-supervisor-badge__icon svg {
@@ -8523,6 +8529,7 @@ textarea.input {
display: inline-flex;
align-items: center;
justify-content: center;
+ color: currentColor;
}
.mobile-dock__icon svg {
@@ -9884,7 +9891,7 @@ textarea.input {
.settings-mobile-item__icon {
display: inline-flex;
- color: var(--text-tertiary);
+ color: var(--icon-secondary);
}
.settings-mobile-item__label {
diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts
index 714354bfc..c3f11430f 100644
--- a/packages/web/src/styles/components.theme.test.ts
+++ b/packages/web/src/styles/components.theme.test.ts
@@ -63,6 +63,46 @@ 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(".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("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);");
@@ -411,7 +451,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)");
});
From cee8e30abe70b696cc18c34a426b5bc133299a46 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Tue, 12 May 2026 10:33:37 +0000
Subject: [PATCH 58/95] feat: theme shared status icons
---
.../ui/confirm-dialog/index.module.css | 3 +-
.../src/components/ui/toast/index.module.css | 19 ++++++--
.../settings/components/config-editor.tsx | 4 +-
packages/web/src/styles/components.css | 21 +++++----
.../web/src/styles/components.theme.test.ts | 44 +++++++++++++++++++
5 files changed, 77 insertions(+), 14 deletions(-)
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/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/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/styles/components.css b/packages/web/src/styles/components.css
index 8e2d0ff7a..cfcf1080e 100644
--- a/packages/web/src/styles/components.css
+++ b/packages/web/src/styles/components.css
@@ -7131,16 +7131,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 {
@@ -7513,20 +7513,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 */
diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts
index c3f11430f..53dd6a141 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));
@@ -103,6 +111,42 @@ describe("components.css theme-sensitive surfaces", () => {
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);");
From 425de63e9bdb7ffff08e40b1c0bb357374fa6528 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Tue, 12 May 2026 10:44:41 +0000
Subject: [PATCH 59/95] test: add icon theme review scenes
---
e2e-ui/report/build-report.test.ts | 23 ++
packages/web/src/ui-preview/catalog.test.tsx | 15 ++
.../web/src/ui-preview/scene-metadata.test.ts | 10 +
packages/web/src/ui-preview/scene-metadata.ts | 35 ++++
.../src/ui-preview/scenes/showcase-scenes.tsx | 196 ++++++++++++++++++
5 files changed, 279 insertions(+)
diff --git a/e2e-ui/report/build-report.test.ts b/e2e-ui/report/build-report.test.ts
index d328ebfbb..1f774db7d 100644
--- a/e2e-ui/report/build-report.test.ts
+++ b/e2e-ui/report/build-report.test.ts
@@ -29,6 +29,29 @@ describe("build-report", () => {
});
});
+ 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([
{
diff --git a/packages/web/src/ui-preview/catalog.test.tsx b/packages/web/src/ui-preview/catalog.test.tsx
index a99815158..4c5add07d 100644
--- a/packages/web/src/ui-preview/catalog.test.tsx
+++ b/packages/web/src/ui-preview/catalog.test.tsx
@@ -174,4 +174,19 @@ describe("UI preview catalog", () => {
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();
+ });
+
+ 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/scene-metadata.test.ts b/packages/web/src/ui-preview/scene-metadata.test.ts
index dc1470396..ec3a73116 100644
--- a/packages/web/src/ui-preview/scene-metadata.test.ts
+++ b/packages/web/src/ui-preview/scene-metadata.test.ts
@@ -7,6 +7,16 @@ 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(
diff --git a/packages/web/src/ui-preview/scene-metadata.ts b/packages/web/src/ui-preview/scene-metadata.ts
index 779482bbd..a9c852a3b 100644
--- a/packages/web/src/ui-preview/scene-metadata.ts
+++ b/packages/web/src/ui-preview/scene-metadata.ts
@@ -211,6 +211,41 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [
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-sheet__content" },
+ },
{
id: "mobile-workspace-drawer",
title: "Mobile Workspace Drawer",
diff --git a/packages/web/src/ui-preview/scenes/showcase-scenes.tsx b/packages/web/src/ui-preview/scenes/showcase-scenes.tsx
index 897110c5f..0c0c8a2db 100644
--- a/packages/web/src/ui-preview/scenes/showcase-scenes.tsx
+++ b/packages/web/src/ui-preview/scenes/showcase-scenes.tsx
@@ -1,10 +1,23 @@
import type { FileNode, GitStatus, Supervisor, Workspace, WorktreeInfo } from "@coder-studio/core";
+import {
+ CircleAlert,
+ CircleCheckBig,
+ CircleDot,
+ File,
+ FileCode2,
+ FileJson2,
+ FileText,
+ Folder,
+ Image as ImageIcon,
+} 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";
@@ -166,6 +179,189 @@ 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
+
+
+
{}} />
+
+
+ ) : (
+
+
+
+
+
+
+ packages
+
+
+
+
+
+ app.tsx
+
+
+
+
+
+ theme.json
+
+
+
+
+
+ README.md
+
+
+
+
+
+ logo.png
+
+
+
+
+
+ LICENSE
+
+
+
+
+
+
+
+ staged.ts
+
+
+
+
+
+ modified.ts
+
+
+
+
+
+ deleted.ts
+
+
+
+
+
+ untracked.ts
+
+
+
+ ),
+ }),
+ 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) => ({
From 6f4b08b54102d305e131ffab8ed9e4d7a4211ced Mon Sep 17 00:00:00 2001
From: Spencer
Date: Tue, 12 May 2026 10:59:55 +0000
Subject: [PATCH 60/95] fix: finalize icon review scenes
---
packages/web/src/ui-preview/catalog.test.tsx | 1 +
packages/web/src/ui-preview/scene-metadata.ts | 2 +-
.../src/ui-preview/scenes/showcase-scenes.tsx | 21 +++++++++++++++++++
3 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/packages/web/src/ui-preview/catalog.test.tsx b/packages/web/src/ui-preview/catalog.test.tsx
index 4c5add07d..51811e55c 100644
--- a/packages/web/src/ui-preview/catalog.test.tsx
+++ b/packages/web/src/ui-preview/catalog.test.tsx
@@ -181,6 +181,7 @@ describe("UI preview catalog", () => {
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 () => {
diff --git a/packages/web/src/ui-preview/scene-metadata.ts b/packages/web/src/ui-preview/scene-metadata.ts
index a9c852a3b..14eaa5f3f 100644
--- a/packages/web/src/ui-preview/scene-metadata.ts
+++ b/packages/web/src/ui-preview/scene-metadata.ts
@@ -244,7 +244,7 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [
devices: ["desktop", "mobile"],
themes: allThemeIds(),
locales: ["zh", "en"],
- capture: { selector: ".supervisor-dialog, .mobile-sheet__content" },
+ capture: { selector: ".supervisor-dialog, .mobile-supervisor-sheet" },
},
{
id: "mobile-workspace-drawer",
diff --git a/packages/web/src/ui-preview/scenes/showcase-scenes.tsx b/packages/web/src/ui-preview/scenes/showcase-scenes.tsx
index 0c0c8a2db..4ac2ef79a 100644
--- a/packages/web/src/ui-preview/scenes/showcase-scenes.tsx
+++ b/packages/web/src/ui-preview/scenes/showcase-scenes.tsx
@@ -9,6 +9,7 @@ import {
FileText,
Folder,
Image as ImageIcon,
+ Terminal,
} from "lucide-react";
import { ConfirmDialog, EmptyState, Notice, Sheet } from "../../components/ui";
import { CommandPalette } from "../../features/command-palette";
@@ -220,6 +221,16 @@ export function createShowcaseScenes(): UiPreviewSceneDefinition[] {
README.md
+
+ Review the terminal empty-state icon and surface treatment.
+
+ }
+ icon={}
+ title={No terminal session
}
+ />
{}} />
@@ -289,6 +300,16 @@ export function createShowcaseScenes(): UiPreviewSceneDefinition[] {
untracked.ts
+
+ Review the terminal empty-state icon and surface treatment.
+
+ }
+ icon={}
+ title={No terminal session
}
+ />
),
}),
From f48e34969e3f5686db30e50a487c10f111810c5a Mon Sep 17 00:00:00 2001
From: pallyoung
Date: Tue, 12 May 2026 19:34:20 +0800
Subject: [PATCH 61/95] feat(web): add single-active session gate recovery
---
.../web/src/app/providers.lifecycle.test.tsx | 179 +++++++++++++++++-
packages/web/src/app/providers.tsx | 91 +++++++++
packages/web/src/atoms/activation.ts | 29 +++
packages/web/src/atoms/index.ts | 1 +
packages/web/src/features/auth/index.test.tsx | 26 +++
packages/web/src/features/auth/index.tsx | 12 +-
.../src/features/auth/session-gate.test.tsx | 101 ++++++++++
.../web/src/features/auth/session-gate.tsx | 91 +++++++++
.../shared/workspace-route-gate.test.tsx | 58 +++---
.../views/shared/workspace-route-gate.tsx | 9 +-
packages/web/src/hooks/use-activation.ts | 131 +++++++++++++
packages/web/src/hooks/use-bootstrap.ts | 16 ++
packages/web/src/locales/en.json | 7 +-
packages/web/src/locales/zh.json | 7 +-
.../web/src/shells/desktop-shell.test.tsx | 56 ++++++
packages/web/src/shells/desktop-shell.tsx | 5 +-
.../src/shells/mobile-shell/index.test.tsx | 6 +
.../web/src/shells/mobile-shell/index.tsx | 5 +-
packages/web/src/ws/__tests__/client.test.ts | 20 ++
packages/web/src/ws/client.ts | 9 +-
20 files changed, 823 insertions(+), 36 deletions(-)
create mode 100644 packages/web/src/atoms/activation.ts
create mode 100644 packages/web/src/features/auth/session-gate.test.tsx
create mode 100644 packages/web/src/features/auth/session-gate.tsx
create mode 100644 packages/web/src/hooks/use-activation.ts
diff --git a/packages/web/src/app/providers.lifecycle.test.tsx b/packages/web/src/app/providers.lifecycle.test.tsx
index 658b24628..4f8d52a54 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 {
+ activationGenerationAtom,
+ activationReasonAtom,
+ activationStatusAtom,
+} from "../atoms/activation";
import { authenticatedAtom } 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.heartbeat" || 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,
@@ -128,7 +160,7 @@ describe("AppProviders lifecycle recovery", () => {
}),
getStatus: vi.fn(() => "disconnected"),
recoverConnection: vi.fn(),
- sendCommand: vi.fn().mockResolvedValue(undefined),
+ sendCommand: createWsSendCommandMock(),
};
});
@@ -452,6 +484,151 @@ 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("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");
diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx
index c83b18d35..cec1535be 100644
--- a/packages/web/src/app/providers.tsx
+++ b/packages/web/src/app/providers.tsx
@@ -33,6 +33,11 @@ import {
workspacesLoadStateAtom,
wsClientAtom,
} from "../atoms";
+import {
+ activationGenerationAtom,
+ activationReasonAtom,
+ activationStatusAtom,
+} from "../atoms/activation";
import { authenticatedAtom } from "../atoms/app-ui";
import type { DispatchCommand } from "../atoms/connection";
import { activeWorkspaceIdAtom } from "../atoms/workspaces";
@@ -45,11 +50,14 @@ 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 type { ConnectionStatus, EventListener } from "../ws";
import { resolveWsUrl, WsClient } from "../ws";
@@ -107,6 +115,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
@@ -183,6 +228,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();
@@ -246,6 +292,18 @@ 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");
@@ -391,6 +449,10 @@ export function AppProviders({ children }: AppProvidersProps) {
};
const triggerForegroundRecovery = () => {
+ if (store.get(activationStatusAtom) === "gated") {
+ return;
+ }
+
syncWorkspaceActivity();
if (document.visibilityState !== "visible") {
lastForegroundRecoveryAtRef.current = null;
@@ -429,6 +491,10 @@ export function AppProviders({ children }: AppProvidersProps) {
};
const handleOnline = () => {
+ if (store.get(activationStatusAtom) === "gated") {
+ return;
+ }
+
wsClientRef.current?.recoverConnection("network_online");
};
@@ -529,6 +595,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);
@@ -544,6 +634,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)
];
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/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/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..c1694c2b7
--- /dev/null
+++ b/packages/web/src/features/auth/session-gate.test.tsx
@@ -0,0 +1,101 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { createStore, Provider } from "jotai";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { activationStatusAtom } from "../../atoms/activation";
+import { authEnabledAtom } from "../../atoms/connection";
+import { SessionGatePage } from "./session-gate";
+
+const originalFetch = globalThis.fetch;
+
+describe("SessionGatePage", () => {
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ });
+
+ it("shows a password form when auth is enabled", () => {
+ const store = createStore();
+ store.set(authEnabledAtom, true);
+ store.set(activationStatusAtom, "gated");
+
+ render(
+
+
+
+
+
+ );
+
+ expect(screen.getByLabelText("密码")).toBeInTheDocument();
+ });
+
+ it("claims re-entry after successful login when auth is enabled", async () => {
+ const requestReentry = 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);
+ store.set(activationStatusAtom, "gated");
+
+ render(
+
+
+
+
+
+ );
+
+ const input = await screen.findByLabelText("密码");
+ fireEvent.change(input, { target: { value: "sekrit" } });
+ fireEvent.click(screen.getByRole("button", { name: "确认" }));
+
+ await waitFor(() => {
+ expect(requestReentry).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("shows an explicit re-enter action when auth is disabled", async () => {
+ const requestReentry = vi.fn().mockResolvedValue(true);
+ const store = createStore();
+ store.set(authEnabledAtom, false);
+ store.set(activationStatusAtom, "gated");
+
+ render(
+
+
+
+
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "重新进入" }));
+
+ await waitFor(() => {
+ expect(requestReentry).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("navigates back to / after successful re-entry when auth is disabled", async () => {
+ const requestReentry = vi.fn().mockResolvedValue(true);
+ const store = createStore();
+ store.set(authEnabledAtom, false);
+ store.set(activationStatusAtom, "gated");
+
+ render(
+
+
+
+
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "重新进入" }));
+
+ await waitFor(() => {
+ expect(requestReentry).toHaveBeenCalledTimes(1);
+ });
+ });
+});
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..325168e2a
--- /dev/null
+++ b/packages/web/src/features/auth/session-gate.tsx
@@ -0,0 +1,91 @@
+import { useAtomValue } from "jotai";
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { authEnabledAtom } from "../../atoms/connection";
+import { Button, EmptyState } from "../../components/ui";
+import { useActivation } from "../../hooks/use-activation";
+import { useViewport } from "../../hooks/use-viewport";
+import { useTranslation } from "../../lib/i18n";
+import { LoginPage } from "./index";
+
+const gateEmptyStateStyle = {
+ minHeight: "auto",
+ padding: 0,
+ gap: "var(--sp-5)",
+ alignItems: "stretch",
+ textAlign: "left" as const,
+};
+
+export function SessionGatePage({ requestReentry }: { requestReentry?: () => Promise }) {
+ const t = useTranslation();
+ const navigate = useNavigate();
+ const authEnabled = useAtomValue(authEnabledAtom);
+ const { claim } = useActivation();
+ const isMobile = useViewport() === "mobile";
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleReentry = async () => {
+ setSubmitting(true);
+ try {
+ const ok = requestReentry ? await requestReentry() : await claim();
+ if (ok) {
+ navigate("/", { replace: true });
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (authEnabled === true) {
+ return ;
+ }
+
+ return (
+
+
+
+ {t("auth.session_gate_title")}
+ {t("app.name")}
+
+ }
+ description={
+
{t("auth.session_gate_description")}
+ }
+ />
+
+
{t("auth.status_title")}
+
{t("auth.session_gate_detail")}
+
+
void handleReentry()}
+ >
+ {submitting ? t("auth.session_gate_reentering") : t("auth.session_gate_reenter")}
+
+
+
+ );
+}
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..5ac2e2715
--- /dev/null
+++ b/packages/web/src/hooks/use-activation.ts
@@ -0,0 +1,131 @@
+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";
+
+const HEARTBEAT_INTERVAL_MS = 10_000;
+
+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 heartbeatTimerRef = useRef | null>(null);
+ const claimInFlightRef = useRef | null>(null);
+
+ const stopHeartbeat = () => {
+ if (heartbeatTimerRef.current) {
+ clearInterval(heartbeatTimerRef.current);
+ heartbeatTimerRef.current = 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(() => {
+ stopHeartbeat();
+
+ if (!wsClient || status !== "active" || generation === null) {
+ return;
+ }
+
+ heartbeatTimerRef.current = setInterval(() => {
+ void wsClient
+ .sendCommand<{ ok: boolean }>("activation.heartbeat", {
+ clientInstanceId,
+ generation,
+ })
+ .then((result) => {
+ if (!result.ok) {
+ stopHeartbeat();
+ setStatus("gated");
+ setReason("heartbeat_rejected");
+ }
+ })
+ .catch(() => {
+ stopHeartbeat();
+ });
+ }, HEARTBEAT_INTERVAL_MS);
+
+ return () => {
+ stopHeartbeat();
+ };
+ }, [clientInstanceId, generation, setReason, setStatus, status, wsClient]);
+
+ useEffect(() => {
+ return () => {
+ stopHeartbeat();
+ 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 cf69bc579..9247976de 100644
--- a/packages/web/src/locales/en.json
+++ b/packages/web/src/locales/en.json
@@ -606,7 +606,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",
diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json
index 8dca7fcc6..645c04ee4 100644
--- a/packages/web/src/locales/zh.json
+++ b/packages/web/src/locales/zh.json
@@ -606,7 +606,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": "连接",
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 (