From 37023f60bbc222ac0a1f1d49b44893c7a713c299 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sat, 28 Mar 2026 00:15:49 +0800 Subject: [PATCH 01/55] docs: add session history and claude settings design --- .stitch/DESIGN.md | 120 ++++ ...ion-history-and-claude-settings-prompts.md | 92 ++++ ...sion-history-and-claude-settings-design.md | 520 ++++++++++++++++++ 3 files changed, 732 insertions(+) create mode 100644 .stitch/DESIGN.md create mode 100644 .stitch/designs/2026-03-28-session-history-and-claude-settings-prompts.md create mode 100644 docs/superpowers/specs/2026-03-28-session-history-and-claude-settings-design.md diff --git a/.stitch/DESIGN.md b/.stitch/DESIGN.md new file mode 100644 index 0000000..80c2698 --- /dev/null +++ b/.stitch/DESIGN.md @@ -0,0 +1,120 @@ +# Design System: Coder Studio + +## 1. Visual Theme & Atmosphere + +Coder Studio is a dark, terminal-first engineering workbench. The mood should feel quiet, precise, and operational rather than glossy or playful. Surfaces are dense but not cramped. Interaction feedback should be crisp and restrained. New UI for history and Claude settings must feel like it belongs inside a serious developer cockpit, not a separate SaaS admin panel. + +Keywords: + +- dark terminal minimalist +- quiet ops +- high-focus productivity +- low-noise, low-gloss +- technical, precise, dense + +## 2. Color Palette & Roles + +- App Background: `#0d1418` +- Elevated Background: `#121b1e` +- Secondary Surface: `#141f24` +- Tertiary Surface: `#1c2a31` +- Overlay Surface: `rgba(20, 31, 36, 0.95)` +- Glass Surface: `rgba(16, 26, 31, 0.9)` +- Primary Text: `#e7f3f7` +- Secondary Text: `#b4cad3` +- Muted Text: `#7d98a4` +- Border: `rgba(180, 216, 225, 0.12)` +- Strong Border: `rgba(180, 216, 225, 0.2)` +- Primary Accent: `#5ac8fa` +- Primary Accent Soft: `rgba(90, 200, 250, 0.15)` +- Secondary Accent: `#8fffae` +- Secondary Accent Soft: `rgba(143, 255, 174, 0.18)` +- Warning Accent: `#ffd37a` +- Warning Soft: `rgba(255, 211, 122, 0.16)` +- Danger Accent: `#ff9eb0` +- Danger Soft: `rgba(255, 158, 176, 0.17)` + +Color usage rules: + +- Use blue accent for focus, selection, restore, active links, and current context. +- Use green accent for healthy / resumed / ready states. +- Use amber for archived or cautionary informational states. +- Use pink-red only for destructive actions like hard delete. +- Never brighten the whole panel; rely on localized accent bars, tags, and focus rings. + +## 3. Typography Rules + +- Primary UI Font: `"IBM Plex Sans", "Noto Sans SC", "Source Han Sans SC", "PingFang SC", sans-serif` +- Monospace: `"JetBrains Mono", "Cascadia Mono", "IBM Plex Mono", "Fira Code", monospace` + +Scale: + +- Micro labels: 11px +- Dense controls: 12px +- Default UI body: 13px +- Section labels: 14px +- Panel titles: 16px to 18px + +Rules: + +- Use compact uppercase labels sparingly for panel chrome. +- Use mono only for command, path, shell, and config snippets. +- Prefer high-contrast title + subdued metadata pairings. + +## 4. Geometry & Component Stylings + +- Overall radius: subtle and technical, mostly `4px` to `8px` +- Avoid pill-heavy styling except for status chips and tiny toggles +- Tabs: flat or lightly raised, integrated into panel chrome +- Drawers: straight-edged container with subtle inner border and soft shadow +- Cards: only when necessary; most surfaces should read as panels or list rows, not marketing cards +- Inputs: dark recessed surfaces with strong focus ring +- Destructive actions: outlined or ghost buttons with danger accent on hover + +## 5. Depth & Motion + +- Shadows should be whisper-soft, mostly diffused black shadows +- Use motion only for: + - left drawer reveal + - state chip transitions + - restore chooser tab switch + - subtle row hover/focus +- Avoid bouncy or playful motion +- Prefer 140ms to 180ms ease-out for panel transitions + +## 6. Layout Principles + +- Dense workbench layout with explicit panel boundaries +- Strong vertical rhythm via separators and compact spacing +- Make hierarchy through alignment and text contrast, not oversized cards +- History drawer should feel attached to the workbench shell, not like a modal +- Claude settings should balance form density with scanability: + - left nav + - grouped sections + - advanced JSON areas clearly separated + +## 7. Feature-Specific Guidance + +### History Drawer + +- Width should feel utility-grade, not oversized +- Workspace group headers should anchor scanning +- Session rows should make primary action obvious: + - active rows feel navigational + - archived rows feel recoverable + - delete remains secondary but visible +- Status chips should be subtle, with accent only where meaningful + +### Restore Chooser In Draft Pane + +- Keep the pane-local context obvious +- Two-mode switch should be clear and minimal +- The restore list should feel like selecting a dormant session into this pane position +- Avoid any cross-workspace ambiguity + +### Claude Settings + +- This is not a generic form page +- It should feel like configuring a runtime +- Surface inheritance and override clearly +- Advanced JSON editors should feel trustworthy, technical, and integrated with the same dark system diff --git a/.stitch/designs/2026-03-28-session-history-and-claude-settings-prompts.md b/.stitch/designs/2026-03-28-session-history-and-claude-settings-prompts.md new file mode 100644 index 0000000..53b50ac --- /dev/null +++ b/.stitch/designs/2026-03-28-session-history-and-claude-settings-prompts.md @@ -0,0 +1,92 @@ +# Stitch Prompts: Session History And Claude Settings + +These prompts are prepared for Stitch generation once a Stitch MCP or CLI environment is available. + +## Screen 1: Global Session History Drawer + +Quiet, dark terminal-first developer workbench UI for Coder Studio. Design a global session history drawer that slides in from the left edge of an existing multi-workspace coding application. This is low-frequency "undo / regret insurance" functionality, so the UI should feel compact, utility-grade, and tightly integrated with the workbench shell instead of looking like a separate product area. + +**DESIGN SYSTEM (REQUIRED):** +- Platform: Web, desktop-first, responsive down to narrow laptop widths +- Theme: dark terminal minimalist, quiet ops, dense engineering cockpit +- Palette: background `#0d1418`, elevated `#121b1e`, secondary surface `#141f24`, tertiary `#1c2a31`, primary text `#e7f3f7`, secondary text `#b4cad3`, muted `#7d98a4`, primary accent `#5ac8fa`, positive accent `#8fffae`, warning accent `#ffd37a`, danger accent `#ff9eb0` +- Typography: IBM Plex Sans / Noto Sans SC for UI, JetBrains Mono for technical snippets +- Geometry: subtle 4px to 8px radius, straight panel boundaries, restrained shadows +- Motion: 160ms drawer slide-in, subtle row hover and focus states + +**PAGE STRUCTURE:** +1. **Workbench Shell Context:** show a compact top workspace tab strip across the top, with a history icon fixed at the far left of the tab row, and the left-side history drawer opened. +2. **Drawer Header:** title "History", short helper text explaining that closed sessions are archived not deleted, close icon on the right. +3. **Grouped Workspace History:** multiple workspace sections, each with workspace title, path summary, target badge like Native or WSL, and session count. +4. **Session Rows:** each row shows title, recent activity time, subtle status chip, and different visual semantics for: + - active session: click jumps and focuses + - archived session: click restores + - interrupted session: click retries restore +5. **Row Actions:** hard delete icon button aligned right, danger on hover but not visually dominant. +6. **States:** include one empty workspace group case hidden entirely, one live session row, one archived row, one interrupted row, and one focused hover state. + +**UI DETAILS:** +- Workspace groups are stacked with tight spacing and divider rhythm. +- Status chips are compact and understated, not colorful pills. +- Active row uses blue accent edge or focus bar. +- Archived row uses amber informational tone, not warning-alert tone. +- Delete button is subtle until hover. +- The drawer should feel attached to the main shell with an inner border and faint shadow. + +## Screen 2: Draft Pane Restore Chooser + +Design a pane-local chooser for a new split inside the same Coder Studio dark workbench. The user has just created a new split pane. Instead of immediately starting a new session, the pane shows two choices: create a fresh session or restore from current workspace history. This interaction should feel lightweight, decisive, and local to the pane position. + +**DESIGN SYSTEM (REQUIRED):** +- Same design system as above +- Must visually inherit the existing workbench shell +- Dense, technical, low-noise + +**PAGE STRUCTURE:** +1. **Pane Frame:** show this chooser inside one split pane of a larger multi-pane agent workspace. +2. **Mode Switch:** top segmented control with two tabs: + - New Session + - Restore From History +3. **New Session Mode:** compact input area with concise placeholder, launch button, minimal empty-state guidance. +4. **Restore Mode:** list only current-workspace recoverable sessions, each with title, last activity time, status chip, and short metadata hint. +5. **Selection Feedback:** one row selected and ready to restore into this exact pane. +6. **Primary Action Area:** restore button makes the "restore into this pane slot" meaning obvious. + +**UI DETAILS:** +- Do not show cross-workspace content anywhere. +- Do not show already-mounted live sessions in the restore list. +- The chooser should read as a replacement state for a draft pane, not a full-screen dialog. +- Include a subtle line explaining that the restored session keeps its original identity. + +## Screen 3: Claude Settings Center + +Design a high-density Claude runtime settings panel for Coder Studio. This replaces a simplistic launch-command setting with a complete Claude configuration center. The screen must feel like configuring an engineering runtime, not a generic SaaS settings page. + +**DESIGN SYSTEM (REQUIRED):** +- Platform: Web, desktop-first +- Same dark terminal-first design language as the rest of the product +- Compact typography and sectional rhythm optimized for serious configuration work + +**PAGE STRUCTURE:** +1. **App Settings Shell:** existing settings page with left navigation. Include top-level nav items General, Claude, Appearance. Claude is selected. +2. **Claude Header:** title, short explanation, runtime validation indicator, and summary of whether current target inherits global config or uses an override. +3. **Target Scope Switch:** clearly show Global, Native Override, WSL Override with inheritance toggles. +4. **Structured Sections:** stacked sections for: + - Launch & Auth + - Model & Behavior + - Permissions + - Sandbox + - Hooks & Automation + - Worktree + - Plugins & MCP + - Global Preferences +5. **Advanced JSON Area:** two integrated editors labeled `settings.json advanced` and `~/.claude.json advanced`, dark technical editor styling, validation state visible. +6. **Field Examples:** include executable path, startup args list, API key / base URL, model selector, permission mode, danger flags, sandbox toggles, plugin controls, IDE auto-connect preferences. + +**UI DETAILS:** +- Strong grouping and separators, not oversized cards. +- Each section should have a compact heading and short muted explanation. +- Inheritance state must be unambiguous. +- Danger-related flags should be visually distinct but not alarmist. +- Validation state should feel operational: neutral info, warning, error, success. +- Use monospace for file paths, command arguments, and JSON labels. diff --git a/docs/superpowers/specs/2026-03-28-session-history-and-claude-settings-design.md b/docs/superpowers/specs/2026-03-28-session-history-and-claude-settings-design.md new file mode 100644 index 0000000..99b9433 --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-session-history-and-claude-settings-design.md @@ -0,0 +1,520 @@ +# Session History And Claude Settings Design + +> Status: Approved in chat +> Date: 2026-03-28 +> Scope: `apps/web` + `apps/server` +> Related: `docs/PRD.md`, `docs/superpowers/specs/2026-03-26-persistent-workspace-runtime-design.md` + +## 背景 + +当前产品已经具备一部分“归档”底层能力,但仍停留在半成品状态: + +- 关闭非草稿 pane 时,`session` 实际上已经会被后端归档。 +- `workspace_sessions` 已经通过 `archived_at` 区分活跃与归档记录。 +- 前端已有只读 archive 视图状态,但没有正式入口、恢复动作、删除动作和统一历史中心。 +- 当前 `archive_session -> agent_stop` 会把已归档会话进一步写成 `Interrupted`,这和“正常关闭/归档”语义冲突。 + +同时,设置系统也存在明显短板: + +- 设置真相源仍是前端 `localStorage`,不支持多端同步。 +- 当前设置里的 `Launch Command` 只覆盖 Claude 启动命令,无法表达完整的 Claude 配置。 +- Claude 相关配置目前只自动维护 `.claude/settings.local.json` 的 hooks,不足以覆盖用户实际会调整的常用项。 + +本设计把这两件事一起收口: + +1. 把“关闭即归档”升级为完整的 session/workspace 历史中心与恢复链路。 +2. 把设置系统迁到后端,并重构为以 Claude 配置为核心的设置模型。 + +## 目标 + +1. 提供一个低频但随手可达的全局历史入口,能按 workspace 组织所有 session 记录。 +2. 明确“关闭 session/workspace”就是“归档”,并补齐恢复、聚焦、删除等完整行为。 +3. 支持在“新建/分屏”时直接从当前 workspace 历史中恢复 session,并决定恢复到哪个 pane 位置。 +4. 将应用设置改为后端持久化的全局真相源,支持多端刷新后收敛。 +5. 移除旧 `Launch Command` 入口,改为完整 Claude 设置中心。 +6. Claude 设置既要有结构化表单,也要允许用户编辑更底层的 `settings.json` / `~/.claude.json` 常用项。 + +## 非目标 + +本期不做以下内容: + +- 历史记录搜索、筛选、批量删除、批量恢复。 +- 跨 workspace 把历史 session 恢复到当前 split pane。 +- 复活已经退出的 OS 进程;恢复只保证恢复同一 session identity,并重建 PTY / Claude 上下文。 +- 其他客户端在设置变更后的实时推送;本期仅保证保存立即生效,其他端刷新或重连后收敛。 +- 多账号、多租户或每用户独立设置空间。 + +## 已确认的产品规则 + +1. 关闭 `session` 等价于归档该 `session`。 +2. 关闭 `workspace` 等价于归档该 `workspace` 下全部 `session`。 +3. 历史记录不是新页面,而是低频使用的“后悔药”入口。 +4. 历史入口位于 workspace 标签列表最左侧,点击后从左侧弹出抽屉。 +5. 历史按 workspace 分组,但记录粒度是“一个 session 一条记录”,不是按归档动作记日志。 +6. 历史里展示所有 session,而不是只展示归档会话。 +7. 历史项点击行为: + - 活跃会话:切换到对应 workspace,并 focus 到该 session 所在 pane。 + - 已归档会话:恢复同一个 session identity,并重新建立 PTY;若存在 `claude_session_id`,优先尝试续到同一个 Claude 会话。 + - 目标 workspace 当前未打开:先自动打开 workspace,再执行聚焦或恢复。 +8. 历史项支持硬删除。删除后不保留任何记录,也无法恢复。 +9. 如果删除的是某个打开中的 workspace 的最后一个 session,workspace 本身保留,并自动补一个新的 draft pane。 +10. 新建 / 分屏时可以在 draft pane 里选择“新建会话”或“从历史恢复”。 +11. “从历史恢复”只允许使用当前 workspace 的历史 session,不允许跨 workspace。 +12. 在 split 恢复列表里,不展示已经 live 且已经挂载在当前 pane tree 中的 session。 +13. Claude 设置要尽可能完整。 +14. Claude 设置既包含 CLI 启动参数,也包含常用 `settings.json` / `~/.claude.json` 配置。 +15. 所有设置都存到后端。 +16. 用户可以自己决定是否为 `Native` / `WSL` 使用覆盖配置。 + +## 总体方案 + +### 1. 历史中心 + +新增一个“全局 session 历史抽屉”: + +- 入口固定在 workspace tabs 最左侧,不随 workspace 关闭而消失。 +- 抽屉从左侧滑出,覆盖 workspace 主内容左缘,但不跳路由。 +- 抽屉按 workspace 分组展示 session 记录,组内按最近活动时间倒序。 +- 空 workspace 分组不显示。 +- 每条记录至少展示: + - 标题 + - 状态标签 + - 最近活动时间 + - 所属 workspace 名称 + - 恢复 / 聚焦语义 + - 删除动作 + +### 2. 关闭、归档、恢复语义 + +现有产品里“关闭”已经被理解为归档,本设计明确它的技术语义: + +- 归档态的唯一事实来源是 `workspace_sessions.archived_at != null`。 +- `status = suspended` 只作为归档后的显示态,不表示异常。 +- 归档过程中停止 agent / shell runtime 时,不允许再把已归档 session 写成 `Interrupted`。 +- `Interrupted` 只保留给异常中断、崩溃、外部 kill、attach 失败等非正常结束。 + +恢复时: + +1. 清除 `archived_at`。 +2. 保留原始 `session_id`、标题、消息、stream、`claude_session_id`、未读数和最后活动时间。 +3. 将 session 放回当前 workspace 的 pane layout 中并 focus。 +4. 为该 session 重建 PTY。 +5. 若已有 `claude_session_id`,尝试用 Claude 的 resume 语义继续;否则按冷启动会话处理。 + +### 3. 新建 / 分屏恢复 + +当前分屏会先生成一个 draft pane。本设计保留该主流程,只增强 draft pane 内容: + +- 默认 tab 为“新建会话”。 +- 旁边新增“从历史恢复”tab。 +- “从历史恢复”tab 只展示当前 workspace 可恢复 session。 +- 可恢复 session 的判断: + - 不在当前 pane tree 中挂载。 + - 不是隐藏 draft placeholder。 + - 历史状态允许恢复,如 archived / interrupted / detached。 +- 用户选中历史 session 后,当前 draft pane 直接被该 session 替换,不额外产生新 session。 + +## 信息架构 + +### 历史抽屉结构 + +1. 抽屉头部 + - 标题:`History` + - 当前说明:该抽屉展示所有 session 记录,关闭只是归档 + - 关闭按钮 + +2. Workspace 分组 + - workspace 名称 + - 路径或 target 摘要 + - 该组 session 数量 + +3. Session 记录行 + - 标题 + - 最近活动时间 + - 状态标签 + - 次要说明:归档 / 活跃 / 中断 + - 主点击区:聚焦或恢复 + - 行尾危险动作:删除 + +### 设置导航结构 + +设置页改为至少三个一级面板: + +1. `General` + - 语言 + - 完成通知 + - 终端兼容模式 + - 默认 idle policy + +2. `Claude` + - 基础配置 + - 高级配置 + +3. `Appearance` + - 维持现有深色主题说明,暂不扩展主题体系 + +`Claude` 面板内部再拆成结构化 section: + +1. Launch & Auth +2. Model & Behavior +3. Permissions +4. Sandbox +5. Hooks & Automation +6. Worktree +7. Plugins & MCP +8. Global Preferences +9. Advanced JSON + +## 后端设计 + +### 1. 会话历史模型 + +`workspace_sessions` 已经具备支撑“一条 session 记录贯穿活跃与归档”的基础能力,本期不再额外引入 archive log 表作为新真相源。 + +保留现有表结构方向: + +- 一条 `workspace_sessions` 行代表一个 session identity。 +- `archived_at` 表示当前是否归档。 +- payload 中保存 session 快照。 + +新增或收敛的不是物理表,而是逻辑 DTO: + +`SessionHistoryRecord` + +- `workspace_id` +- `workspace_title` +- `workspace_path` +- `session_id` +- `title` +- `status` +- `archived` +- `mounted` +- `recoverable` +- `last_active_at` +- `archived_at` +- `claude_session_id` + +历史抽屉读取该 DTO,而不是直接消费当前 `archive` 视图。 + +### 2. 归档命令 + +新增或调整以下后端命令: + +1. `archive_session` + - 现有命令保留,但改为“先写归档态,再停止 runtime,且停止 runtime 不允许覆盖为 interrupted” +2. `archive_workspace_sessions` + - 对 workspace 下所有 live session 批量归档 +3. `list_session_history` + - 返回所有 workspace 分组后的 session history DTO +4. `restore_session` + - 恢复指定 session +5. `delete_session` + - 硬删除指定 session 及其关联数据 + +### 3. 恢复命令 + +`restore_session` 的约束: + +- 入参必须带 `workspace_id` 和 `session_id`。 +- 允许从历史抽屉恢复,也允许从当前 workspace draft pane 恢复。 +- 如果该 session 已经活跃并挂载,命令返回“already_active”,前端走聚焦逻辑,不重复启动。 +- 如果 workspace 当前未打开,前端先调用打开 / 激活 workspace,再调用恢复。 + +### 4. 删除命令 + +`delete_session` 是硬删除,必须清理: + +- `workspace_sessions` 记录 +- session stream / transcript 持久化 +- Claude lifecycle 历史 +- 任何 session 级 runtime attach 索引 +- 历史中心可见性 + +删除后不保留 tombstone,也不支持撤销。 + +### 5. 关闭 workspace + +`close_workspace` 语义调整为: + +1. 归档该 workspace 下全部 session。 +2. 释放 controller。 +3. 关闭 workspace 级 terminal。 +4. 停止 watch。 +5. 从当前 UI open tabs 中移除 workspace。 + +注意:关闭 workspace 不是删除 workspace 记录,历史抽屉仍需要能按该 workspace 分组显示其 session 历史。 + +### 6. 设置存储模型 + +新增后端全局设置模型,例如 `app_settings` 单例表或等价存储: + +- 单机单用户作用域 +- 后端作为唯一真相源 +- 首次读取时由后端给默认值 +- 启动时把旧 localStorage 配置迁移到后端一次 + +建议数据结构: + +```json +{ + "general": { + "locale": "zh", + "terminalCompatibilityMode": "standard", + "completionNotifications": { + "enabled": true, + "onlyWhenBackground": true + }, + "idlePolicy": { + "enabled": true, + "idleMinutes": 10, + "maxActive": 3, + "pressure": true + } + }, + "claude": { + "global": { + "executable": "claude", + "startupArgs": [], + "env": {}, + "settingsJson": {}, + "globalConfigJson": {} + }, + "overrides": { + "native": null, + "wsl": null + } + } +} +``` + +`overrides.native` / `overrides.wsl` 只有在用户显式启用时才生效,否则继承 `global`。 + +说明: + +- `general.idlePolicy` 表示新建 workspace 的默认 idle policy 与全局回填值,不直接覆盖已经存在的 workspace 级 idle policy。 +- 历史抽屉数据不应塞进 `workbench_bootstrap` 常驻载荷,建议用单独的按需接口 `list_session_history` 拉取,避免 bootstrap 长期膨胀。 + +## Claude 设置范围 + +本设计的 Claude 设置表单需要覆盖“高频可理解项”,并把剩余配置交给高级 JSON 编辑器。下面列出的字段是基于 Claude 官方文档整理出的“首版优先结构化的常见项示例”,不是要在 spec 阶段冻结全部上游 schema;实现前应再次按当时官方文档做一次字段校验。 + +### 1. Launch & Auth + +结构化字段至少包括: + +- Claude 可执行文件路径 +- 启动参数数组 +- `ANTHROPIC_API_KEY` +- `ANTHROPIC_AUTH_TOKEN` +- `ANTHROPIC_BASE_URL` +- `ANTHROPIC_CUSTOM_HEADERS` +- `apiKeyHelper` +- 额外环境变量 + +### 2. Model & Behavior + +优先结构化官方常见配置中的模型与行为项,例如: + +- `model` +- `permissionMode` +- `effort` +- `includeGitInstructions` +- `cleanupPeriodDays` +- `fallback model` 类能力 +- `language` / locale related behavior +- 其他在实现时仍由官方文档确认存在的常用行为开关 + +### 3. Permissions + +- `permissions.allow` +- `permissions.ask` +- `permissions.deny` +- `additionalDirectories` +- `defaultMode` +- `disableBypassPermissionsMode` +- 对应 CLI flag: + - `--allowedTools` + - `--disallowedTools` + - `--tools` + - `--dangerously-skip-permissions` + - `--allow-dangerously-skip-permissions` + +### 4. Sandbox + +官方常见 sandbox 配置示例: + +- `sandbox.enabled` +- `sandbox.failIfUnavailable` +- `sandbox.autoAllowBashIfSandboxed` +- `sandbox.excludedCommands` +- `sandbox.allowUnsandboxedCommands` +- `sandbox.filesystem.*` +- `sandbox.network.*` + +### 5. Hooks & Automation + +官方 hooks / automation 常见配置示例: + +- `hooks` +- `disableAllHooks` +- `allowedHttpHookUrls` +- `httpHookAllowedEnvVars` +- `disableDeepLinkRegistration` +- system prompt related settings / flags +- init / maintenance related flags + +### 6. Worktree + +- `worktree.symlinkDirectories` +- `worktree.sparsePaths` +- `--add-dir` + +### 7. Plugins & MCP + +官方插件与 MCP 常见配置示例: + +- `enabledPlugins` +- `extraKnownMarketplaces` +- `plugin-dir` +- `mcp-config` +- `strict-mcp-config` +- `setting-sources` + +### 8. Global Preferences + +当前文档和可见偏好字段里的常见 `~/.claude.json` 类偏好示例: + +- `autoConnectIde` +- `autoInstallIdeExtension` +- `editorMode` +- `showTurnDuration` +- `terminalProgressBarEnabled` + +### 9. Advanced JSON + +保留两个高级编辑器: + +1. `settings.json advanced` +2. `~/.claude.json advanced` + +原则: + +- 结构化表单覆盖高频项。 +- 高级编辑器显示合成后的 JSON 草稿。 +- 若用户只改高级编辑器,不强制要求结构化表单认识全部字段。 +- 保存前做 JSON 校验和 schema 级基础校验。 + +## 前端设计 + +### 1. Workspace 顶部 + +- 在 workspace tabs 最左侧插入历史 icon。 +- icon 作为全局入口,不绑定某个具体 workspace。 +- 打开抽屉后,不切走当前 workspace,仅叠加抽屉。 + +### 2. 历史抽屉行为 + +- 首次打开时按需拉取历史快照。 +- 后续对 archive / restore / delete / activate 行为做本地收敛更新。 +- 点击历史行的主区域,根据状态决定“聚焦”还是“恢复”。 +- 删除动作要求二次确认。 + +### 3. Draft Pane 恢复选择器 + +- 只在 draft pane 显示。 +- 两个主 tab: + - `新建会话` + - `从历史恢复` +- 恢复列表为空时显示当前 workspace 无可恢复历史的空状态。 + +### 4. Settings 页面 + +- `Claude` 成为一级导航项。 +- 删除旧 `Launch Command` UI 与其可用性检测逻辑。 +- 结构化表单和高级 JSON 分层呈现: + - 上半区:常用字段 + - 下半区:高级 JSON +- `Native` / `WSL` override 使用显式开关控制,关闭时界面展示“继承全局”。 + +## 数据同步与迁移 + +### 1. 历史数据 + +不需要重建 archive 数据;已有 `workspace_sessions.archived_at` 可以直接作为历史基础。 + +需要的迁移与修复: + +- 修正 archive 时的状态写入顺序,避免已归档 session 被 stop 流程改写为 `Interrupted`。 +- 历史接口默认过滤隐藏 draft placeholder。 + +### 2. 设置迁移 + +首次读取后端设置时: + +1. 若后端为空,写入默认值。 +2. 若前端 localStorage 中存在旧设置,则做一次迁移: + - `agentCommand` -> `claude.global.executable` 或启动参数草稿 + - 现有通知、idle policy、terminal compatibility、locale -> 后端 +3. 迁移完成后,前端停止把 localStorage 当真相源。 + +## 错误处理 + +1. 恢复失败 + - session 记录保留 + - 显示错误 toast + - 保持在历史中可再次尝试 + +2. 删除失败 + - 不从 UI 乐观移除 + - 返回原始错误 + +3. Claude 设置保存失败 + - 结构化表单与高级编辑器都保留草稿 + - 明确标出失败字段或 JSON 校验错误 + +4. 目标环境缺少 Claude 可执行文件 + - 在 Claude 面板里给出运行时校验状态 + - 该校验按 `global` / `Native override` / `WSL override` 分别展示 + +## 测试策略 + +### 后端 + +- `archive_session` 不再把已归档 session 写成 `Interrupted` +- `archive_workspace_sessions` 覆盖多 session +- `restore_session` 覆盖 archived / active / missing 三种分支 +- `delete_session` 覆盖最后一个 session、已归档 session、活跃 session +- settings CRUD 与 target override merge 规则 + +### 前端 + +- 历史 icon 与抽屉显隐 +- 历史列表分组与排序 +- 活跃 session 点击聚焦 +- 归档 session 点击恢复 +- draft pane 从当前 workspace 历史恢复 +- 删除最后一个 session 后自动补 draft pane +- Claude 设置的结构化字段、override 开关、高级 JSON 校验 + +### 集成 + +- 关闭 session -> 历史出现 -> 恢复 -> 重新 attach Claude +- 关闭 workspace -> 所有 session 进入历史 -> 再次打开其中一条 +- A 端改设置,B 端刷新后可见 + +## 风险与约束 + +1. 归档/恢复会显著拉高 session 生命周期复杂度,必须明确 archive、interrupted、mounted 三个维度是不同概念。 +2. 关闭 workspace 当前会直接 stop agents / terminals;实现时需要先 archive 再 stop,避免丢失历史语义。 +3. Claude 配置字段很多,结构化表单不能追求 100% 全覆盖,否则首版会失控;必须坚持“高频结构化 + 高级 JSON 补齐”。 +4. 当前环境没有 Stitch MCP,因此本次只产出 Stitch-ready 设计系统和 prompt,不直接生成 screen 文件。 + +## 外部参考 + +Claude 官方文档用于 Claude 配置项选型: + +- https://code.claude.com/docs/en/settings +- https://code.claude.com/docs/en/env-vars +- https://code.claude.com/docs/en/model-config +- https://code.claude.com/docs/en/cli-reference +- https://code.claude.com/docs/en/hooks From d3e22b39099fea7c5d112575ef2b7edfde507f97 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sat, 28 Mar 2026 00:33:06 +0800 Subject: [PATCH 02/55] docs: add implementation plan --- ...-28-session-history-and-claude-settings.md | 1505 +++++++++++++++++ 1 file changed, 1505 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-28-session-history-and-claude-settings.md diff --git a/docs/superpowers/plans/2026-03-28-session-history-and-claude-settings.md b/docs/superpowers/plans/2026-03-28-session-history-and-claude-settings.md new file mode 100644 index 0000000..57acdcb --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-session-history-and-claude-settings.md @@ -0,0 +1,1505 @@ +# Session History And Claude Settings 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:** Ship a global session history drawer with archive/restore/delete flows, plus a backend-persisted Claude settings center that replaces the old launch-command setting. + +**Architecture:** Keep the server as the source of truth for both settings and session history. Add backend RPCs for history and settings, move Claude launch resolution to the backend, then layer focused frontend helpers and UI components on top of existing `WorkspaceScreen`, `TopBar`, and `Settings` surfaces. + +**Tech Stack:** Rust (`axum`, `rusqlite`, existing server test harness), React 19 + TypeScript, node:test, Playwright, Vite + +--- + +## File Map + +### Create + +- `apps/server/src/services/app_settings.rs` — server-side settings CRUD, defaults, legacy migration helpers, and Claude target-profile resolution. +- `apps/web/src/services/http/settings.service.ts` — frontend RPC wrappers for `app_settings_get` and `app_settings_update`. +- `apps/web/src/shared/app/claude-settings.ts` — normalize/merge/serialize Claude settings and target override helpers for the web app. +- `apps/web/src/features/workspace/session-history.ts` — group, sort, and filter global session history records for the drawer and restore chooser. +- `apps/web/src/features/workspace/session-restore-chooser.ts` — pane-local filtering and action selection for “restore into this pane”. +- `apps/web/src/components/HistoryDrawer/HistoryDrawer.tsx` — left drawer UI for grouped session history. +- `apps/web/src/components/HistoryDrawer/index.ts` — barrel export. +- `apps/web/src/components/Settings/ClaudeSettingsPanel.tsx` — dedicated Claude settings panel with structured sections and advanced JSON editors. +- `tests/claude-settings.test.ts` — node tests for Claude settings merge, target override resolution, and advanced JSON preservation. +- `tests/session-history.test.ts` — node tests for history grouping, sorting, active/focus action selection, and recoverable filtering. +- `tests/session-restore-chooser.test.ts` — node tests for “restore from current workspace history” filtering rules. + +### Modify + +- `apps/server/src/models.rs` +- `apps/server/src/infra/db.rs` +- `apps/server/src/services/agent.rs` +- `apps/server/src/services/claude.rs` +- `apps/server/src/services/workspace.rs` +- `apps/server/src/command/http.rs` +- `apps/server/src/main.rs` +- `apps/web/src/types/app.ts` +- `apps/web/src/shared/app/settings.ts` +- `apps/web/src/features/app/AppController.tsx` +- `apps/web/src/features/app/WorkbenchRuntimeCoordinator.tsx` +- `apps/web/src/components/TopBar/TopBar.tsx` +- `apps/web/src/components/Settings/Settings.tsx` +- `apps/web/src/features/settings/SettingsScreen.tsx` +- `apps/web/src/features/workspace/WorkspaceScreen.tsx` +- `apps/web/src/features/workspace/session-actions.ts` +- `apps/web/src/services/http/agent.service.ts` +- `apps/web/src/services/http/session.service.ts` +- `apps/web/src/services/http/workspace.service.ts` +- `apps/web/src/shared/utils/workspace.ts` +- `apps/web/src/components/icons.tsx` +- `apps/web/src/styles/app.css` +- `tests/app-settings.test.ts` +- `tests/session-actions.test.ts` +- `tests/e2e/e2e.spec.ts` + +### Delete + +- `apps/web/src/features/app/workbench-settings-sync.ts` — obsolete once app settings stop mutating live workspace tabs. +- `tests/workbench-settings-sync.test.ts` — obsolete with the file above. + +## Task 1: Add Server-Side App Settings Storage And RPC + +**Files:** +- Create: `apps/server/src/services/app_settings.rs` +- Modify: `apps/server/src/models.rs` +- Modify: `apps/server/src/infra/db.rs` +- Modify: `apps/server/src/command/http.rs` +- Modify: `apps/server/src/main.rs` +- Test: `apps/server/src/command/http.rs` + +- [ ] **Step 1: Write the failing server RPC test** + +```rust +#[test] +fn app_settings_rpc_round_trips_defaults_and_updates() { + let app = test_app(); + let authorized = authorized_request(); + + let initial = dispatch_rpc(&app, "app_settings_get", json!({}), &authorized) + .expect("default settings should load"); + let initial: AppSettingsPayload = serde_json::from_value(initial).unwrap(); + assert_eq!(initial.general.terminal_compatibility_mode, "standard"); + assert_eq!(initial.claude.global.executable, "claude"); + + let saved = dispatch_rpc( + &app, + "app_settings_update", + json!({ + "settings": { + "general": { + "locale": "zh", + "terminal_compatibility_mode": "compatibility", + "completion_notifications": { + "enabled": true, + "only_when_background": false + }, + "idle_policy": { + "enabled": true, + "idle_minutes": 12, + "max_active": 4, + "pressure": true + } + }, + "claude": { + "global": { + "executable": "claude-nightly", + "startup_args": ["--dangerously-skip-permissions"], + "env": { + "ANTHROPIC_BASE_URL": "https://anthropic.example" + }, + "settings_json": { + "model": "sonnet" + }, + "global_config_json": { + "showTurnDuration": true + } + }, + "overrides": { + "native": null, + "wsl": null + } + } + } + }), + &authorized, + ) + .expect("settings update should succeed"); + + let saved: AppSettingsPayload = serde_json::from_value(saved).unwrap(); + assert_eq!(saved.general.locale, "zh"); + assert_eq!(saved.claude.global.executable, "claude-nightly"); + assert_eq!( + saved.claude.global.env.get("ANTHROPIC_BASE_URL").map(String::as_str), + Some("https://anthropic.example") + ); +} +``` + +- [ ] **Step 2: Run the new test and confirm it fails** + +Run: + +```bash +cargo test --manifest-path apps/server/Cargo.toml command::http::tests::app_settings_rpc_round_trips_defaults_and_updates -- --exact +``` + +Expected: + +```text +error[E0412]: cannot find type `AppSettingsPayload` in this scope +``` + +- [ ] **Step 3: Implement settings models, DB storage, and RPC dispatch** + +```rust +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct AppSettingsPayload { + pub general: GeneralSettingsPayload, + pub claude: ClaudeSettingsPayload, +} + +pub(crate) fn load_or_default_app_settings(state: State<'_, AppState>) -> Result { + with_db(state, |conn| { + ensure_app_settings_row(conn)?; + let raw: String = conn.query_row( + "SELECT payload FROM app_settings WHERE id = 1", + [], + |row| row.get(0), + ).map_err(|e| e.to_string())?; + parse_json(&raw) + }) +} + +pub(crate) fn save_app_settings( + state: State<'_, AppState>, + settings: &AppSettingsPayload, +) -> Result { + with_db(state, |conn| { + conn.execute( + "INSERT INTO app_settings (id, payload, updated_at) + VALUES (1, ?1, ?2) + ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at", + params![json_string(settings)?, now_ts()], + ).map_err(|e| e.to_string())?; + Ok(settings.clone()) + }) +} +``` + +```rust +#[derive(Deserialize)] +struct AppSettingsUpdateRequest { + settings: AppSettingsPayload, +} + +match command { + "app_settings_get" => serde_json::to_value(app_settings_get(app.state()).map_err(rpc_bad_request)?) + .map_err(|e| rpc_bad_request(e.to_string())), + "app_settings_update" => { + let req: AppSettingsUpdateRequest = parse_payload(payload).map_err(rpc_bad_request)?; + serde_json::to_value(app_settings_update(req.settings, app.state()).map_err(rpc_bad_request)?) + .map_err(|e| rpc_bad_request(e.to_string())) + } + _ => { /* existing cases */ } +} +``` + +- [ ] **Step 4: Run the server RPC test and the existing server suite slice** + +Run: + +```bash +cargo test --manifest-path apps/server/Cargo.toml command::http::tests::app_settings_rpc_round_trips_defaults_and_updates -- --exact +cargo test --manifest-path apps/server/Cargo.toml command::http::tests::dispatches_workspace_runtime_attach_command -- --exact +``` + +Expected: + +```text +test command::http::tests::app_settings_rpc_round_trips_defaults_and_updates ... ok +test command::http::tests::dispatches_workspace_runtime_attach_command ... ok +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/models.rs apps/server/src/infra/db.rs apps/server/src/services/app_settings.rs apps/server/src/command/http.rs apps/server/src/main.rs +git commit -m "feat: persist app settings on the server" +``` + +## Task 2: Resolve Claude Launch Config On The Backend + +**Files:** +- Modify: `apps/server/src/services/app_settings.rs` +- Modify: `apps/server/src/services/claude.rs` +- Modify: `apps/server/src/services/agent.rs` +- Modify: `apps/server/src/command/http.rs` +- Test: `apps/server/src/services/claude.rs` +- Test: `apps/server/src/command/http.rs` + +- [ ] **Step 1: Write failing Claude profile resolution tests** + +```rust +#[test] +fn resolve_claude_runtime_profile_prefers_enabled_target_override() { + let settings = AppSettingsPayload { + general: GeneralSettingsPayload { + locale: "en".into(), + terminal_compatibility_mode: "standard".into(), + completion_notifications: CompletionNotificationSettings { + enabled: true, + only_when_background: true, + }, + idle_policy: default_idle_policy(), + }, + claude: ClaudeSettingsPayload { + global: ClaudeRuntimeProfile { + executable: "claude".into(), + startup_args: vec!["--verbose".into()], + env: BTreeMap::new(), + settings_json: json!({ "model": "sonnet" }), + global_config_json: json!({}), + }, + overrides: ClaudeTargetOverrides { + native: Some(TargetClaudeOverride { + enabled: true, + profile: ClaudeRuntimeProfile { + executable: "claude-native".into(), + startup_args: vec!["--dangerously-skip-permissions".into()], + env: BTreeMap::new(), + settings_json: json!({ "model": "opus" }), + global_config_json: json!({}), + }, + }), + wsl: None, + }, + }, + }; + + let resolved = resolve_claude_runtime_profile(&settings, &ExecTarget::Native); + assert_eq!(resolved.executable, "claude-native"); + assert_eq!(resolved.startup_args, vec!["--dangerously-skip-permissions"]); + assert_eq!(resolved.settings_json["model"], "opus"); +} + +#[test] +fn resolve_claude_runtime_profile_keeps_global_when_override_is_disabled() { + let settings = AppSettingsPayload { + general: GeneralSettingsPayload { + locale: "en".into(), + terminal_compatibility_mode: "standard".into(), + completion_notifications: CompletionNotificationSettings { + enabled: true, + only_when_background: true, + }, + idle_policy: default_idle_policy(), + }, + claude: ClaudeSettingsPayload { + global: ClaudeRuntimeProfile { + executable: "claude".into(), + startup_args: vec![], + env: BTreeMap::new(), + settings_json: json!({}), + global_config_json: json!({}), + }, + overrides: ClaudeTargetOverrides { + native: None, + wsl: None, + }, + }, + }; + let resolved = resolve_claude_runtime_profile( + &settings, + &ExecTarget::Wsl { distro: Some("Ubuntu".into()) }, + ); + assert_eq!(resolved.executable, "claude"); +} +``` + +- [ ] **Step 2: Run the new Claude tests and confirm they fail** + +Run: + +```bash +cargo test --manifest-path apps/server/Cargo.toml services::claude::tests::resolve_claude_runtime_profile_prefers_enabled_target_override -- --exact +``` + +Expected: + +```text +error[E0425]: cannot find function `resolve_claude_runtime_profile` in this scope +``` + +- [ ] **Step 3: Implement backend Claude profile resolution and stop taking command from the client** + +```rust +fn merge_json_objects(base: &Value, override_: &Value) -> Value { + match (base, override_) { + (Value::Object(base_map), Value::Object(override_map)) => { + let mut merged = base_map.clone(); + for (key, value) in override_map { + let next = merged.get(key).map(|existing| merge_json_objects(existing, value)).unwrap_or_else(|| value.clone()); + merged.insert(key.clone(), next); + } + Value::Object(merged) + } + (_, Value::Null) => base.clone(), + _ => override_.clone(), + } +} + +fn merge_claude_runtime_profile( + base: &ClaudeRuntimeProfile, + override_: &ClaudeRuntimeProfile, +) -> ClaudeRuntimeProfile { + ClaudeRuntimeProfile { + executable: if override_.executable.trim().is_empty() { + base.executable.clone() + } else { + override_.executable.clone() + }, + startup_args: if override_.startup_args.is_empty() { + base.startup_args.clone() + } else { + override_.startup_args.clone() + }, + env: base + .env + .iter() + .chain(override_.env.iter()) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + settings_json: merge_json_objects(&base.settings_json, &override_.settings_json), + global_config_json: merge_json_objects(&base.global_config_json, &override_.global_config_json), + } +} + +pub(crate) fn resolve_claude_runtime_profile( + settings: &AppSettingsPayload, + target: &ExecTarget, +) -> ClaudeRuntimeProfile { + match target { + ExecTarget::Native => settings + .claude + .overrides + .native + .as_ref() + .filter(|override_| override_.enabled) + .map(|override_| merge_claude_runtime_profile(&settings.claude.global, &override_.profile)) + .unwrap_or_else(|| settings.claude.global.clone()), + ExecTarget::Wsl { .. } => settings + .claude + .overrides + .wsl + .as_ref() + .filter(|override_| override_.enabled) + .map(|override_| merge_claude_runtime_profile(&settings.claude.global, &override_.profile)) + .unwrap_or_else(|| settings.claude.global.clone()), + } +} +``` + +```rust +pub struct AgentStartParams { + pub workspace_id: String, + pub session_id: String, + pub provider: String, + pub cols: Option, + pub rows: Option, +} + +let settings = load_or_default_app_settings(state)?; +let launch = resolve_claude_runtime_profile(&settings, &target); +let command = launch.executable.clone(); +let args = launch.startup_args.clone(); +``` + +```rust +#[derive(Deserialize)] +struct AgentStartRequest { + #[serde(flatten)] + controller: WorkspaceControllerMutationRequest, + session_id: String, + provider: String, + cols: Option, + rows: Option, +} +``` + +- [ ] **Step 4: Run the Claude tests plus the settings RPC regression** + +Run: + +```bash +cargo test --manifest-path apps/server/Cargo.toml services::claude::tests::resolve_claude_runtime_profile_prefers_enabled_target_override -- --exact +cargo test --manifest-path apps/server/Cargo.toml services::claude::tests::resolve_claude_runtime_profile_keeps_global_when_override_is_disabled -- --exact +cargo test --manifest-path apps/server/Cargo.toml command::http::tests::app_settings_rpc_round_trips_defaults_and_updates -- --exact +``` + +Expected: + +```text +test services::claude::tests::resolve_claude_runtime_profile_prefers_enabled_target_override ... ok +test services::claude::tests::resolve_claude_runtime_profile_keeps_global_when_override_is_disabled ... ok +test command::http::tests::app_settings_rpc_round_trips_defaults_and_updates ... ok +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/services/app_settings.rs apps/server/src/services/claude.rs apps/server/src/services/agent.rs apps/server/src/command/http.rs +git commit -m "feat: resolve claude launch settings on the backend" +``` + +## Task 3: Add Session History, Restore, Delete, And Workspace Archive Semantics + +**Files:** +- Modify: `apps/server/src/models.rs` +- Modify: `apps/server/src/infra/db.rs` +- Modify: `apps/server/src/services/workspace.rs` +- Modify: `apps/server/src/command/http.rs` +- Modify: `apps/server/src/main.rs` +- Test: `apps/server/src/services/workspace.rs` +- Test: `apps/server/src/command/http.rs` + +- [ ] **Step 1: Write failing workspace history lifecycle tests** + +```rust +#[test] +fn archive_session_keeps_suspended_status_after_runtime_stop() { + let app = test_app(); + let workspace_id = launch_test_workspace(&app, "/tmp/ws-history-archive-test"); + let created = create_session(workspace_id.clone(), SessionMode::Branch, app.state()).unwrap(); + set_session_status(app.state(), &workspace_id, created.id, SessionStatus::Running).unwrap(); + + let _entry = archive_session(workspace_id.clone(), created.id, app.state()).unwrap(); + let snapshot = workspace_snapshot(workspace_id.clone(), app.state()).unwrap(); + let archived = snapshot.archive.iter().find(|entry| entry.session_id == created.id).unwrap(); + let status = archived.snapshot["status"].as_str().unwrap(); + assert_eq!(status, "suspended"); +} + +#[test] +fn restore_and_delete_session_round_trip_history_records() { + let app = test_app(); + let workspace_id = launch_test_workspace(&app, "/tmp/ws-history-restore-test"); + let created = create_session(workspace_id.clone(), SessionMode::Branch, app.state()).unwrap(); + archive_session(workspace_id.clone(), created.id, app.state()).unwrap(); + + let history_before = list_session_history(app.state()).unwrap(); + assert!(history_before.iter().any(|record| record.session_id == created.id && record.archived)); + + let restored = restore_session(workspace_id.clone(), created.id, app.state()).unwrap(); + assert_eq!(restored.id, created.id); + + delete_session(workspace_id.clone(), created.id, app.state()).unwrap(); + let history_after = list_session_history(app.state()).unwrap(); + assert!(!history_after.iter().any(|record| record.session_id == created.id)); +} + +#[test] +fn close_workspace_archives_all_sessions_but_keeps_workspace_history_visible() { + let app = test_app(); + let workspace_id = launch_test_workspace(&app, "/tmp/ws-history-close-test"); + let one = create_session(workspace_id.clone(), SessionMode::Branch, app.state()).unwrap(); + let two = create_session(workspace_id.clone(), SessionMode::Branch, app.state()).unwrap(); + + close_workspace_scoped(workspace_id.clone(), None, None, app.state()).unwrap(); + + let history = list_session_history(app.state()).unwrap(); + let records: Vec<_> = history.into_iter().filter(|record| record.workspace_id == workspace_id).collect(); + assert_eq!(records.len(), 2); + assert!(records.iter().all(|record| record.archived)); + assert!(records.iter().any(|record| record.session_id == one.id)); + assert!(records.iter().any(|record| record.session_id == two.id)); +} +``` + +- [ ] **Step 2: Run the failing workspace history tests** + +Run: + +```bash +cargo test --manifest-path apps/server/Cargo.toml services::workspace::tests::archive_session_keeps_suspended_status_after_runtime_stop -- --exact +``` + +Expected: + +```text +thread 'services::workspace::tests::archive_session_keeps_suspended_status_after_runtime_stop' panicked +assertion `left == right` failed +left: "interrupted" +right: "suspended" +``` + +- [ ] **Step 3: Implement history DTOs, restore/delete RPCs, and archive-on-close semantics** + +```rust +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct SessionHistoryRecord { + pub workspace_id: String, + pub workspace_title: String, + pub workspace_path: String, + pub session_id: u64, + pub title: String, + pub status: SessionStatus, + pub archived: bool, + pub mounted: bool, + pub recoverable: bool, + pub last_active_at: i64, + pub archived_at: Option, + pub claude_session_id: Option, +} + +pub(crate) fn restore_workspace_session( + state: State<'_, AppState>, + workspace_id: &str, + session_id: u64, +) -> Result { + with_db(state, |conn| { + let row = load_session_row(conn, workspace_id, session_id)?; + let mut session = session_from_payload(&row.payload)?; + conn.execute( + "UPDATE workspace_sessions SET archived_at = NULL, status = ?3, last_active_at = ?4 WHERE workspace_id = ?1 AND id = ?2", + params![workspace_id, session_id as i64, status_label(&SessionStatus::Idle), now_ts()], + ).map_err(|e| e.to_string())?; + session.status = SessionStatus::Idle; + session.last_active_at = now_ts(); + Ok(session) + }) +} + +pub(crate) fn delete_workspace_session( + state: State<'_, AppState>, + workspace_id: &str, + session_id: u64, +) -> Result<(), String> { + with_db(state, |conn| { + conn.execute( + "DELETE FROM workspace_sessions WHERE workspace_id = ?1 AND id = ?2", + params![workspace_id, session_id as i64], + ).map_err(|e| e.to_string())?; + conn.execute( + "DELETE FROM agent_lifecycle_history WHERE workspace_id = ?1 AND session_id = ?2", + params![workspace_id, session_id as i64], + ).map_err(|e| e.to_string())?; + Ok(()) + }) +} +``` + +```rust +fn stop_agent_runtime_without_status_mutation( + workspace_id: &str, + session_id: u64, + state: State<'_, AppState>, +) -> Result<(), String> { + let key = format!("{workspace_id}:{session_id}"); + let mut sessions = state.agent_sessions.lock().map_err(|e| e.to_string())?; + if let Some(runtime) = sessions.remove(&key) { + let _ = runtime.stdin.send("\u{3}".into()); + } + Ok(()) +} + +pub(crate) fn archive_session( + workspace_id: String, + session_id: u64, + state: State<'_, AppState>, +) -> Result { + let entry = archive_workspace_session(state, &workspace_id, session_id)?; + let _ = stop_agent_runtime_without_status_mutation(&workspace_id, session_id, state); + Ok(entry) +} + +pub(crate) fn close_workspace_scoped( + workspace_id: String, + device_id: Option<&str>, + client_id: Option<&str>, + state: State<'_, AppState>, +) -> Result { + archive_workspace_sessions(state, &workspace_id)?; + let ui_state = close_workspace_ui(state, &workspace_id, device_id, client_id)?; + release_workspace_controller(&workspace_id, state)?; + close_workspace_terminals(&workspace_id, state); + stop_workspace_watch(state, &workspace_id); + Ok(ui_state) +} +``` + +- [ ] **Step 4: Run the targeted history tests and the HTTP dispatch regression** + +Run: + +```bash +cargo test --manifest-path apps/server/Cargo.toml services::workspace::tests::archive_session_keeps_suspended_status_after_runtime_stop -- --exact +cargo test --manifest-path apps/server/Cargo.toml services::workspace::tests::restore_and_delete_session_round_trip_history_records -- --exact +cargo test --manifest-path apps/server/Cargo.toml services::workspace::tests::close_workspace_archives_all_sessions_but_keeps_workspace_history_visible -- --exact +``` + +Expected: + +```text +test services::workspace::tests::archive_session_keeps_suspended_status_after_runtime_stop ... ok +test services::workspace::tests::restore_and_delete_session_round_trip_history_records ... ok +test services::workspace::tests::close_workspace_archives_all_sessions_but_keeps_workspace_history_visible ... ok +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/models.rs apps/server/src/infra/db.rs apps/server/src/services/workspace.rs apps/server/src/command/http.rs apps/server/src/main.rs +git commit -m "feat: add session history restore and delete flows" +``` + +## Task 4: Move Frontend App Settings To Backend And Add Claude Settings Helpers + +**Files:** +- Create: `apps/web/src/services/http/settings.service.ts` +- Create: `apps/web/src/shared/app/claude-settings.ts` +- Modify: `apps/web/src/types/app.ts` +- Modify: `apps/web/src/shared/app/settings.ts` +- Modify: `apps/web/src/features/app/AppController.tsx` +- Modify: `apps/web/src/features/app/WorkbenchRuntimeCoordinator.tsx` +- Modify: `apps/web/src/services/http/agent.service.ts` +- Modify: `apps/web/src/features/workspace/WorkspaceScreen.tsx` +- Delete: `apps/web/src/features/app/workbench-settings-sync.ts` +- Delete: `tests/workbench-settings-sync.test.ts` +- Test: `tests/app-settings.test.ts` +- Test: `tests/claude-settings.test.ts` + +- [ ] **Step 1: Write failing frontend settings helper tests** + +```ts +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + defaultAppSettings, + mergeLegacySettingsIntoAppSettings, + resolveClaudeRuntimeProfile, +} from '../apps/web/src/shared/app/claude-settings.ts'; + +test('mergeLegacySettingsIntoAppSettings migrates launch command into claude global executable', () => { + const merged = mergeLegacySettingsIntoAppSettings(defaultAppSettings(), { + agentCommand: 'claude-nightly --verbose', + completionNotifications: { enabled: true, onlyWhenBackground: true }, + }); + + assert.equal(merged.claude.global.executable, 'claude-nightly'); + assert.deepEqual(merged.claude.global.startupArgs, ['--verbose']); +}); + +test('resolveClaudeRuntimeProfile only uses target override when enabled', () => { + const settings = defaultAppSettings(); + settings.claude.overrides.native = { + enabled: true, + profile: { + ...settings.claude.global, + executable: 'claude-native', + startupArgs: ['--dangerously-skip-permissions'], + }, + }; + + const native = resolveClaudeRuntimeProfile(settings, { type: 'native' }); + const wsl = resolveClaudeRuntimeProfile(settings, { type: 'wsl', distro: 'Ubuntu' }); + + assert.equal(native.executable, 'claude-native'); + assert.equal(wsl.executable, 'claude'); +}); +``` + +- [ ] **Step 2: Run the helper tests and confirm they fail** + +Run: + +```bash +node --test tests/claude-settings.test.ts +``` + +Expected: + +```text +Error [ERR_MODULE_NOT_FOUND]: Cannot find module '../apps/web/src/shared/app/claude-settings.ts' +``` + +- [ ] **Step 3: Implement backend-backed settings bootstrap, legacy migration, and frontend Claude helpers** + +```ts +export type ClaudeRuntimeProfile = { + executable: string; + startupArgs: string[]; + env: Record; + settingsJson: Record; + globalConfigJson: Record; +}; + +export const resolveClaudeRuntimeProfile = ( + settings: AppSettings, + target: ExecTarget, +): ClaudeRuntimeProfile => { + if (target.type === 'native' && settings.claude.overrides.native?.enabled) { + return mergeClaudeRuntimeProfile(settings.claude.global, settings.claude.overrides.native.profile); + } + if (target.type === 'wsl' && settings.claude.overrides.wsl?.enabled) { + return mergeClaudeRuntimeProfile(settings.claude.global, settings.claude.overrides.wsl.profile); + } + return settings.claude.global; +}; + +export const mergeLegacySettingsIntoAppSettings = ( + base: AppSettings, + legacy: { + agentCommand?: string; + completionNotifications?: { enabled?: boolean; onlyWhenBackground?: boolean }; + terminalCompatibilityMode?: 'standard' | 'compatibility'; + }, +): AppSettings => { + const [executable = base.claude.global.executable, ...startupArgs] = (legacy.agentCommand ?? '').trim().split(/\s+/).filter(Boolean); + return { + ...base, + general: { + ...base.general, + terminalCompatibilityMode: legacy.terminalCompatibilityMode ?? base.general.terminalCompatibilityMode, + completionNotifications: { + enabled: legacy.completionNotifications?.enabled ?? base.general.completionNotifications.enabled, + onlyWhenBackground: legacy.completionNotifications?.onlyWhenBackground ?? base.general.completionNotifications.onlyWhenBackground, + }, + }, + claude: { + ...base.claude, + global: { + ...base.claude.global, + executable, + startupArgs: startupArgs.length > 0 ? startupArgs : base.claude.global.startupArgs, + }, + }, + }; +}; +``` + +```ts +export const getAppSettings = () => + invokeRpc('app_settings_get', {}); + +export const updateAppSettings = (settings: AppSettings) => + invokeRpc('app_settings_update', { settings }); +``` + +```tsx +useEffect(() => { + let cancelled = false; + + getAppSettings() + .then((serverSettings) => { + if (cancelled) return; + setAppSettings(serverSettings); + setLocale(serverSettings.general.locale === 'zh' ? 'zh' : 'en'); + }) + .catch(async () => { + const legacy = readStoredAppSettings(); + if (!legacy) return; + const migrated = mergeLegacySettingsIntoAppSettings(defaultAppSettings(), legacy); + const saved = await updateAppSettings(migrated); + if (cancelled) return; + setAppSettings(saved); + }); + + return () => { + cancelled = true; + }; +}, []); +``` + +```ts +export const startAgent = (args: { + workspaceId: string; + controller: WorkspaceControllerState; + sessionId: string; + provider: 'claude'; + cols?: TerminalGridSize['cols']; + rows?: TerminalGridSize['rows']; +}) => invokeRpc('agent_start', createWorkspaceControllerRpcPayload(args.workspaceId, args.controller, { + sessionId: args.sessionId, + provider: args.provider, + cols: args.cols, + rows: args.rows, +})); +``` + +- [ ] **Step 4: Run the frontend settings tests and the production build** + +Run: + +```bash +node --test tests/app-settings.test.ts tests/claude-settings.test.ts +pnpm build:web +``` + +Expected: + +```text +# pass +✓ built in +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/services/http/settings.service.ts apps/web/src/shared/app/claude-settings.ts apps/web/src/types/app.ts apps/web/src/shared/app/settings.ts apps/web/src/features/app/AppController.tsx apps/web/src/features/app/WorkbenchRuntimeCoordinator.tsx apps/web/src/services/http/agent.service.ts apps/web/src/features/workspace/WorkspaceScreen.tsx tests/app-settings.test.ts tests/claude-settings.test.ts +git rm apps/web/src/features/app/workbench-settings-sync.ts tests/workbench-settings-sync.test.ts +git commit -m "feat: hydrate app settings from backend claude config" +``` + +## Task 5: Build History Grouping Logic And The Global Drawer Shell + +**Files:** +- Create: `apps/web/src/features/workspace/session-history.ts` +- Create: `apps/web/src/components/HistoryDrawer/HistoryDrawer.tsx` +- Create: `apps/web/src/components/HistoryDrawer/index.ts` +- Modify: `apps/web/src/types/app.ts` +- Modify: `apps/web/src/components/TopBar/TopBar.tsx` +- Modify: `apps/web/src/features/workspace/WorkspaceScreen.tsx` +- Modify: `apps/web/src/services/http/workspace.service.ts` +- Modify: `apps/web/src/components/icons.tsx` +- Modify: `apps/web/src/styles/app.css` +- Test: `tests/session-history.test.ts` + +- [ ] **Step 1: Write failing history grouping tests** + +```ts +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + groupSessionHistory, + selectHistoryPrimaryAction, +} from '../apps/web/src/features/workspace/session-history.ts'; + +test('groupSessionHistory sorts records by workspace then recent activity, hiding empty groups', () => { + const groups = groupSessionHistory([ + { + workspaceId: 'ws-a', + workspaceTitle: 'Alpha', + workspacePath: '/tmp/a', + sessionId: '1', + title: 'Live session', + status: 'running', + archived: false, + mounted: true, + recoverable: false, + lastActiveAt: 30, + archivedAt: null, + claudeSessionId: 'claude-1', + }, + { + workspaceId: 'ws-a', + workspaceTitle: 'Alpha', + workspacePath: '/tmp/a', + sessionId: '2', + title: 'Archived session', + status: 'suspended', + archived: true, + mounted: false, + recoverable: true, + lastActiveAt: 20, + archivedAt: 25, + claudeSessionId: 'claude-2', + }, + ]); + + assert.equal(groups.length, 1); + assert.deepEqual(groups[0].records.map((record) => record.sessionId), ['1', '2']); +}); + +test('selectHistoryPrimaryAction returns focus for mounted records and restore for archived ones', () => { + assert.equal( + selectHistoryPrimaryAction({ archived: false, mounted: true, recoverable: false } as any), + 'focus', + ); + assert.equal( + selectHistoryPrimaryAction({ archived: true, mounted: false, recoverable: true } as any), + 'restore', + ); +}); +``` + +- [ ] **Step 2: Run the history tests and confirm they fail** + +Run: + +```bash +node --test tests/session-history.test.ts +``` + +Expected: + +```text +Error [ERR_MODULE_NOT_FOUND]: Cannot find module '../apps/web/src/features/workspace/session-history.ts' +``` + +- [ ] **Step 3: Implement the history grouping helpers, drawer component, and topbar entry point** + +```ts +export const groupSessionHistory = (records: SessionHistoryRecord[]): SessionHistoryGroup[] => { + const byWorkspace = new Map(); + + for (const record of records) { + const existing = byWorkspace.get(record.workspaceId) ?? { + workspaceId: record.workspaceId, + workspaceTitle: record.workspaceTitle, + workspacePath: record.workspacePath, + records: [], + }; + existing.records.push(record); + byWorkspace.set(record.workspaceId, existing); + } + + return [...byWorkspace.values()] + .map((group) => ({ + ...group, + records: group.records.sort((left, right) => right.lastActiveAt - left.lastActiveAt), + })) + .filter((group) => group.records.length > 0) + .sort((left, right) => right.records[0].lastActiveAt - left.records[0].lastActiveAt); +}; + +export const selectHistoryPrimaryAction = (record: Pick) => { + if (record.mounted && !record.archived) return 'focus'; + if (record.recoverable) return 'restore'; + return 'noop'; +}; +``` + +```tsx + +``` + +```tsx + setHistoryOpen(false)} + onSelectRecord={handleHistoryRecordSelect} + onDeleteRecord={handleHistoryRecordDelete} + t={t} +/> +``` + +- [ ] **Step 4: Run the history helper tests and the web build** + +Run: + +```bash +node --test tests/session-history.test.ts +pnpm build:web +``` + +Expected: + +```text +# pass +✓ built in +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/features/workspace/session-history.ts apps/web/src/components/HistoryDrawer/HistoryDrawer.tsx apps/web/src/components/HistoryDrawer/index.ts apps/web/src/types/app.ts apps/web/src/components/TopBar/TopBar.tsx apps/web/src/features/workspace/WorkspaceScreen.tsx apps/web/src/services/http/workspace.service.ts apps/web/src/components/icons.tsx apps/web/src/styles/app.css tests/session-history.test.ts +git commit -m "feat: add global session history drawer shell" +``` + +## Task 6: Wire Restore/Delete Actions And The Draft-Pane Restore Chooser + +**Files:** +- Create: `apps/web/src/features/workspace/session-restore-chooser.ts` +- Modify: `apps/web/src/features/workspace/session-actions.ts` +- Modify: `apps/web/src/features/workspace/WorkspaceScreen.tsx` +- Modify: `apps/web/src/services/http/session.service.ts` +- Modify: `apps/web/src/services/http/workspace.service.ts` +- Modify: `apps/web/src/shared/utils/workspace.ts` +- Modify: `apps/web/src/state/workbench-core.ts` +- Test: `tests/session-actions.test.ts` +- Test: `tests/session-restore-chooser.test.ts` + +- [ ] **Step 1: Write failing restore/delete chooser tests** + +```ts +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { listRestoreCandidatesForWorkspace } from '../apps/web/src/features/workspace/session-restore-chooser.ts'; + +test('listRestoreCandidatesForWorkspace excludes mounted and cross-workspace sessions', () => { + const candidates = listRestoreCandidatesForWorkspace({ + workspaceId: 'ws-1', + mountedSessionIds: new Set(['session-live']), + records: [ + { + workspaceId: 'ws-1', + sessionId: 'session-archived', + title: 'Archived', + archived: true, + mounted: false, + recoverable: true, + }, + { + workspaceId: 'ws-1', + sessionId: 'session-live', + title: 'Live', + archived: false, + mounted: true, + recoverable: false, + }, + { + workspaceId: 'ws-2', + sessionId: 'session-other', + title: 'Other workspace', + archived: true, + mounted: false, + recoverable: true, + }, + ] as any, + }); + + assert.deepEqual(candidates.map((record) => record.sessionId), ['session-archived']); +}); +``` + +```ts +test('archiveSessionForTab keeps an empty workspace alive by inserting a draft pane after delete', async () => { + const state = createState(); + state.tabs[0].sessions = [state.tabs[0].sessions[0]]; + state.tabs[0].activeSessionId = 'session-active'; + + const actions = createWorkspaceSessionActions(/* existing helpers */); + await actions.deleteSessionFromHistory('ws-1', 'session-active'); + + assert.equal(stateRef.current.tabs[0].sessions.length, 1); + assert.equal(stateRef.current.tabs[0].sessions[0].isDraft, true); +}); +``` + +- [ ] **Step 2: Run the chooser/action tests and confirm they fail** + +Run: + +```bash +node --test tests/session-restore-chooser.test.ts tests/session-actions.test.ts +``` + +Expected: + +```text +Error [ERR_MODULE_NOT_FOUND]: Cannot find module '../apps/web/src/features/workspace/session-restore-chooser.ts' +``` + +- [ ] **Step 3: Implement restore/delete RPC clients, chooser filtering, and session action handlers** + +```ts +export const restoreSession = ( + workspaceId: string, + sessionId: number, + controller: WorkspaceControllerState, +) => invokeRpc( + 'restore_session', + createWorkspaceControllerRpcPayload(workspaceId, controller, { sessionId }), +); + +export const deleteSession = ( + workspaceId: string, + sessionId: number, + controller: WorkspaceControllerState, +) => invokeRpc( + 'delete_session', + createWorkspaceControllerRpcPayload(workspaceId, controller, { sessionId }), +); +``` + +```ts +export const listRestoreCandidatesForWorkspace = ({ + workspaceId, + mountedSessionIds, + records, +}: { + workspaceId: string; + mountedSessionIds: Set; + records: SessionHistoryRecord[]; +}) => records.filter((record) => + record.workspaceId === workspaceId + && record.recoverable + && !mountedSessionIds.has(record.sessionId) +); +``` + +```ts +const restoreSessionIntoPane = async (tabId: string, paneId: string, sessionId: string) => { + const numericId = parseNumericId(sessionId); + if (numericId === null) return; + const restored = await withServiceFallback( + () => restoreSessionRequest(tabId, numericId, controllerForTab(tabId)!), + null, + ); + if (!restored) return; + + updateTab(tabId, (tab) => ({ + ...tab, + sessions: [createSessionFromBackend(restored, locale), ...tab.sessions.filter((session) => session.id !== sessionId)], + paneLayout: replacePaneNode(tab.paneLayout, paneId, (leaf) => ({ ...leaf, sessionId })), + activePaneId: paneId, + activeSessionId: sessionId, + })); +}; +``` + +- [ ] **Step 4: Run the node tests and the production build** + +Run: + +```bash +node --test tests/session-restore-chooser.test.ts tests/session-actions.test.ts tests/session-history.test.ts +pnpm build:web +``` + +Expected: + +```text +# pass +✓ built in +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/features/workspace/session-restore-chooser.ts apps/web/src/features/workspace/session-actions.ts apps/web/src/features/workspace/WorkspaceScreen.tsx apps/web/src/services/http/session.service.ts apps/web/src/services/http/workspace.service.ts apps/web/src/shared/utils/workspace.ts apps/web/src/state/workbench-core.ts tests/session-actions.test.ts tests/session-restore-chooser.test.ts +git commit -m "feat: restore and delete sessions from history" +``` + +## Task 7: Build The Claude Settings Panel And Remove The Old Launch Command UI + +**Files:** +- Create: `apps/web/src/components/Settings/ClaudeSettingsPanel.tsx` +- Modify: `apps/web/src/components/Settings/Settings.tsx` +- Modify: `apps/web/src/features/settings/SettingsScreen.tsx` +- Modify: `apps/web/src/types/app.ts` +- Modify: `apps/web/src/features/workspace/WorkspaceScreen.tsx` +- Modify: `apps/web/src/styles/app.css` +- Test: `tests/claude-settings.test.ts` + +- [ ] **Step 1: Extend the Claude settings test with advanced JSON preservation and target override behavior** + +```ts +test('updating structured Claude fields preserves advanced JSON content', () => { + const settings = defaultAppSettings(); + settings.claude.global.globalConfigJson = { showTurnDuration: true }; + + const next = patchClaudeStructuredSettings(settings, { + scope: 'global', + executable: 'claude-enterprise', + startupArgs: ['--verbose'], + }); + + assert.equal(next.claude.global.executable, 'claude-enterprise'); + assert.deepEqual(next.claude.global.startupArgs, ['--verbose']); + assert.deepEqual(next.claude.global.globalConfigJson, { showTurnDuration: true }); +}); + +test('disabling a target override falls back to inherited global config', () => { + const settings = defaultAppSettings(); + settings.claude.overrides.native = { + enabled: false, + profile: { ...settings.claude.global, executable: 'claude-native', startupArgs: [] }, + }; + + const resolved = resolveClaudeRuntimeProfile(settings, { type: 'native' }); + assert.equal(resolved.executable, 'claude'); +}); +``` + +- [ ] **Step 2: Run the Claude settings tests and confirm they fail on missing UI-state helpers** + +Run: + +```bash +node --test tests/claude-settings.test.ts +``` + +Expected: + +```text +not ok 1 - updating structured Claude fields preserves advanced JSON content +``` + +- [ ] **Step 3: Implement the Claude panel, remove the launch-command field, and reuse runtime validation per target** + +```tsx +export const ClaudeSettingsPanel = ({ + settings, + activeScope, + runtimeValidation, + onScopeChange, + onStructuredChange, + onAdvancedJsonChange, +}: ClaudeSettingsPanelProps) => { + const profile = + activeScope === 'global' + ? settings.claude.global + : activeScope === 'native' + ? settings.claude.overrides.native?.profile ?? settings.claude.global + : settings.claude.overrides.wsl?.profile ?? settings.claude.global; + + return ( +
+
+
+ Claude +

Claude Runtime

+
+
+ {runtimeValidation.text} +
+
+ +
+ {(['global', 'native', 'wsl'] as const).map((scope) => ( + + ))} +
+ + + +