diff --git a/.changeset/bright-rabbits-attend.md b/.changeset/bright-rabbits-attend.md new file mode 100644 index 000000000..24f418aef --- /dev/null +++ b/.changeset/bright-rabbits-attend.md @@ -0,0 +1,4 @@ +"@spencer-kit/coder-studio": patch +--- + +Fix supervisor dialog polish by removing the duplicated desktop intro panel and ensuring the scheduled-time picker popover renders above modal overlays. diff --git a/README.md b/README.md index a6670cfe2..78eba8d2c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Coder Studio -**A browser-based AI coding workspace for developers who move between desktop, tablet, and phone.** +**Coder Studio, made for vibe coding.** Run Claude Code and Codex in one workspace. Keep your terminal, files, Git view, and AI sessions available from any device. @@ -21,14 +21,34 @@ Run Claude Code and Codex in one workspace. Keep your terminal, files, Git view, [![Workspace Preview](docs/help/assets/screenshot-desktop-workspace-full.png)](docs/help/assets/screenshot-desktop-workspace-full.png) -
Preview the full workspace layout built for AI coding, supervision, and device switching.
+
Preview the full workspace layout built for vibe coding, supervision, and device switching.
## Why It Feels Different -- **One browser workspace for AI coding** — Keep terminal, files, Git, and AI sessions in one place. +- **One browser workspace for vibe coding** — Keep terminal, files, Git, and agent sessions in one place. - **Built for device switching** — Start on desktop, continue on tablet, and check progress from your phone. - **Objective-driven multi-step orchestration** — Let Supervisor steer long-running AI tasks so you do not have to babysit every turn, reduce repetitive manual prompting, and get more consistent outcomes. +## Why Coder Studio? + +Vibe coding agents are powerful, but the raw workflow is still fragmented: + +- the agent runs in one terminal +- files and diffs live in another editor +- verification happens in separate shell tabs +- long-running tasks are hard to monitor away from your desk +- mobile access usually means SSH or remote desktop + +Coder Studio turns that scattered workflow into one local browser workspace. + +| Pain | Without Coder Studio | With Coder Studio | +|------|----------------------|-------------------| +| Long agent tasks | Watch a terminal or come back later and reconstruct context | Keep sessions, terminal output, files, and Git changes visible in one workspace | +| Cross-device work | Use SSH, remote desktop, or rebuild context on another machine | Reopen the same local workspace from desktop, tablet, or phone | +| Reviewing AI changes | Jump between terminal, editor, and Git tools | Inspect files and diffs beside the agent session | +| Multiple agents | Manage separate terminal windows and histories | Run Claude and Codex sessions side by side in one workspace | +| Local-first control | Move work into a hosted IDE or cloud VM | Keep the runtime and project files on your own machine | + ## Quick Start ```bash @@ -119,10 +139,12 @@ The same workspace URL works across all devices — interface adapts automatical | [App Overview](docs/help/app-overview.md) | Core concepts and features | | [Provider Setup](docs/help/providers.md) | Claude Code / Codex CLI installation | | [Desktop Guide](docs/help/desktop-guide.md) | PC interface and shortcuts | -| [Mobile Guide](docs/help/mobile-guide.md) | Phone / tablet usage | +| [Mobile & Remote Access Guide](docs/help/mobile-guide.md) | Phone / tablet usage, LAN access, Tailscale/ngrok/Cloudflare Tunnel | | [Common Workflows](docs/help/workflows.md) | Task-based tutorials | | [Troubleshooting](docs/help/troubleshooting.md) | FAQ and known issues | | [CLI Reference](docs/help/cli.md) | Command-line options | +| [GitHub Wiki Source](docs/wiki/README.md) | Wiki pages for product positioning, security, and workflows | +| [AI Coding Terms](docs/wiki/AI-Coding-Terms.md) | Vibe coding, agentic harnesses, eval harnesses, and where Coder Studio fits | --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 74cf9bd12..a154c4b37 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,7 +4,7 @@ # Coder Studio -**一个浏览器里的 AI 编程工作台,适合在桌面、平板和手机之间切换的开发者。** +**Coder Studio,生来就是 vibe coding。** 在同一个工作台里运行 Claude Code 和 Codex,让终端、文件、Git 视图和 AI 会话跟着你在不同设备间延续。 @@ -21,7 +21,7 @@ [![工作区预览](docs/help/assets/screenshot-desktop-workspace-full.png)](docs/help/assets/screenshot-desktop-workspace-full.png) -
预览这个为 AI 编程、Supervisor 监督和跨设备切换而设计的完整工作区布局。
+
预览这个为 vibe coding、Supervisor 监督和跨设备切换而设计的完整工作区布局。
## 为什么它不一样 @@ -29,6 +29,26 @@ - **真正为设备切换而设计** — 在桌面端开始,在平板继续,用手机随时查看 Agent 进度。 - **目标驱动的多轮调度** — 让 Supervisor 接管长任务推进,你不必全程盯守每一轮输出,减少机械重复的人工催促,并获得更稳定的执行效果。 +## 为什么选择 Coder Studio? + +vibe coding agent 已经很强,但原始工作流仍然是割裂的: + +- Agent 跑在一个终端里 +- 文件和 diff 在另一个编辑器里 +- 验证命令散落在不同 shell tab +- 长任务离开电脑后很难继续观察 +- 手机访问通常只能靠 SSH 或远程桌面 + +Coder Studio 把这些分散的环节收进同一个本地浏览器工作台。 + +| 痛点 | 没有 Coder Studio | 使用 Coder Studio | +|------|-------------------|-------------------| +| 长时间 Agent 任务 | 盯着终端,或者回来后重新拼上下文 | 会话、终端输出、文件和 Git 变更都在同一个工作区里 | +| 跨设备继续 | SSH、远程桌面,或在另一台机器重新配置 | 桌面、平板、手机重新打开同一个本地工作区 | +| 审阅 AI 改动 | 在终端、编辑器、Git 工具之间切换 | 在 Agent 会话旁边直接查看文件和 diff | +| 多 Agent 并行 | 多个终端窗口和历史记录分散管理 | Claude 和 Codex 会话在同一个工作区里并行管理 | +| 本地优先 | 把环境迁到云 IDE 或远程 VM | 运行时和项目文件留在自己的机器上 | + ## 快速开始 ```bash @@ -119,10 +139,12 @@ coder-studio open | [功能总览](docs/help/app-overview.md) | 核心概念和功能 | | [Provider 配置](docs/help/providers.md) | Claude Code / Codex CLI 安装 | | [桌面端指南](docs/help/desktop-guide.md) | PC 界面和快捷键 | -| [移动端指南](docs/help/mobile-guide.md) | 手机/平板使用 | +| [移动端与远程访问指南](docs/help/mobile-guide.md) | 手机/平板使用、局域网访问、Tailscale/ngrok/Cloudflare Tunnel | | [常用工作流](docs/help/workflows.md) | 任务式教程 | | [故障排除](docs/help/troubleshooting.md) | 常见问题和修复 | | [CLI 参考](docs/help/cli.md) | 命令行选项 | +| [GitHub Wiki 源文件](docs/wiki/README.md) | 产品定位、安全说明和工作流 Wiki 页面 | +| [AI Coding 术语](docs/wiki/AI-Coding-Terms.md) | Vibe coding、agentic harness、eval harness,以及 Coder Studio 的定位 | --- diff --git a/docs/help/README.md b/docs/help/README.md index 49b84e1df..c69bf4b1f 100644 --- a/docs/help/README.md +++ b/docs/help/README.md @@ -14,7 +14,7 @@ ## 我主要在手机上使用 -- [移动端使用指南](mobile-guide.md) — 手机端界面和操作 +- [移动端与远程访问指南](mobile-guide.md) — 手机端界面、局域网访问、Tailscale/ngrok/Cloudflare Tunnel ## 我想了解产品结构 diff --git a/docs/help/assets/screenshot-desktop-workspace-full.png b/docs/help/assets/screenshot-desktop-workspace-full.png index 34e14af7c..d869f0d96 100644 Binary files a/docs/help/assets/screenshot-desktop-workspace-full.png and b/docs/help/assets/screenshot-desktop-workspace-full.png differ diff --git a/docs/help/assets/screenshot-mobile.png b/docs/help/assets/screenshot-mobile.png index 26e8ca497..13570e3ec 100644 Binary files a/docs/help/assets/screenshot-mobile.png and b/docs/help/assets/screenshot-mobile.png differ diff --git a/docs/help/assets/screenshot-pc.png b/docs/help/assets/screenshot-pc.png index 927a682ed..d0efaa1d6 100644 Binary files a/docs/help/assets/screenshot-pc.png and b/docs/help/assets/screenshot-pc.png differ diff --git a/docs/help/mobile-guide.md b/docs/help/mobile-guide.md index a340f1f22..dbe428219 100644 --- a/docs/help/mobile-guide.md +++ b/docs/help/mobile-guide.md @@ -1,18 +1,161 @@ -# 移动端使用指南 +# 移动端与远程访问指南 -这篇文档介绍如何通过手机浏览器使用 Coder Studio。移动端适合查看状态、监控 Agent 进度和轻量操作。 +这篇文档介绍如何通过手机浏览器使用 Coder Studio,以及如何把本机运行的 Coder Studio 安全地开放给其他设备或外网访问。移动端适合查看状态、监控 Agent 进度和轻量操作。 ## 这篇文档解决什么问题 -如何在手机上访问和使用 Coder Studio,包括界面导航、会话管理和文件查看。 +如何在手机上访问和使用 Coder Studio,包括局域网访问、远程访问、界面导航、会话管理和文件查看。 ## 前置条件 - Coder Studio 服务已在电脑上运行(`coder-studio serve` 或 `coder-studio open`) -- 手机和电脑在同一局域网 -- 手机浏览器访问 `http://<电脑IP>:<端口>` +- 如果要从其他设备访问,建议先设置密码:`coder-studio config --password <强密码>` +- 如果要从局域网其他设备访问,服务需要监听非 localhost 地址:`coder-studio config --host 0.0.0.0` +- 修改配置后重启服务:`coder-studio serve --restart` -> 如果不知道本机 IP 和端口,执行 `coder-studio status` 查看 "Listen URL"。 +> 如果不知道本机 IP 和端口,执行 `coder-studio status` 查看监听地址和端口。 + +## 选择访问方式 + +| 场景 | 推荐方式 | 适合谁 | +|------|----------|--------| +| 手机和电脑在同一个 Wi-Fi | 局域网 IP 访问 | 家里、办公室内网、临时查看 | +| 只有自己的设备需要远程访问 | Tailscale | 多设备开发者、长期使用 | +| 临时给外网一个访问地址 | ngrok | 演示、临时测试、Webhook | +| 有自己的域名并想长期暴露 | Cloudflare Tunnel | 固定域名、长期远程访问 | + +不建议直接把 Coder Studio 端口暴露到公网。至少先启用密码,并优先使用 Tailscale、ngrok 或 Cloudflare Tunnel 这类反向隧道/VPN 方案。 + +## 局域网访问 + +局域网访问适合手机和电脑在同一 Wi-Fi 下的情况。 + +### 1. 启用密码 + +```bash +coder-studio config --password <强密码> +``` + +### 2. 监听局域网地址 + +默认如果服务只监听 `localhost`,手机无法访问电脑上的服务。改成监听所有网卡: + +```bash +coder-studio config --host 0.0.0.0 +coder-studio serve --restart +``` + +### 3. 查看地址 + +```bash +coder-studio status +``` + +在手机浏览器中访问: + +```text +http://<电脑局域网IP>:<端口> +``` + +例如: + +```text +http://192.168.1.23:4173 +``` + +### 4. 如果打不开 + +- 确认手机和电脑在同一个 Wi-Fi,且没有开启访客网络隔离 +- 确认电脑防火墙允许该端口的入站连接 +- 确认 Coder Studio 不是只监听 `localhost` +- 如果正在使用 VPN、公司网络或校园网,网络可能禁止设备互访 +- 在电脑上重新执行 `coder-studio status`,确认端口没有变化 + +## Tailscale 访问 + +Tailscale 适合“只让自己的设备访问”。它会把你的电脑、手机、平板加入同一个私有 tailnet,不需要把服务直接暴露到公网。 + +基本流程: + +1. 在电脑和手机上安装并登录 Tailscale +2. 在电脑上启动 Coder Studio +3. 启用密码并重启服务 +4. 在手机上用电脑的 Tailscale IP 或 MagicDNS 名称访问 + +```bash +coder-studio config --password <强密码> +coder-studio config --host 0.0.0.0 +coder-studio serve --restart +tailscale status +``` + +然后在手机浏览器访问: + +```text +http://<电脑的 Tailscale IP>:<端口> +``` + +如果你希望使用 Tailscale 的 HTTPS/Serve/Funnel 能力,请参考官方文档: + +- Tailscale Serve:适合 tailnet 内设备访问 +- Tailscale Funnel:适合公开到互联网,风险更高,启用前确认访问控制和认证 + +## ngrok 临时外网访问 + +ngrok 适合临时把本机服务暴露给外网,例如演示或测试。它会生成一个公开 HTTPS 地址,并转发到本机端口。 + +先确认端口: + +```bash +coder-studio status +``` + +假设 Coder Studio 运行在 `4173`: + +```bash +ngrok http 4173 +``` + +ngrok 会显示一个 `https://...ngrok...` 地址。用手机打开这个地址即可。 + +使用 ngrok 前请注意: + +- 一定先设置 Coder Studio 密码 +- 不要把生成的公网地址发给不可信的人 +- 演示结束后停止 ngrok 进程 +- 免费/付费能力、固定域名和访问控制以 ngrok 官方说明为准 + +## Cloudflare Tunnel 长期外网访问 + +Cloudflare Tunnel 适合你有自己的域名,并希望长期通过 HTTPS 访问本机服务。它通过 `cloudflared` 从你的电脑主动连接 Cloudflare,不需要在路由器上开公网入站端口。 + +临时 Quick Tunnel 示例: + +```bash +cloudflared tunnel --url http://localhost:<端口> +``` + +长期使用建议在 Cloudflare Zero Trust 中创建受管理的 Tunnel,并绑定自己的域名,例如: + +```text +https://coder.example.com -> http://localhost:<端口> +``` + +使用 Cloudflare Tunnel 前请注意: + +- 一定先设置 Coder Studio 密码 +- 更推荐配合 Cloudflare Access 或其他身份校验 +- 如果电脑关机、休眠或 `cloudflared` 停止,远程地址也会不可用 +- 固定域名和访问策略以 Cloudflare 官方文档为准 + +## 安全建议 + +- 只在可信网络中使用局域网访问 +- 开放给其他设备前先执行 `coder-studio config --password <强密码>` +- 不要把无密码的 Coder Studio 暴露到公网 +- 不要在公开链接中打开敏感项目,除非你确认认证、网络和访问控制都已配置好 +- 如果只是自己跨设备使用,优先选择 Tailscale,而不是直接公网暴露 +- 临时分享结束后,停止 ngrok、Cloudflare Tunnel 或 Tailscale Funnel ## 移动端界面结构 @@ -89,7 +232,21 @@ 确保 Provider 已正确安装且服务在运行。 **Q:手机上怎么知道访问地址?** -在电脑上执行 `coder-studio status`,找到 "Local URL" 对应的地址。确保手机和电脑在同一网络。 +在电脑上执行 `coder-studio status`,找到当前监听地址和端口。如果手机和电脑在同一局域网,访问 `http://<电脑局域网IP>:<端口>`。如果使用 Tailscale,访问 `http://<电脑 Tailscale IP>:<端口>`。 + +**Q:为什么电脑能打开,手机打不开?** +常见原因是服务只监听 `localhost`、手机和电脑不在同一网络、Wi-Fi 开启了设备隔离,或电脑防火墙阻止端口访问。先执行 `coder-studio config --host 0.0.0.0`,再执行 `coder-studio serve --restart`。 + +**Q:可以直接把端口映射到公网吗?** +不建议。Coder Studio 能操作本机项目、终端和 AI 会话,公网暴露前至少要启用密码。更推荐使用 Tailscale、ngrok 或 Cloudflare Tunnel。 + +## 参考文档 + +- [Tailscale Funnel 文档](https://tailscale.com/docs/features/tailscale-funnel) +- [Tailscale Funnel CLI 参考](https://tailscale.com/docs/reference/tailscale-cli/funnel) +- [ngrok HTTP Endpoints 文档](https://ngrok.com/docs/http/) +- [Cloudflare Tunnel 文档](https://developers.cloudflare.com/tunnel/) +- [Cloudflare Quick Tunnels](https://try.cloudflare.com/) ## 下一步 diff --git a/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-3-mobile-continuation.md b/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-3-mobile-continuation.md index dbb744068..de16cbab3 100644 --- a/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-3-mobile-continuation.md +++ b/docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-3-mobile-continuation.md @@ -6,11 +6,13 @@ > > **Spec:** `docs/superpowers/specs/2026-05-14-conversion-first-activation-design.md` -**Goal:** Productize phone continuation so the user can move from first success on desktop to the next differentiated outcome without reading docs. +**Goal:** Help users continue from desktop to phone or remote devices without overbuilding a low-value in-app assistant. -**Architecture:** Phase 3 builds on the setup success state and the mobile-access command introduced in Phase 1. It adds a reusable mobile assistant surface, exposes it from setup success and settings, and promotes `Continue on Phone` into the workspace shell so cross-device continuation becomes a normal next step instead of hidden product knowledge. +**Decision update:** The original Mobile Access Assistant mostly displayed LAN URLs and auth warnings. That is useful, but too thin to justify a dedicated feature surface right now. The higher-value user need is a clear remote-access guide that covers LAN access, auth, Tailscale, ngrok, Cloudflare Tunnel, and troubleshooting. Treat any product UI as a lightweight documentation entry point until real usage shows that a stateful assistant is needed. -**Tech Stack:** TypeScript, React, Jotai, Vitest, setup/mobile status command, existing workspace shell +**Architecture:** Phase 3 becomes documentation-first. Keep `setup.mobileAccessStatus` available as future infrastructure, but do not add `packages/web/src/features/mobile-access/*`, workspace-shell CTA, or a new setup success assistant in this phase. The product can later add a small settings/help link to the guide if the help surface supports it. + +**Tech Stack:** Markdown docs, existing help center, README links --- @@ -18,82 +20,65 @@ **Depends on:** -- Phase 1 readiness contract -- Phase 2 setup success state +- Existing help center +- Existing CLI config/status commands **Includes master task:** -- [Task 5](./2026-05-14-conversion-first-activation.md#task-5-add-the-mobile-access-assistant-and-continue-on-phone): mobile assistant and cross-device CTA +- Supersedes [Task 5](./2026-05-14-conversion-first-activation.md#task-5-add-the-mobile-access-assistant-and-continue-on-phone) for now. Do not implement the full mobile assistant until this decision is revisited. **Exit criteria:** -- mobile assistant renders candidate LAN URLs -- local-only or unauthenticated exposure states are explained in product language -- setup success shows `Continue on Phone` -- settings exposes the same assistant -- workspace shell can surface the same continuation CTA +- mobile guide explains LAN access from phone +- guide explains why `localhost` is not reachable from another device +- guide recommends enabling password before exposing the service +- guide covers Tailscale, ngrok, and Cloudflare Tunnel at a practical level +- README/help index points users to the guide ## Deliverables -- `packages/web/src/features/mobile-access/*` -- `SetupSuccessStep` -- settings integration for mobile continuation -- workspace-level `Continue on Phone` surface -- localized strings for mobile continuation states +- Update `docs/help/mobile-guide.md` +- Update `docs/help/README.md` +- Update README documentation table entries +- Keep the original in-app assistant as a deferred idea, not Phase 3 scope ## Tracking Checklist -- [ ] Add `useMobileAccess` -- [ ] Render LAN candidate URLs from `setup.mobileAccessStatus` -- [ ] Show the auth warning when only localhost/no password is configured -- [ ] Add `SetupSuccessStep` -- [ ] Show `Continue on Phone` in setup success -- [ ] Expose the same assistant in settings -- [ ] Expose the workspace-shell CTA -- [ ] Pass targeted mobile-access, settings, and workspace tests -- [ ] Commit Phase 3 changes +- [x] Re-evaluate the value of a dedicated Mobile Access Assistant +- [x] Change Phase 3 to documentation-first scope +- [x] Expand the mobile guide with LAN access instructions +- [x] Add Tailscale/ngrok/Cloudflare Tunnel guidance +- [x] Add security and troubleshooting notes +- [x] Update README/help links +- [ ] Commit Phase 3 documentation changes ## Files In Play -- Create: `packages/web/src/features/mobile-access/index.ts` -- Create: `packages/web/src/features/mobile-access/actions/use-mobile-access.ts` -- Create: `packages/web/src/features/mobile-access/views/mobile-access-assistant.tsx` -- Create: `packages/web/src/features/mobile-access/views/mobile-access-assistant.test.tsx` -- Modify: `packages/web/src/features/setup/views/setup-success-step.tsx` -- 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/features/workspace/views/desktop/workspace-desktop-view.tsx` -- Create: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx` -- Modify: `packages/web/src/locales/en.json` -- Modify: `packages/web/src/locales/zh.json` +- Modify: `docs/help/mobile-guide.md` +- Modify: `docs/help/README.md` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `docs/superpowers/plans/2026-05-14-conversion-first-activation-phase-3-mobile-continuation.md` ## Verification -Run these before closing the phase: - -```bash -pnpm exec vitest run \ - packages/web/src/features/mobile-access/views/mobile-access-assistant.test.tsx \ - packages/web/src/features/settings/components/settings-page.test.tsx \ - packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx -``` - -Expected outcome: the assistant is available from setup success and settings, and the workspace shell can promote phone continuation. +No code tests are required for this documentation-only phase. Verify the Markdown renders and the links point to the intended local docs and official tunnel-provider docs. ## Watchouts -- Keep the assistant productized; do not collapse back into doc-like instructions. -- Use the server-reported URLs and auth state directly rather than adding duplicated client inference. -- Avoid over-investing in QR or polish unless the core continuation path is clearly working. +- Do not reintroduce a workspace-shell `Continue on Phone` CTA without evidence that users need it. +- Do not encourage direct public port exposure. +- Keep third-party tunnel instructions practical but not exhaustive; link official docs for provider-specific details. +- If a future in-app assistant is revived, it should do more than static information display: status-aware diagnostics, copy actions, QR, and next-step fixes. ## Detailed Execution Source -Use the detailed step-by-step instructions in the master plan: +The master plan Task 5 is intentionally superseded for this phase: - [Task 5 detailed steps](./2026-05-14-conversion-first-activation.md#task-5-add-the-mobile-access-assistant-and-continue-on-phone) ## Suggested Commit Boundary ```bash -git commit -m "feat: add phone continuation flow" +git commit -m "docs: add mobile remote access guide" ``` diff --git a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-1-positioning.md b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-1-positioning.md new file mode 100644 index 000000000..279905a23 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-1-positioning.md @@ -0,0 +1,93 @@ +# Agentic Workspace Phase 1 Positioning 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. +> +> **Spec:** `docs/superpowers/specs/2026-05-17-agentic-workspace-platform-design.md` + +**Goal:** Reposition Coder Studio as an agentic workspace for real development while preserving the current vibe coding slogan. + +**Architecture:** Documentation-only phase. Update README and Wiki copy so the product is no longer framed as only a Claude Code and Codex workspace, while still naming current supported providers accurately. + +**Tech Stack:** Markdown, GitHub README/Wiki docs. + +--- + +## Scope + +Includes: + +- README positioning update. +- Chinese README positioning update. +- Wiki positioning updates. +- New Wiki page explaining agentic workspace. +- New Wiki page explaining provider-agnostic roadmap. +- New Wiki page comparing Coder Studio and Warp. + +Excludes: + +- Code changes. +- Custom provider implementation. +- Provider settings UI changes. +- Clone GitHub onboarding. +- Continue recent workspace onboarding. +- Claude/Codex install diagnosis. + +## Files + +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `docs/wiki/Home.md` +- Modify: `docs/wiki/Why-Coder-Studio.md` +- Modify: `docs/wiki/README.md` +- Create: `docs/wiki/What-is-an-Agentic-Workspace.md` +- Create: `docs/wiki/Agent-Providers.md` +- Create: `docs/wiki/Coder-Studio-vs-Warp.md` + +## Tasks + +- [ ] Update README hero copy to use: + +```text +Coder Studio, made for vibe coding. + +An agentic workspace for real development. Run, inspect, and supervise coding agents with terminals, files, Git, sessions, and review in one browser workspace. +``` + +- [ ] Update Chinese README hero copy to use: + +```text +Coder Studio,生来就是 vibe coding。 + +面向真实开发的 agentic workspace。用一个浏览器工作区运行、检查和监督 coding agent,把终端、文件、Git、会话和代码审查放在一起。 +``` + +- [ ] Replace narrow Claude/Codex-only framing with "Claude Code and Codex today, more coding agents over time" where appropriate. +- [ ] Keep feature bullets concrete: terminal, files, Git, sessions, review, cross-device. +- [ ] Move `local-first` into secondary trust copy such as "Your code and runtime stay on your machine." +- [ ] Create `What-is-an-Agentic-Workspace.md` covering definition, why terminal/editor alone is not enough, and why review matters. +- [ ] Create `Agent-Providers.md` covering built-in providers, future presets, custom command providers, and non-goals. +- [ ] Create `Coder-Studio-vs-Warp.md` with the frame: "Warp is the agentic terminal. Coder Studio is the agentic workspace." +- [ ] Update `docs/wiki/README.md` with the new pages. + +## Acceptance Criteria + +- README does not make Coder Studio sound limited to only Claude Code and Codex. +- README still accurately says current built-in support is Claude Code and Codex. +- Wiki contains a clear provider-agnostic roadmap. +- `local-first` is present as a supporting trust message, not as the headline. + +## Verification + +```bash +git diff --check -- README.md README.zh-CN.md docs/wiki +``` + +Expected: no whitespace errors. + +## Suggested Commit + +```bash +git add README.md README.zh-CN.md docs/wiki +git commit -m "docs: position coder studio as agentic workspace" +``` + diff --git a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-2-provider-registry.md b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-2-provider-registry.md new file mode 100644 index 000000000..d707a0552 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-2-provider-registry.md @@ -0,0 +1,130 @@ +# Agentic Workspace Phase 2 Provider Registry 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. +> +> **Spec:** `docs/superpowers/specs/2026-05-17-agentic-workspace-platform-design.md` + +**Goal:** Productize the existing provider registry so Coder Studio can present itself as an agent-agnostic platform without replacing the current Claude Code and Codex implementation. + +**Architecture:** Extend the existing `ProviderDefinition` shape with platform-facing metadata and capabilities. Add a provider listing command that exposes safe provider metadata to the web app without leaking executable internals. + +**Tech Stack:** TypeScript, Zod, Vitest, existing websocket command dispatch. + +--- + +## Scope + +Includes: + +- Capability metadata on `ProviderDefinition`. +- Safe DTO for frontend provider listing. +- `provider.list` server command. +- Tests for Claude/Codex provider metadata. +- Frontend hook to consume provider list. + +Excludes: + +- Custom provider persistence. +- New provider presets. +- Install diagnosis redesign. +- Provider marketplace. + +## Files + +- Modify: `packages/core/src/provider/definition.ts` +- Modify: `packages/core/src/domain/types.ts` +- Modify: `packages/providers/src/claude/definition.ts` +- Modify: `packages/providers/src/codex/definition.ts` +- Modify: `packages/providers/src/registry.ts` +- Modify: `packages/providers/src/registry.test.ts` +- Modify: `packages/server/src/commands/provider.ts` +- Create: `packages/server/src/__tests__/provider-list.test.ts` +- Create: `packages/web/src/features/agent-providers/actions/use-agent-providers.ts` +- Create: `packages/web/src/features/agent-providers/actions/use-agent-providers.test.tsx` + +## Data Model + +Add: + +```ts +export type ProviderKind = "built_in" | "preset" | "custom"; + +export type ProviderCapabilityKey = + | "interactive_session" + | "supervisor_eval" + | "idle_detection" + | "context_attach" + | "review"; + +export interface ProviderCapabilityDescriptor { + key: ProviderCapabilityKey; + supported: boolean; + label: string; +} + +export interface ProviderListItem { + id: string; + displayName: string; + badge: string; + kind: ProviderKind; + capability: "full" | "limited" | "unsupported"; + capabilities: ProviderCapabilityDescriptor[]; + requiredCommands: string[]; +} +``` + +Extend `ProviderDefinition` with: + +```ts +kind: ProviderKind; +capabilities: ProviderCapabilityDescriptor[]; +``` + +## Tasks + +- [ ] Add provider platform types to `packages/core/src/provider/definition.ts` or `packages/core/src/domain/types.ts`. +- [ ] Set `kind: "built_in"` for Claude Code and Codex definitions. +- [ ] Add explicit capabilities for Claude Code and Codex: + +```ts +[ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, +] +``` + +- [ ] Add `toProviderListItem(provider)` helper in `packages/providers/src/registry.ts`. +- [ ] Add tests proving Claude and Codex return safe DTOs. +- [ ] Register `provider.list` in `packages/server/src/commands/provider.ts`. +- [ ] Add server command test asserting `provider.list` returns built-in provider DTOs. +- [ ] Create frontend hook `useAgentProviders()` that calls `provider.list`. +- [ ] Add hook test with mocked websocket dispatch. + +## Acceptance Criteria + +- The frontend has a provider-agnostic list API. +- Provider executable construction remains server-only. +- Claude Code and Codex appear as built-in providers. +- No custom provider functionality is introduced yet. + +## Verification + +```bash +pnpm exec vitest run \ + packages/providers/src/registry.test.ts \ + packages/server/src/__tests__/provider-list.test.ts \ + packages/web/src/features/agent-providers/actions/use-agent-providers.test.tsx +``` + +Expected: all tests pass. + +## Suggested Commit + +```bash +git add packages/core packages/providers packages/server packages/web/src/features/agent-providers +git commit -m "feat: expose agent provider registry" +``` + diff --git a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-3-workspace-intelligence.md b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-3-workspace-intelligence.md new file mode 100644 index 000000000..d1facb580 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-3-workspace-intelligence.md @@ -0,0 +1,127 @@ +# Agentic Workspace Phase 3 Workspace Intelligence 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. +> +> **Spec:** `docs/superpowers/specs/2026-05-17-agentic-workspace-platform-design.md` + +**Goal:** Add a project understanding layer that summarizes Git, package manager, framework, commands, docs, and agent instruction state for the active workspace. + +**Architecture:** Server-side workspace inspection produces a typed `WorkspaceIntelligenceSummary`. Web UI consumes it through a command and renders a compact setup/context panel inside the workspace. + +**Tech Stack:** TypeScript, Node filesystem APIs, Zod, Vitest, React Testing Library. + +--- + +## Scope + +Includes: + +- Workspace inspection module. +- `workspace.intelligence` command. +- Summary panel in the workspace UI. +- Detection for Git, package managers, package scripts, common frameworks, README/docs, and `AGENTS.md`. + +Excludes: + +- Code indexing. +- Clone GitHub repo. +- Continue recent workspace flow. +- Provider install diagnosis. +- Automatic command execution. + +## Files + +- Modify: `packages/core/src/domain/types.ts` +- Create: `packages/server/src/workspace/intelligence.ts` +- Create: `packages/server/src/__tests__/workspace/intelligence.test.ts` +- Modify: `packages/server/src/commands/workspace.ts` +- Create: `packages/server/src/__tests__/workspace-intelligence-command.test.ts` +- Create: `packages/web/src/features/workspace-intelligence/actions/use-workspace-intelligence.ts` +- Create: `packages/web/src/features/workspace-intelligence/components/workspace-intelligence-panel.tsx` +- Create: `packages/web/src/features/workspace-intelligence/components/workspace-intelligence-panel.test.tsx` +- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` + +## Data Model + +Add: + +```ts +export interface WorkspaceIntelligenceSummary { + workspaceId: string; + rootPath: string; + git: { + isRepo: boolean; + branch?: string; + }; + packageManager?: "npm" | "pnpm" | "yarn" | "bun"; + frameworks: string[]; + scripts: { + dev?: string; + test?: string; + build?: string; + lint?: string; + }; + recommendedCommands: Array<{ + key: "dev" | "test" | "build" | "lint"; + command: string; + source: "package_json" | "makefile" | "detected"; + }>; + docs: Array<{ + path: string; + kind: "readme" | "docs"; + }>; + agentInstructions: { + exists: boolean; + path: "AGENTS.md"; + }; +} +``` + +## Tasks + +- [ ] Implement package manager detection by lockfile priority: `pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`, `package-lock.json`, then `package.json`. +- [ ] Implement package script extraction from `package.json`. +- [ ] Map common script names to recommended commands: + - `dev` + - `test` + - `build` + - `lint` +- [ ] Detect frameworks from dependencies and config files: + - React + - Vite + - Next.js + - Node + - monorepo via `pnpm-workspace.yaml`, `turbo.json`, or `nx.json` +- [ ] Detect Git repository by checking `.git` file or directory. +- [ ] Detect docs via `README.md` and top-level `docs/`. +- [ ] Detect `AGENTS.md`. +- [ ] Register `workspace.intelligence`. +- [ ] Add a desktop panel showing project type, commands, Git state, docs, and instruction state. +- [ ] Keep the panel action-oriented: show recommended commands and `AGENTS.md` state instead of a passive dashboard. + +## Acceptance Criteria + +- Opening a workspace can produce a stable typed summary. +- Summary works for non-Git folders. +- Summary works when `package.json` is missing. +- UI makes clear whether `AGENTS.md` exists. +- No provider-specific assumptions are required. + +## Verification + +```bash +pnpm exec vitest run \ + packages/server/src/__tests__/workspace/intelligence.test.ts \ + packages/server/src/__tests__/workspace-intelligence-command.test.ts \ + packages/web/src/features/workspace-intelligence/components/workspace-intelligence-panel.test.tsx +``` + +Expected: all tests pass. + +## Suggested Commit + +```bash +git add packages/core packages/server packages/web/src/features/workspace-intelligence packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx +git commit -m "feat: add workspace intelligence summary" +``` + diff --git a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-4-agent-instructions.md b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-4-agent-instructions.md new file mode 100644 index 000000000..498c53bec --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-4-agent-instructions.md @@ -0,0 +1,137 @@ +# Agentic Workspace Phase 4 Agent Instructions 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. +> +> **Spec:** `docs/superpowers/specs/2026-05-17-agentic-workspace-platform-design.md` + +**Goal:** Let users create, inspect, and edit universal project-level agent instructions through `AGENTS.md`. + +**Architecture:** Build on Workspace Intelligence. The server generates deterministic `AGENTS.md` content from project facts; the web UI exposes create/edit actions without tying the feature to any single provider. + +**Tech Stack:** TypeScript, Node filesystem APIs, Zod, Vitest, React Testing Library. + +--- + +## Scope + +Includes: + +- `AGENTS.md` generation from workspace intelligence. +- Server commands to read, generate, and write instructions. +- UI panel for instruction state and editing. +- Provider-specific notes section. +- Instruction health checks. + +Excludes: + +- Large template marketplace. +- Automatic provider prompt injection. +- Non-`AGENTS.md` instruction file formats. +- Complex natural-language rewriting. + +## Files + +- Modify: `packages/core/src/domain/types.ts` +- Create: `packages/server/src/agent-instructions/generator.ts` +- Create: `packages/server/src/agent-instructions/health.ts` +- Create: `packages/server/src/__tests__/agent-instructions/generator.test.ts` +- Create: `packages/server/src/__tests__/agent-instructions/health.test.ts` +- Create: `packages/server/src/commands/agent-instructions.ts` +- Modify: `packages/server/src/commands/index.ts` +- Create: `packages/server/src/__tests__/agent-instructions-command.test.ts` +- Create: `packages/web/src/features/agent-instructions/actions/use-agent-instructions.ts` +- Create: `packages/web/src/features/agent-instructions/components/agent-instructions-panel.tsx` +- Create: `packages/web/src/features/agent-instructions/components/agent-instructions-panel.test.tsx` + +## Commands + +Add: + +- `agentInstructions.read` +- `agentInstructions.generate` +- `agentInstructions.write` +- `agentInstructions.health` + +## Generated File Shape + +Generated `AGENTS.md` should use this structure: + +```markdown +# Agent Instructions + +## Project Overview + +[Generated summary from workspace intelligence.] + +## Development Commands + +- Dev: `[command]` +- Test: `[command]` +- Build: `[command]` +- Lint: `[command]` + +## Working Rules + +- Keep changes focused on the requested task. +- Do not revert user changes unless explicitly asked. +- Prefer the project's existing patterns. +- Run the relevant verification command before reporting completion. + +## Review Expectations + +- Summarize changed files. +- Report verification commands and results. +- Call out risks, skipped tests, and assumptions. + +## Provider Notes + +- Claude Code: use the project rules above. +- Codex: use the project rules above. +``` + +Omit command lines that are unknown rather than inserting placeholders. + +## Tasks + +- [ ] Add `AgentInstructionsHealth` and `AgentInstructionsDocument` core types. +- [ ] Implement deterministic markdown generation. +- [ ] Implement health checks: + - has project overview + - has command section + - has review expectations + - has safety rules +- [ ] Implement server commands using safe workspace path resolution. +- [ ] Add command tests for missing workspace, missing file, generated content, write roundtrip, and health output. +- [ ] Add UI panel with actions: + - create from project context + - open existing instructions + - save changes + - show health issues +- [ ] Add tests for create, edit, and health states. + +## Acceptance Criteria + +- Users can create `AGENTS.md` without leaving Coder Studio. +- Generated content includes commands only when detected. +- Feature language is provider-agnostic. +- Existing `AGENTS.md` is never overwritten without explicit user action. + +## Verification + +```bash +pnpm exec vitest run \ + packages/server/src/__tests__/agent-instructions/generator.test.ts \ + packages/server/src/__tests__/agent-instructions/health.test.ts \ + packages/server/src/__tests__/agent-instructions-command.test.ts \ + packages/web/src/features/agent-instructions/components/agent-instructions-panel.test.tsx +``` + +Expected: all tests pass. + +## Suggested Commit + +```bash +git add packages/core packages/server packages/web/src/features/agent-instructions +git commit -m "feat: add universal agent instructions" +``` + diff --git a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-5-custom-agent-mvp.md b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-5-custom-agent-mvp.md new file mode 100644 index 000000000..80a80c8d2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-5-custom-agent-mvp.md @@ -0,0 +1,138 @@ +# Agentic Workspace Phase 5 Custom Agent MVP 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. +> +> **Spec:** `docs/superpowers/specs/2026-05-17-agentic-workspace-platform-design.md` + +**Goal:** Allow users to define a command-based custom coding agent and launch it like a built-in provider. + +**Architecture:** Add persistent custom provider configs that are converted into runtime `ProviderDefinition` objects on the server. Keep the MVP limited to command-based PTY sessions and one-shot command sessions. + +**Tech Stack:** TypeScript, SQLite repository pattern, Zod, Vitest, existing PTY session manager. + +--- + +## Scope + +Includes: + +- Custom provider storage. +- Command-based custom provider definition builder. +- Settings UI for custom providers. +- Launch support through existing `session.create`. +- Basic command validation. + +Excludes: + +- Marketplace. +- Community provider import/export. +- OAuth/auth setup. +- Vendor-specific install diagnosis. +- Complex output parsing. + +## Files + +- Modify: `packages/core/src/provider/definition.ts` +- Modify: `packages/core/src/domain/types.ts` +- Modify: `packages/server/src/storage/migrations/001_init.sql` +- Create: `packages/server/src/storage/repositories/custom-provider-repo.ts` +- Create: `packages/server/src/__tests__/custom-provider-repo.test.ts` +- Create: `packages/server/src/provider-runtime/custom-provider.ts` +- Create: `packages/server/src/__tests__/provider-runtime/custom-provider.test.ts` +- Create: `packages/server/src/commands/custom-provider.ts` +- Modify: `packages/server/src/commands/index.ts` +- Modify: `packages/server/src/server.ts` +- Create: `packages/server/src/__tests__/custom-provider-command.test.ts` +- Create: `packages/web/src/features/agent-providers/components/custom-provider-form.tsx` +- Create: `packages/web/src/features/agent-providers/components/custom-provider-form.test.tsx` +- Modify: `packages/web/src/features/settings/components/provider-settings.tsx` + +## Data Model + +Add: + +```ts +export type CustomProviderSessionMode = "interactive" | "one_shot"; + +export interface CustomProviderConfig { + id: string; + displayName: string; + command: string; + args: string[]; + env: Record; + cwdMode: "workspace_root"; + sessionMode: CustomProviderSessionMode; + startupPrompt?: string; + capabilities: ProviderCapabilityDescriptor[]; + createdAt: number; + updatedAt: number; +} +``` + +## Commands + +Add: + +- `customProvider.list` +- `customProvider.create` +- `customProvider.update` +- `customProvider.delete` + +## Tasks + +- [ ] Add custom provider types and validation rules. +- [ ] Add SQLite table: + +```sql +CREATE TABLE IF NOT EXISTS custom_providers ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + config TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +``` + +- [ ] Implement repository list/get/set/delete. +- [ ] Implement custom provider builder that returns a `ProviderDefinition`. +- [ ] Make `requiredCommands` use the first command token. +- [ ] Make `buildCommand` resolve cwd to workspace root. +- [ ] Merge built-in registry and custom provider definitions in server command context. +- [ ] Add command tests for create/update/delete/list. +- [ ] Add settings UI form with fields: + - display name + - command + - args + - env vars + - session mode + - startup prompt + - capabilities +- [ ] Add launch test proving a custom provider can be selected and passed to `session.create`. + +## Acceptance Criteria + +- Users can create a custom provider without editing repo files. +- Custom providers appear in provider list as `kind: "custom"`. +- Custom interactive providers can launch through the existing session flow. +- Invalid empty command is rejected. +- Custom provider deletion does not delete historical sessions. + +## Verification + +```bash +pnpm exec vitest run \ + packages/server/src/__tests__/custom-provider-repo.test.ts \ + packages/server/src/__tests__/provider-runtime/custom-provider.test.ts \ + packages/server/src/__tests__/custom-provider-command.test.ts \ + packages/web/src/features/agent-providers/components/custom-provider-form.test.tsx +``` + +Expected: all tests pass. + +## Suggested Commit + +```bash +git add packages/core packages/server packages/web/src/features/agent-providers packages/web/src/features/settings/components/provider-settings.tsx +git commit -m "feat: add command-based custom agents" +``` + diff --git a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-6-session-metadata.md b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-6-session-metadata.md new file mode 100644 index 000000000..48f00d94b --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-6-session-metadata.md @@ -0,0 +1,106 @@ +# Agentic Workspace Phase 6 Session Metadata 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. +> +> **Spec:** `docs/superpowers/specs/2026-05-17-agentic-workspace-platform-design.md` + +**Goal:** Extend session records with metadata needed for review, verification, and multi-agent workflows. + +**Architecture:** Add a companion session metadata store keyed by `sessionId` so the existing session lifecycle can stay stable. Capture Git baseline on session creation when possible and allow verification records to be added later. + +**Tech Stack:** TypeScript, SQLite repository pattern, Git CLI helpers, Vitest. + +--- + +## Scope + +Includes: + +- Session metadata table. +- Git baseline capture. +- Session objective field. +- Verification record storage. +- `session.metadata.get` and `session.verification.add` commands. + +Excludes: + +- Full review UI. +- Perfect attribution of file changes to a session. +- Automatic test command execution. + +## Files + +- Modify: `packages/core/src/domain/types.ts` +- Modify: `packages/server/src/storage/migrations/001_init.sql` +- Create: `packages/server/src/storage/repositories/session-metadata-repo.ts` +- Create: `packages/server/src/__tests__/session-metadata-repo.test.ts` +- Modify: `packages/server/src/session/manager.ts` +- Create: `packages/server/src/commands/session-metadata.ts` +- Modify: `packages/server/src/commands/index.ts` +- Create: `packages/server/src/__tests__/session-metadata-command.test.ts` +- Modify: `packages/web/src/features/agent-panes/components/session-card.tsx` +- Modify: `packages/web/src/features/agent-panes/components/session-card.test.tsx` + +## Data Model + +Add: + +```ts +export interface AgentSessionMetadata { + sessionId: string; + workspaceId: string; + providerId: string; + objective?: string; + baselineGitHead?: string; + baselineCapturedAt?: number; + verificationRuns: AgentSessionVerificationRun[]; +} + +export interface AgentSessionVerificationRun { + id: string; + command: string; + status: "passed" | "failed" | "unknown"; + exitCode?: number; + summary?: string; + createdAt: number; +} +``` + +## Tasks + +- [ ] Add metadata and verification types. +- [ ] Add SQLite tables for session metadata and verification runs. +- [ ] Implement metadata repository. +- [ ] Capture baseline Git HEAD during session creation when workspace is a Git repo. +- [ ] Store initial objective from session draft when available. +- [ ] Register `session.metadata.get`. +- [ ] Register `session.verification.add`. +- [ ] Add tests for non-Git workspaces, Git workspaces, and verification append. +- [ ] Update session card to show objective and baseline state when available. + +## Acceptance Criteria + +- Existing sessions still load. +- New sessions have metadata records. +- Git baseline capture failure does not block session creation. +- Verification runs can be stored without running commands automatically. + +## Verification + +```bash +pnpm exec vitest run \ + packages/server/src/__tests__/session-metadata-repo.test.ts \ + packages/server/src/__tests__/session-metadata-command.test.ts \ + packages/server/src/__tests__/session-commands.test.ts \ + packages/web/src/features/agent-panes/components/session-card.test.tsx +``` + +Expected: all tests pass. + +## Suggested Commit + +```bash +git add packages/core packages/server packages/web/src/features/agent-panes +git commit -m "feat: add agent session metadata" +``` + diff --git a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-7-ai-change-review.md b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-7-ai-change-review.md new file mode 100644 index 000000000..465002fbf --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-7-ai-change-review.md @@ -0,0 +1,109 @@ +# Agentic Workspace Phase 7 AI Change Review 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. +> +> **Spec:** `docs/superpowers/specs/2026-05-17-agentic-workspace-platform-design.md` + +**Goal:** Add a review flow that shows changed files and diffs for an agent session using the session Git baseline. + +**Architecture:** Use Git baseline metadata as the review anchor. Server commands compute changed files and diffs against baseline; web UI renders a session review panel beside the agent session. + +**Tech Stack:** TypeScript, Git CLI helpers, Zod, Vitest, React Testing Library. + +--- + +## Scope + +Includes: + +- Review summary command. +- Per-file diff command for session baseline. +- Session review panel. +- Verification checklist display. +- Manual "mark verification" action. + +Excludes: + +- Automatic causal attribution. +- Automatic accept/discard hunk UI. +- LLM-generated review summary. +- Cross-agent review action; that comes in Context Attach. + +## Files + +- Modify: `packages/core/src/domain/types.ts` +- Create: `packages/server/src/session-review/review.ts` +- Create: `packages/server/src/__tests__/session-review/review.test.ts` +- Create: `packages/server/src/commands/session-review.ts` +- Modify: `packages/server/src/commands/index.ts` +- Create: `packages/server/src/__tests__/session-review-command.test.ts` +- Create: `packages/web/src/features/session-review/actions/use-session-review.ts` +- Create: `packages/web/src/features/session-review/components/session-review-panel.tsx` +- Create: `packages/web/src/features/session-review/components/session-review-panel.test.tsx` +- Modify: `packages/web/src/features/agent-panes/views/shared/session-card.tsx` + +## Data Model + +Add: + +```ts +export interface SessionReviewSummary { + sessionId: string; + workspaceId: string; + baselineGitHead?: string; + changedFiles: GitFileChange[]; + verificationRuns: AgentSessionVerificationRun[]; + warnings: Array<{ + code: "missing_baseline" | "not_git_repo" | "dirty_before_session"; + message: string; + }>; +} +``` + +## Commands + +Add: + +- `sessionReview.summary` +- `sessionReview.diff` + +## Tasks + +- [ ] Implement changed file detection from `baselineGitHead` to working tree. +- [ ] Return a warning when baseline is missing. +- [ ] Return a warning when workspace is not a Git repo. +- [ ] Implement per-file diff against baseline. +- [ ] Register session review commands. +- [ ] Add UI panel showing: + - changed files + - selected diff + - verification runs + - baseline warnings +- [ ] Add action to manually add verification result using `session.verification.add`. +- [ ] Add tests for clean session, changed session, missing baseline, and non-Git workspace. + +## Acceptance Criteria + +- Users can inspect what changed since a session started. +- Review panel does not claim perfect agent attribution. +- Verification state is visible next to diff state. +- Non-Git workspaces degrade gracefully. + +## Verification + +```bash +pnpm exec vitest run \ + packages/server/src/__tests__/session-review/review.test.ts \ + packages/server/src/__tests__/session-review-command.test.ts \ + packages/web/src/features/session-review/components/session-review-panel.test.tsx +``` + +Expected: all tests pass. + +## Suggested Commit + +```bash +git add packages/core packages/server packages/web/src/features/session-review packages/web/src/features/agent-panes +git commit -m "feat: add agent session change review" +``` + diff --git a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-8-context-attach-presets.md b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-8-context-attach-presets.md new file mode 100644 index 000000000..74f864806 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-8-context-attach-presets.md @@ -0,0 +1,138 @@ +# Agentic Workspace Phase 8 Context Attach And Presets 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. +> +> **Spec:** `docs/superpowers/specs/2026-05-17-agentic-workspace-platform-design.md` + +**Goal:** Let users send workspace context to selected agents and add a lightweight preset foundation for future providers. + +**Architecture:** Build a provider-agnostic context package format. UI actions create text payloads from files, diffs, terminal output, project summary, or session review and send them to a selected existing or new session. + +**Tech Stack:** TypeScript, React, existing terminal/session input actions, Vitest, React Testing Library. + +--- + +## Scope + +Includes: + +- Context package data model. +- Server helpers for file/diff/project/session context. +- Frontend "Send to agent" actions. +- Send diff to another agent. +- Preset provider metadata format, without installing presets by default. + +Excludes: + +- Real marketplace. +- Remote registry downloads. +- OAuth. +- Background cloud agents. +- Automatic prompt engineering beyond deterministic wrappers. + +## Files + +- Modify: `packages/core/src/domain/types.ts` +- Create: `packages/server/src/agent-context/context-package.ts` +- Create: `packages/server/src/__tests__/agent-context/context-package.test.ts` +- Create: `packages/server/src/commands/agent-context.ts` +- Modify: `packages/server/src/commands/index.ts` +- Create: `packages/server/src/__tests__/agent-context-command.test.ts` +- Create: `packages/web/src/features/agent-context/actions/use-send-context-to-agent.ts` +- Create: `packages/web/src/features/agent-context/components/send-context-menu.tsx` +- Create: `packages/web/src/features/agent-context/components/send-context-menu.test.tsx` +- Modify: `packages/web/src/features/session-review/components/session-review-panel.tsx` +- Modify: `packages/web/src/features/code-editor/views/shared/code-editor-host.tsx` +- Create: `packages/providers/src/presets.ts` +- Create: `packages/providers/src/presets.test.ts` + +## Data Model + +Add: + +```ts +export type AgentContextKind = + | "file" + | "selection" + | "git_diff" + | "terminal_output" + | "project_summary" + | "session_review"; + +export interface AgentContextPackage { + id: string; + kind: AgentContextKind; + title: string; + body: string; + source: { + workspaceId: string; + path?: string; + sessionId?: string; + terminalId?: string; + }; + createdAt: number; +} +``` + +## Commands + +Add: + +- `agentContext.fromFile` +- `agentContext.fromDiff` +- `agentContext.fromProjectSummary` +- `agentContext.fromSessionReview` + +## Tasks + +- [ ] Add context package types. +- [ ] Implement deterministic wrappers: + +```text +Context: [title] +Source: [source] + +[body] +``` + +- [ ] Implement context builders for file, diff, project summary, and session review. +- [ ] Add send action that can: + - append context to an existing session + - start a new session with context as draft +- [ ] Add UI menu actions: + - Send file to agent + - Send diff to agent + - Send review summary to agent + - Send project context to agent +- [ ] Add first preset metadata for future providers without exposing them as enabled providers: + - Gemini CLI + - Aider + - OpenCode +- [ ] Add tests for context body shape and selected-agent routing. + +## Acceptance Criteria + +- Context attach actions are provider-agnostic. +- A user can ask one agent to review another agent's diff. +- Presets are stored as metadata only and do not appear as active providers until a later enable flow exists. +- Context package output is deterministic and testable. + +## Verification + +```bash +pnpm exec vitest run \ + packages/server/src/__tests__/agent-context/context-package.test.ts \ + packages/server/src/__tests__/agent-context-command.test.ts \ + packages/web/src/features/agent-context/components/send-context-menu.test.tsx \ + packages/providers/src/presets.test.ts +``` + +Expected: all tests pass. + +## Suggested Commit + +```bash +git add packages/core packages/server packages/web/src/features/agent-context packages/web/src/features/session-review packages/web/src/features/code-editor packages/providers +git commit -m "feat: attach workspace context to agents" +``` + diff --git a/docs/superpowers/plans/2026-05-17-file-context-menu.md b/docs/superpowers/plans/2026-05-17-file-context-menu.md new file mode 100644 index 000000000..5bea6d6cc --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-file-context-menu.md @@ -0,0 +1,2245 @@ +# File Context Menu Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the browser-native file-tree context menu with a custom file-aware menu that supports create, rename, delete, path copy, and open-in-terminal across desktop tree rows, desktop search rows, and mobile long-press. + +**Architecture:** Keep `FileTreePanel` as the composition layer, but move context-target state, context-menu state, and action assembly into feature-owned hooks so tree rows, search rows, and mobile long-press consume one action model. Add the missing server primitives first (`file.rename`, `terminal.create(...cwdPath)`), then share terminal creation state across the terminal panel and file actions, then wire a dedicated desktop context menu plus a mobile sheet without mutating the shared `ActionMenu` primitive. + +**Tech Stack:** TypeScript, React 19, Jotai, Vitest, Testing Library, Zod, Node `fs/promises`, existing `Sheet` and modal primitives, vanilla CSS in `components.css`. + +**Spec reference:** `docs/superpowers/specs/2026-05-17-file-context-menu-design.md` + +--- + +## File Structure + +**New files:** +- `packages/web/src/lib/clipboard.ts` +- `packages/web/src/lib/clipboard.test.ts` +- `packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.ts` +- `packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.test.tsx` +- `packages/web/src/features/workspace/actions/use-file-actions.test.tsx` +- `packages/web/src/features/workspace/actions/use-file-context-actions.ts` +- `packages/web/src/features/workspace/actions/use-file-tree-context-menu.ts` +- `packages/web/src/features/workspace/views/shared/file-context-menu.tsx` +- `packages/web/src/features/workspace/views/shared/file-context-menu.test.tsx` + +**Modified files:** +- `packages/server/src/fs/file-io.ts` +- `packages/server/src/commands/file.ts` +- `packages/server/src/commands/terminal.ts` +- `packages/server/src/__tests__/file-commands.test.ts` +- `packages/server/src/__tests__/terminal-commands.test.ts` +- `packages/web/src/features/terminal-panel/atoms/terminals.ts` +- `packages/web/src/features/terminal-panel/actions/use-terminal-actions.ts` +- `packages/web/src/features/terminal-panel/__tests__/terminal-panel.test.tsx` +- `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` +- `packages/web/src/features/workspace/actions/use-file-actions.ts` +- `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` +- `packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx` +- `packages/web/src/locales/en.json` +- `packages/web/src/locales/zh.json` +- `packages/web/src/styles/components.css` + +**No changes in this plan:** +- `packages/web/src/components/ui/action-menu/*` +- `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx` +- any global application-wide right-click system +- inline rename in the tree +- multi-select or drag-and-drop file actions + +### Task 1: Add The Server `file.rename` Command + +**Files:** +- Modify: `packages/server/src/__tests__/file-commands.test.ts` +- Modify: `packages/server/src/fs/file-io.ts` +- Modify: `packages/server/src/commands/file.ts` + +- [ ] **Step 1: Write the failing rename command tests** + +Add focused coverage to `packages/server/src/__tests__/file-commands.test.ts`: + +```ts +import { readFile as fsReadFile } from "fs/promises"; + +it("renames files and emits fs.dirty", async () => { + const result = await dispatch( + { + kind: "command", + id: "file-rename-1", + op: "file.rename", + args: { + workspaceId, + fromPath: "README.md", + toPath: "GUIDE.md", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(await fsReadFile(join(testDir, "GUIDE.md"), "utf-8")).toBe("readme\n"); + expect(eventBus.emit).toHaveBeenCalledWith({ + type: "fs.dirty", + workspaceId, + reason: "fs_change", + }); +}); + +it("renames directories recursively", async () => { + const result = await dispatch( + { + kind: "command", + id: "file-rename-2", + op: "file.rename", + args: { + workspaceId, + fromPath: "docs", + toPath: "guides", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(await fsReadFile(join(testDir, "guides", "src-note.md"), "utf-8")).toBe("note\n"); +}); + +it("rejects colliding or escaping rename targets", async () => { + const collision = await dispatch( + { + kind: "command", + id: "file-rename-3", + op: "file.rename", + args: { + workspaceId, + fromPath: "README.md", + toPath: "src.ts", + }, + }, + ctx + ); + + const escaped = await dispatch( + { + kind: "command", + id: "file-rename-4", + op: "file.rename", + args: { + workspaceId, + fromPath: "README.md", + toPath: "../outside.md", + }, + }, + ctx + ); + + expect(collision.ok).toBe(false); + expect(collision.error).toMatchObject({ code: "already_exists" }); + expect(escaped.ok).toBe(false); + expect(escaped.error).toMatchObject({ code: "path_escape" }); +}); +``` + +- [ ] **Step 2: Run the server file-command tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/file-commands.test.ts +``` + +Expected: FAIL with `Unknown command: file.rename`. + +- [ ] **Step 3: Implement `renameEntry()` and register `file.rename`** + +Update `packages/server/src/fs/file-io.ts`: + +```ts +import { + readFile as fsReadFile, + rename as fsRename, + writeFile as fsWriteFile, + mkdir, + rm, + stat, +} from "fs/promises"; + +export async function renameEntry( + rootPath: string, + fromPath: string, + toPath: string +): Promise { + const fromAbs = resolveSafe(rootPath, fromPath); + const toAbs = resolveSafe(rootPath, toPath); + const source = await statSafe(fromAbs); + const target = await statSafe(toAbs); + + if (!source) { + throw { code: "not_found", message: "Source not found" }; + } + + if (target) { + throw { code: "already_exists", message: "Target already exists" }; + } + + await mkdir(dirname(toAbs), { recursive: true }); + await fsRename(fromAbs, toAbs); +} +``` + +Update `packages/server/src/commands/file.ts`: + +```ts +import { + createDirectory, + createFile, + deleteEntry, + readFile, + renameEntry, + writeFile, +} from "../fs/file-io.js"; + +registerCommand( + "file.rename", + z.object({ + workspaceId: z.string(), + fromPath: z.string(), + toPath: z.string(), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + await renameEntry(workspace.path, args.fromPath, args.toPath); + ctx.eventBus.emit({ + type: "fs.dirty", + workspaceId: args.workspaceId, + reason: "fs_change", + }); + return { ok: true }; + } +); +``` + +- [ ] **Step 4: Run the server file-command tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/file-commands.test.ts +``` + +Expected: PASS, including the new rename coverage. + +- [ ] **Step 5: Commit the server rename slice** + +```bash +git add packages/server/src/__tests__/file-commands.test.ts packages/server/src/fs/file-io.ts packages/server/src/commands/file.ts +git commit -m "feat: add server file rename command" +``` + +### Task 2: Support `cwdPath` In `terminal.create` + +**Files:** +- Modify: `packages/server/src/__tests__/terminal-commands.test.ts` +- Modify: `packages/server/src/commands/terminal.ts` + +- [ ] **Step 1: Write the failing terminal cwd tests** + +Add coverage to `packages/server/src/__tests__/terminal-commands.test.ts`: + +```ts +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; + +it("uses cwdPath when creating a shell terminal", async () => { + const workspacePath = await mkdtemp(join(tmpdir(), "terminal-create-cwd-")); + await mkdir(join(workspacePath, "apps", "web"), { recursive: true }); + const ctx = createContext({ + workspaceMgr: { + get: vi.fn().mockReturnValue({ + id: "ws-1", + path: workspacePath, + }), + } as never, + }); + + try { + const result = await dispatch( + { + kind: "command", + id: "terminal-create-cwd-1", + op: "terminal.create", + args: { + workspaceId: "ws-1", + cwdPath: "apps/web", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(ctx.terminalMgr.create).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: join(workspacePath, "apps", "web"), + }) + ); + } finally { + await rm(workspacePath, { recursive: true, force: true }); + } +}); + +it("falls back to the workspace root when cwdPath is omitted", async () => { + const ctx = createContext(); + + await dispatch( + { + kind: "command", + id: "terminal-create-cwd-2", + op: "terminal.create", + args: { + workspaceId: "ws-1", + }, + }, + ctx + ); + + expect(ctx.terminalMgr.create).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: "/tmp/workspace", + }) + ); +}); + +it("rejects absolute and non-directory cwdPath values", async () => { + const workspacePath = await mkdtemp(join(tmpdir(), "terminal-create-cwd-invalid-")); + await writeFile(join(workspacePath, "README.md"), "readme\n"); + const ctx = createContext({ + workspaceMgr: { + get: vi.fn().mockReturnValue({ + id: "ws-1", + path: workspacePath, + }), + } as never, + }); + + try { + const absolute = await dispatch( + { + kind: "command", + id: "terminal-create-cwd-3", + op: "terminal.create", + args: { + workspaceId: "ws-1", + cwdPath: join(workspacePath, "apps", "web"), + }, + }, + ctx + ); + + const notDirectory = await dispatch( + { + kind: "command", + id: "terminal-create-cwd-4", + op: "terminal.create", + args: { + workspaceId: "ws-1", + cwdPath: "README.md", + }, + }, + ctx + ); + + expect(absolute.ok).toBe(false); + expect(absolute.error).toMatchObject({ code: "invalid_cwd_path" }); + expect(notDirectory.ok).toBe(false); + expect(notDirectory.error).toMatchObject({ code: "cwd_not_directory" }); + } finally { + await rm(workspacePath, { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 2: Run the terminal command tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/terminal-commands.test.ts +``` + +Expected: FAIL because `terminal.create` ignores `cwdPath`. + +- [ ] **Step 3: Extend `terminal.create` to validate and use `cwdPath`** + +Update `packages/server/src/commands/terminal.ts`: + +```ts +import { stat } from "fs/promises"; +import { basename, isAbsolute } from "node:path"; +import { resolveSafe } from "../fs/file-io.js"; + +registerCommand( + "terminal.create", + z.object({ + workspaceId: z.string(), + cols: z.number().int().positive().optional(), + rows: z.number().int().positive().optional(), + cwdPath: z.string().optional(), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + let cwd = workspace.path; + if (args.cwdPath && args.cwdPath !== ".") { + if (isAbsolute(args.cwdPath)) { + throw { code: "invalid_cwd_path", message: "cwdPath must be workspace-relative" }; + } + + const resolvedCwd = resolveSafe(workspace.path, args.cwdPath); + const cwdStats = await stat(resolvedCwd).catch(() => null); + if (!cwdStats) { + throw { code: "cwd_not_found", message: `Directory not found: ${args.cwdPath}` }; + } + if (!cwdStats.isDirectory()) { + throw { code: "cwd_not_directory", message: `Not a directory: ${args.cwdPath}` }; + } + + cwd = resolvedCwd; + } + + const shell = resolveShellCommand(); + return ctx.terminalMgr.create({ + workspaceId: args.workspaceId, + kind: "shell", + argv: shell.argv, + title: shell.title, + cwd, + cols: args.cols ?? 120, + rows: args.rows ?? 30, + }); + } +); +``` + +- [ ] **Step 4: Run the terminal command tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/terminal-commands.test.ts +``` + +Expected: PASS with `cwdPath` success, root fallback, and invalid-path rejection. + +- [ ] **Step 5: Commit the terminal cwd slice** + +```bash +git add packages/server/src/__tests__/terminal-commands.test.ts packages/server/src/commands/terminal.ts +git commit -m "feat: support terminal cwd overrides" +``` + +### Task 3: Share Shell-Terminal Creation State Across Surfaces + +**Files:** +- Create: `packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.ts` +- Create: `packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.test.tsx` +- Modify: `packages/web/src/features/terminal-panel/atoms/terminals.ts` +- Modify: `packages/web/src/features/terminal-panel/actions/use-terminal-actions.ts` +- Modify: `packages/web/src/features/terminal-panel/__tests__/terminal-panel.test.tsx` + +- [ ] **Step 1: Write the failing shared terminal-creation tests** + +Create `packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.test.tsx`: + +```tsx +// @vitest-environment jsdom + +import { act, renderHook } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { localeAtom } from "../../../atoms/app-ui"; +import { wsClientAtom } from "../../../atoms/connection"; +import { toastsAtom } from "../../notifications/atoms"; +import { + terminalActiveIdAtomFamily, + terminalIdsAtomFamily, + terminalMetaAtomFamily, +} from "../atoms"; +import { useCreateShellTerminal } from "./use-create-shell-terminal"; + +function wrapperFor(store: ReturnType) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +it("creates a shell terminal, stores it under the workspace, and activates it immediately", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + id: "term_2", + workspaceId: "ws-test", + kind: "shell", + title: "Workspace Shell", + cwd: "/tmp/ws-test/src", + argv: ["/bin/bash"], + cols: 120, + rows: 30, + alive: true, + createdAt: 1, + }); + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand } as never); + store.set(terminalIdsAtomFamily("ws-test"), ["term_1"]); + store.set(terminalActiveIdAtomFamily("ws-test"), "term_1"); + + const { result } = renderHook(() => useCreateShellTerminal("ws-test"), { + wrapper: wrapperFor(store), + }); + + await act(async () => { + await result.current.createShellTerminal({ cwdPath: "src" }); + }); + + expect(sendCommand).toHaveBeenCalledWith( + "terminal.create", + { + workspaceId: "ws-test", + cwdPath: "src", + }, + undefined + ); + expect(store.get(terminalIdsAtomFamily("ws-test"))).toEqual(["term_1", "term_2"]); + expect(store.get(terminalActiveIdAtomFamily("ws-test"))).toBe("term_2"); + expect(store.get(terminalMetaAtomFamily("term_2"))).toMatchObject({ + id: "term_2", + workspaceId: "ws-test", + kind: "shell", + }); +}); + +it("shows an error toast and leaves terminal atoms unchanged when creation fails", async () => { + const sendCommand = vi.fn().mockRejectedValue(new Error("spawn failed")); + const store = createStore(); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand } as never); + store.set(terminalIdsAtomFamily("ws-test"), ["term_1"]); + store.set(terminalActiveIdAtomFamily("ws-test"), "term_1"); + + const { result } = renderHook(() => useCreateShellTerminal("ws-test"), { + wrapper: wrapperFor(store), + }); + + await act(async () => { + await result.current.createShellTerminal({ cwdPath: "src" }); + }); + + expect(store.get(terminalIdsAtomFamily("ws-test"))).toEqual(["term_1"]); + expect(store.get(terminalActiveIdAtomFamily("ws-test"))).toBe("term_1"); + expect(store.get(toastsAtom)[0]).toMatchObject({ + kind: "error", + title: "Could not create terminal", + body: "spawn failed", + }); +}); +``` + +Keep `packages/web/src/features/terminal-panel/__tests__/terminal-panel.test.tsx` green by asserting the existing immediate-render behavior still works after the refactor. + +- [ ] **Step 2: Run the terminal-panel tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/terminal-panel/actions/use-create-shell-terminal.test.tsx \ + src/features/terminal-panel/__tests__/terminal-panel.test.tsx +``` + +Expected: FAIL because the shared hook and workspace-scoped terminal atoms do not exist yet. + +- [ ] **Step 3: Add workspace-scoped terminal ids/active id atoms and the shared create hook** + +Update `packages/web/src/features/terminal-panel/atoms/terminals.ts`: + +```ts +export const terminalIdsAtomFamily = atomFamily((_workspaceId: string) => atom([])); + +export const terminalActiveIdAtomFamily = atomFamily((_workspaceId: string) => + atom(null) +); + +export const activeTerminalsAtomFamily = atomFamily((workspaceId: string) => + atom((get) => get(terminalIdsAtomFamily(workspaceId))) +); +``` + +Create `packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.ts`: + +```ts +import type { Terminal as TerminalDto } from "@coder-studio/core"; +import { useAtomValue, useSetAtom, useStore } from "jotai"; +import { dispatchCommandAtom } from "../../../atoms/connection"; +import { useTranslation } from "../../../lib/i18n"; +import { pushToastAtom } from "../../notifications/atoms"; +import { + terminalActiveIdAtomFamily, + terminalIdsAtomFamily, + terminalMetaAtomFamily, +} from "../atoms"; + +function toTerminalMeta(terminal: TerminalDto) { + return { + id: terminal.id, + workspaceId: terminal.workspaceId, + kind: terminal.kind, + alive: terminal.alive, + exitCode: terminal.exitCode, + title: terminal.title, + } as const; +} + +export function useCreateShellTerminal(workspaceId: string | null) { + const t = useTranslation(); + const dispatch = useAtomValue(dispatchCommandAtom); + const pushToast = useSetAtom(pushToastAtom); + const store = useStore(); + + return { + async createShellTerminal(args: { cwdPath?: string } = {}) { + if (!workspaceId) { + pushToast({ + kind: "warning", + title: t("terminal.create_unavailable_title"), + body: t("terminal.create_unavailable_body"), + }); + return null; + } + + const result = await dispatch("terminal.create", { + workspaceId, + cwdPath: args.cwdPath, + }); + + if (!result.ok || !result.data) { + pushToast({ + kind: "error", + title: t("terminal.create_failed_title"), + body: result.error?.message ?? t("terminal.create_failed_body"), + }); + return null; + } + + const terminal = result.data; + store.set(terminalMetaAtomFamily(terminal.id), toTerminalMeta(terminal)); + store.set(terminalIdsAtomFamily(workspaceId), (current) => + current.includes(terminal.id) ? current : [...current, terminal.id] + ); + store.set(terminalActiveIdAtomFamily(workspaceId), terminal.id); + return terminal; + }, + }; +} +``` + +- [ ] **Step 4: Update `useTerminalActions()` to consume the shared state** + +Replace local `useState()` terminal lists in `packages/web/src/features/terminal-panel/actions/use-terminal-actions.ts` with workspace-scoped atoms and the shared create hook: + +```ts +const EMPTY_WORKSPACE_ID = "__terminal_panel_empty__"; + +const workspaceAtomKey = activeWorkspaceId ?? EMPTY_WORKSPACE_ID; +const [terminalIds, setTerminalIds] = useAtom(terminalIdsAtomFamily(workspaceAtomKey)); +const [activeTerminalId, setActiveTerminalId] = useAtom( + terminalActiveIdAtomFamily(workspaceAtomKey) +); +const { createShellTerminal } = useCreateShellTerminal(activeWorkspaceId); +``` + +Keep the rest of the hook behavior aligned with the current panel: + +```ts +useEffect(() => { + if (!activeWorkspaceId) { + setTerminalIds([]); + setActiveTerminalId(null); + return; + } + + let cancelled = false; + setTerminalIds([]); + setActiveTerminalId(null); + + void dispatch("terminal.list", { workspaceId: activeWorkspaceId }).then( + (result) => { + if (cancelled || !result.ok || !result.data) { + return; + } + + const shellTerminals = result.data.filter((terminal) => terminal.kind === "shell"); + const shellIds = shellTerminals.map((terminal) => terminal.id); + + for (const terminal of shellTerminals) { + store.set(terminalMetaAtomFamily(terminal.id), toTerminalMeta(terminal)); + } + + setTerminalIds((current) => mergeTerminalIds(current, shellIds)); + setActiveTerminalId((current) => current ?? shellIds[0] ?? null); + } + ); + + return () => { + cancelled = true; + }; +}, [activeWorkspaceId, dispatch, setActiveTerminalId, setTerminalIds, store]); + +const handleCreateTerminal = useCallback(async () => { + await createShellTerminal(); +}, [createShellTerminal]); +``` + +The WS `terminal.*.created` subscription should keep appending to `terminalIdsAtomFamily(activeWorkspaceId)` and setting `terminalActiveIdAtomFamily(activeWorkspaceId)` so both in-panel and out-of-panel creates converge on one state source. + +- [ ] **Step 5: Re-run the shared terminal tests and commit** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/terminal-panel/actions/use-create-shell-terminal.test.tsx \ + src/features/terminal-panel/__tests__/terminal-panel.test.tsx +``` + +Expected: PASS, including the existing "renders the new terminal immediately from terminal.create result" regression. + +Then commit: + +```bash +git add packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.ts packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.test.tsx packages/web/src/features/terminal-panel/atoms/terminals.ts packages/web/src/features/terminal-panel/actions/use-terminal-actions.ts packages/web/src/features/terminal-panel/__tests__/terminal-panel.test.tsx +git commit -m "refactor: share shell terminal creation state" +``` + +### Task 4: Extract A Shared Clipboard Helper + +**Files:** +- Create: `packages/web/src/lib/clipboard.ts` +- Create: `packages/web/src/lib/clipboard.test.ts` +- Modify: `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` + +- [ ] **Step 1: Write the failing clipboard helper tests** + +Create `packages/web/src/lib/clipboard.test.ts`: + +```ts +import { afterEach, describe, expect, it, vi } from "vitest"; +import { copyTextWithFallback } from "./clipboard"; + +describe("copyTextWithFallback", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("prefers navigator.clipboard.writeText", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { + clipboard: { writeText }, + }); + + await copyTextWithFallback("alpha"); + + expect(writeText).toHaveBeenCalledWith("alpha"); + }); + + it("falls back to document.execCommand when clipboard.writeText rejects", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("denied")); + Object.assign(navigator, { + clipboard: { writeText }, + }); + const execCommand = vi.spyOn(document, "execCommand").mockReturnValue(true); + + await copyTextWithFallback("beta"); + + expect(execCommand).toHaveBeenCalledWith("copy"); + }); + + it("rethrows the clipboard error when the fallback is unavailable", async () => { + const failure = new Error("denied"); + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockRejectedValue(failure) }, + }); + vi.spyOn(document, "execCommand").mockImplementation(undefined as never); + + await expect(copyTextWithFallback("gamma")).rejects.toThrow("denied"); + }); +}); +``` + +- [ ] **Step 2: Run the clipboard tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/lib/clipboard.test.ts +``` + +Expected: FAIL because the helper does not exist yet. + +- [ ] **Step 3: Implement the shared helper and switch `xterm-host` to it** + +Create `packages/web/src/lib/clipboard.ts`: + +```ts +export async function copyTextWithFallback(text: string): Promise { + let clipboardError: unknown; + + try { + await navigator.clipboard.writeText(text); + return; + } catch (error) { + clipboardError = error; + } + + if (typeof document === "undefined" || typeof document.execCommand !== "function") { + throw clipboardError ?? new Error("Clipboard copy unavailable"); + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.setAttribute("aria-hidden", "true"); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.width = "1px"; + textarea.style.height = "1px"; + textarea.style.padding = "0"; + textarea.style.border = "0"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + + document.body.appendChild(textarea); + try { + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + if (!document.execCommand("copy")) { + throw clipboardError ?? new Error("Clipboard copy unavailable"); + } + } finally { + textarea.remove(); + } +} +``` + +Update `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` to import this helper and remove the private inline copy implementation so terminal copy and file-path copy share identical fallback behavior. + +- [ ] **Step 4: Re-run the clipboard test and one xterm regression** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/lib/clipboard.test.ts \ + src/features/terminal-panel/__tests__/terminal-panel.test.tsx +``` + +Expected: PASS, with no terminal regressions from the import swap. + +- [ ] **Step 5: Commit the shared clipboard slice** + +```bash +git add packages/web/src/lib/clipboard.ts packages/web/src/lib/clipboard.test.ts packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx +git commit -m "refactor: share clipboard fallback helper" +``` + +### Task 5: Extend `useFileActions()` With Rename State And Path Rewrites + +**Files:** +- Create: `packages/web/src/features/workspace/actions/use-file-actions.test.tsx` +- Modify: `packages/web/src/features/workspace/actions/use-file-actions.ts` + +- [ ] **Step 1: Write the failing rename hook tests** + +Create `packages/web/src/features/workspace/actions/use-file-actions.test.tsx`: + +```tsx +// @vitest-environment jsdom + +import { act, renderHook } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { wsClientAtom } from "../../../atoms/connection"; +import { + activeFilePathAtomFamily, + fileTreeAtomFamily, + openFilesAtomFamily, +} from "../atoms"; +import { useFileActions } from "./use-file-actions"; + +function wrapperFor(store: ReturnType) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +it("renames the active file and rewrites the open-file map key", async () => { + const sendCommand = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ path: "/workspace", children: [] }); + + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + store.set(activeFilePathAtomFamily("ws-test"), "src/app.tsx"); + store.set(openFilesAtomFamily("ws-test"), { + "src/app.tsx": { + kind: "text", + path: "src/app.tsx", + content: "export {};", + baseHash: "hash-1", + isDirty: false, + }, + }); + store.set(fileTreeAtomFamily("ws-test"), new Map([[".", []]])); + + const { result } = renderHook(() => useFileActions({ workspaceId: "ws-test" }), { + wrapper: wrapperFor(store), + }); + + act(() => { + result.current.openRenameDialog({ + path: "src/app.tsx", + name: "app.tsx", + kind: "file", + }); + result.current.updateRenameDraft("main.tsx"); + }); + + await act(async () => { + await result.current.submitRenameDialog(); + }); + + expect(sendCommand).toHaveBeenNthCalledWith( + 1, + "file.rename", + { + workspaceId: "ws-test", + fromPath: "src/app.tsx", + toPath: "src/main.tsx", + }, + undefined + ); + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/main.tsx"); + expect(store.get(openFilesAtomFamily("ws-test"))["src/main.tsx"]).toMatchObject({ + path: "src/main.tsx", + }); +}); + +it("rewrites descendant editor paths when renaming a directory", async () => { + const sendCommand = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ path: "/workspace", children: [] }); + + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + store.set(activeFilePathAtomFamily("ws-test"), "src/nested/app.tsx"); + store.set(openFilesAtomFamily("ws-test"), { + "src/nested/app.tsx": { + kind: "text", + path: "src/nested/app.tsx", + content: "export {};", + baseHash: "hash-2", + isDirty: false, + }, + }); + store.set(fileTreeAtomFamily("ws-test"), new Map([[".", []]])); + + const { result } = renderHook(() => useFileActions({ workspaceId: "ws-test" }), { + wrapper: wrapperFor(store), + }); + + act(() => { + result.current.openRenameDialog({ + path: "src/nested", + name: "nested", + kind: "dir", + }); + result.current.updateRenameDraft("renamed"); + }); + + await act(async () => { + await result.current.submitRenameDialog(); + }); + + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/renamed/app.tsx"); + expect(store.get(openFilesAtomFamily("ws-test"))["src/renamed/app.tsx"]).toMatchObject({ + path: "src/renamed/app.tsx", + }); +}); + +it("rejects blank names and names containing path separators before dispatch", async () => { + const sendCommand = vi.fn(); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + store.set(fileTreeAtomFamily("ws-test"), new Map([[".", []]])); + + const { result } = renderHook(() => useFileActions({ workspaceId: "ws-test" }), { + wrapper: wrapperFor(store), + }); + + act(() => { + result.current.openRenameDialog({ + path: "src/app.tsx", + name: "app.tsx", + kind: "file", + }); + result.current.updateRenameDraft("bad/name.tsx"); + }); + + await act(async () => { + await result.current.submitRenameDialog(); + }); + + expect(sendCommand).not.toHaveBeenCalled(); + expect(result.current.renameDialog?.error).toBeTruthy(); +}); +``` + +- [ ] **Step 2: Run the rename hook tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/actions/use-file-actions.test.tsx +``` + +Expected: FAIL because the rename dialog state and rename path rewrite logic are missing. + +- [ ] **Step 3: Add rename dialog state, validation, and path-rewrite helpers** + +Update `packages/web/src/features/workspace/actions/use-file-actions.ts`: + +```ts +export interface RenameDialogState { + fromPath: string; + currentName: string; + nextName: string; + kind: "file" | "dir"; + error: string | null; +} + +function rewriteDescendantPath(path: string, fromPath: string, toPath: string): string { + if (path === fromPath) { + return toPath; + } + + if (path.startsWith(`${fromPath}/`)) { + return `${toPath}${path.slice(fromPath.length)}`; + } + + return path; +} + +function rewriteOpenFiles( + openFiles: Record, + fromPath: string, + toPath: string +): Record { + const nextEntries = Object.entries(openFiles).map(([path, file]) => { + const rewrittenPath = rewriteDescendantPath(path, fromPath, toPath); + if (rewrittenPath === path) { + return [path, file] as const; + } + + return [ + rewrittenPath, + { + ...file, + path: rewrittenPath, + }, + ] as const; + }); + + return Object.fromEntries(nextEntries); +} +``` + +Expose rename state from the hook: + +```ts +const [renameDialog, setRenameDialog] = useState(null); + +const openRenameDialog = useCallback( + ({ path, name, kind }: { path: string; name: string; kind: "file" | "dir" }) => { + setRenameDialog({ + fromPath: path, + currentName: name, + nextName: name, + kind, + error: null, + }); + }, + [] +); + +const updateRenameDraft = useCallback((nextName: string) => { + setRenameDialog((current) => + current + ? { + ...current, + nextName, + error: null, + } + : current + ); +}, []); +``` + +- [ ] **Step 4: Implement rename submit behavior and return it from the hook** + +Continue `packages/web/src/features/workspace/actions/use-file-actions.ts`: + +```ts +const submitRenameDialog = useCallback(async () => { + if (!renameDialog) { + return; + } + + const nextName = renameDialog.nextName.trim(); + if (!nextName) { + setRenameDialog((current) => + current + ? { + ...current, + error: t("file.rename_required"), + } + : current + ); + return; + } + + if (nextName.includes("/") || nextName.includes("\\")) { + setRenameDialog((current) => + current + ? { + ...current, + error: t("file.rename_invalid_name"), + } + : current + ); + return; + } + + if (nextName === renameDialog.currentName) { + setRenameDialog(null); + return; + } + + const lastSlashIndex = renameDialog.fromPath.lastIndexOf("/"); + const parentDir = lastSlashIndex === -1 ? "" : renameDialog.fromPath.slice(0, lastSlashIndex); + const toPath = parentDir ? `${parentDir}/${nextName}` : nextName; + + const result = await dispatch("file.rename", { + workspaceId, + fromPath: renameDialog.fromPath, + toPath, + }); + + if (!result.ok) { + setRenameDialog((current) => + current + ? { + ...current, + error: result.error?.message ?? t("file.rename_failed"), + } + : current + ); + return; + } + + setActiveFilePath((current) => + current ? rewriteDescendantPath(current, renameDialog.fromPath, toPath) : current + ); + setOpenFiles((current) => rewriteOpenFiles(current, renameDialog.fromPath, toPath)); + await loadFileTree(); + setRenameDialog(null); +}, [dispatch, loadFileTree, renameDialog, setActiveFilePath, setOpenFiles, t, workspaceId]); +``` + +Return these from the hook: + +```ts +return { + activeFilePath, + createDialog, + renameDialog, + pendingDelete, + cancelDelete, + confirmDelete, + handleSelectFile, + loadChildren, + loadSearchResults, + openCreateDialog, + openRenameDialog, + requestDelete: setPendingDelete, + updateRenameDraft, + submitRenameDialog, + closeRenameDialog: () => setRenameDialog(null), + updateDraftPath, + submitCreateDialog, + closeCreateDialog, +}; +``` + +- [ ] **Step 5: Re-run the hook tests and commit** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/actions/use-file-actions.test.tsx +``` + +Expected: PASS, including active-file and open-file path rewrites. + +Then commit: + +```bash +git add packages/web/src/features/workspace/actions/use-file-actions.ts packages/web/src/features/workspace/actions/use-file-actions.test.tsx +git commit -m "feat: add file rename dialog state" +``` + +### Task 6: Build Shared File Context Actions And A Dedicated Menu Surface + +**Files:** +- Create: `packages/web/src/features/workspace/actions/use-file-context-actions.ts` +- Create: `packages/web/src/features/workspace/actions/use-file-tree-context-menu.ts` +- Create: `packages/web/src/features/workspace/views/shared/file-context-menu.tsx` +- Create: `packages/web/src/features/workspace/views/shared/file-context-menu.test.tsx` + +- [ ] **Step 1: Write the failing custom menu tests** + +Create `packages/web/src/features/workspace/views/shared/file-context-menu.test.tsx`: + +```tsx +// @vitest-environment jsdom + +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { FileContextMenu } from "./file-context-menu"; + +const sections = [ + { + id: "edit", + title: "Edit", + items: [ + { id: "rename", label: "Rename", onSelect: vi.fn() }, + { id: "delete", label: "Delete", tone: "danger", onSelect: vi.fn() }, + ], + }, +]; + +it("renders a desktop menu with menu semantics and keyboard navigation", () => { + const onClose = vi.fn(); + render( + + ); + + const menu = screen.getByRole("menu", { name: "File actions" }); + expect(menu).toBeInTheDocument(); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + fireEvent.keyDown(menu, { key: "Enter" }); + + expect(sections[0]?.items[0]?.onSelect).toHaveBeenCalled(); + + fireEvent.keyDown(menu, { key: "Escape" }); + expect(onClose).toHaveBeenCalled(); +}); + +it("renders a mobile sheet with grouped actions", () => { + render( + + ); + + expect(screen.getByText("Edit")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Rename" })).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the menu tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/file-context-menu.test.tsx +``` + +Expected: FAIL because the dedicated menu component and the action/state hooks do not exist yet. + +- [ ] **Step 3: Implement the shared action builder and context-menu state hook** + +Create `packages/web/src/features/workspace/actions/use-file-context-actions.ts`: + +```ts +import type { FileNode } from "@coder-studio/core"; +import { useMemo } from "react"; +import { copyTextWithFallback } from "../../../lib/clipboard"; + +export interface FileContextMenuItem { + id: string; + label: string; + icon?: React.ReactNode; + tone?: "default" | "danger"; + disabled?: boolean; + onSelect: () => void | Promise; +} + +export interface FileContextMenuSection { + id: string; + title: string; + items: FileContextMenuItem[]; +} + +function toAbsolutePath(workspacePath: string, relativePath: string): string { + const separator = workspacePath.includes("\\") ? "\\" : "/"; + const normalizedBase = workspacePath.replace(/[\\/]+$/, ""); + const normalizedRelative = relativePath.split("/").join(separator); + return normalizedRelative ? `${normalizedBase}${separator}${normalizedRelative}` : normalizedBase; +} + +function getTerminalCwdPath(node: FileNode): string | undefined { + if (node.kind === "dir") { + return node.path === "." ? undefined : node.path; + } + + const lastSlash = node.path.lastIndexOf("/"); + return lastSlash === -1 ? undefined : node.path.slice(0, lastSlash); +} +``` + +Build the four required groups in one place: + +```ts +return useMemo(() => { + if (!target) { + return []; + } + + const relativePath = target.node.path; + const absolutePath = workspacePath ? toAbsolutePath(workspacePath, relativePath) : null; + const terminalCwdPath = getTerminalCwdPath(target.node); + + const createSection = + target.node.kind === "dir" + ? [ + { + id: "create", + title: t("file.context_section_create"), + items: [ + { id: "new-file", label: t("file.new_file"), onSelect: () => openCreateDialog("file", target.node.path) }, + { id: "new-folder", label: t("file.new_folder"), onSelect: () => openCreateDialog("folder", target.node.path) }, + ], + }, + ] + : []; + + return [ + ...createSection, + { + id: "edit", + title: t("file.context_section_edit"), + items: [ + { id: "rename", label: t("file.rename"), onSelect: () => openRenameDialog({ path: target.node.path, name: target.node.name, kind: target.node.kind }) }, + { id: "delete", label: t("file.delete"), tone: "danger", onSelect: () => requestDelete({ path: target.node.path, name: target.node.name, error: null }) }, + ], + }, + { + id: "path", + title: t("file.context_section_path"), + items: [ + { id: "copy-relative-path", label: t("file.copy_relative_path"), onSelect: () => copyTextWithFallback(relativePath) }, + { id: "copy-absolute-path", label: t("file.copy_absolute_path"), disabled: !absolutePath, onSelect: () => absolutePath ? copyTextWithFallback(absolutePath) : undefined }, + ], + }, + { + id: "terminal", + title: t("file.context_section_terminal"), + items: [ + { id: "open-in-terminal", label: t("file.open_in_terminal"), onSelect: () => createShellTerminal(terminalCwdPath ? { cwdPath: terminalCwdPath } : {}) }, + ], + }, + ]; +}, [createShellTerminal, openCreateDialog, openRenameDialog, requestDelete, t, target, workspacePath]); +``` + +Create `packages/web/src/features/workspace/actions/use-file-tree-context-menu.ts`: + +```ts +const LONG_PRESS_MS = 450; +const MOVE_TOLERANCE_PX = 10; + +export interface FileContextTarget { + node: FileNode; + surface: "tree" | "search" | "mobile"; + triggerElement: HTMLElement | null; +} + +export function useFileTreeContextMenu() { + const [contextTarget, setContextTarget] = useState(null); + const [desktopAnchorPoint, setDesktopAnchorPoint] = useState<{ x: number; y: number } | null>(null); + const [mobileOpen, setMobileOpen] = useState(false); + const suppressNextClickRef = useRef(false); + const longPressRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + timer: number; + target: FileContextTarget; + } | null>(null); + + const closeMenu = useCallback(() => { + setDesktopAnchorPoint(null); + setMobileOpen(false); + }, []); + + const openDesktopMenu = useCallback((event: React.MouseEvent, target: FileContextTarget) => { + event.preventDefault(); + setContextTarget(target); + setDesktopAnchorPoint({ x: event.clientX, y: event.clientY }); + setMobileOpen(false); + }, []); +``` + +Continue the same file with long-press open/cancel behavior: + +```ts + const beginLongPress = useCallback((event: React.PointerEvent, target: FileContextTarget) => { + if (event.pointerType === "mouse") { + return; + } + + window.clearTimeout(longPressRef.current?.timer); + longPressRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + timer: window.setTimeout(() => { + setContextTarget(target); + setMobileOpen(true); + setDesktopAnchorPoint(null); + suppressNextClickRef.current = true; + }, LONG_PRESS_MS), + target, + }; + }, []); + + const updateLongPress = useCallback((event: React.PointerEvent) => { + const current = longPressRef.current; + if (!current || current.pointerId !== event.pointerId) { + return; + } + + const movedX = Math.abs(event.clientX - current.startX); + const movedY = Math.abs(event.clientY - current.startY); + if (movedX > MOVE_TOLERANCE_PX || movedY > MOVE_TOLERANCE_PX) { + window.clearTimeout(current.timer); + longPressRef.current = null; + } + }, []); + + const cancelLongPress = useCallback((pointerId?: number) => { + const current = longPressRef.current; + if (!current) { + return; + } + + if (pointerId !== undefined && current.pointerId !== pointerId) { + return; + } + + window.clearTimeout(current.timer); + longPressRef.current = null; + }, []); + + const consumeSuppressedClick = useCallback(() => { + const suppressed = suppressNextClickRef.current; + suppressNextClickRef.current = false; + return suppressed; + }, []); +``` + +- [ ] **Step 4: Implement the dedicated desktop menu and mobile sheet** + +Create `packages/web/src/features/workspace/views/shared/file-context-menu.tsx` with feature-local rendering, not `ActionMenu` mutation: + +```tsx +import { Sheet } from "../../../../components/ui"; +import { Portal } from "../../../../components/ui/_internal/portal"; + +export function FileContextMenu({ + mode, + anchorPoint, + open, + sections, + title, + onClose, +}: FileContextMenuProps) { + if (!open) { + return null; + } + + if (mode === "mobile") { + return ( + + {sections.map((section) => ( +
+
{section.title}
+ {section.items.map((item) => ( + + ))} +
+ ))} + + } + /> + ); + } + + return ( + +
+
+ {sections.map((section) => ( +
+
{section.title}
+ {section.items.map((item) => ( + + ))} +
+ ))} +
+
+
+ ); +} +``` + +Match the keyboard rules from the spec in this component: +- `Escape` closes the desktop menu. +- `ArrowUp` / `ArrowDown` move between enabled items. +- `Enter` / `Space` invoke the focused item. +- focus returns to the triggering row element after close. + +- [ ] **Step 5: Re-run the menu test and commit** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/file-context-menu.test.tsx +``` + +Expected: PASS with both desktop and mobile render modes. + +Then commit: + +```bash +git add packages/web/src/features/workspace/actions/use-file-context-actions.ts packages/web/src/features/workspace/actions/use-file-tree-context-menu.ts packages/web/src/features/workspace/views/shared/file-context-menu.tsx packages/web/src/features/workspace/views/shared/file-context-menu.test.tsx +git commit -m "feat: add file context menu primitives" +``` + +### Task 7: Integrate The Custom Menu Into `FileTreePanel` + +**Files:** +- Modify: `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` +- Modify: `packages/web/src/styles/components.css` + +- [ ] **Step 1: Write the failing integration tests** + +Add coverage to `packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx` for all required surfaces: + +```tsx +import { seedReadyWorkspaceState } from "../../../test-utils/workspace-state"; + +it("opens the custom menu on desktop file right-click and prevents the native menu", async () => { + const sendCommand = vi.fn().mockResolvedValue({ path: "/workspace", children: [] }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + store.set( + fileTreeAtomFamily("ws-test"), + new Map([ + [ + ".", + [ + { + path: "src/app.tsx", + name: "app.tsx", + kind: "file", + }, + ], + ], + ]) + ); + + render( + + + + ); + + const row = screen.getByText("app.tsx").closest(".tree-item"); + const preventDefault = vi.fn(); + fireEvent.contextMenu(row!, { preventDefault }); + + expect(preventDefault).toHaveBeenCalled(); + expect(await screen.findByRole("menu")).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "file.rename" })).toBeInTheDocument(); +}); + +it("opens the same menu from a search result row", async () => { + const sendCommand = vi + .fn() + .mockResolvedValueOnce({ + files: [{ path: "src/app.tsx", name: "app.tsx", kind: "file" }], + }) + .mockResolvedValue({ path: "/workspace", children: [] }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + store.set(fileTreeAtomFamily("ws-test"), new Map([[".", []]])); + + render( + + + + ); + + fireEvent.change(screen.getByRole("searchbox", { name: "action.search_files" }), { + target: { value: "app" }, + }); + + const row = (await screen.findByText("app.tsx")).closest(".tree-item"); + fireEvent.contextMenu(row!); + + expect(await screen.findByRole("menu")).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "file.rename" })).toBeInTheDocument(); +}); + +it("dispatches file.rename from the rename modal", async () => { + const sendCommand = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ path: "/workspace", children: [] }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + store.set( + fileTreeAtomFamily("ws-test"), + new Map([ + [ + ".", + [ + { + path: "src/app.tsx", + name: "app.tsx", + kind: "file", + }, + ], + ], + ]) + ); + + render( + + + + ); + + fireEvent.contextMenu(screen.getByText("app.tsx").closest(".tree-item")!); + fireEvent.click(await screen.findByRole("menuitem", { name: "file.rename" })); + fireEvent.change(screen.getByLabelText("file.rename_name"), { + target: { value: "main.tsx" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Confirm" })); + + await waitFor(() => { + expect(sendCommand).toHaveBeenNthCalledWith( + 1, + "file.rename", + { + workspaceId: "ws-test", + fromPath: "src/app.tsx", + toPath: "src/main.tsx", + }, + undefined + ); + }); +}); + +it("copies relative and absolute paths through the shared clipboard helper", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { + clipboard: { writeText }, + }); + + const store = createStore(); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/tmp/ws-test", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set( + fileTreeAtomFamily("ws-test"), + new Map([ + [ + ".", + [ + { + path: "src/app.tsx", + name: "app.tsx", + kind: "file", + }, + ], + ], + ]) + ); + + render( + + + + ); + + fireEvent.contextMenu(screen.getByText("app.tsx").closest(".tree-item")!); + fireEvent.click(await screen.findByRole("menuitem", { name: "file.copy_relative_path" })); + fireEvent.contextMenu(screen.getByText("app.tsx").closest(".tree-item")!); + fireEvent.click(await screen.findByRole("menuitem", { name: "file.copy_absolute_path" })); + + expect(writeText).toHaveBeenNthCalledWith(1, "src/app.tsx"); + expect(writeText).toHaveBeenNthCalledWith(2, "/tmp/ws-test/src/app.tsx"); +}); + +it("dispatches terminal.create with a folder-aware cwdPath", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + id: "term_2", + workspaceId: "ws-test", + kind: "shell", + title: "Workspace Shell", + cwd: "/tmp/ws-test/src", + argv: ["/bin/bash"], + cols: 120, + rows: 30, + alive: true, + createdAt: 1, + }); + const store = createStore(); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/tmp/ws-test", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + store.set(wsClientAtom, { sendCommand } as never); + store.set( + fileTreeAtomFamily("ws-test"), + new Map([ + [ + ".", + [ + { + path: "src", + name: "src", + kind: "dir", + children: [], + }, + { + path: "README.md", + name: "README.md", + kind: "file", + }, + ], + ], + ]) + ); + + render( + + + + ); + + fireEvent.contextMenu(screen.getByText("README.md").closest(".tree-item")!); + fireEvent.click(await screen.findByRole("menuitem", { name: "file.open_in_terminal" })); + fireEvent.contextMenu(screen.getByText("src").closest(".tree-item")!); + fireEvent.click(await screen.findByRole("menuitem", { name: "file.open_in_terminal" })); + + expect(sendCommand).toHaveBeenNthCalledWith( + 1, + "terminal.create", + { workspaceId: "ws-test" }, + undefined + ); + expect(sendCommand).toHaveBeenNthCalledWith( + 2, + "terminal.create", + { workspaceId: "ws-test", cwdPath: "src" }, + undefined + ); +}); + +it("opens a mobile action sheet on long press but not on ordinary tap", async () => { + vi.useFakeTimers(); + const sendCommand = vi.fn().mockResolvedValue({ path: "/workspace", children: [] }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + store.set( + fileTreeAtomFamily("ws-test"), + new Map([ + [ + ".", + [ + { + path: "src/app.tsx", + name: "app.tsx", + kind: "file", + }, + ], + ], + ]) + ); + + render( + + + + ); + + const row = screen.getByText("app.tsx").closest(".tree-item")!; + fireEvent.click(row); + expect(screen.queryByText("file.context_section_edit")).toBeNull(); + + fireEvent.pointerDown(row, { + pointerId: 1, + pointerType: "touch", + clientX: 20, + clientY: 20, + }); + await vi.advanceTimersByTimeAsync(450); + + expect(await screen.findByText("file.context_section_edit")).toBeInTheDocument(); +}); + +it("cancels the long press when the pointer moves before the timeout", async () => { + vi.useFakeTimers(); + const sendCommand = vi.fn().mockResolvedValue({ path: "/workspace", children: [] }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + store.set( + fileTreeAtomFamily("ws-test"), + new Map([ + [ + ".", + [ + { + path: "src/app.tsx", + name: "app.tsx", + kind: "file", + }, + ], + ], + ]) + ); + + render( + + + + ); + + const row = screen.getByText("app.tsx").closest(".tree-item")!; + fireEvent.pointerDown(row, { + pointerId: 1, + pointerType: "touch", + clientX: 20, + clientY: 20, + }); + fireEvent.pointerMove(row, { + pointerId: 1, + pointerType: "touch", + clientX: 50, + clientY: 50, + }); + await vi.advanceTimersByTimeAsync(450); + + expect(screen.queryByText("file.context_section_edit")).toBeNull(); +}); +``` + +Add these assertions in the same test file: +- desktop file rows no longer show delete buttons +- desktop folder rows keep only `New File` and `New Folder` +- mobile rows render no `.tree-item-actions` + +- [ ] **Step 2: Run the file-tree panel tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/file-tree-panel.test.tsx +``` + +Expected: FAIL because the panel still uses native right-click behavior and has no rename flow. + +- [ ] **Step 3: Wire the panel, menu, rename modal, and row-behavior changes** + +Update `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` to compose the new hooks: + +```tsx +import { useAtomValue } from "jotai"; +import { workspaceByIdAtomFamily } from "../../../../atoms/workspaces"; +import { useCreateShellTerminal } from "../../../terminal-panel/actions/use-create-shell-terminal"; +import { useFileContextActions } from "../../actions/use-file-context-actions"; +import { useFileTreeContextMenu } from "../../actions/use-file-tree-context-menu"; +import { FileContextMenu } from "./file-context-menu"; + +const workspace = useAtomValue(workspaceByIdAtomFamily(workspaceId)); +const { createShellTerminal } = useCreateShellTerminal(workspaceId); +const contextMenu = useFileTreeContextMenu(); +const contextSections = useFileContextActions({ + target: contextMenu.contextTarget, + workspacePath: workspace?.path ?? null, + createShellTerminal, + openCreateDialog, + openRenameDialog, + requestDelete, +}); +``` + +Mount the rename modal and the custom menu: + +```tsx + + + +``` + +Update tree rows and search rows so they use both active-file selection and context-target highlighting: + +```tsx +
{ + if (variant === "mobile" && contextMenu.consumeSuppressedClick()) { + return; + } + onSelectFile(node.path); + }} + onContextMenu={(event) => { + handleSelectFile(node.path); + contextMenu.openDesktopMenu(event, { + node, + surface: "search", + triggerElement: event.currentTarget, + }); + }} + onPointerDown={(event) => + variant === "mobile" + ? contextMenu.beginLongPress(event, { + node, + surface: "mobile", + triggerElement: event.currentTarget, + }) + : undefined + } + onPointerMove={variant === "mobile" ? contextMenu.updateLongPress : undefined} + onPointerUp={(event) => (variant === "mobile" ? contextMenu.cancelLongPress(event.pointerId) : undefined)} + onPointerCancel={(event) => (variant === "mobile" ? contextMenu.cancelLongPress(event.pointerId) : undefined)} +> +``` + +Also make these concrete edits inside the same file: +- files: right-click selects the file first, then opens the custom menu +- folders: right-click only sets the context target, never toggles expansion +- search rows: use the same context menu entry point as tree rows +- desktop file rows: remove delete icon +- desktop folder rows: keep only create-file and create-folder icons +- mobile rows: remove `.tree-item-actions` entirely + +Add a dedicated rename modal beside the existing create/delete modals: + +```tsx +function RenamePathModal({ + dialog, + onCancel, + onConfirm, + onNameChange, +}: { + dialog: RenameDialogState | null; + onCancel: () => void; + onConfirm: () => Promise; + onNameChange: (value: string) => void; +}) { + if (!dialog) { + return null; + } + + return ( + inputRef.current} onOpenChange={onCancel} open> + + {t("file.rename")} + + +
+ + onNameChange(event.target.value)} + invalid={Boolean(dialog.error)} + /> + {t("file.rename_helper")} + {dialog.error ? {dialog.error} : null} +
+
+ + + + +
+ ); +} +``` + +- [ ] **Step 4: Add strings, styles, then run the focused verification set** + +Add localized labels to `packages/web/src/locales/en.json` and `packages/web/src/locales/zh.json`: + +```json +"file": { + "rename": "Rename", + "rename_name": "Name", + "rename_helper": "Rename within the current directory only.", + "rename_required": "Name is required.", + "rename_invalid_name": "Name cannot contain / or \\.", + "rename_failed": "Could not rename file", + "copy_relative_path": "Copy Relative Path", + "copy_absolute_path": "Copy Absolute Path", + "open_in_terminal": "Open in Terminal", + "context_menu_title": "File actions", + "context_section_create": "Create", + "context_section_edit": "Edit", + "context_section_path": "Path", + "context_section_terminal": "Terminal" +} +``` + +Add the new classes to `packages/web/src/styles/components.css`: + +```css +.tree-item--context-target { + background: color-mix(in srgb, var(--bg-accent) 18%, transparent); +} + +.file-context-menu-layer { + position: fixed; + inset: 0; + z-index: var(--z-dropdown); +} + +.file-context-menu { + position: fixed; + min-width: 220px; + padding: var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + background: var(--bg-surface); + box-shadow: var(--shadow-lg); +} + +.file-context-menu__section + .file-context-menu__section { + margin-top: var(--sp-2); + padding-top: var(--sp-2); + border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent); +} + +.file-context-menu__item { + display: flex; + width: 100%; + min-height: 44px; + align-items: center; + padding: var(--sp-3); + border: 0; + border-radius: var(--radius-lg); + background: transparent; + text-align: left; +} + +.file-context-menu__item--danger { + color: var(--color-error); +} + +.file-tree-shell--desktop .tree-item--file .tree-item-actions { + display: none; +} + +.file-tree-shell--mobile .tree-item-actions { + display: none; +} +``` + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/file-commands.test.ts \ + src/__tests__/terminal-commands.test.ts +``` + +and: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/lib/clipboard.test.ts \ + src/features/terminal-panel/actions/use-create-shell-terminal.test.tsx \ + src/features/terminal-panel/__tests__/terminal-panel.test.tsx \ + src/features/workspace/actions/use-file-actions.test.tsx \ + src/features/workspace/views/shared/file-context-menu.test.tsx \ + src/features/workspace/views/shared/file-tree-panel.test.tsx +``` + +and: + +```bash +pnpm --filter @coder-studio/web exec biome check \ + src/lib/clipboard.ts \ + src/lib/clipboard.test.ts \ + src/features/terminal-panel/actions/use-create-shell-terminal.ts \ + src/features/terminal-panel/actions/use-create-shell-terminal.test.tsx \ + src/features/terminal-panel/atoms/terminals.ts \ + src/features/terminal-panel/actions/use-terminal-actions.ts \ + src/features/terminal-panel/views/shared/xterm-host.tsx \ + src/features/workspace/actions/use-file-actions.ts \ + src/features/workspace/actions/use-file-actions.test.tsx \ + src/features/workspace/actions/use-file-context-actions.ts \ + src/features/workspace/actions/use-file-tree-context-menu.ts \ + src/features/workspace/views/shared/file-context-menu.tsx \ + src/features/workspace/views/shared/file-context-menu.test.tsx \ + src/features/workspace/views/shared/file-tree-panel.tsx \ + src/features/workspace/views/shared/file-tree-panel.test.tsx \ + src/locales/en.json \ + src/locales/zh.json \ + src/styles/components.css +``` + +Expected: all targeted server tests, web tests, and Biome checks pass. + +- [ ] **Step 5: Commit the integrated file-context menu feature** + +```bash +git add packages/web/src/features/workspace/views/shared/file-tree-panel.tsx packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx packages/web/src/locales/en.json packages/web/src/locales/zh.json packages/web/src/styles/components.css +git commit -m "feat: add custom file context menus" +``` + +## Self-Review + +- Spec coverage: + - desktop tree-row right-click is covered in Task 7 + - desktop search-result right-click is covered in Task 7 + - mobile long-press action sheet is covered in Tasks 6-7 + - `rename`, `delete`, `copy relative path`, `copy absolute path`, and `open in terminal` all route through Task 6 + - `rename` command path, validation, and active/open-file rewrites are covered in Tasks 1 and 5 + - `open in terminal` with immediate terminal activation is covered in Tasks 2-3 +- Placeholder scan: + - no `TODO`, `TBD`, or “similar to Task N” shortcuts remain +- Type consistency: + - `cwdPath`, `terminalIdsAtomFamily`, `terminalActiveIdAtomFamily`, `RenameDialogState`, and `FileContextTarget` are named consistently across later tasks diff --git a/docs/superpowers/plans/2026-05-17-header-system.md b/docs/superpowers/plans/2026-05-17-header-system.md new file mode 100644 index 000000000..6ee7137bb --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-header-system.md @@ -0,0 +1,345 @@ +# Header 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:** Unify all web headers into three fixed public components: `PageHeader`, `PanelHeader`, and `DialogHeader`. + +**Architecture:** Keep `PageHeader` as the shared navigation header, add a new `PanelHeader` for dense surface chrome, and make modal headers the canonical `DialogHeader`. Existing page-specific headers should be migrated onto these primitives without introducing any new ad hoc header shapes. + +**Tech Stack:** React, TypeScript, Vitest, Testing Library, CSS modules / global component CSS + +--- + +### Task 1: Extend the shared page header primitive + +**Files:** +- Modify: `packages/web/src/features/shared/components/page-header.tsx` +- Modify: `packages/web/src/features/shared/components/page-header.test.tsx` +- Modify: `packages/web/src/features/shared/components/mobile-page-header.tsx` +- Modify: `packages/web/src/features/shared/components/mobile-page-header.test.tsx` +- Modify: `packages/web/src/styles/components.css` + +- [ ] **Step 1: Write the failing test** + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { PageHeader } from "./page-header"; + +describe("PageHeader", () => { + it("supports primary and secondary levels with distinct title sizing hooks", () => { + render( + <> + + + + ); + + expect(document.querySelectorAll(".page-header--primary").length).toBe(1); + expect(document.querySelectorAll(".page-header--secondary").length).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest packages/web/src/features/shared/components/page-header.test.tsx -t "supports primary and secondary levels with distinct title sizing hooks" -v` +Expected: FAIL because `level` and the modifier classes do not exist yet. + +- [ ] **Step 3: Write the minimal implementation** + +```tsx +export interface PageHeaderProps { + title: string; + level?: "primary" | "secondary"; + onBack?: () => void; + backLabel?: string; + backAriaLabel?: string; + kicker?: ReactNode; + rightSlot?: ReactNode; + titleAs?: PageHeaderTitleElement; + className?: string; +} + +export function PageHeader({ + title, + level = "secondary", + onBack, + backLabel = "Back", + backAriaLabel, + kicker, + rightSlot, + titleAs = "h2", + className, +}: PageHeaderProps) { + const TitleTag = titleAs; + + return ( +
+
+ {onBack ? ( + + ) : null} +
+ {kicker ?
{kicker}
: null} + {title} +
+
+ {rightSlot ?
{rightSlot}
: null} +
+ ); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm vitest packages/web/src/features/shared/components/page-header.test.tsx -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/shared/components/page-header.tsx packages/web/src/features/shared/components/page-header.test.tsx packages/web/src/features/shared/components/mobile-page-header.tsx packages/web/src/features/shared/components/mobile-page-header.test.tsx packages/web/src/styles/components.css +git commit -m "feat(web): add header levels to shared page header" +``` + +### Task 2: Add a dedicated PanelHeader primitive and migrate dense panel chrome + +**Files:** +- Create: `packages/web/src/features/shared/components/panel-header.tsx` +- Create: `packages/web/src/features/shared/components/panel-header.test.tsx` +- Modify: `packages/web/src/features/agent-panes/views/shared/session-card.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/git-diff-viewer.tsx` +- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` +- Modify: `packages/web/src/styles/components.css` + +- [ ] **Step 1: Write the failing test** + +```tsx +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { PanelHeader } from "./panel-header"; + +describe("PanelHeader", () => { + it("renders title, meta, and right-side actions in a dense panel layout", () => { + render( + New File} + /> + ); + + expect(screen.getByText("Files")).toBeInTheDocument(); + expect(screen.getByText("3 items")).toBeInTheDocument(); + expect(within(document.querySelector(".panel-header") as HTMLElement).getByRole("button")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest packages/web/src/features/shared/components/panel-header.test.tsx -v` +Expected: FAIL because the component does not exist yet. + +- [ ] **Step 3: Write the minimal implementation** + +```tsx +export interface PanelHeaderProps { + title: string; + meta?: ReactNode; + status?: ReactNode; + actions?: ReactNode; + className?: string; +} + +export function PanelHeader({ title, meta, status, actions, className }: PanelHeaderProps) { + return ( +
+
+
{title}
+ {meta || status ?
{meta ?? status}
: null} +
+ {actions ?
{actions}
: null} +
+ ); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm vitest packages/web/src/features/shared/components/panel-header.test.tsx -v` +Expected: PASS. + +- [ ] **Step 5: Migrate panel chrome to the new primitive** + +Replace the hand-written headers in: + +- `packages/web/src/features/agent-panes/views/shared/session-card.tsx` +- `packages/web/src/features/workspace/views/shared/git-diff-viewer.tsx` +- `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` + +Keep the existing surface behavior, but move header layout and sizing into `PanelHeader` and shared CSS classes. + +- [ ] **Step 6: Commit** + +```bash +git add packages/web/src/features/shared/components/panel-header.tsx packages/web/src/features/shared/components/panel-header.test.tsx packages/web/src/features/agent-panes/views/shared/session-card.tsx packages/web/src/features/workspace/views/shared/git-diff-viewer.tsx packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx packages/web/src/styles/components.css +git commit -m "feat(web): introduce shared panel header" +``` + +### Task 3: Make modal headers the canonical DialogHeader + +**Files:** +- Modify: `packages/web/src/components/ui/modal/index.tsx` +- Modify: `packages/web/src/components/ui/modal/index.module.css` +- Modify: `packages/web/src/components/ui/modal/index.test.tsx` +- Modify: `packages/web/src/components/ui/index.ts` +- Modify: `packages/web/src/components/ui/modal/README.md` +- Modify: `packages/web/src/features/supervisor/views/shared/objective-dialog.tsx` + +- [ ] **Step 1: Write the failing test** + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { DialogHeader, ModalTitle } from "."; // new public alias + +describe("Modal dialog header", () => { + it("exports a dialog header alias with the same modal chrome", () => { + render( + + Workspace details + + ); + + expect(screen.getByText("Workspace details")).toBeInTheDocument(); + expect(document.querySelector(".modal-header")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest packages/web/src/components/ui/modal/index.test.tsx -v` +Expected: FAIL because `DialogHeader` is not exported yet. + +- [ ] **Step 3: Write the minimal implementation** + +```tsx +export function DialogHeader({ className, ...props }: HTMLAttributes) { + return
; +} + +export { DialogHeader as ModalHeader }; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm vitest packages/web/src/components/ui/modal/index.test.tsx -v` +Expected: PASS. + +- [ ] **Step 5: Migrate supervisor dialog to the dialog header contract** + +Update `packages/web/src/features/supervisor/views/shared/objective-dialog.tsx` so it uses the canonical dialog header structure for icon, title, description, and close action instead of an ad hoc header block. + +- [ ] **Step 6: Update exports and docs** + +Expose `DialogHeader` from `packages/web/src/components/ui/index.ts` and describe the preserved compatibility class names in `packages/web/src/components/ui/modal/README.md`. + +- [ ] **Step 7: Commit** + +```bash +git add packages/web/src/components/ui/modal/index.tsx packages/web/src/components/ui/modal/index.module.css packages/web/src/components/ui/modal/index.test.tsx packages/web/src/components/ui/index.ts packages/web/src/components/ui/modal/README.md packages/web/src/features/supervisor/views/shared/objective-dialog.tsx +git commit -m "feat(web): add dialog header alias" +``` + +### Task 4: Migrate settings and lock the header contract with coverage + +**Files:** +- Modify: `packages/web/src/features/settings/components/settings-page.tsx` +- Modify: `packages/web/src/features/shared/components/page-header.test.tsx` +- Modify: `packages/web/src/features/shared/components/mobile-page-header.test.tsx` +- Modify: `packages/web/src/features/agent-panes/components/session-card.test.tsx` +- Modify: `packages/web/src/components/ui/modal/index.test.tsx` +- Modify: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Write the failing test** + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SettingsPage } from "./settings-page"; + +describe("SettingsPage", () => { + it("renders the page header through the shared PageHeader contract on desktop", () => { + render( + + ); + + expect(document.querySelector(".settings-header")).toBeTruthy(); + expect(document.querySelector(".page-header")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest packages/web/src/features/settings/components/settings-page.tsx -v` +Expected: FAIL until the desktop settings header is migrated to `PageHeader`. + +- [ ] **Step 3: Write the minimal implementation** + +```tsx +
+ +
+``` + +- [ ] **Step 4: Update coverage for the contract** + +Add or adjust tests so the project now verifies: + +- `PageHeader` primary / secondary rendering +- `PanelHeader` dense layout expectations +- `DialogHeader` export / modal compatibility +- mobile page header still suppresses kicker on narrow layouts + +- [ ] **Step 5: Run the targeted test set** + +Run: + +```bash +pnpm vitest packages/web/src/features/shared/components/page-header.test.tsx packages/web/src/features/shared/components/mobile-page-header.test.tsx packages/web/src/features/shared/components/panel-header.test.tsx packages/web/src/components/ui/modal/index.test.tsx packages/web/src/features/settings/components/settings-page.tsx packages/web/src/features/agent-panes/components/session-card.test.tsx -v +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/web/src/features/settings/components/settings-page.tsx packages/web/src/features/shared/components/page-header.test.tsx packages/web/src/features/shared/components/mobile-page-header.test.tsx packages/web/src/features/agent-panes/components/session-card.test.tsx packages/web/src/components/ui/modal/index.test.tsx packages/web/src/styles/components.theme.test.ts +git commit -m "feat(web): migrate settings header to shared contract" +``` + +--- + +**Execution notes** + +- Keep all new public header usage inside the shared primitives. +- Do not introduce any new `*-header` business-only component during implementation. +- Prefer compatibility aliases during migration, but remove ad hoc layout logic as each surface is moved. diff --git a/docs/superpowers/plans/2026-05-17-terminal-font-size.md b/docs/superpowers/plans/2026-05-17-terminal-font-size.md new file mode 100644 index 000000000..51712dec3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-terminal-font-size.md @@ -0,0 +1,1038 @@ +# Terminal Font Size 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:** Add a persisted global terminal font-size setting that applies to shell terminals and agent session terminals, updates open terminals immediately, and validates values end-to-end. + +**Architecture:** Extend the existing `appearance.*` settings pipeline with a new `appearance.terminalFontSize` integer, thread it through the lightweight `terminalPreferencesAtom`, and let `XtermHost` treat font size as a live xterm runtime option. Reuse the current settings page numeric-input pattern and the existing stale-hydration guards in `AppProviders` and `SettingsPage` so user changes do not get overwritten by delayed `settings.get` responses. + +**Tech Stack:** TypeScript, React, Jotai, xterm.js, Zod, Vitest, Testing Library + +--- + +## File Structure + +- Modify: `packages/server/src/commands/settings.ts` + - Accept and validate `appearance.terminalFontSize`. +- Modify: `packages/server/src/commands/settings.test.ts` + - Add persistence and validation coverage for the new setting. +- Modify: `packages/web/src/features/terminal-panel/preferences.ts` + - Extend terminal preferences with font-size constants, resolver, and default state. +- Modify: `packages/web/src/app/providers.tsx` + - Hydrate `terminalPreferencesAtom.fontSize` from `settings.get` and preserve local updates. +- Modify: `packages/web/src/app/providers.lifecycle.test.tsx` + - Add hydration and stale-response regressions for font size. +- Modify: `packages/web/src/features/settings/components/settings-page.tsx` + - Add local state, validation, save flow, and UI for terminal font size. +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` + - Add UI, validation, atom-sync, and stale-response tests for font size. +- Modify: `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` + - Initialize xterm with the configured font size and update `terminal.options.fontSize` at runtime. +- Modify: `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` + - Add runtime font-size behavior tests and update terminal preference fixtures. +- Modify: `packages/web/src/locales/zh.json` + - Add Chinese settings copy for terminal font size. +- Modify: `packages/web/src/locales/en.json` + - Add English settings copy for terminal font size. + +## Task 1: Lock The Server Contract In Tests + +**Files:** +- Modify: `packages/server/src/commands/settings.test.ts` +- Test: `packages/server/src/commands/settings.test.ts` + +- [ ] **Step 1: Write the failing persistence test for `appearance.terminalFontSize`** + +Add this test near the existing appearance-setting coverage: + +```ts + it("settings.update persists appearance.terminalFontSize into user_settings", async () => { + const result = await dispatch( + { + kind: "command", + id: "settings-update-terminal-font-size", + op: "settings.update", + args: { + settings: { + appearance: { + terminalFontSize: 14, + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect( + db + .prepare("SELECT value FROM user_settings WHERE key = ?") + .get("appearance.terminalFontSize") + ).toEqual({ value: "14" }); + }); +``` + +- [ ] **Step 2: Write the failing validation tests for out-of-range and fractional values** + +Add the invalid cases immediately after the persistence test: + +```ts + it("settings.update rejects appearance.terminalFontSize below the supported minimum", async () => { + const result = await dispatch( + { + kind: "command", + id: "settings-update-terminal-font-size-too-small", + op: "settings.update", + args: { + settings: { + appearance: { + terminalFontSize: 9, + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("validation_error"); + expect( + db + .prepare("SELECT value FROM user_settings WHERE key = ?") + .get("appearance.terminalFontSize") + ).toBeUndefined(); + }); + + it("settings.update rejects appearance.terminalFontSize above the supported maximum", async () => { + const result = await dispatch( + { + kind: "command", + id: "settings-update-terminal-font-size-too-large", + op: "settings.update", + args: { + settings: { + appearance: { + terminalFontSize: 19, + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("validation_error"); + }); + + it("settings.update rejects fractional appearance.terminalFontSize values", async () => { + const result = await dispatch( + { + kind: "command", + id: "settings-update-terminal-font-size-fractional", + op: "settings.update", + args: { + settings: { + appearance: { + terminalFontSize: 12.5, + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("validation_error"); + }); +``` + +- [ ] **Step 3: Run the server settings tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- packages/server/src/commands/settings.test.ts +``` + +Expected: + +- FAIL because `SettingsSchema.appearance` does not yet accept `terminalFontSize` + +- [ ] **Step 4: Implement the minimal server schema change** + +Update `packages/server/src/commands/settings.ts` inside `SettingsSchema.appearance`: + +```ts +const SettingsSchema = z.object({ + defaultProviderId: z.string().optional(), + notifications: z + .object({ + enabled: z.boolean().optional(), + soundEnabled: z.boolean().optional(), + onlyWhenBackgrounded: z.boolean().optional(), + }) + .optional(), + supervisor: z + .object({ + evaluationTimeoutSec: z + .number() + .int() + .min(1) + .max(MAX_SUPERVISOR_EVALUATION_TIMEOUT_SEC) + .default(DEFAULT_SUPERVISOR_EVALUATION_TIMEOUT_SEC) + .optional(), + retryEnabled: z.boolean().optional(), + retryMaxCount: z.number().int().min(0).max(MAX_SUPERVISOR_RETRY_MAX_COUNT).optional(), + retryDelaySec: z.number().int().min(1).max(MAX_SUPERVISOR_RETRY_DELAY_SEC).optional(), + retryOnTimeout: z.boolean().optional(), + retryOnEvaluatorError: z.boolean().optional(), + }) + .optional(), + appearance: z + .object({ + theme: z.enum(["dark", "light"]).optional(), + themeId: z.string().optional(), + terminalRenderer: z.enum(["standard", "compatibility"]).optional(), + terminalCopyOnSelect: z.boolean().optional(), + terminalFontSize: z.number().int().min(10).max(18).optional(), + locale: z.enum(["zh", "en"]).optional(), + }) + .optional(), + providers: ProviderSettingsSchema.optional(), +}); +``` + +- [ ] **Step 5: Re-run the server settings tests** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- packages/server/src/commands/settings.test.ts +``` + +Expected: + +- PASS for the new terminal font-size persistence and validation tests +- PASS for existing settings command coverage + +- [ ] **Step 6: Commit the server contract** + +```bash +git add packages/server/src/commands/settings.ts packages/server/src/commands/settings.test.ts +git commit -m "feat(server): validate terminal font size setting" +``` + +## Task 2: Extend Terminal Preferences And Provider Hydration + +**Files:** +- Modify: `packages/web/src/features/terminal-panel/preferences.ts` +- Modify: `packages/web/src/app/providers.tsx` +- Modify: `packages/web/src/app/providers.lifecycle.test.tsx` + +- [ ] **Step 1: Write the failing preference resolver tests via provider lifecycle** + +Add these tests to `packages/web/src/app/providers.lifecycle.test.tsx` near the existing terminal copy-on-select hydration tests: + +```tsx + it("hydrates terminal font-size preferences from settings.get once connected", async () => { + const store = createStore(); + setVisibilityState("visible"); + + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.terminalCopyOnSelect": true, + "appearance.terminalFontSize": 15, + }; + } + + 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(store.get(terminalPreferencesAtom)).toEqual({ + copyOnSelect: true, + fontSize: 15, + }); + }); + }); + + it("preserves a newer local terminal font-size update when startup hydration resolves later", 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(terminalPreferencesAtom, { copyOnSelect: false, fontSize: 16 }); + }); + + await act(async () => { + resolveSettingsGet?.({ + "appearance.terminalFontSize": 11, + }); + await settingsGetPromise; + }); + + expect(store.get(terminalPreferencesAtom)).toEqual({ + copyOnSelect: false, + fontSize: 16, + }); + }); +``` + +- [ ] **Step 2: Run the provider lifecycle tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- packages/web/src/app/providers.lifecycle.test.tsx +``` + +Expected: + +- FAIL because `terminalPreferencesAtom` has no `fontSize` +- FAIL because hydration only writes `copyOnSelect` + +- [ ] **Step 3: Implement terminal preference constants, defaults, and resolver** + +Replace `packages/web/src/features/terminal-panel/preferences.ts` with this shape: + +```ts +import { atomWithStorage } from "jotai/utils"; + +export const DEFAULT_TERMINAL_FONT_SIZE = 11; +export const MIN_TERMINAL_FONT_SIZE = 10; +export const MAX_TERMINAL_FONT_SIZE = 18; + +export interface TerminalPreferences { + copyOnSelect: boolean; + fontSize: number; +} + +export const DEFAULT_TERMINAL_PREFERENCES: TerminalPreferences = { + copyOnSelect: false, + fontSize: DEFAULT_TERMINAL_FONT_SIZE, +}; + +export function resolveTerminalCopyOnSelectSetting(settings: Record): boolean { + const value = settings["appearance.terminalCopyOnSelect"]; + return typeof value === "boolean" ? value : DEFAULT_TERMINAL_PREFERENCES.copyOnSelect; +} + +export function resolveTerminalFontSizeSetting(settings: Record): number { + const value = settings["appearance.terminalFontSize"]; + if ( + typeof value === "number" && + Number.isInteger(value) && + value >= MIN_TERMINAL_FONT_SIZE && + value <= MAX_TERMINAL_FONT_SIZE + ) { + return value; + } + + return DEFAULT_TERMINAL_FONT_SIZE; +} + +export const terminalPreferencesAtom = atomWithStorage( + "ui.terminalPreferences", + DEFAULT_TERMINAL_PREFERENCES +); +``` + +- [ ] **Step 4: Hydrate both terminal preference fields in `AppProviders`** + +Update the terminal preference hydration block in `packages/web/src/app/providers.tsx`: + +```tsx +import { + resolveTerminalCopyOnSelectSetting, + resolveTerminalFontSizeSetting, + terminalPreferencesAtom, +} from "../features/terminal-panel/preferences"; + +// ... + + setTerminalPreferences({ + copyOnSelect: resolveTerminalCopyOnSelectSetting(result.data), + fontSize: resolveTerminalFontSizeSetting(result.data), + }); +``` + +Keep the existing `localTerminalPreferencesUpdated` guard unchanged so delayed `settings.get` results do not overwrite user updates. + +- [ ] **Step 5: Normalize provider lifecycle fixtures to the expanded atom shape** + +Update every `terminalPreferencesAtom` set/get in `packages/web/src/app/providers.lifecycle.test.tsx` to include `fontSize`, for example: + +```tsx + expect(store.get(terminalPreferencesAtom)).toEqual({ + copyOnSelect: true, + fontSize: 11, + }); + + act(() => { + store.set(terminalPreferencesAtom, { copyOnSelect: true, fontSize: 11 }); + }); + + act(() => { + store.set(terminalPreferencesAtom, { copyOnSelect: false, fontSize: 11 }); + store.set(terminalPreferencesAtom, { copyOnSelect: true, fontSize: 11 }); + }); +``` + +- [ ] **Step 6: Re-run the provider lifecycle tests** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- packages/web/src/app/providers.lifecycle.test.tsx +``` + +Expected: + +- PASS for the new font-size hydration and stale-response tests +- PASS for the existing copy-on-select and theme hydration tests + +- [ ] **Step 7: Commit the preference hydration layer** + +```bash +git add \ + packages/web/src/features/terminal-panel/preferences.ts \ + packages/web/src/app/providers.tsx \ + packages/web/src/app/providers.lifecycle.test.tsx +git commit -m "feat(web): hydrate global terminal font size" +``` + +## Task 3: Add Settings Page State, Validation, And Copy + +**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 the failing settings page hydration and save tests** + +Add these tests near the existing terminal renderer / copy-on-select tests in `packages/web/src/features/settings/components/settings-page.test.tsx`: + +```tsx + it("renders terminal font size from loaded general settings", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.terminalFontSize": 15, + }; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "通用" })); + + await waitFor(() => { + expect(screen.getByLabelText("终端字号")).toHaveValue(15); + }); + expect(store.get(terminalPreferencesAtom)).toEqual({ + copyOnSelect: false, + fontSize: 15, + }); + }); + + it("updates terminal font size through the general settings input and syncs the global atom", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.terminalFontSize": 11, + }; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "通用" })); + + const input = await screen.findByLabelText("终端字号"); + fireEvent.change(input, { target: { value: "16" } }); + fireEvent.blur(input); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + appearance: { + terminalFontSize: 16, + }, + }, + }, + undefined + ); + }); + + expect(store.get(terminalPreferencesAtom)).toEqual({ + copyOnSelect: false, + fontSize: 16, + }); + }); +``` + +- [ ] **Step 2: Write the failing settings page validation and stale-response tests** + +Add these tests in the same area: + +```tsx + it("rejects invalid terminal font size input and restores the last saved value", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.terminalFontSize": 11, + }; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "通用" })); + + const input = await screen.findByLabelText("终端字号"); + fireEvent.change(input, { target: { value: "99" } }); + fireEvent.blur(input); + + await waitFor(() => { + expect(input).toHaveValue(11); + }); + expect(screen.getByRole("alert")).toHaveTextContent("请输入 10 到 18 之间的整数"); + expect(sendCommand).not.toHaveBeenCalledWith("settings.update", expect.anything(), undefined); + }); + + it("preserves terminal font size when a stale general settings load resolves afterward", async () => { + 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: "通用" })); + + const input = await screen.findByLabelText("终端字号"); + fireEvent.change(input, { target: { value: "16" } }); + fireEvent.blur(input); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + appearance: { + terminalFontSize: 16, + }, + }, + }, + undefined + ); + }); + + await act(async () => { + resolveSettingsGet?.({ + "appearance.terminalFontSize": 11, + }); + await settingsGetPromise; + }); + + expect(screen.getByLabelText("终端字号")).toHaveValue(16); + expect(store.get(terminalPreferencesAtom)).toEqual({ + copyOnSelect: false, + fontSize: 16, + }); + }); +``` + +- [ ] **Step 3: Run the settings page tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- packages/web/src/features/settings/components/settings-page.test.tsx +``` + +Expected: + +- FAIL because there is no terminal font-size field +- FAIL because `terminalPreferencesAtom` does not yet carry `fontSize` through the settings page + +- [ ] **Step 4: Implement settings page state, validation, and save flow** + +In `packages/web/src/features/settings/components/settings-page.tsx`, add: + +```tsx +import { + DEFAULT_TERMINAL_FONT_SIZE, + MAX_TERMINAL_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, + resolveTerminalCopyOnSelectSetting, + resolveTerminalFontSizeSetting, + terminalPreferencesAtom, +} from "../../terminal-panel/preferences"; + +function parseTerminalFontSizeInput(value: string): number | null { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) { + return null; + } + + const parsed = Number(trimmed); + if ( + !Number.isSafeInteger(parsed) || + parsed < MIN_TERMINAL_FONT_SIZE || + parsed > MAX_TERMINAL_FONT_SIZE + ) { + return null; + } + + return parsed; +} +``` + +Extend page state and version tracking: + +```tsx + const appearanceSelectionVersionRef = useRef({ + theme: 0, + locale: 0, + terminalRenderer: 0, + terminalCopyOnSelect: 0, + terminalFontSize: 0, + }); +``` + +Hydrate from `settings.get` using the same stale-response guard: + +```tsx + if ( + appearanceSelectionVersionRef.current.terminalFontSize === + appearanceSelectionVersionAtRequestStart.terminalFontSize + ) { + const resolvedTerminalFontSize = resolveTerminalFontSizeSetting(settings); + setTerminalPreferences((current) => ({ + ...current, + fontSize: resolvedTerminalFontSize, + })); + } +``` + +Add a local selection helper: + +```tsx + const handleTerminalFontSizeSelection = (value: number) => { + appearanceSelectionVersionRef.current.terminalFontSize += 1; + setTerminalPreferences((current) => ({ + ...current, + fontSize: value, + })); + }; +``` + +Keep `copyOnSelect` updates merged instead of replacing the whole object: + +```tsx + const handleTerminalCopyOnSelectSelection = (value: boolean) => { + appearanceSelectionVersionRef.current.terminalCopyOnSelect += 1; + setTerminalPreferences((current) => ({ + ...current, + copyOnSelect: value, + })); + }; +``` + +- [ ] **Step 5: Render the input and localized copy in `GeneralSettings`** + +Add localized strings: + +```json +// packages/web/src/locales/zh.json +"terminal_font_size": "终端字号", +"terminal_font_size_hint": "应用到所有终端和会话", +"terminal_font_size_validation_error": "请输入 10 到 18 之间的整数" +``` + +```json +// packages/web/src/locales/en.json +"terminal_font_size": "Terminal font size", +"terminal_font_size_hint": "Applied to all terminals and sessions", +"terminal_font_size_validation_error": "Enter an integer between 10 and 18" +``` + +Add the `GeneralSettings` props and UI: + +```tsx +interface GeneralSettingsProps { + // ... + terminalFontSize: number; + setTerminalFontSize: (value: number) => void; +} + + const terminalFontSizeLabelId = useId(); + const terminalFontSizeDescId = useId(); + const [terminalFontSizeDraft, setTerminalFontSizeDraft] = useState(String(terminalFontSize)); + const [terminalFontSizeError, setTerminalFontSizeError] = useState(null); + + useEffect(() => { + setTerminalFontSizeDraft(String(terminalFontSize)); + }, [terminalFontSize]); + + useEffect(() => { + setTerminalFontSizeError(null); + }, [terminalFontSize]); + + const commitTerminalFontSize = async () => { + const parsed = parseTerminalFontSizeInput(terminalFontSizeDraft); + if (parsed === null) { + setTerminalFontSizeDraft(String(terminalFontSize)); + setTerminalFontSizeError(t("settings.terminal_font_size_validation_error")); + return; + } + + if (parsed === terminalFontSize) { + setTerminalFontSizeDraft(String(parsed)); + setTerminalFontSizeError(null); + return; + } + + const result = await saveSettings({ + appearance: { + terminalFontSize: parsed, + }, + }); + + if (!result.ok) { + setTerminalFontSizeDraft(String(terminalFontSize)); + setTerminalFontSizeError(result.error?.message || t("settings.config_files.save_failed")); + return; + } + + setTerminalFontSize(parsed); + setTerminalFontSizeDraft(String(parsed)); + setTerminalFontSizeError(null); + }; +``` + +Render the field in the terminal settings group: + +```tsx +
+ +
+ { + setTerminalFontSizeDraft(event.target.value); + if (terminalFontSizeError) { + setTerminalFontSizeError(null); + } + }} + onBlur={() => { + void commitTerminalFontSize(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void commitTerminalFontSize(); + } + }} + /> +
+ + {t("settings.terminal_font_size_hint")} + + {terminalFontSizeError ? ( + + {terminalFontSizeError} + + ) : null} +
+``` + +- [ ] **Step 6: Normalize settings page terminal preference expectations** + +Update existing `terminalPreferencesAtom` expectations in `packages/web/src/features/settings/components/settings-page.test.tsx` to include `fontSize: 11`, for example: + +```tsx + expect(store.get(terminalPreferencesAtom)).toEqual({ + copyOnSelect: true, + fontSize: 11, + }); + + store.set(terminalPreferencesAtom, { copyOnSelect: true, fontSize: 11 }); +``` + +- [ ] **Step 7: Re-run the settings page tests** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- packages/web/src/features/settings/components/settings-page.test.tsx +``` + +Expected: + +- PASS for new terminal font-size UI and validation tests +- PASS for existing general and appearance settings tests + +- [ ] **Step 8: Commit the settings page work** + +```bash +git add \ + packages/web/src/features/settings/components/settings-page.tsx \ + packages/web/src/features/settings/components/settings-page.test.tsx \ + packages/web/src/locales/zh.json \ + packages/web/src/locales/en.json +git commit -m "feat(web): add terminal font size setting" +``` + +## Task 4: Update XtermHost To Apply Font Size Live + +**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` + +- [ ] **Step 1: Write the failing xterm host tests** + +Add these tests to `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx`: + +```tsx + it("creates the xterm instance with the configured terminal font size", () => { + const store = createStore(); + store.set(terminalPreferencesAtom, { copyOnSelect: false, fontSize: 15 }); + + render( + + + + ); + + expect(mockTerminal.options.fontSize).toBe(15); + }); + + it("updates terminal.options.fontSize when the global terminal font size changes", async () => { + const store = createStore(); + store.set(terminalPreferencesAtom, { copyOnSelect: false, fontSize: 11 }); + + render( + + + + ); + + expect(mockTerminal.options.fontSize).toBe(11); + + act(() => { + store.set(terminalPreferencesAtom, { copyOnSelect: false, fontSize: 16 }); + }); + + await waitFor(() => { + expect(mockTerminal.options.fontSize).toBe(16); + expect(mockFitAddon.fit).toHaveBeenCalled(); + }); + }); +``` + +- [ ] **Step 2: Run the xterm host tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +``` + +Expected: + +- FAIL because `XtermHost` still hardcodes `fontSize: 11` +- FAIL because no runtime effect updates `terminal.options.fontSize` + +- [ ] **Step 3: Implement live font-size support in `XtermHost`** + +In `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx`, use the atom value during terminal creation: + +```tsx + const terminal = new Terminal({ + theme: getThemeById(initialThemeRef.current).terminalTheme, + fontFamily: "JetBrains Mono, Fira Code, SF Mono, monospace", + fontSize: terminalPreferences.fontSize, + scrollback: 5000, + cursorBlink: isInteractive && !uploadBusy, + cursorStyle: "block", + disableStdin: !isInteractive || uploadBusy, + allowProposedApi: true, + }); +``` + +Add a runtime update effect next to the existing theme effect: + +```tsx + useEffect(() => { + if (!terminalRef.current) { + return; + } + + if (terminalRef.current.options.fontSize === terminalPreferences.fontSize) { + return; + } + + terminalRef.current.options.fontSize = terminalPreferences.fontSize; + scheduleFit(); + }, [scheduleFit, terminalPreferences.fontSize]); +``` + +Do not modify `lineHeight`. + +- [ ] **Step 4: Normalize xterm host terminal preference fixtures** + +Update all `terminalPreferencesAtom` seeds in `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` to include `fontSize: 11` unless a test needs another value, for example: + +```tsx + store.set(terminalPreferencesAtom, { copyOnSelect: true, fontSize: 11 }); + store.set(terminalPreferencesAtom, { copyOnSelect: false, fontSize: 11 }); +``` + +- [ ] **Step 5: Re-run the xterm host tests** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +``` + +Expected: + +- PASS for the new initialization and live-update tests +- PASS for existing copy-on-select, replay, and mobile-input tests + +- [ ] **Step 6: Commit the xterm host changes** + +```bash +git add \ + packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx \ + packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +git commit -m "feat(web): apply terminal font size to xterm" +``` + +## Task 5: Final Verification And Integration Review + +**Files:** +- Test: `packages/server/src/commands/settings.test.ts` +- Test: `packages/web/src/app/providers.lifecycle.test.tsx` +- Test: `packages/web/src/features/settings/components/settings-page.test.tsx` +- Test: `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` + +- [ ] **Step 1: Run the full targeted verification set** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- packages/server/src/commands/settings.test.ts +pnpm --filter @coder-studio/web test -- packages/web/src/app/providers.lifecycle.test.tsx +pnpm --filter @coder-studio/web test -- packages/web/src/features/settings/components/settings-page.test.tsx +pnpm --filter @coder-studio/web test -- packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +``` + +Expected: + +- PASS with 0 failures in all four commands + +- [ ] **Step 2: Run one combined web verification pass** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- \ + packages/web/src/app/providers.lifecycle.test.tsx \ + packages/web/src/features/settings/components/settings-page.test.tsx \ + packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +``` + +Expected: + +- PASS so the new terminal preference shape works across the hydrated app, settings page, and xterm host together + +- [ ] **Step 3: Review spec coverage before handoff** + +Confirm the implementation covers all approved requirements: + +```text +- global-only font size, no session override +- persisted appearance.terminalFontSize setting +- settings page numeric input with 10-18 integer validation +- shell terminal and session terminal both read the same value +- already-open terminals update without terminal recreation +- invalid or stale settings fall back to 11 +``` + +- [ ] **Step 4: Commit the verification checkpoint** + +```bash +git add docs/superpowers/plans/2026-05-17-terminal-font-size.md +git commit -m "docs: add terminal font size implementation plan" +``` + +- [ ] **Step 5: Report exact verification evidence** + +Include the exact commands from Steps 1 and 2 and whether they passed. Do not claim completion without fresh test output. diff --git a/docs/superpowers/plans/2026-05-17-ui-typography-token-redesign.md b/docs/superpowers/plans/2026-05-17-ui-typography-token-redesign.md new file mode 100644 index 000000000..b3aa4b14b --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-ui-typography-token-redesign.md @@ -0,0 +1,1300 @@ +# UI Typography Token Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace ad-hoc UI font sizing with a two-layer desktop/mobile typography token system, migrate ordinary UI surfaces onto semantic typography roles, and add tests that prevent raw or legacy typography values from re-entering non-code UI. + +**Architecture:** Introduce a new typography layer in `tokens.css` with neutral base scale tokens plus semantic UI typography tokens. Rebind `base.css`, shared CSS modules, and `components.css` feature shells to the semantic tokens in migration batches, while keeping terminal/editor/diff code surfaces on their own typography. Lock the contract in stylesheet-oriented tests and finish with an automated guardrail plus a final `rg` audit so only exempt code surfaces retain raw font sizes. + +**Tech Stack:** TypeScript, React 19, Vitest, vanilla CSS custom properties, CSS Modules, Biome + +**Spec reference:** `docs/superpowers/specs/2026-05-17-typography-design.md` + +--- + +## File Structure + +- Modify: `packages/web/src/styles/tokens.css` + - Add the new base-scale and semantic typography tokens plus mobile overrides. +- Modify: `packages/web/src/styles/tokens-touch.test.ts` + - Lock the new desktop/mobile typography token contract in tests. +- Modify: `packages/web/src/styles/base.css` + - Rebind root text elements, heading tags, and helper classes to semantic typography tokens. +- Modify: `packages/web/src/styles/base.theme.test.ts` + - Assert the new base typography bindings. +- Modify: `packages/web/src/styles/components.css` + - Migrate shared chrome, settings, launch, welcome, auth, workspace empty states, and residual ordinary UI selectors to semantic typography tokens. +- Modify: `packages/web/src/styles/components.theme.test.ts` + - Add stylesheet assertions for CSS modules and feature-shell typography mappings. +- Modify: `packages/web/src/components/ui/button/index.module.css` + - Move shared button typography to semantic tokens. +- Modify: `packages/web/src/components/ui/input/index.module.css` + - Move input typography to semantic tokens. +- Modify: `packages/web/src/components/ui/textarea/index.module.css` + - Move textarea typography to semantic tokens. +- Modify: `packages/web/src/components/ui/tabs/index.module.css` + - Move tab typography to semantic tokens. +- Modify: `packages/web/src/components/ui/tag/index.module.css` + - Replace raw badge/tag sizes with semantic kicker tokens. +- Modify: `packages/web/src/components/ui/badge/index.module.css` + - Replace raw badge sizes with semantic kicker tokens. +- Modify: `packages/web/src/components/ui/pill/index.module.css` + - Replace pill sizes with semantic label tokens. +- Modify: `packages/web/src/components/ui/tooltip/index.module.css` + - Replace tooltip sizes with semantic meta tokens. +- Modify: `packages/web/src/components/ui/notice/index.module.css` + - Replace notice title/message sizes with semantic kicker/meta tokens. +- Modify: `packages/web/src/components/ui/modal/index.module.css` + - Replace modal title size with semantic section-title tokens. +- Modify: `packages/web/src/components/ui/empty-state/index.module.css` + - Replace empty-state title/description sizing with app-title/body tokens. +- Create: `packages/web/src/styles/typography.guard.test.ts` + - Fail when shared UI files reintroduce raw or legacy font sizes outside exempt code surfaces. + +## Task 1: Capture The Baseline And Lock The Token Contract + +**Files:** +- Modify: `packages/web/src/styles/tokens-touch.test.ts` +- Modify: `packages/web/src/styles/tokens.css` +- Test: `packages/web/src/styles/tokens-touch.test.ts` + +- [ ] **Step 1: Record the current font-size baseline before changing tokens** + +Run: + +```bash +node -e "const fs=require('fs');const s=fs.readFileSync('packages/web/src/styles/components.css','utf8');const m=[...s.matchAll(/font-size:\\s*([^;]+);/g)].map(x=>x[1].trim());const c={};for(const v of m)c[v]=(c[v]||0)+1;console.log(Object.entries(c).sort((a,b)=>b[1]-a[1]).map(([k,v])=>v+' '+k).join('\\n'));" +``` + +Expected: + +- output still shows the current legacy mix such as `var(--text-sm)`, `var(--text-xs)`, raw `10px`, raw `11px`, raw `12px`, raw `13px`, and `clamp(...)` + +- [ ] **Step 2: Write the failing desktop/mobile typography token tests** + +Add these tests to `packages/web/src/styles/tokens-touch.test.ts` after the existing touch-token assertions: + +```ts + it("defines the desktop typography scale and semantic aliases on :root", () => { + const root = getRuleBlock(":root"); + + expect(root).toContain("--font-size-100: 11px"); + expect(root).toContain("--font-size-200: 12px"); + expect(root).toContain("--font-size-300: 14px"); + expect(root).toContain("--font-size-400: 16px"); + expect(root).toContain("--font-size-500: 18px"); + expect(root).toContain("--font-size-600: 24px"); + expect(root).toContain("--font-size-700: 32px"); + + expect(root).toContain("--type-kicker-size: var(--font-size-100)"); + expect(root).toContain("--type-kicker-line-height: 1.2"); + expect(root).toContain("--type-kicker-weight: var(--font-semibold)"); + expect(root).toContain("--type-kicker-letter-spacing: 0.08em"); + + expect(root).toContain("--type-label-size: var(--font-size-200)"); + expect(root).toContain("--type-label-line-height: 1.35"); + expect(root).toContain("--type-label-weight: var(--font-medium)"); + + expect(root).toContain("--type-meta-size: var(--font-size-200)"); + expect(root).toContain("--type-body-size: var(--font-size-300)"); + expect(root).toContain("--type-body-strong-size: var(--font-size-300)"); + expect(root).toContain("--type-code-inline-size: var(--font-size-200)"); + expect(root).toContain("--type-code-inline-family: var(--font-mono)"); + expect(root).toContain("--type-app-title-size: var(--font-size-400)"); + expect(root).toContain("--type-section-title-size: var(--font-size-500)"); + expect(root).toContain("--type-page-title-size: var(--font-size-600)"); + expect(root).toContain("--type-display-size: var(--font-size-700)"); + }); + + it("overrides the typography scale and dense body line-heights for mobile viewports", () => { + const mediaMatch = /@media\\s*\\(max-width:\\s*899px\\)\\s*\\{([\\s\\S]*?)\\}\\s*\\}/m.exec(stylesheet); + + expect(mediaMatch, "expected @media (max-width: 899px) block").not.toBeNull(); + + const body = mediaMatch![1]; + + expect(body).toContain("--font-size-100: 12px"); + expect(body).toContain("--font-size-200: 13px"); + expect(body).toContain("--font-size-300: 15px"); + expect(body).toContain("--font-size-400: 17px"); + expect(body).toContain("--font-size-500: 20px"); + expect(body).toContain("--font-size-600: 28px"); + expect(body).toContain("--font-size-700: 36px"); + expect(body).toContain("--type-body-line-height: 1.55"); + expect(body).toContain("--type-body-strong-line-height: 1.5"); + }); +``` + +- [ ] **Step 3: Run the token tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/tokens-touch.test.ts +``` + +Expected: + +- FAIL because `tokens.css` does not yet define `--font-size-100` through `--font-size-700` +- FAIL because the mobile `@media (max-width: 899px)` block does not yet override typography tokens + +- [ ] **Step 4: Implement the minimal token layer in `tokens.css`** + +Add the new typography tokens near the current font-size section in `packages/web/src/styles/tokens.css`: + +```css + /* Typography scale for semantic UI tokens */ + --font-size-100: 11px; + --font-size-200: 12px; + --font-size-300: 14px; + --font-size-400: 16px; + --font-size-500: 18px; + --font-size-600: 24px; + --font-size-700: 32px; + + /* Semantic typography */ + --type-kicker-size: var(--font-size-100); + --type-kicker-line-height: 1.2; + --type-kicker-weight: var(--font-semibold); + --type-kicker-letter-spacing: 0.08em; + + --type-label-size: var(--font-size-200); + --type-label-line-height: 1.35; + --type-label-weight: var(--font-medium); + + --type-meta-size: var(--font-size-200); + --type-meta-line-height: 1.45; + --type-meta-weight: var(--font-normal); + + --type-body-size: var(--font-size-300); + --type-body-line-height: 1.5; + --type-body-weight: var(--font-normal); + + --type-body-strong-size: var(--font-size-300); + --type-body-strong-line-height: 1.45; + --type-body-strong-weight: var(--font-medium); + + --type-code-inline-size: var(--font-size-200); + --type-code-inline-line-height: 1.4; + --type-code-inline-weight: var(--font-medium); + --type-code-inline-family: var(--font-mono); + + --type-app-title-size: var(--font-size-400); + --type-app-title-line-height: 1.25; + --type-app-title-weight: var(--font-semibold); + + --type-section-title-size: var(--font-size-500); + --type-section-title-line-height: 1.2; + --type-section-title-weight: var(--font-semibold); + + --type-page-title-size: var(--font-size-600); + --type-page-title-line-height: 1.1; + --type-page-title-weight: var(--font-semibold); + + --type-display-size: var(--font-size-700); + --type-display-line-height: 1.05; + --type-display-weight: var(--font-semibold); + --type-display-letter-spacing: -0.03em; + + /* Legacy compatibility aliases during migration */ + --text-xs: 11px; + --text-sm: 12px; + --text-base: 13px; + --text-lg: 14px; + --text-xl: 16px; + --text-2xl: 18px; + --text-3xl: 20px; +``` + +Then extend the existing mobile media override block at the bottom of `tokens.css`: + +```css + --font-size-100: 12px; + --font-size-200: 13px; + --font-size-300: 15px; + --font-size-400: 17px; + --font-size-500: 20px; + --font-size-600: 28px; + --font-size-700: 36px; + --type-body-line-height: 1.55; + --type-body-strong-line-height: 1.5; +``` + +- [ ] **Step 5: Re-run the token tests** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/tokens-touch.test.ts +``` + +Expected: + +- PASS for the new typography scale and mobile override tests +- PASS for the existing touch-token tests + +- [ ] **Step 6: Commit the token contract** + +```bash +git add packages/web/src/styles/tokens.css packages/web/src/styles/tokens-touch.test.ts +git commit -m "feat(web): add semantic typography token scale" +``` + +## Task 2: Rebind Base Typography Defaults + +**Files:** +- Modify: `packages/web/src/styles/base.css` +- Modify: `packages/web/src/styles/base.theme.test.ts` +- Test: `packages/web/src/styles/base.theme.test.ts` + +- [ ] **Step 1: Write the failing base typography tests** + +Replace the current legacy heading-helper assertions in `packages/web/src/styles/base.theme.test.ts` with these tests: + +```ts + it("maps base text elements onto semantic typography tokens", () => { + expect(getRuleBlock("body")).toContain("font-size: var(--type-body-size)"); + expect(getRuleBlock("body")).toContain("line-height: var(--type-body-line-height)"); + expect(getRuleBlock("body")).toContain("font-weight: var(--type-body-weight)"); + + expect(getRuleBlock("button")).toContain("font-size: var(--type-body-strong-size)"); + expect(getRuleBlock("button")).toContain("line-height: var(--type-body-strong-line-height)"); + expect(getRuleBlock("input")).toContain("font-size: var(--type-body-strong-size)"); + expect(getRuleBlock("textarea")).toContain("font-size: var(--type-body-strong-size)"); + expect(getRuleBlock("select")).toContain("font-size: var(--type-body-strong-size)"); + }); + + it("maps headings and helper text onto the new semantic hierarchy", () => { + expect(getRuleBlock("h1")).toContain("font-size: var(--type-page-title-size)"); + expect(getRuleBlock("h2")).toContain("font-size: var(--type-section-title-size)"); + expect(getRuleBlock("h3")).toContain("font-size: var(--type-app-title-size)"); + expect(getRuleBlock("h4")).toContain("font-size: var(--type-body-strong-size)"); + expect(getRuleBlock("h5")).toContain("font-size: var(--type-label-size)"); + expect(getRuleBlock("h6")).toContain("font-size: var(--type-meta-size)"); + + expect(getRuleBlock(".page-kicker")).toContain("font-size: var(--type-kicker-size)"); + expect(getRuleBlock(".page-title")).toContain("font-size: var(--type-page-title-size)"); + expect(getRuleBlock(".section-title")).toContain("font-size: var(--type-kicker-size)"); + expect(getRuleBlock(".meta-text")).toContain("font-size: var(--type-meta-size)"); + expect(getRuleBlock(".hint-text")).toContain("font-size: var(--type-meta-size)"); + expect(getRuleBlock(".mono-meta")).toContain("font-size: var(--type-code-inline-size)"); + expect(getRuleBlock(".mono-meta")).toContain("font-family: var(--type-code-inline-family)"); + }); +``` + +- [ ] **Step 2: Run the base stylesheet tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/base.theme.test.ts +``` + +Expected: + +- FAIL because `base.css` still points `body`, `button`, headings, and helper classes at legacy `--text-*` tokens + +- [ ] **Step 3: Rebind the base stylesheet to the semantic typography tokens** + +Update `packages/web/src/styles/base.css` with these declarations: + +```css +body { + font-family: var(--font-sans); + font-size: var(--type-body-size); + line-height: var(--type-body-line-height); + font-weight: var(--type-body-weight); + color: var(--text-primary); + background-color: var(--bg-page); + overflow: hidden; +} + +h1 { + font-size: var(--type-page-title-size); + line-height: var(--type-page-title-line-height); + font-weight: var(--type-page-title-weight); +} + +h2 { + font-size: var(--type-section-title-size); + line-height: var(--type-section-title-line-height); + font-weight: var(--type-section-title-weight); +} + +h3 { + font-size: var(--type-app-title-size); + line-height: var(--type-app-title-line-height); + font-weight: var(--type-app-title-weight); +} + +h4 { + font-size: var(--type-body-strong-size); + line-height: var(--type-body-strong-line-height); + font-weight: var(--type-body-strong-weight); +} + +h5 { + font-size: var(--type-label-size); + line-height: var(--type-label-line-height); + font-weight: var(--type-label-weight); +} + +h6 { + font-size: var(--type-meta-size); + line-height: var(--type-meta-line-height); + font-weight: var(--type-meta-weight); +} + +.page-kicker, +.section-kicker, +.section-title { + font-size: var(--type-kicker-size); + line-height: var(--type-kicker-line-height); + font-weight: var(--type-kicker-weight); + text-transform: uppercase; + letter-spacing: var(--type-kicker-letter-spacing); + color: var(--text-tertiary); +} + +.page-title { + font-size: var(--type-page-title-size); + line-height: var(--type-page-title-line-height); + font-weight: var(--type-page-title-weight); + color: var(--text-primary); +} + +.meta-text, +.hint-text { + font-size: var(--type-meta-size); + line-height: var(--type-meta-line-height); + font-weight: var(--type-meta-weight); +} + +.mono-meta, +code, +pre, +kbd, +samp { + font-family: var(--type-code-inline-family); + font-size: var(--type-code-inline-size); + line-height: var(--type-code-inline-line-height); + font-weight: var(--type-code-inline-weight); +} + +button { + font-family: var(--font-sans); + font-size: var(--type-body-strong-size); + line-height: var(--type-body-strong-line-height); + font-weight: var(--type-body-strong-weight); +} + +input, +textarea, +select { + font-family: var(--font-sans); + font-size: var(--type-body-strong-size); + line-height: var(--type-body-strong-line-height); + font-weight: var(--type-body-strong-weight); +} +``` + +- [ ] **Step 4: Re-run the base stylesheet tests** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/base.theme.test.ts +``` + +Expected: + +- PASS for the new semantic base typography tests +- PASS for the existing theme-sensitive shell assertions + +- [ ] **Step 5: Commit the base typography remap** + +```bash +git add packages/web/src/styles/base.css packages/web/src/styles/base.theme.test.ts +git commit -m "feat(web): rebind base typography to semantic tokens" +``` + +## Task 3: Migrate Text Entry And Navigation Primitives + +**Files:** +- Modify: `packages/web/src/components/ui/button/index.module.css` +- Modify: `packages/web/src/components/ui/input/index.module.css` +- Modify: `packages/web/src/components/ui/textarea/index.module.css` +- Modify: `packages/web/src/components/ui/tabs/index.module.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Test: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Add failing style assertions for button, input, textarea, and tabs** + +At the top of `packages/web/src/styles/components.theme.test.ts`, add the missing stylesheet sources: + +```ts +const buttonStyles = readFileSync(`${process.cwd()}/src/components/ui/button/index.module.css`, "utf8"); +const inputStyles = readFileSync(`${process.cwd()}/src/components/ui/input/index.module.css`, "utf8"); +const textareaStyles = readFileSync(`${process.cwd()}/src/components/ui/textarea/index.module.css`, "utf8"); +const tabsStyles = readFileSync(`${process.cwd()}/src/components/ui/tabs/index.module.css`, "utf8"); +``` + +Then add this test near the existing shared UI primitive assertions: + +```ts + it("maps text-entry and navigation primitives onto semantic typography tokens", () => { + expect(getLastRuleBlockFrom(buttonStyles, ".btn")).toContain( + "font-size: var(--type-body-strong-size)" + ); + expect(getLastRuleBlockFrom(buttonStyles, ".btn")).toContain( + "line-height: var(--type-body-strong-line-height)" + ); + expect(getLastRuleBlockFrom(buttonStyles, ".sm")).toContain( + "font-size: var(--type-label-size)" + ); + expect(getLastRuleBlockFrom(buttonStyles, ".lg")).not.toContain("font-size:"); + + expect(getLastRuleBlockFrom(inputStyles, ".input")).toContain( + "font-size: var(--type-body-strong-size)" + ); + expect(getLastRuleBlockFrom(inputStyles, ".sm")).toContain( + "font-size: var(--type-label-size)" + ); + expect(getLastRuleBlockFrom(inputStyles, ".lg")).not.toContain("font-size:"); + + expect(getLastRuleBlockFrom(textareaStyles, ".input")).toContain( + "font-size: var(--type-body-strong-size)" + ); + expect(getLastRuleBlockFrom(textareaStyles, ".lg")).not.toContain("font-size:"); + + expect(getLastRuleBlockFrom(tabsStyles, ":global(.panel-tab)")).toContain( + "font-size: var(--type-label-size)" + ); + expect(getLastRuleBlockFrom(tabsStyles, ":global(.panel-tab)")).toContain( + "line-height: var(--type-label-line-height)" + ); + expect(getLastRuleBlockFrom(tabsStyles, ":global(.worktree-tab)")).toContain( + "font-size: var(--type-label-size)" + ); + }); +``` + +- [ ] **Step 2: Run the theme stylesheet test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: + +- FAIL because the shared primitives still use `var(--text-*)` or raw font sizes + +- [ ] **Step 3: Rebind the primitives to semantic tokens** + +Update `packages/web/src/components/ui/button/index.module.css`: + +```css +.btn, +:global(.btn) { + font-size: var(--type-body-strong-size); + line-height: var(--type-body-strong-line-height); + font-weight: var(--type-body-strong-weight); +} + +.sm, +:global(.btn-sm) { + height: 24px; + padding: 0 var(--sp-2); + border-radius: var(--radius-sm); + font-size: var(--type-label-size); + line-height: var(--type-label-line-height); + font-weight: var(--type-label-weight); +} + +.lg, +:global(.btn-lg) { + height: 40px; + padding: 0 var(--sp-6); + border-radius: var(--radius-lg); +} +``` + +Update `packages/web/src/components/ui/input/index.module.css`: + +```css +.input { + font-size: var(--type-body-strong-size); + line-height: var(--type-body-strong-line-height); + font-weight: var(--type-body-strong-weight); +} + +.sm { + height: var(--input-height-sm); + border-radius: var(--radius-sm); + font-size: var(--type-label-size); + line-height: var(--type-label-line-height); + font-weight: var(--type-label-weight); +} + +.lg { + height: var(--input-height-lg); + border-radius: var(--radius-lg); +} +``` + +Update `packages/web/src/components/ui/textarea/index.module.css`: + +```css +.input { + font-size: var(--type-body-strong-size); + line-height: var(--type-body-strong-line-height); + font-weight: var(--type-body-strong-weight); +} + +.lg { + min-height: 120px; + border-radius: var(--radius-lg); +} +``` + +Update `packages/web/src/components/ui/tabs/index.module.css`: + +```css +:global(.panel-tab) { + font-size: var(--type-label-size); + line-height: var(--type-label-line-height); + font-weight: var(--type-label-weight); + padding: 2px 8px; + border-radius: 4px; + color: var(--text-ter); +} + +:global(.worktree-tab) { + padding: var(--sp-3) var(--sp-4); + font-size: var(--type-label-size); + line-height: var(--type-label-line-height); + font-weight: var(--type-label-weight); + color: var(--text-secondary); +} +``` + +- [ ] **Step 4: Re-run the theme stylesheet test** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: + +- PASS for the new button/input/textarea/tabs typography assertions +- PASS for the pre-existing `components.theme.test.ts` assertions that are unaffected by this slice + +- [ ] **Step 5: Commit the text-entry and navigation primitive migration** + +```bash +git add \ + packages/web/src/components/ui/button/index.module.css \ + packages/web/src/components/ui/input/index.module.css \ + packages/web/src/components/ui/textarea/index.module.css \ + packages/web/src/components/ui/tabs/index.module.css \ + packages/web/src/styles/components.theme.test.ts +git commit -m "feat(web): migrate text entry primitives to semantic typography" +``` + +## Task 4: Migrate Display And Status Primitives + +**Files:** +- Modify: `packages/web/src/components/ui/tag/index.module.css` +- Modify: `packages/web/src/components/ui/badge/index.module.css` +- Modify: `packages/web/src/components/ui/pill/index.module.css` +- Modify: `packages/web/src/components/ui/tooltip/index.module.css` +- Modify: `packages/web/src/components/ui/notice/index.module.css` +- Modify: `packages/web/src/components/ui/modal/index.module.css` +- Modify: `packages/web/src/components/ui/empty-state/index.module.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Test: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Add failing style assertions for display/status primitives** + +Extend `packages/web/src/styles/components.theme.test.ts` with the missing stylesheet sources: + +```ts +const tagStyles = readFileSync(`${process.cwd()}/src/components/ui/tag/index.module.css`, "utf8"); +const badgeStyles = readFileSync(`${process.cwd()}/src/components/ui/badge/index.module.css`, "utf8"); +const tooltipStyles = readFileSync(`${process.cwd()}/src/components/ui/tooltip/index.module.css`, "utf8"); +const modalStyles = readFileSync(`${process.cwd()}/src/components/ui/modal/index.module.css`, "utf8"); +const emptyStateStyles = readFileSync(`${process.cwd()}/src/components/ui/empty-state/index.module.css`, "utf8"); +``` + +Add this test: + +```ts + it("maps display and status primitives onto semantic typography roles", () => { + expect(getLastRuleBlockFrom(tagStyles, ":where(.tag)")).toContain( + "font-size: var(--type-kicker-size)" + ); + expect(getLastRuleBlockFrom(tagStyles, ":where(.tag)")).toContain( + "letter-spacing: var(--type-kicker-letter-spacing)" + ); + expect(getLastRuleBlockFrom(tagStyles, ".sm")).not.toContain("font-size:"); + + expect(getLastRuleBlockFrom(badgeStyles, ":where(.badge)")).toContain( + "font-size: var(--type-kicker-size)" + ); + expect(getLastRuleBlockFrom(pillStylesheet, ".pill")).toContain( + "font-size: var(--type-label-size)" + ); + expect(getLastRuleBlockFrom(tooltipStyles, ".tooltip")).toContain( + "font-size: var(--type-meta-size)" + ); + expect(getLastRuleBlockFrom(noticeStylesheet, ".title")).toContain( + "font-size: var(--type-kicker-size)" + ); + expect(getLastRuleBlockFrom(noticeStylesheet, ".message")).toContain( + "font-size: var(--type-meta-size)" + ); + expect(getLastRuleBlockFrom(modalStyles, ".title")).toContain( + "font-size: var(--type-section-title-size)" + ); + expect(getLastRuleBlockFrom(emptyStateStyles, ".title")).toContain( + "font-size: var(--type-app-title-size)" + ); + expect(getLastRuleBlockFrom(emptyStateStyles, ".description")).toContain( + "font-size: var(--type-body-size)" + ); + }); +``` + +- [ ] **Step 2: Run the theme stylesheet test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: + +- FAIL because tag, badge, tooltip, modal, and empty-state still use raw or legacy typography values + +- [ ] **Step 3: Rebind the display/status primitives** + +Update `packages/web/src/components/ui/tag/index.module.css`: + +```css +:where(.tag), +:global(:where(.badge)) { + font-size: var(--type-kicker-size); + line-height: var(--type-kicker-line-height); + font-weight: var(--type-kicker-weight); + letter-spacing: var(--type-kicker-letter-spacing); + text-transform: uppercase; +} + +.sm { + height: 18px; + padding: 0 var(--sp-1); +} +``` + +Update `packages/web/src/components/ui/badge/index.module.css`: + +```css +:where(.badge), +:global(:where(.topbar-unread)) { + font-size: var(--type-kicker-size); + line-height: var(--type-kicker-line-height); + font-weight: var(--type-kicker-weight); +} +``` + +Update `packages/web/src/components/ui/pill/index.module.css`: + +```css +.pill { + font-size: var(--type-label-size); + line-height: var(--type-label-line-height); + font-weight: var(--type-label-weight); +} +``` + +Update `packages/web/src/components/ui/tooltip/index.module.css`: + +```css +.tooltip { + font-size: var(--type-meta-size); + line-height: var(--type-meta-line-height); + font-weight: var(--type-meta-weight); +} +``` + +Update `packages/web/src/components/ui/notice/index.module.css`: + +```css +.title { + font-size: var(--type-kicker-size); + line-height: var(--type-kicker-line-height); + font-weight: var(--type-kicker-weight); + letter-spacing: var(--type-kicker-letter-spacing); + text-transform: uppercase; + color: var(--text-primary); +} + +.message { + font-size: var(--type-meta-size); + line-height: var(--type-meta-line-height); + font-weight: var(--type-meta-weight); + color: var(--text-secondary); + word-break: break-word; +} +``` + +Update `packages/web/src/components/ui/modal/index.module.css`: + +```css +.title, +:global(.modal-title) { + display: flex; + align-items: center; + gap: var(--sp-2); + margin: 0; + font-size: var(--type-section-title-size); + line-height: var(--type-section-title-line-height); + font-weight: var(--type-section-title-weight); + color: var(--text-primary); +} +``` + +Update `packages/web/src/components/ui/empty-state/index.module.css`: + +```css +.title { + color: var(--text-primary); + font-size: var(--type-app-title-size); + line-height: var(--type-app-title-line-height); + font-weight: var(--type-app-title-weight); +} + +.description { + width: min(100%, 30rem); + max-width: 30rem; + color: var(--text-secondary); + font-size: var(--type-body-size); + line-height: var(--type-body-line-height); + font-weight: var(--type-body-weight); +} +``` + +- [ ] **Step 4: Re-run the theme stylesheet test** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: + +- PASS for the new display/status primitive assertions +- PASS for the existing pill and notice behavior assertions + +- [ ] **Step 5: Commit the display/status primitive migration** + +```bash +git add \ + packages/web/src/components/ui/tag/index.module.css \ + packages/web/src/components/ui/badge/index.module.css \ + packages/web/src/components/ui/pill/index.module.css \ + packages/web/src/components/ui/tooltip/index.module.css \ + packages/web/src/components/ui/notice/index.module.css \ + packages/web/src/components/ui/modal/index.module.css \ + packages/web/src/components/ui/empty-state/index.module.css \ + packages/web/src/styles/components.theme.test.ts +git commit -m "feat(web): migrate display primitives to semantic typography" +``` + +## Task 5: Migrate Shared Chrome, Settings, And Launch Surfaces + +**Files:** +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Test: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Add failing assertions for shared chrome and settings typography** + +In `packages/web/src/styles/components.theme.test.ts`, add these assertions to the existing desktop/mobile shell tests: + +```ts + const commandPaletteKicker = getLastRuleBlock(".command-palette-kicker"); + const commandPaletteMeta = getLastRuleBlock(".command-palette-meta"); + const commandPaletteInput = getLastRuleBlock(".command-palette-input"); + const commandPaletteItemLabel = getLastRuleBlock(".command-palette-item-label"); + const commandPaletteItemDesc = getLastRuleBlock(".command-palette-item-desc"); + const commandPaletteItemShortcut = getLastRuleBlock(".command-palette-item-shortcut"); + const pageHeaderKicker = getLastRuleBlock(".page-header__kicker"); + const pageHeaderTitle = getLastRuleBlock(".page-header__title"); + const topbarTabName = getLastRuleBlock(".topbar-tab-name"); + const topbarBtnLabel = getLastRuleBlock(".topbar-btn-label"); + const dockLabel = getLastRuleBlock(".mobile-dock__label"); + const statusStrip = getLastRuleBlock(".mobile-shell__bottom-stack .git-panel-status-strip"); + + expect(commandPaletteKicker).toContain("font-size: var(--type-kicker-size)"); + expect(commandPaletteMeta).toContain("font-size: var(--type-meta-size)"); + expect(commandPaletteInput).toContain("font-size: var(--type-body-strong-size)"); + expect(commandPaletteItemLabel).toContain("font-size: var(--type-body-strong-size)"); + expect(commandPaletteItemDesc).toContain("font-size: var(--type-meta-size)"); + expect(commandPaletteItemShortcut).toContain("font-size: var(--type-code-inline-size)"); + expect(pageHeaderKicker).toContain("font-size: var(--type-kicker-size)"); + expect(pageHeaderTitle).toContain("font-size: var(--type-app-title-size)"); + expect(topbarTabName).toContain("font-size: var(--type-label-size)"); + expect(topbarBtnLabel).toContain("font-size: var(--type-label-size)"); + expect(dockLabel).toContain("font-size: var(--type-kicker-size)"); + expect(statusStrip).toContain("font-size: var(--type-kicker-size)"); +``` + +Then update the existing settings/launch assertions in the same file: + +```ts + expect(settingsGroupTitle).toContain("font-size: var(--type-kicker-size)"); + expect(settingsGroupDesc).toContain("font-size: var(--type-meta-size)"); + expect(settingsInfoLabel).toContain("font-size: var(--type-kicker-size)"); + expect(settingsStatusHint).toContain("font-size: var(--type-meta-size)"); + expect(desktopSettingsTitle).toContain("font-size: var(--type-section-title-size)"); + expect(launchActionButton).toContain("font-size: var(--type-body-strong-size)"); +``` + +- [ ] **Step 2: Run the theme stylesheet test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: + +- FAIL because `components.css` still uses `var(--text-*)`, raw `10px`, raw `13px`, and raw `18px` for shared chrome and settings copy + +- [ ] **Step 3: Rebind the shared chrome and settings selectors** + +Update `packages/web/src/styles/components.css` with these semantic mappings: + +```css +.command-palette-kicker { + font-size: var(--type-kicker-size); + line-height: var(--type-kicker-line-height); + font-weight: var(--type-kicker-weight); + letter-spacing: var(--type-kicker-letter-spacing); +} + +.command-palette-meta, +.command-palette-item-desc, +.settings-group-desc, +.settings-status-hint { + font-size: var(--type-meta-size); + line-height: var(--type-meta-line-height); + font-weight: var(--type-meta-weight); +} + +.command-palette-input, +.command-palette-item-label, +.mobile-launch-sheet__footer .launch-start-btn--mobile { + font-size: var(--type-body-strong-size); + line-height: var(--type-body-strong-line-height); + font-weight: var(--type-body-strong-weight); +} + +.command-palette-item-shortcut { + font-family: var(--type-code-inline-family); + font-size: var(--type-code-inline-size); + line-height: var(--type-code-inline-line-height); + font-weight: var(--type-code-inline-weight); +} + +.page-header__kicker, +.settings-group-title, +.settings-info-label, +.mobile-dock__label, +.mobile-shell__bottom-stack .git-panel-status-strip { + font-size: var(--type-kicker-size); + line-height: var(--type-kicker-line-height); + font-weight: var(--type-kicker-weight); + letter-spacing: var(--type-kicker-letter-spacing); + text-transform: uppercase; +} + +.page-header__title, +.settings-header .page-header__title { + font-size: var(--type-app-title-size); + line-height: var(--type-app-title-line-height); + font-weight: var(--type-app-title-weight); +} + +.settings-page--desktop .settings-header .page-header__title { + font-size: var(--type-section-title-size); + line-height: var(--type-section-title-line-height); + font-weight: var(--type-section-title-weight); +} + +.topbar-tab-name, +.topbar-btn-label { + font-size: var(--type-label-size); + line-height: var(--type-label-line-height); + font-weight: var(--type-label-weight); +} +``` + +- [ ] **Step 4: Re-run the theme stylesheet test** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: + +- PASS for the updated command palette, settings, topbar, launch, dock, and status-strip typography assertions + +- [ ] **Step 5: Commit the shared chrome migration** + +```bash +git add packages/web/src/styles/components.css packages/web/src/styles/components.theme.test.ts +git commit -m "feat(web): migrate shared chrome typography" +``` + +## Task 6: Migrate Welcome, Auth, Workspace Empty States, And Display Titles + +**Files:** +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Test: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Add failing assertions for page-level titles and body copy** + +Update `packages/web/src/styles/components.theme.test.ts` where it currently asserts legacy `clamp(...)` or raw `14px/18px` values: + +```ts + const welcomeKicker = getLastRuleBlock(".welcome-kicker"); + const welcomeTitle = getLastRuleBlock(".welcome-title"); + const welcomeBody = getLastRuleBlock(".welcome-body"); + const workspaceResolvingKicker = getLastRuleBlock(".workspace-resolving-kicker"); + const workspaceResolvingTitle = getLastRuleBlock(".workspace-resolving-title"); + const workspaceResolvingDesc = getLastRuleBlock(".workspace-resolving-desc"); + const mobileEmptyTitle = getLastRuleBlock(".mobile-shell__empty-title"); + const mobileEmptyCopy = getLastRuleBlock(".mobile-shell__placeholder-copy p"); + const mobileEmptyCta = getLastRuleBlock(".mobile-shell__empty-cta"); + + expect(welcomeKicker).toContain("font-size: var(--type-kicker-size)"); + expect(welcomeTitle).toContain("font-size: var(--type-page-title-size)"); + expect(welcomeBody).toContain("font-size: var(--type-body-size)"); + expect(workspaceResolvingKicker).toContain("font-size: var(--type-kicker-size)"); + expect(workspaceResolvingTitle).toContain("font-size: var(--type-display-size)"); + expect(workspaceResolvingDesc).toContain("font-size: var(--type-body-size)"); + expect(mobileEmptyTitle).toContain("font-size: var(--type-display-size)"); + expect(mobileEmptyCopy).toContain("font-size: var(--type-body-size)"); + expect(mobileEmptyCta).toContain("font-size: var(--type-body-strong-size)"); +``` + +- [ ] **Step 2: Run the theme stylesheet test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: + +- FAIL because welcome, workspace resolving, and mobile empty-state shells still use legacy `--text-*`, raw `14px`, or `clamp(...)` + +- [ ] **Step 3: Rebind the page-level display and body selectors** + +Update `packages/web/src/styles/components.css`: + +```css +.welcome-kicker, +.workspace-resolving-kicker { + font-size: var(--type-kicker-size); + line-height: var(--type-kicker-line-height); + font-weight: var(--type-kicker-weight); + letter-spacing: var(--type-kicker-letter-spacing); + text-transform: uppercase; +} + +.welcome-title, +.auth-card-shell .welcome-title, +.welcome-card--mobile .welcome-title, +.auth-card-shell--mobile .welcome-title { + font-size: var(--type-page-title-size); + line-height: var(--type-page-title-line-height); + font-weight: var(--type-page-title-weight); +} + +.welcome-body, +.workspace-resolving-desc, +.mobile-shell__placeholder-copy p { + font-size: var(--type-body-size); + line-height: var(--type-body-line-height); + font-weight: var(--type-body-weight); +} + +.welcome-btn, +.mobile-shell__empty-cta { + font-size: var(--type-body-strong-size); + line-height: var(--type-body-strong-line-height); + font-weight: var(--type-body-strong-weight); +} + +.welcome-link { + font-size: var(--type-meta-size); + line-height: var(--type-meta-line-height); + font-weight: var(--type-meta-weight); +} + +.workspace-resolving-title, +.mobile-shell__empty-title { + font-size: var(--type-display-size); + line-height: var(--type-display-line-height); + font-weight: var(--type-display-weight); + letter-spacing: var(--type-display-letter-spacing); +} +``` + +- [ ] **Step 4: Re-run the theme stylesheet test** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: + +- PASS for the updated page-level typography assertions +- PASS for the existing welcome/mobile empty-state structural assertions that still apply + +- [ ] **Step 5: Commit the page-level migration** + +```bash +git add packages/web/src/styles/components.css packages/web/src/styles/components.theme.test.ts +git commit -m "feat(web): migrate page-level typography surfaces" +``` + +## Task 7: Add A Typography Guardrail And Finish Verification + +**Files:** +- Create: `packages/web/src/styles/typography.guard.test.ts` +- Modify: `packages/web/src/styles/components.css` +- Test: `packages/web/src/styles/typography.guard.test.ts` + +- [ ] **Step 1: Write the failing typography guardrail test** + +Create `packages/web/src/styles/typography.guard.test.ts` with this content: + +```ts +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const baseStyles = readFileSync(`${process.cwd()}/src/styles/base.css`, "utf8"); +const componentsStyles = readFileSync(`${process.cwd()}/src/styles/components.css`, "utf8"); +const sharedUiSources = [ + "src/components/ui/button/index.module.css", + "src/components/ui/input/index.module.css", + "src/components/ui/textarea/index.module.css", + "src/components/ui/tabs/index.module.css", + "src/components/ui/tag/index.module.css", + "src/components/ui/badge/index.module.css", + "src/components/ui/pill/index.module.css", + "src/components/ui/tooltip/index.module.css", + "src/components/ui/notice/index.module.css", + "src/components/ui/modal/index.module.css", + "src/components/ui/empty-state/index.module.css", +].map((file) => [file, readFileSync(`${process.cwd()}/${file}`, "utf8")] as const); + +const forbiddenSharedPattern = /font-size:\\s*(?:\\d+px|clamp\\(|var\\(--text-)/; +const exemptComponentSelectors = [ + /\\.session-terminal/, + /\\.bottom-terminal/, + /\\.xterm/, + /\\.code-editor/, + /\\.monaco/, + /\\.git-diff/, + /\\.diff-/, + /\\.review-/, +]; + +describe("typography guardrails", () => { + it("keeps base.css and shared UI modules off raw and legacy font sizes", () => { + expect(baseStyles).not.toMatch(forbiddenSharedPattern); + + for (const [file, source] of sharedUiSources) { + expect(source, file).not.toMatch(forbiddenSharedPattern); + } + }); + + it("limits raw or legacy font-size values in components.css to exempt code surfaces", () => { + const offenderBlocks = Array.from( + componentsStyles.matchAll(/([^{}]+)\\{([^}]*font-size:\\s*(?:\\d+px|clamp\\(|var\\(--text-)[^}]*)\\}/g) + ) + .map((match) => `${match[1]}{${match[2]}}`) + .filter((block) => !exemptComponentSelectors.some((pattern) => pattern.test(block))); + + expect(offenderBlocks).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run the guardrail test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/typography.guard.test.ts +``` + +Expected: + +- FAIL because `components.css` still has residual raw or legacy font-size declarations outside exempt code surfaces + +- [ ] **Step 3: Finish the residual cleanup until only exempt code surfaces remain** + +Run this audit to find the remaining non-exempt offenders: + +```bash +rg -n "font-size:\\s*(?:[0-9]+px|var\\(--text-|clamp\\()" \ + packages/web/src/styles/base.css \ + packages/web/src/styles/components.css \ + packages/web/src/components/ui \ + -g '*.css' \ + -g '*.module.css' +``` + +Use the audit output to clean the remaining ordinary UI selectors in `packages/web/src/styles/components.css`. The first residual fixes should look like this: + +```css +.topbar-unread { + font-size: var(--type-kicker-size); + line-height: var(--type-kicker-line-height); + font-weight: var(--type-kicker-weight); +} + +.settings-command-preview, +.settings-provider-mobile-entry-meta { + font-family: var(--type-code-inline-family); + font-size: var(--type-code-inline-size); + line-height: var(--type-code-inline-line-height); + font-weight: var(--type-code-inline-weight); +} + +.mobile-topbar__session-label, +.mobile-topbar__workspace-name, +.mobile-topbar__session-name, +.mobile-topbar__session-empty { + font-size: var(--type-label-size); + line-height: var(--type-label-line-height); + font-weight: var(--type-label-weight); +} +``` + +Keep iterating until the `rg` audit only reports terminal/editor/diff-related code-surface selectors that the guardrail intentionally allows. + +- [ ] **Step 4: Run the full typography verification set** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/styles/tokens-touch.test.ts \ + src/styles/base.theme.test.ts \ + src/styles/components.theme.test.ts \ + src/styles/typography.guard.test.ts +``` + +Run: + +```bash +pnpm --filter @coder-studio/web exec biome check \ + src/styles/tokens.css \ + src/styles/base.css \ + src/styles/components.css \ + src/styles/tokens-touch.test.ts \ + src/styles/base.theme.test.ts \ + src/styles/components.theme.test.ts \ + src/styles/typography.guard.test.ts \ + src/components/ui/button/index.module.css \ + src/components/ui/input/index.module.css \ + src/components/ui/textarea/index.module.css \ + src/components/ui/tabs/index.module.css \ + src/components/ui/tag/index.module.css \ + src/components/ui/badge/index.module.css \ + src/components/ui/pill/index.module.css \ + src/components/ui/tooltip/index.module.css \ + src/components/ui/notice/index.module.css \ + src/components/ui/modal/index.module.css \ + src/components/ui/empty-state/index.module.css +``` + +Run the raw-font audit one last time: + +```bash +rg -n "font-size:\\s*(?:[0-9]+px|var\\(--text-|clamp\\()" \ + packages/web/src/styles/base.css \ + packages/web/src/styles/components.css \ + packages/web/src/components/ui \ + -g '*.css' \ + -g '*.module.css' +``` + +Expected: + +- all four Vitest files PASS +- `biome check` reports no issues +- the final `rg` audit only prints exempt terminal/editor/diff code-surface selectors, or prints nothing if all raw/legacy sizes are gone + +- [ ] **Step 5: Commit the guardrail and final cleanup** + +```bash +git add \ + packages/web/src/styles/base.css \ + packages/web/src/styles/components.css \ + packages/web/src/styles/tokens.css \ + packages/web/src/styles/tokens-touch.test.ts \ + packages/web/src/styles/base.theme.test.ts \ + packages/web/src/styles/components.theme.test.ts \ + packages/web/src/styles/typography.guard.test.ts \ + packages/web/src/components/ui/button/index.module.css \ + packages/web/src/components/ui/input/index.module.css \ + packages/web/src/components/ui/textarea/index.module.css \ + packages/web/src/components/ui/tabs/index.module.css \ + packages/web/src/components/ui/tag/index.module.css \ + packages/web/src/components/ui/badge/index.module.css \ + packages/web/src/components/ui/pill/index.module.css \ + packages/web/src/components/ui/tooltip/index.module.css \ + packages/web/src/components/ui/notice/index.module.css \ + packages/web/src/components/ui/modal/index.module.css \ + packages/web/src/components/ui/empty-state/index.module.css +git commit -m "feat(web): enforce semantic ui typography" +``` + +## Self-Review + +- Spec coverage: token architecture, desktop/mobile typography scale, base-element rebinding, shared primitive migration, chrome/page migration, raw-font guardrail, and final verification all map to concrete tasks above. +- Placeholder scan: no `TODO`, `TBD`, “implement later”, or “add tests for the above” placeholders remain. +- Type consistency: all tasks use the same token names: + - `--font-size-100` through `--font-size-700` + - `--type-kicker-*` + - `--type-label-*` + - `--type-meta-*` + - `--type-body-*` + - `--type-body-strong-*` + - `--type-code-inline-*` + - `--type-app-title-*` + - `--type-section-title-*` + - `--type-page-title-*` + - `--type-display-*` diff --git a/docs/superpowers/plans/2026-05-18-mobile-files-content-pc-alignment.md b/docs/superpowers/plans/2026-05-18-mobile-files-content-pc-alignment.md new file mode 100644 index 000000000..41557feed --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-mobile-files-content-pc-alignment.md @@ -0,0 +1,355 @@ +# Mobile Files Content PC Alignment 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:** Restyle the mobile workspace files content area so its tabs, actions, search, file tree, and Git list align with the flat PC sidebar language while preserving mobile touch sizing and existing behavior. + +**Architecture:** Keep the current `MobileFilesSheet`, `FileTreePanel`, and `GitPanel` structure, then tighten the mobile-only CSS scopes so the content area reuses PC sidebar semantics instead of the current floating card language. Lock the redesign in stylesheet tests first, then update the scoped mobile selectors in `components.css`, and finally rerun the targeted mobile files tests to verify behavior did not regress. + +**Tech Stack:** TypeScript, React, Vitest, Testing Library, vanilla CSS custom properties + +**Spec reference:** `docs/superpowers/specs/2026-05-18-mobile-files-content-pc-alignment-design.md` + +--- + +## File Structure + +- Modify: `packages/web/src/styles/components.theme.test.ts` + - Replace the current mobile files surface assertions with the new flat-panel contract. +- Modify: `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx` + - Keep the current structural assertions aligned if header wrappers or class expectations shift. +- Modify: `packages/web/src/styles/components.css` + - Rewrite the scoped mobile files content selectors to align tabs, actions, search, file tree, and Git mobile surfaces with PC sidebar language. +- Modify: `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx` + - Only if needed for a lightweight header wrapper that improves tab/actions alignment without changing behavior. + +## Task 1: Lock The New Mobile Files Style Contract In Tests + +**Files:** +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Test: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Write the failing mobile files content style assertions** + +Update the mobile workspace home-screen style test in `packages/web/src/styles/components.theme.test.ts` so it captures the new files content language: + +```ts + const mobileFilesSurface = getLastRuleBlock(".mobile-sheet--files .file-tree-shell--mobile"); + const mobileFilesGitSurface = getLastRuleBlock(".mobile-sheet--files .git-panel--mobile"); + const mobileFilesSegmented = getLastRuleBlock(".mobile-files-sheet__segmented"); + const mobileFilesSegment = getLastRuleBlock(".mobile-files-sheet__segment"); + const mobileFilesSegmentActive = getLastRuleBlock(".mobile-files-sheet__segment.active"); + const mobileFilesSegmentIndicator = getLastRuleBlock(".mobile-files-sheet__segment.active::after"); + const mobileFilesTabAction = getLastRuleBlock(".mobile-files-sheet__tab-action"); + const mobileFileSearch = getLastRuleBlock(".file-tree-shell--mobile .file-tree-search"); + const mobileFileRow = getLastRuleBlock(".file-tree-shell--mobile .tree-item"); + const mobileFileRowSelected = getLastRuleBlock(".file-tree-shell--mobile .tree-item.selected"); + + expect(mobileFilesSegmented).toContain("border-bottom: 1px solid color-mix(in srgb, var(--border) 78%, transparent)"); + expect(mobileFilesSegmented).toContain("border-radius: 0"); + expect(mobileFilesSegmented).not.toContain("linear-gradient("); + expect(mobileFilesSegmented).not.toContain("box-shadow:"); + + expect(mobileFilesSegment).toContain("padding: 0"); + expect(mobileFilesSegment).toContain("font-weight: var(--type-label-weight)"); + expect(mobileFilesSegmentActive).toContain("background: transparent"); + expect(mobileFilesSegmentIndicator).toContain("height: 1.5px"); + + expect(mobileFilesTabAction).toContain("border: none"); + expect(mobileFilesTabAction).toContain("border-radius: 6px"); + expect(mobileFilesTabAction).toContain("background: transparent"); + + expect(mobileFilesSurface).toContain("border: 1px solid color-mix(in srgb, var(--border) 80%, transparent)"); + expect(mobileFilesSurface).toContain("border-radius: var(--radius-md)"); + expect(mobileFilesSurface).not.toContain("box-shadow:"); + expect(mobileFilesSurface).not.toContain("linear-gradient("); + + expect(mobileFilesGitSurface).toContain("border: 1px solid color-mix(in srgb, var(--border) 80%, transparent)"); + expect(mobileFilesGitSurface).toContain("border-radius: var(--radius-md)"); + expect(mobileFilesGitSurface).not.toContain("box-shadow:"); + + expect(mobileFileSearch).toContain("margin: 0"); + expect(mobileFileSearch).toContain("border-radius: 0"); + expect(mobileFileSearch).toContain("border-right: none"); + expect(mobileFileSearch).toContain("border-left: none"); + expect(mobileFileSearch).toContain("background: transparent"); + + expect(mobileFileRow).toContain("min-height: 40px"); + expect(mobileFileRow).toContain("border-radius: 0"); + expect(mobileFileRowSelected).toContain("border-left: 2px solid color-mix(in srgb, var(--accent-blue) 88%, white 12%)"); +``` + +- [ ] **Step 2: Run the style test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: + +- FAIL because the current mobile files selectors still use rounded segmented chrome, circular actions, and floating-card surfaces + +- [ ] **Step 3: Commit nothing yet** + +Do not stage or commit after the red run. The next task will make the code pass. + +## Task 2: Flatten The Mobile Files Header And Content Surfaces + +**Files:** +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx` (only if layout wrapper is needed) +- Test: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Update the mobile files header styles to match the PC sidebar language** + +In `packages/web/src/styles/components.css`, replace the existing `.mobile-files-sheet__segmented`, `.mobile-files-sheet__segment`, `.mobile-files-sheet__tab-actions`, and `.mobile-files-sheet__tab-action` block bodies with this contract: + +```css +.mobile-files-sheet__segmented { + display: flex; + align-items: center; + flex-shrink: 0; + gap: var(--sp-3); + padding: 0 0 var(--sp-2); + border-bottom: 1px solid color-mix(in srgb, var(--border) 78%, transparent); + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.mobile-files-sheet__segment { + position: relative; + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + min-height: 32px; + padding: 0; + border: none; + border-radius: 0; + background: transparent; + color: var(--text-tertiary); + font-size: var(--type-label-size); + line-height: var(--type-label-line-height); + font-weight: var(--type-label-weight); + transition: color var(--duration-fast) var(--ease-out); +} + +.mobile-files-sheet__segment.active { + background: transparent; + color: var(--text-primary); +} + +.mobile-files-sheet__segment.active::after { + content: ""; + position: absolute; + right: 0; + bottom: -10px; + left: 0; + height: 1.5px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent-blue) 90%, white 10%); +} + +.mobile-files-sheet__tab-actions { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-left: auto; +} + +.mobile-files-sheet__tab-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-tertiary); + transition: + background-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} +``` + +- [ ] **Step 2: Update the mobile-only files surfaces to remove the floating-card shell** + +In the `.mobile-sheet--files` section of `packages/web/src/styles/components.css`, rewrite the shared mobile files surface selector: + +```css + .mobile-sheet--files .file-tree-shell--mobile, + .mobile-sheet--files .git-panel--mobile, + .mobile-sheet--files .workspace-git-editor, + .mobile-sheet--files .workspace-git-view { + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: var(--radius-md); + background: var(--bg-panel); + box-shadow: none; + } +``` + +Keep the existing exclusions for code-editor and diff wrappers unchanged. + +- [ ] **Step 3: Align the mobile file-tree search and row styling with the PC sidebar** + +In the `file-tree-shell--mobile` section of `packages/web/src/styles/components.css`, keep the touch-friendly height but flatten the presentation: + +```css +.file-tree-shell--mobile .file-tree-search { + margin: 0; + border-right: none; + border-left: none; + border-radius: 0; + padding-inline: 12px; + background: transparent; +} + +.file-tree-shell--mobile .file-tree { + padding-bottom: 10px; +} + +.file-tree-shell--mobile .tree-item { + min-height: 40px; + margin: 0; + border-radius: 0; + padding-top: 8px; + padding-bottom: 8px; +} + +.file-tree-shell--mobile .tree-item.selected { + padding-left: 14px; + border-left: 2px solid color-mix(in srgb, var(--accent-blue) 88%, white 12%); + background: color-mix(in srgb, var(--accent-blue) 12%, transparent); +} +``` + +- [ ] **Step 4: Re-run the style test to verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: + +- PASS for the updated mobile files surface assertions +- PASS for existing unrelated theme-sensitive surface assertions + +- [ ] **Step 5: Commit the style implementation** + +```bash +git add packages/web/src/styles/components.css packages/web/src/styles/components.theme.test.ts +git commit -m "feat(web): align mobile files content with pc sidebar" +``` + +## Task 3: Verify Mobile Files Behavior Did Not Regress + +**Files:** +- Modify: `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx` (only if needed) +- Test: `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx` + +- [ ] **Step 1: Review the current mobile files sheet structure tests** + +Open `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx` and confirm whether any selectors or wrapper assumptions changed. Only edit the test if the header layout gained a new wrapper or if class assertions need to include a new structural class. + +- [ ] **Step 2: If needed, add a focused regression assertion for the unchanged behavior** + +If the component structure changes, keep the behavior lock tight with a focused test like this: + +```tsx + it("keeps file actions in the same header row as the tabs after the visual refresh", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + path: "/workspace", + children: [], + }); + + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + render( + + + + ); + + const tabList = screen.getByRole("tablist", { name: "Files tabs" }); + const newFileButton = await screen.findByRole("button", { name: "New File" }); + + expect(tabList.closest(".mobile-files-sheet__segmented")).toBe( + newFileButton.closest(".mobile-files-sheet__segmented") + ); + }); +``` + +- [ ] **Step 3: Run the targeted mobile files tests** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/mobile/mobile-files-sheet.test.tsx +``` + +Expected: + +- PASS for the existing structural tests +- PASS for any new regression assertion added in Step 2 + +- [ ] **Step 4: Commit any structural-test-only adjustments** + +If `mobile-files-sheet.test.tsx` changed: + +```bash +git add packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx +git commit -m "test(web): keep mobile files header behavior covered" +``` + +If it did not change, skip this commit. + +## Task 4: Final Verification + +**Files:** +- No new files +- Test: `packages/web/src/styles/components.theme.test.ts` +- Test: `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx` + +- [ ] **Step 1: Run the combined verification suite** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts src/features/workspace/views/mobile/mobile-files-sheet.test.tsx +``` + +Expected: + +- PASS with `0` failures +- No regressions in the mobile files content style contract or structure contract + +- [ ] **Step 2: Perform a quick requirement checklist against the spec** + +Confirm each item directly against the code and test coverage: + +- tabs are flat and aligned with PC sidebar language +- header actions are flat icon actions, not circular floating buttons +- file and Git surfaces no longer use floating-card shadows +- file-tree search and rows keep mobile touch sizing +- editor, diff, terminal, top header, and bottom status bar selectors were not changed as part of this task + +- [ ] **Step 3: Commit the verification checkpoint only if there are uncommitted plan-scope changes left** + +If there are still plan-scope code changes not yet committed: + +```bash +git add packages/web/src/styles/components.css packages/web/src/styles/components.theme.test.ts packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx +git commit -m "chore(web): verify mobile files content refresh" +``` + +If there are no plan-scope changes left, skip this step. diff --git a/docs/superpowers/plans/2026-05-18-supervisor-objective-dialog-flat-redesign.md b/docs/superpowers/plans/2026-05-18-supervisor-objective-dialog-flat-redesign.md new file mode 100644 index 000000000..e373f802b --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-supervisor-objective-dialog-flat-redesign.md @@ -0,0 +1,507 @@ +# Supervisor Objective Dialog Flat Redesign 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:** Redesign the desktop Supervisor objective dialog into a flatter, denser settings-panel style surface with smaller typography and smaller input text, without changing Supervisor behavior. + +**Architecture:** Keep the existing `ObjectiveDialog` modal shell and command flow intact, then tighten the feature in three layers: component markup, feature-local styling, and regression tests. The redesign stays scoped to `.supervisor-dialog` so global modal or input tokens do not change. + +**Tech Stack:** React, TypeScript, shared UI primitives (`Modal`, `Input`, `Textarea`, `Select`, `DateTimePicker`), Vitest, Testing Library, CSS design tokens + +--- + +## File Structure + +- Modify: `packages/web/src/features/supervisor/views/shared/objective-dialog.tsx` + - Keep the canonical dialog header and footer. + - Continue rendering the same modal shell. + - Only adjust body structure if the intro strip is owned here. + +- Modify: `packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx` + - Add the flattened intro strip for enable/edit modes. + - Downgrade form control sizes from `lg` to compact variants. + - Keep disable mode semantics and field callbacks unchanged. + +- Modify: `packages/web/src/styles/components.css` + - Replace the heavy Supervisor intro card treatment with a flat inline strip. + - Tighten spacing and typography inside `.supervisor-dialog`. + - Shrink warning callout density and ensure inputs render at the smaller token scale. + +- Modify: `packages/web/src/features/supervisor/views/shared/objective-dialog-content.test.tsx` + - Add markup and size assertions for the intro strip and compact controls. + +- Modify: `packages/web/src/features/supervisor/components/objective-dialog.test.tsx` + - Add assertions that the dialog body renders the new intro strip only for enable/edit flows. + +- Modify: `packages/web/src/styles/components.theme.test.ts` + - Update the style contract from “large specialized card” assumptions to the new flat Supervisor dialog contract. + +## Task 1: Update dialog structure and compact control sizing + +**Files:** +- Modify: `packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx` +- Modify: `packages/web/src/features/supervisor/views/shared/objective-dialog.tsx` +- Test: `packages/web/src/features/supervisor/views/shared/objective-dialog-content.test.tsx` +- Test: `packages/web/src/features/supervisor/components/objective-dialog.test.tsx` + +- [ ] **Step 1: Write the failing component tests** + +Add coverage for the flattened intro strip and compact control classes. + +```tsx +it("renders a flat supervisor intro strip for enable mode", () => { + render( + + ); + + expect(screen.getByText("supervisor.dialog.enable.title")).toBeInTheDocument(); + expect(document.querySelector(".supervisor-dialog-intro")).toBeTruthy(); +}); + +it("renders compact control classes instead of large form controls", () => { + render( + + ); + + expect(screen.getByLabelText("supervisor.field.objective")).not.toHaveClass("textarea-lg"); + expect(screen.getByRole("button", { name: "supervisor.field.evaluator Claude" })).toHaveClass( + "input-sm" + ); + expect(screen.getByLabelText("supervisor.field.evaluator_model")).toHaveClass("input-sm"); + expect(screen.getByLabelText("supervisor.field.max_supervision_count")).toHaveClass("input-sm"); +}); +``` + +Extend the desktop dialog test with an enable-mode intro assertion and a disable-mode absence assertion. + +```tsx +it("renders the intro strip inside the desktop dialog for enable mode", () => { + render( + + + + ); + + expect(document.querySelector(".supervisor-dialog-intro")).toBeTruthy(); +}); + +it("does not render the intro strip for disable mode", () => { + render( + + + + ); + + expect(document.querySelector(".supervisor-dialog-intro")).toBeNull(); +}); +``` + +- [ ] **Step 2: Run the targeted component tests to verify they fail** + +Run: + +```bash +pnpm vitest run \ + packages/web/src/features/supervisor/views/shared/objective-dialog-content.test.tsx \ + packages/web/src/features/supervisor/components/objective-dialog.test.tsx +``` + +Expected: + +- FAIL because `.supervisor-dialog-intro` does not exist yet +- FAIL because form controls still render `lg` sizing + +- [ ] **Step 3: Implement the flat intro strip and compact form control props** + +In `objective-dialog-content.tsx`, add a small intro block for enable/edit and move control sizes to compact variants. + +```tsx +function SupervisorDialogIntro({ + mode, + title, + subtitle, +}: { + mode: "enable" | "edit"; + title: string; + subtitle: string; +}) { + return ( +
+ +
+ {title} +

{subtitle}

+
+
+ ); +} +``` + +Render it above the form fields for non-disable modes. + +```tsx +{mode !== "disable" ? ( + +) : null} +``` + +Downgrade the form controls from large sizing. + +```tsx +