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/README.en.md b/README.en.md index 12a8ca0..1dd4c45 100644 --- a/README.en.md +++ b/README.en.md @@ -2,251 +2,243 @@ [中文](README.md) | [English](README.en.md) -Coder Studio is a local-first developer workbench that currently runs as a local server with a web UI, bringing repositories, Claude-based coding agents, code browsing, Git review, and embedded terminals into one surface. +Coder Studio is a local-first AI coding workbench built around the `Claude` workflow. It brings `Claude`, repositories, code editing, Git review, terminals, and session history into one interface. It is not positioned as a generic chat product. It is a workbench designed for real repositories and real `Claude Code` usage. -## What This Project Is +Think of it as a workspace for the actual development loop: -This project is not currently positioned as a generic multi-provider AI platform. It is a local workbench centered around real Git repositories, exposed by a local server runtime. +- connect a local folder or remote Git repository +- run multiple Claude sessions in parallel inside one workspace +- inspect code, edit files, and commit changes without leaving the app +- archive and restore past sessions when you want to continue earlier work +- run against `Native` or `WSL` targets -Its core job is to reduce context switching across the full workflow: +## Who It Is For -- attach a local or remote repository -- start and split parallel agent tasks -- inspect agent output while reading files and diffs -- run Git actions and shell commands without leaving the workspace +Coder Studio is a good fit if you: -## Current Feature Set +- already use `Claude Code` on real repositories +- want agents, code, Git, and terminals on one screen +- often split one task into several parallel sessions +- prefer a local-first, self-hosted, controllable workbench -- Workspace onboarding with `Remote Git` and `Local Folder` -- Execution targets: `Native`, plus `WSL` when available -- Parallel agent work via split panes -- Draft task input before agent startup -- First submitted input becomes the session title -- PTY-based terminal interaction after launch -- Code panel with file tree, file search, Monaco preview/edit, and save -- Git panel with Stage / Unstage / Discard / Commit -- Embedded multi-terminal panel -- Quick actions palette with `Cmd/Ctrl + K` -- Settings for Launch Command, Idle Policy, and language -- Bilingual UI: Chinese / English -- Public mode auth with one passphrase, session cookie, IP blocking, and a single `root.path` access root +## Claude-Focused Capabilities -## Preview +This is the part worth emphasizing most. -The screenshots below use a purpose-built demo workspace with mock data so the core flow is easier to read at a glance. +### 1. Treat Claude sessions as real working units -### Workspace Overview +- split one workspace into multiple Claude sessions +- each session keeps its own context and terminal interaction flow +- useful when implementation, verification, follow-up notes, and review need to move in parallel -![Coder Studio workspace overview](docs/assets/readme/workspace-overview.png) +### 2. Archive, restore, and continue past Claude work -- Parallel agent panes stay visible while you inspect code and shell output -- The right-hand code panel gives you file search plus Monaco-based preview and editing -- The bottom terminal keeps Git and one-off commands in the same workspace +- closing a session or workspace archives it instead of making it disappear +- history is grouped by workspace so repository context stays readable +- restore archived sessions and continue previous work +- permanently delete a history record when you do not want to keep it anymore -### Parallel Agent Work +### 3. Manage how Claude starts from one Settings surface -![Coder Studio parallel agent panes](docs/assets/readme/multi-agent.png) +- configure Claude startup behavior in Settings instead of hand-writing one long launch command +- common CLI flags are exposed directly +- preview the full effective launch command +- keep separate Claude profiles for `Native` and `WSL` -- Split one workspace into multiple focused agent lanes -- Keep separate streams for implementation, verification, and follow-up tasks -- Reduce context switching when several subtasks need to move together +### 4. Edit common Claude config fields directly -### Code And Review +- expose common `settings.json` fields +- expose common `config.json` fields +- manage API key, auth token, base URL, and extra environment variables for auth and gateway setups +- if you already have local Claude config files, the Settings UI tries to surface common values for you -![Coder Studio code and source control review](docs/assets/readme/git-review.png) +### 5. Keep Claude, code, and Git in one loop -- Review code while the Source Control panel stays open on the same screen -- Draft commit messages without leaving the workbench -- Move quickly between agent output, file inspection, and Git review +- inspect Claude output and jump straight into files or diffs +- make edits and commit without leaving the same workspace +- avoid bouncing between chat, editor, terminal, and Git tools -## 3-Minute Quick Start +## What You Can Do With It -If you want the fastest path to a working local setup, do this: +### 1. Work on real repositories through workspaces -1. Prepare the runtime: install `Node.js`, `pnpm`, `Rust`, and `Git`. If you want to start real agents, also make sure `claude` is executable. -2. Install dependencies: run `pnpm install` at the repo root. -3. Start the app: run `pnpm dev:stack`, then open `http://127.0.0.1:5174`. -4. First entry flow: if public mode is enabled, enter the passphrase first. After auth and before workspace selection, the app now runs an environment check for `Claude Code` and `Git`. -5. Pick a workspace: choose `Local Folder` or `Remote Git`, then choose `Native` or `WSL`. -6. Start working: enter the first task in the agent pane, press Enter, then open the code, Git, and terminal panels as needed. +- `Local Folder` and `Remote Git` +- `Native` and `WSL` execution targets +- each workspace keeps its own code, sessions, and terminal context -If you are using the published npm CLI, you can also start it like this: +### 2. Read and edit code in the same surface -```bash -coder-studio start -coder-studio open -``` +- file tree +- file search +- Monaco preview and editing +- save support -## Prerequisites +### 3. Review and commit Git changes directly -Before running locally, prepare: +- inspect diffs +- `Stage / Unstage / Discard` +- write commit messages and commit in place -- `Node.js` -- `pnpm` -- `Rust` toolchain -- platform-specific `Tauri 2` system dependencies -- `Git` +### 4. Keep terminals inside the workflow -To actually start agents, you also need: +- multi-terminal support +- run `git status`, scripts, and one-off commands +- avoid bouncing between external terminals and the workbench -- an executable launch command, defaulting to `claude` -- if you use `WSL`, the command must also be available in the target environment +### 5. Expose it in a controlled public mode -## Install +- one-passphrase login +- `HttpOnly` session cookie +- IP-based lockout on repeated failures +- single-root access restrictions via `root.path` -```bash -pnpm install -``` +## Preview -## npm CLI Install +The screenshots below use a demo workspace and mock data to make the core workflow easier to scan. -Once published, install it directly with: +### Workspace Overview -```bash -npm install -g @spencer-kit/coder-studio -``` +![Coder Studio workspace overview](docs/assets/readme/workspace-overview.png) -Available commands: +- agent panes on the left +- code panel on the right +- built-in terminal at the bottom -```bash -coder-studio start -coder-studio stop -coder-studio restart -coder-studio status -coder-studio logs -f -coder-studio open -coder-studio doctor -coder-studio config show -coder-studio config validate -coder-studio config root set /srv/coder-studio/workspaces -coder-studio config password set --stdin -coder-studio auth status -coder-studio auth ip list -coder-studio help start -coder-studio help completion -eval "$(coder-studio completion bash)" -coder-studio completion install bash -coder-studio completion uninstall bash -``` +### Parallel Sessions -For the detailed command reference, see `docs/development/cli.en.md`. +![Coder Studio parallel agent panes](docs/assets/readme/multi-agent.png) -## Source / Template / Artifact Layers +- split one task into multiple sessions +- keep each session in its own context +- useful for implementation, verification, and follow-up work moving together -The repository is organized into three layers: +### Code And Review -- source - - `apps/web`: frontend source - - `apps/server`: Rust / Tauri server source - - `packages/cli`: npm CLI package source and publish metadata - - `packages/cli/src`: CLI TypeScript source -- templates - - `templates/npm/platform-packages/*`: per-platform npm package templates -- build outputs - - `.build/web/dist`: frontend build output - - `.build/server/target`: Rust build output - - `.build/cli`: compiled CLI output - - `.build/stage/npm/*`: pre-publish staging packages - - `.artifacts/`: tarballs, manifests, and checksums +![Coder Studio code and source control review](docs/assets/readme/git-review.png) + +- inspect files, diffs, and commit flow in one place +- better suited to the full loop of tasking, reviewing, editing, and committing -This keeps maintainable source, publish templates, and generated artifacts out of the same directories. +## 3-Minute Quick Start -## Run +### Option 1: Start with the CLI -### Option 1: Combined development mode (recommended) +If you use the published npm CLI, the fastest path is: ```bash -pnpm dev:stack +npm install -g @spencer-kit/coder-studio +coder-studio start +coder-studio open ``` -This starts the frontend dev server, the local server runtime, and the linked development flow used by local E2E. +Then: -### Option 2: Split frontend/server debugging +1. choose `Local Folder` or `Remote Git` +2. choose `Native` or `WSL` +3. enter your first task in the agent pane and press Enter +4. split panes, open history, or restore archived sessions as needed +5. open Settings if you want to confirm Claude startup flags or auth settings -Terminal 1: +### Option 2: Run from source ```bash -pnpm dev +pnpm install +pnpm dev:stack ``` -Terminal 2: +Then open: -```bash -pnpm dev:server +```text +http://127.0.0.1:5174 ``` -Current development ports: - -- frontend: `http://127.0.0.1:5174` -- local server transport service: `http://127.0.0.1:41033` +## Prerequisites -The frontend dev server proxies `/api`, `/ws`, and `/health` to the local server. +### If you use the published CLI -## Build +- `Node.js` +- `Git` +- an executable `claude` command -Frontend build: +If you use `WSL`, `claude` also needs to be available in the target environment. -```bash -pnpm build -``` +### If you run from source -Server runtime build: +- `Node.js` +- `pnpm` +- `Rust` toolchain +- platform-specific `Tauri 2` system dependencies +- `Git` +- an executable `claude` command -```bash -pnpm build:server -``` +## Common Commands -CLI build: +### CLI ```bash -pnpm build:cli +coder-studio start +coder-studio stop +coder-studio restart +coder-studio status +coder-studio logs -f +coder-studio open +coder-studio doctor +coder-studio config show +coder-studio config validate +coder-studio config root set /srv/coder-studio/workspaces +coder-studio config password set --stdin +coder-studio auth status +coder-studio auth ip list ``` -Full runtime build: +For the full command reference, see `docs/development/cli.en.md`. + +### Local Development ```bash +pnpm dev:stack +pnpm dev +pnpm dev:server pnpm build:web pnpm build:server pnpm build:cli ``` +## Useful Shortcuts + +- `Cmd/Ctrl + K`: open quick actions +- `Cmd/Ctrl + N`: create a new workspace +- `Cmd/Ctrl + Shift + [`: previous workspace +- `Cmd/Ctrl + Shift + ]`: next workspace +- `Cmd/Ctrl + S`: save current file +- `F`: toggle Focus Mode +- `Alt/⌘ + D`: split the current agent pane vertically +- `Shift + Alt/⌘ + D`: split the current agent pane horizontally + ## Public Deployment -For a publicly reachable deployment, the current build now includes: +For publicly reachable deployments, the current build supports: -- single-passphrase login +- one-passphrase login - `HttpOnly` session cookie - a `24` hour IP block after `3` failed passphrase attempts within `10` minutes -- server-side single-root restrictions via `root.path` -- public access over HTTP or HTTPS, with HTTPS reverse proxy still recommended +- single-root server restrictions via `root.path` +- HTTP or HTTPS access, with HTTPS reverse proxy still recommended -Deployment details are documented here: +Deployment details: - Chinese deployment guide: `docs/deployment/README.md` - English deployment guide: `docs/deployment/README.en.md` -## Getting Started +## Developer Entry Points -1. Launch the app. -2. In the onboarding overlay, choose `Remote Git` or `Local Folder`. -3. Pick the execution target: `Native` or `WSL`. -4. Once the workspace opens, enter your first task in the draft input shown in the agent pane. -5. Press Enter. The app materializes a session, starts the agent, and uses the first input as the session title. -6. Split the current pane if you want to run another task in parallel. -7. Open the code panel to inspect files, edit content, or review diffs. -8. Open the Git panel for Stage / Unstage / Discard / Commit. -9. Open the terminal panel to run shell commands. +If you are here to modify the product or build on top of it: -## Useful Shortcuts - -- `Cmd/Ctrl + K`: open quick actions -- `Cmd/Ctrl + N`: create a new workspace -- `Cmd/Ctrl + Shift + [`: previous workspace -- `Cmd/Ctrl + Shift + ]`: next workspace -- `Cmd/Ctrl + S`: save current file -- `F`: toggle Focus Mode -- `Alt/⌘ + D`: split the current agent pane vertically -- `Shift + Alt/⌘ + D`: split the current agent pane horizontally +- frontend: `apps/web` +- server: `apps/server` +- CLI: `packages/cli` +- Chinese development docs: `docs/development/README.md` +- English development docs: `docs/development/README.en.md` ## Current Boundaries @@ -254,29 +246,7 @@ The following should not be described as fully shipped user-facing functionality - multiple agent providers - light theme -- full queue / dispatch board UI -- full archive center UI +- full visual queueing UI +- a more complete archive / dispatch center - explicit worktree management entry points - fully closed-loop auto-suspend behavior - -## Documentation - -Product docs: - -- Changelog: `CHANGELOG.md` -- Chinese PRD: `docs/PRD.md` -- English PRD: `docs/PRD.en.md` - -Development docs: - -- Chinese index: `docs/development/README.md` -- Chinese deployment guide: `docs/deployment/README.md` -- Chinese CLI manual: `docs/development/cli.md` -- Chinese npm packaging guide: `docs/development/npm-release.md` -- English index: `docs/development/README.en.md` -- English deployment guide: `docs/deployment/README.en.md` -- English CLI manual: `docs/development/cli.en.md` -- English npm packaging guide: `docs/development/npm-release.en.md` -- Architecture: `docs/development/architecture.en.md` -- Frontend state: `docs/development/frontend-state.en.md` -- Tauri commands: `docs/development/tauri-commands.en.md` diff --git a/README.md b/README.md index 3a16bb5..98fa710 100644 --- a/README.md +++ b/README.md @@ -2,134 +2,182 @@ [English](README.en.md) | [中文](README.md) -Coder Studio 是一个本地优先的开发工作台,当前以本地 server + Web 界面形态运行,用于把仓库接入、Claude Agent 运行、代码浏览与编辑、Git 操作、内置终端放到同一个界面中。 +Coder Studio 是一个以 `Claude` 工作流为中心的本地优先 AI 编程工作台,把 `Claude`、仓库、代码编辑、Git 审阅、终端和会话历史放在同一个界面里。它不是一个通用聊天产品,而是一个围绕真实代码仓库和 `Claude Code` 使用场景设计的开发工作台。 + +你可以把它理解成一个更贴近实际开发流程的工作区: + +- 连接本地目录或远程 Git 仓库 +- 在一个 workspace 里并行运行多个 Claude session +- 一边看代码、一边改文件、一边做 Git 提交 +- 归档和恢复历史 session,继续之前的工作上下文 +- 在 `Native` 或 `WSL` 目标环境里运行 ## 社区支持 -感谢 LinuxDo 各位佬的支持!欢迎大家加入 [LinuxDo](https://linux.do/),各种技术交流、AI 前沿资讯、AI 经验分享,尽在 LinuxDo! +感谢 LinuxDo 各位佬的支持。欢迎加入 [LinuxDo](https://linux.do/),这里有技术交流、AI 前沿资讯和实战经验分享。 + +## 适合谁 + +如果你符合下面这些场景,Coder Studio 会比较合适: + +- 你已经在真实仓库里使用 `Claude Code` +- 你希望把 Agent、代码、Git 和终端放进一个界面 +- 你经常把一个任务拆成多个并行 session 推进 +- 你需要本地优先、可自托管、可控的开发工作台 + +## Claude 相关能力 + +这是当前 README 最想强调的部分。 + +### 1. 把 Claude session 当成真正的工作单元 + +- 一个 workspace 里可以并行拆分多个 Claude session +- 每个 session 都有自己的上下文和终端交互过程 +- 适合把实现、验证、补充说明、代码审阅拆开跑 + +### 2. 归档、恢复和继续之前的 Claude 会话 + +- 关闭 session / workspace 时会进入归档历史,而不是直接消失 +- 历史记录按 workspace 分组展示,方便回看同一仓库下的上下文 +- 你可以恢复归档 session,继续之前的工作 +- 也可以永久删除某条历史记录,不再保留任何会话痕迹 + +### 3. 集中管理 Claude 启动方式 -## 项目是什么 +- 在设置页统一管理 Claude 启动参数,而不是手写一整串启动命令 +- 支持常用 CLI flags +- 支持完整启动命令预览,方便确认最终会怎么启动 +- 可以为 `Native` 和 `WSL` 分别维护不同的 Claude 配置 -这个项目当前的产品形态不是“通用 AI 平台”,而是一个围绕真实 Git 仓库工作的本地工作台;默认通过本地 server 暴露界面与 API。 +### 4. 直接编辑 Claude 常用配置项 -它解决的核心问题是: +- 支持常见 `settings.json` 配置项 +- 支持常见 `config.json` 配置项 +- 支持 API Key、Auth Token、Base URL、额外环境变量等鉴权与网关配置 +- 如果你本机已有 Claude 配置文件,设置页会尽量把常见值带出来 -- 用一个工作区连接本地仓库或远程仓库 -- 在同一界面里启动和并行拆分 Agent 任务 -- 一边看 Agent 输出,一边检查文件、Diff、Git 状态和终端命令 -- 用更少的上下文切换完成“提任务 → 跑 Agent → 看改动 → 提交代码”的闭环 +### 5. 把 Claude、代码和 Git 放在同一个操作闭环里 -## 当前核心功能 +- 看 Claude 输出时,可以立刻切到文件和 diff +- 改完代码后,可以直接做 Git 提交 +- 不需要在聊天窗口、编辑器、终端和 Git 工具之间反复切换 -- 工作区接入:支持 `Remote Git` 和 `Local Folder` -- 执行目标:支持 `Native`,在环境允许时支持 `WSL` -- Agent 会话:通过分屏 Pane 并行运行多个任务 -- 任务启动:Agent 未启动前显示草稿输入框,首条输入用于生成 session 名称 -- Agent 交互:启动后切换为 PTY 终端式交互 -- 代码面板:文件树、文件搜索、Monaco 预览/编辑、文件保存 -- Git 面板:查看改动、Stage/Unstage/Discard、Commit -- 终端面板:多终端创建、切换、关闭 -- 快速操作:`Cmd/Ctrl + K` 打开命令面板 -- 设置:Launch Command、Idle Policy、语言切换 -- 国际化:中文 / English -- Public Mode:单口令鉴权、会话 Cookie、IP 封禁、`root.path` 单根目录白名单 +## 你可以用它做什么 + +### 1. 用 workspace 管理真实仓库 + +- 支持 `Local Folder` 和 `Remote Git` +- 支持 `Native` 和 `WSL` 两类执行目标 +- 一个工作区就是一套独立的代码、会话和终端上下文 + +### 2. 在同一个界面里看代码和改代码 + +- 文件树浏览 +- 文件搜索 +- Monaco 代码预览和编辑 +- 保存当前文件 + +### 3. 直接做 Git 审阅和提交 + +- 查看改动 +- `Stage / Unstage / Discard` +- 填写提交信息并提交 + +### 4. 把终端也留在工作流里 + +- 支持多终端 +- 可以随时检查 `git status`、跑脚本、看命令输出 +- 不需要在外部终端和工作台之间来回切换 + +### 5. 以公网模式提供给受控用户访问 + +- 单口令登录 +- `HttpOnly` session cookie +- IP 级错误次数封禁 +- `root.path` 单根目录白名单 ## 界面预览 -以下截图使用的是一个专门准备的 demo 工作区和 mock 数据,目的是把真实工作流里的关键界面展示清楚。 +下面的截图使用的是 demo 工作区和 mock 数据,目的是快速展示核心工作流。 ### 工作台总览 ![Coder Studio 工作台总览](docs/assets/readme/workspace-overview.png) -- 左侧是并行 Agent Pane,可同时跑多个任务 -- 右侧是代码面板,支持文件搜索、Monaco 编辑与预览 -- 底部是内置终端,可以直接检查 `git status`、跑脚本、看命令输出 +- 左侧是并行 Agent Pane +- 右侧是代码面板 +- 底部是内置终端 -### 多 Agent 并行 +### 多 Session 并行 ![Coder Studio 多 Agent 并行](docs/assets/readme/multi-agent.png) -- 一个工作区内可以把任务拆成多个 Pane 并行推进 -- 每个 Pane 都保留独立的命令流和上下文 -- 很适合把“主任务 / 验证 / 文档整理”拆开跑 +- 一个任务可以拆成多个并行 session +- 每个 session 保留独立上下文 +- 适合把主任务、验证任务、补充任务分开推进 ### 代码与变更审阅 ![Coder Studio 代码与变更审阅](docs/assets/readme/git-review.png) -- 右侧 Source Control 面板可直接查看改动、填写提交信息 -- 代码区支持边看文件边做 Diff / Git 审阅 -- 适合在 Agent 输出、代码检查、提交动作之间快速切换 +- 在同一个界面里看文件、看 diff、做 Git 提交 +- 更适合完成“提任务 -> 看输出 -> 查代码 -> 提交代码”的闭环 -## 3 分钟入门 +## 3 分钟上手 -如果你想最快跑起来,按下面做: +### 方式 1:使用 CLI 启动 -1. 准备环境:安装 `Node.js`、`pnpm`、`Rust`、`Git`,如果你要真正启动 Agent,再准备一个可执行的 `claude` 命令。 -2. 安装依赖:在仓库根目录执行 `pnpm install`。 -3. 启动应用:执行 `pnpm dev:stack`,然后打开 `http://127.0.0.1:5174`。 -4. 首次进入:如果启用了公网模式,先输入访问口令;进入后,在工作区选择之前,应用会先做运行环境校验,检查 `Claude Code` 和 `Git` 是否可用。 -5. 选择工作区:在浮层里选择 `Local Folder` 或 `Remote Git`,再选择 `Native` 或 `WSL`。 -6. 开始工作:进入工作区后,在 Agent Pane 输入第一条任务并回车;然后根据需要打开代码面板、Git 面板和终端面板。 - -如果你使用已经发布的 npm CLI,也可以这样启动: +如果你使用已发布的 npm CLI,最快的方式是: ```bash +npm install -g @spencer-kit/coder-studio coder-studio start coder-studio open ``` -## 安装前提 - -在本地运行前,请先准备: - -- `Node.js` -- `pnpm` -- `Rust` toolchain -- `Tauri 2` 对应平台的系统依赖 -- `Git` - -如果你需要真正启动 Agent,还需要: +进入后: -- 一个可执行的 Agent 命令,默认是 `claude` -- 如果使用 `WSL`,目标环境中也需要能执行对应命令 +1. 选择 `Local Folder` 或 `Remote Git` +2. 选择 `Native` 或 `WSL` +3. 在 Agent Pane 输入第一条任务并回车 +4. 按需要分屏、查看历史、恢复旧 session +5. 打开设置页确认 Claude 的启动参数和鉴权配置 -## 安装 +### 方式 2:从源码运行 ```bash pnpm install +pnpm dev:stack +``` + +然后打开: + +```text +http://127.0.0.1:5174 ``` -## 目录分层 +## 运行前准备 -当前仓库按三层组织: +### 如果你使用已发布的 CLI -- 源码层 - - `apps/web`:前端源码 - - `apps/server`:Rust / Tauri 服务端源码 - - `packages/cli`:npm CLI 包源码与发布元数据 - - `packages/cli/src`:CLI TypeScript 源码 -- 模板层 - - `templates/npm/platform-packages/*`:各平台 npm 包模板 -- 产物层 - - `.build/web/dist`:前端构建产物 - - `.build/server/target`:Rust 构建产物 - - `.build/cli`:CLI 编译产物 - - `.build/stage/npm/*`:发布前 staging 包 - - `.artifacts/`:最终 tarball、manifest、checksum +- `Node.js` +- `Git` +- 一个可执行的 `claude` 命令 -这样做的目标是让“可维护源码”、“发布模板”和“构建产物”不再混在同一层目录里。 +如果你使用 `WSL`,目标环境里也需要能执行 `claude`。 -## npm CLI 安装 +### 如果你从源码运行 -发布后可以直接安装: +- `Node.js` +- `pnpm` +- `Rust` toolchain +- 平台对应的 `Tauri 2` 系统依赖 +- `Git` +- 一个可执行的 `claude` 命令 -```bash -npm install -g @spencer-kit/coder-studio -``` +## 常用命令 -安装后可用命令: +### CLI ```bash coder-studio start @@ -145,141 +193,64 @@ coder-studio config root set /srv/coder-studio/workspaces coder-studio config password set --stdin coder-studio auth status coder-studio auth ip list -coder-studio help start -coder-studio help completion -eval "$(coder-studio completion bash)" -coder-studio completion install bash -coder-studio completion uninstall bash ``` -详细命令说明见:`docs/development/cli.md` - -## 运行 +完整命令说明见 `docs/development/cli.md`。 -### 方式 1:联动开发模式(推荐) +### 本地开发 ```bash pnpm dev:stack -``` - -这会同时拉起前端开发服务器、本地 server 和开发态 E2E 所需的联动环境。 - -### 方式 2:前后端分离调试 - -终端 1: - -```bash pnpm dev -``` - -终端 2: - -```bash pnpm dev:server -``` - -当前开发端口: - -- 前端:`http://127.0.0.1:5174` -- 本地 server 传输服务:`http://127.0.0.1:41033` - -前端开发服务器会把 `/api`、`/ws`、`/health` 代理到本地 server。 - -## 构建 - -前端构建: - -```bash -pnpm build -``` - -构建 server runtime: - -```bash +pnpm build:web pnpm build:server -``` - -构建 CLI: - -```bash pnpm build:cli ``` -构建完整运行时: +## 常用快捷键 -```bash -pnpm build:web -pnpm build:server -pnpm build:cli -``` +- `Cmd/Ctrl + K`:打开快速操作面板 +- `Cmd/Ctrl + N`:新建工作区 +- `Cmd/Ctrl + Shift + [`:切换到上一个工作区 +- `Cmd/Ctrl + Shift + ]`:切换到下一个工作区 +- `Cmd/Ctrl + S`:保存当前文件 +- `F`:切换 Focus Mode +- `Alt/⌘ + D`:纵向分屏当前 Agent Pane +- `Shift + Alt/⌘ + D`:横向分屏当前 Agent Pane ## 公开部署 -如果要把这个项目部署到公网可访问设备上,当前版本已经提供: +如果你要把它部署到公网可访问设备上,当前版本已经支持: - 单口令登录 - `HttpOnly` session cookie - 同一 IP `10` 分钟内 `3` 次口令错误后封禁 `24` 小时 -- 基于 `root.path` 的服务端单根目录白名单 -- 支持通过 HTTP 或 HTTPS 对外访问,推荐在公网入口前使用 HTTPS 反向代理 +- 基于 `root.path` 的服务端单根目录限制 +- 通过 HTTP 或 HTTPS 提供访问,推荐前置 HTTPS 反向代理 -部署细节请看: +部署细节见: - 中文部署文档:`docs/deployment/README.md` - English Deployment Guide: `docs/deployment/README.en.md` -## 用户如何上手 +## 开发者入口 -1. 启动应用。 -2. 在启动浮层中选择 `Remote Git` 或 `Local Folder`。 -3. 选择运行目标:`Native` 或 `WSL`。 -4. 打开工作区后,在 Agent Pane 的输入框里输入第一条任务。 -5. 回车后,应用会创建 session、启动 Agent,并把首条输入作为 session 标题。 -6. 如需并行处理任务,使用 Pane 分屏按钮新增一个草稿任务区。 -7. 打开右侧代码面板查看文件、编辑内容或检查 Diff。 -8. 打开 Git 面板做 Stage / Unstage / Discard / Commit。 -9. 打开终端面板执行仓库命令。 +如果你是来改代码或做二次开发,入口如下: -## 常用快捷操作 - -- `Cmd/Ctrl + K`:打开快速操作面板 -- `Cmd/Ctrl + N`:新建工作区 -- `Cmd/Ctrl + Shift + [`:切换到上一个工作区 -- `Cmd/Ctrl + Shift + ]`:切换到下一个工作区 -- `Cmd/Ctrl + S`:保存当前文件 -- `F`:切换 Focus Mode -- `Alt/⌘ + D`:纵向分屏当前 Agent Pane -- `Shift + Alt/⌘ + D`:横向分屏当前 Agent Pane +- 前端:`apps/web` +- 服务端:`apps/server` +- CLI:`packages/cli` +- 开发文档:`docs/development/README.md` +- 英文开发文档:`docs/development/README.en.md` ## 当前边界 -以下内容不应视为当前版本已经完整交付: +下面这些不应被描述成当前已经完整交付的用户能力: - 多 Agent Provider 支持 - 浅色主题 -- 完整可见的任务队列 UI -- 完整可见的 Archive 中心 -- 明确的 Worktree 管理入口 -- 完整闭环的自动挂起能力 - -## 文档导航 - -用户文档: - -- 更新日志:`CHANGELOG.md` -- 中文 PRD:`docs/PRD.md` -- English PRD: `docs/PRD.en.md` - -开发文档: - -- 开发文档入口:`docs/development/README.md` -- 部署文档:`docs/deployment/README.md` -- CLI 命令手册:`docs/development/cli.md` -- npm 发布与 CLI:`docs/development/npm-release.md` -- Development Docs: `docs/development/README.en.md` -- Deployment Guide: `docs/deployment/README.en.md` -- CLI Manual: `docs/development/cli.en.md` -- npm Packaging and Release: `docs/development/npm-release.en.md` -- 架构说明:`docs/development/architecture.md` -- Frontend 状态:`docs/development/frontend-state.md` -- Tauri 命令清单:`docs/development/tauri-commands.md` +- 完整可视化任务队列 +- 更完整的 Archive Center / 调度中心 +- 显式的 worktree 管理入口 +- 完全闭环的自动挂起策略 diff --git a/apps/server/src/command/http.rs b/apps/server/src/command/http.rs index 2cfa94c..a72044c 100644 --- a/apps/server/src/command/http.rs +++ b/apps/server/src/command/http.rs @@ -70,6 +70,15 @@ struct ArchiveSessionRequest { session_id: u64, } +#[derive(Deserialize)] +struct SessionHistoryMutationRequest { + workspace_id: String, + session_id: u64, + device_id: Option, + client_id: Option, + fencing_token: Option, +} + #[derive(Deserialize)] struct IdlePolicyRequest { #[serde(flatten)] @@ -91,6 +100,11 @@ struct WorkbenchLayoutRequest { client_id: Option, } +#[derive(Deserialize)] +struct AppSettingsUpdateRequest { + settings: Value, +} + #[derive(Deserialize)] struct PathTargetRequest { path: String, @@ -228,12 +242,12 @@ struct TerminalCloseRequest { } #[derive(Deserialize)] +#[serde(deny_unknown_fields)] struct AgentStartRequest { #[serde(flatten)] controller: WorkspaceControllerMutationRequest, session_id: String, provider: String, - command: String, cols: Option, rows: Option, } @@ -423,6 +437,36 @@ fn require_workspace_controller_mutation( Ok(()) } +fn require_optional_workspace_history_mutation( + app: &AppHandle, + request: &SessionHistoryMutationRequest, + authorized: &AuthorizedRequest, +) -> Result<(), RpcError> { + require_workspace_access(app, &request.workspace_id, authorized)?; + match ( + request.device_id.as_deref(), + request.client_id.as_deref(), + request.fencing_token, + ) { + (Some(device_id), Some(client_id), Some(fencing_token)) => { + assert_workspace_controller_can_mutate( + &request.workspace_id, + device_id, + client_id, + fencing_token, + app, + app.state(), + ) + .map_err(rpc_forbidden)?; + Ok(()) + } + (None, None, None) => Ok(()), + _ => Err(rpc_bad_request( + "incomplete_workspace_controller".to_string(), + )), + } +} + fn require_workspace_path_controller_mutation( app: &AppHandle, controller: &WorkspaceControllerMutationRequest, @@ -512,6 +556,28 @@ fn filter_bootstrap_for_public_mode( } } +fn filter_session_history_for_public_mode( + app: &AppHandle, + records: Vec, + authorized: &AuthorizedRequest, +) -> Vec { + if !authorized.request.public_mode { + return records; + } + + records + .into_iter() + .filter(|record| { + workspace_access_context(app.state(), &record.workspace_id) + .and_then(|(path, target)| { + ensure_path_allowed(&path, &target, &authorized.allowed_roots) + .map_err(|e| e.to_string()) + }) + .is_ok() + }) + .collect() +} + fn dispatch_rpc( app: &AppHandle, command: &str, @@ -519,6 +585,18 @@ fn dispatch_rpc( authorized: &AuthorizedRequest, ) -> Result { 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 = + serde_json::from_value(payload).map_err(|e| rpc_bad_request(e.to_string()))?; + 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())) + } "launch_workspace" => { let req: LaunchWorkspaceRequest = parse_payload(payload).map_err(rpc_bad_request)?; if authorized.request.public_mode { @@ -568,6 +646,12 @@ fn dispatch_rpc( serde_json::to_value(filter_bootstrap_for_public_mode(bootstrap, authorized)) .map_err(|e| rpc_bad_request(e.to_string())) } + "list_session_history" => serde_json::to_value(filter_session_history_for_public_mode( + app, + list_session_history(app.state()).map_err(rpc_bad_request)?, + authorized, + )) + .map_err(|e| rpc_bad_request(e.to_string())), "workspace_snapshot" => { let req: WorkspaceIdRequest = parse_payload(payload).map_err(rpc_bad_request)?; require_workspace_access(app, &req.workspace_id, authorized)?; @@ -750,6 +834,24 @@ fn dispatch_rpc( ) .map_err(|e| rpc_bad_request(e.to_string())) } + "restore_session" => { + let req: SessionHistoryMutationRequest = + parse_payload(payload).map_err(rpc_bad_request)?; + require_optional_workspace_history_mutation(app, &req, authorized)?; + serde_json::to_value( + restore_session(req.workspace_id, req.session_id, app.state()) + .map_err(rpc_bad_request)?, + ) + .map_err(|e| rpc_bad_request(e.to_string())) + } + "delete_session" => { + let req: SessionHistoryMutationRequest = + parse_payload(payload).map_err(rpc_bad_request)?; + require_optional_workspace_history_mutation(app, &req, authorized)?; + delete_session(req.workspace_id, req.session_id, app.state()) + .map_err(rpc_bad_request)?; + Ok(Value::Null) + } "update_idle_policy" => { let req: IdlePolicyRequest = parse_payload(payload).map_err(rpc_bad_request)?; require_workspace_controller_mutation(app, &req.controller, authorized)?; @@ -1088,7 +1190,6 @@ fn dispatch_rpc( workspace_id: req.controller.workspace_id, session_id: req.session_id, provider: req.provider, - command: req.command, cols: req.cols, rows: req.rows, }, @@ -1475,8 +1576,13 @@ pub(crate) fn build_transport_router(app: &AppHandle) -> Router { } pub(crate) fn start_transport_server(app: &AppHandle) -> Result { + let dev_backend_port = std::env::var("CODER_STUDIO_DEV_BACKEND_PORT") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(DEV_BACKEND_PORT); let (bind_host, bind_port) = if cfg!(debug_assertions) { - ("127.0.0.1".to_string(), DEV_BACKEND_PORT) + ("127.0.0.1".to_string(), dev_backend_port) } else { transport_bind_config(app)? }; @@ -1503,6 +1609,7 @@ pub(crate) fn start_transport_server(app: &AppHandle) -> Result AppHandle { let (app, _shutdown_rx) = RuntimeHandle::new(); @@ -1540,6 +1647,63 @@ mod tests { result.snapshot.workspace.workspace_id } + fn create_temp_workspace_root(name: &str) -> String { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("coder-studio-{name}-{unique}")); + std::fs::create_dir_all(&root).unwrap(); + root.to_string_lossy().to_string() + } + + fn test_agent_launch_profile() -> (String, Vec) { + #[cfg(target_os = "windows")] + { + ( + "cmd".to_string(), + vec![ + "/D".to_string(), + "/S".to_string(), + "/C".to_string(), + "echo %TEST_MARKER%".to_string(), + ], + ) + } + #[cfg(not(target_os = "windows"))] + { + ( + "sh".to_string(), + vec!["-lc".to_string(), "printf %s \"$TEST_MARKER\"".to_string()], + ) + } + } + + fn test_agent_marker_profile(marker_file: &str) -> (String, Vec) { + #[cfg(target_os = "windows")] + { + ( + "cmd".to_string(), + vec![ + "/D".to_string(), + "/S".to_string(), + "/C".to_string(), + format!("echo %TEST_MARKER%> {marker_file}"), + ], + ) + } + #[cfg(not(target_os = "windows"))] + { + ( + "sh".to_string(), + vec![ + "-lc".to_string(), + format!("printf %s \"$TEST_MARKER\" > {marker_file}"), + ], + ) + } + } + #[test] fn dispatches_workspace_runtime_attach_command() { let app = test_app(); @@ -1938,4 +2102,475 @@ mod tests { assert_eq!(scoped_b.ui_state.layout.left_width, 320.0); assert!(!scoped_b.ui_state.layout.show_code_panel); } + + #[test] + fn session_history_rpc_lists_restores_and_deletes_records() { + let app = test_app(); + let authorized = authorized_request(); + let workspace_id = launch_test_workspace(&app, "/tmp/ws-history-rpc-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 = dispatch_rpc(&app, "list_session_history", json!({}), &authorized) + .expect("history rpc should load"); + let history: Vec = serde_json::from_value(history).unwrap(); + assert!(history + .iter() + .any(|record| record.workspace_id == workspace_id + && record.session_id == created.id + && record.archived)); + + let restored = dispatch_rpc( + &app, + "restore_session", + json!({ + "workspace_id": workspace_id.clone(), + "session_id": created.id, + }), + &authorized, + ) + .expect("restore rpc should succeed"); + let restored: SessionRestoreResult = serde_json::from_value(restored).unwrap(); + assert_eq!(restored.session.id, created.id); + assert!(!restored.already_active); + + dispatch_rpc( + &app, + "delete_session", + json!({ + "workspace_id": workspace_id.clone(), + "session_id": created.id, + }), + &authorized, + ) + .expect("delete rpc should succeed"); + + let history = dispatch_rpc(&app, "list_session_history", json!({}), &authorized) + .expect("history rpc should reload"); + let history: Vec = serde_json::from_value(history).unwrap(); + assert!(!history.iter().any(|record| record.session_id == created.id)); + } + + #[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") + ); + } + + #[test] + fn app_settings_update_merges_partial_payload_without_resetting_other_fields() { + let app = test_app(); + let authorized = authorized_request(); + let (executable, startup_args) = test_agent_launch_profile(); + + dispatch_rpc( + &app, + "app_settings_update", + json!({ + "settings": { + "general": { + "locale": "zh" + }, + "claude": { + "global": { + "executable": "claude-nightly", + "startup_args": [], + "env": { + "TEST_MARKER": "persisted-value" + }, + "settings_json": { + "model": "opus" + }, + "global_config_json": {} + } + } + } + }), + &authorized, + ) + .unwrap(); + + let updated = dispatch_rpc( + &app, + "app_settings_update", + json!({ + "settings": { + "claude": { + "global": { + "executable": executable, + "startup_args": startup_args + } + } + } + }), + &authorized, + ) + .expect("partial settings update should succeed"); + let updated: AppSettingsPayload = serde_json::from_value(updated).unwrap(); + + assert_eq!(updated.general.locale, "zh"); + assert_eq!( + updated + .claude + .global + .env + .get("TEST_MARKER") + .map(String::as_str), + Some("persisted-value") + ); + assert_eq!(updated.claude.global.settings_json["model"], "opus"); + } + + #[test] + fn app_settings_update_normalizes_camel_case_payloads_before_merge() { + let app = test_app(); + let authorized = authorized_request(); + + dispatch_rpc( + &app, + "app_settings_update", + json!({ + "settings": { + "general": { + "completion_notifications": { + "enabled": true, + "only_when_background": false + } + }, + "claude": { + "global": { + "startup_args": ["--existing"], + "settings_json": { + "model": "opus" + }, + "global_config_json": { + "showTurnDuration": true + } + } + } + } + }), + &authorized, + ) + .unwrap(); + + let updated = dispatch_rpc( + &app, + "app_settings_update", + json!({ + "settings": { + "general": { + "completionNotifications": { + "enabled": false + } + }, + "claude": { + "global": { + "startupArgs": ["--verbose"], + "settingsJson": { + "model": "opus", + "permissionMode": "acceptEdits" + }, + "globalConfigJson": { + "showTurnDuration": true, + "theme": "dark" + } + } + } + } + }), + &authorized, + ) + .expect("camelCase settings update should succeed"); + let updated: AppSettingsPayload = serde_json::from_value(updated).unwrap(); + + assert!(!updated.general.completion_notifications.enabled); + assert!( + !updated + .general + .completion_notifications + .only_when_background + ); + assert_eq!(updated.claude.global.startup_args, vec!["--verbose"]); + assert_eq!(updated.claude.global.settings_json["model"], "opus"); + assert_eq!( + updated.claude.global.settings_json["permissionMode"], + "acceptEdits" + ); + assert_eq!(updated.claude.global.global_config_json["theme"], "dark"); + assert_eq!( + updated.claude.global.global_config_json["showTurnDuration"], + true + ); + } + + #[test] + fn app_settings_update_replaces_object_fields_so_cleared_keys_stay_cleared() { + let app = test_app(); + let authorized = authorized_request(); + + dispatch_rpc( + &app, + "app_settings_update", + json!({ + "settings": { + "claude": { + "global": { + "env": { + "TEST_MARKER": "persisted-value", + "ANTHROPIC_BASE_URL": "https://anthropic.example" + }, + "settings_json": { + "model": "opus", + "permissionMode": "acceptEdits" + }, + "global_config_json": { + "showTurnDuration": true + } + } + } + } + }), + &authorized, + ) + .unwrap(); + + let updated = dispatch_rpc( + &app, + "app_settings_update", + json!({ + "settings": { + "claude": { + "global": { + "env": { + "ANTHROPIC_BASE_URL": "https://next.example" + }, + "settings_json": { + "permissionMode": "plan" + }, + "global_config_json": {} + } + } + } + }), + &authorized, + ) + .expect("object field replacement should succeed"); + let updated: AppSettingsPayload = serde_json::from_value(updated).unwrap(); + + assert_eq!( + updated + .claude + .global + .env + .get("ANTHROPIC_BASE_URL") + .map(String::as_str), + Some("https://next.example") + ); + assert_eq!(updated.claude.global.env.get("TEST_MARKER"), None); + assert_eq!( + updated.claude.global.settings_json.get("permissionMode"), + Some(&json!("plan")) + ); + assert_eq!(updated.claude.global.settings_json.get("model"), None); + assert_eq!( + updated + .claude + .global + .global_config_json + .as_object() + .map(|value| value.is_empty()), + Some(true) + ); + } + + #[test] + fn agent_start_uses_server_resolved_settings_from_storage() { + let app = test_app(); + let authorized = authorized_request(); + let root = create_temp_workspace_root("agent-start-settings"); + let workspace_id = launch_test_workspace(&app, &root); + let marker_path = PathBuf::from(&root).join(".agent-start-marker"); + *app.state().hook_endpoint.lock().unwrap() = Some("http://127.0.0.1:1/claude-hook".into()); + + dispatch_rpc( + &app, + "app_settings_update", + json!({ + "settings": { + "general": { + "locale": "zh" + }, + "claude": { + "global": { + "executable": "claude-nightly", + "startup_args": [], + "env": { + "TEST_MARKER": "server-resolved" + } + } + } + } + }), + &authorized, + ) + .unwrap(); + + let (executable, startup_args) = test_agent_marker_profile(".agent-start-marker"); + dispatch_rpc( + &app, + "app_settings_update", + json!({ + "settings": { + "claude": { + "global": { + "executable": executable, + "startup_args": startup_args + } + } + } + }), + &authorized, + ) + .unwrap(); + + let attach = dispatch_rpc( + &app, + "workspace_runtime_attach", + json!({ + "workspace_id": workspace_id, + "device_id": "device-a", + "client_id": "client-a", + }), + &authorized, + ) + .unwrap(); + let runtime: WorkspaceRuntimeSnapshot = serde_json::from_value(attach).unwrap(); + let session_id = load_workspace_snapshot(app.state(), &workspace_id) + .unwrap() + .sessions + .first() + .unwrap() + .id; + + let started = dispatch_rpc( + &app, + "agent_start", + json!({ + "workspace_id": workspace_id, + "device_id": "device-a", + "client_id": "client-a", + "fencing_token": runtime.controller.fencing_token, + "session_id": session_id.to_string(), + "provider": "claude", + "cols": 80, + "rows": 24, + }), + &authorized, + ) + .expect("agent_start should succeed with server-resolved settings"); + let started: AgentStartResult = serde_json::from_value(started).unwrap(); + + assert!(started.started); + let mut marker_value = String::new(); + for _ in 0..100 { + if let Ok(value) = std::fs::read_to_string(&marker_path) { + marker_value = value; + break; + } + std::thread::sleep(Duration::from_millis(25)); + } + assert!( + marker_value.contains("server-resolved"), + "expected marker file to contain server value, got: {marker_value:?}" + ); + } + + #[test] + fn agent_start_rejects_client_supplied_command() { + let app = test_app(); + let authorized = authorized_request(); + + let error = dispatch_rpc( + &app, + "agent_start", + json!({ + "workspace_id": "ws_test", + "device_id": "device-a", + "client_id": "client-a", + "fencing_token": 1, + "session_id": "1", + "provider": "claude", + "command": "claude" + }), + &authorized, + ) + .expect_err("agent_start should reject legacy command payloads"); + + assert_eq!(error.status, StatusCode::BAD_REQUEST); + } } diff --git a/apps/server/src/infra/db.rs b/apps/server/src/infra/db.rs index a10ab47..d192335 100644 --- a/apps/server/src/infra/db.rs +++ b/apps/server/src/infra/db.rs @@ -6,6 +6,7 @@ const SESSION_STREAM_LIMIT: usize = 200_000; const TERMINAL_STREAM_LIMIT: usize = 200_000; const AGENT_LIFECYCLE_HISTORY_LIMIT_PER_SESSION: i64 = 128; const APP_UI_STATE_ROW_ID: i64 = 1; +const APP_SETTINGS_ROW_ID: i64 = 1; #[derive(Clone, Serialize, Deserialize)] struct DeviceWorkbenchUiState { @@ -86,6 +87,10 @@ fn session_title(id: u64) -> String { format!("Session {}", id) } +fn archive_entry_id(session_id: u64, archived_at: i64) -> u64 { + ((archived_at as u64) << 32) ^ session_id +} + fn workspace_title_from_path(path: &str) -> String { PathBuf::from(path) .file_name() @@ -199,6 +204,24 @@ fn load_workspace_row_by_root( } } +fn load_all_workspace_rows(conn: &Connection) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT id, title, root_path, source_kind, source_value, git_url, target_json, idle_policy_json + FROM workspaces + ORDER BY last_opened_at DESC, created_at DESC, id DESC", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], parse_workspace_row) + .map_err(|e| e.to_string())?; + let mut items = Vec::new(); + for row in rows { + items.push(row.map_err(|e| e.to_string())?); + } + Ok(items) +} + fn session_from_payload(payload: &str) -> Result { parse_json(payload) } @@ -888,6 +911,55 @@ fn load_sessions_from_conn( Ok(items) } +fn load_all_session_rows_from_conn( + conn: &Connection, + workspace_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT workspace_id, archived_at, sort_order, payload + FROM workspace_sessions + WHERE workspace_id = ?1 + ORDER BY last_active_at DESC, id DESC", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![workspace_id], session_row_from_query) + .map_err(|e| e.to_string())?; + let mut items = Vec::new(); + for row in rows { + items.push(row.map_err(|e| e.to_string())?); + } + Ok(items) +} + +fn collect_pane_session_ids(value: &Value, ids: &mut HashSet) { + let Value::Object(map) = value else { + return; + }; + let Some(Value::String(kind)) = map.get("type") else { + return; + }; + + if kind == "leaf" { + if let Some(Value::String(session_id)) = + map.get("session_id").or_else(|| map.get("sessionId")) + { + ids.insert(session_id.clone()); + } + return; + } + + if kind == "split" { + if let Some(next) = map.get("first") { + collect_pane_session_ids(next, ids); + } + if let Some(next) = map.get("second") { + collect_pane_session_ids(next, ids); + } + } +} + fn session_rows_to_archive(rows: Vec) -> Result, String> { rows.into_iter() .map(|row| { @@ -898,7 +970,7 @@ fn session_rows_to_archive(rows: Vec) -> Result, S .map(|dt| dt.format("%H:%M").to_string()) .unwrap_or_else(now_label); Ok(ArchiveEntry { - id: row.archived_at.unwrap_or(now_ts()) as u64, + id: archive_entry_id(session.id, row.archived_at.unwrap_or(now_ts())), session_id: session.id, mode: session.mode.clone(), time, @@ -908,6 +980,58 @@ fn session_rows_to_archive(rows: Vec) -> Result, S .collect() } +fn session_row_to_history_record( + workspace: &WorkspaceRow, + mounted_session_ids: &HashSet, + row: SessionRow, +) -> Result { + let session = session_from_payload(&row.payload)?; + let archived = row.archived_at.is_some(); + let mounted = !archived + && (mounted_session_ids.is_empty() + || mounted_session_ids.contains(&session.id.to_string())); + Ok(SessionHistoryRecord { + workspace_id: workspace.id.clone(), + workspace_title: workspace.title.clone(), + workspace_path: workspace.root_path.clone(), + session_id: session.id, + title: session.title, + status: session.status.clone(), + archived, + mounted, + recoverable: archived || !mounted, + last_active_at: session.last_active_at, + archived_at: row.archived_at, + claude_session_id: session.claude_session_id, + }) +} + +fn build_history_from_conn(conn: &Connection) -> Result, String> { + let mut records = Vec::new(); + for workspace in load_all_workspace_rows(conn)? { + let mounted_session_ids = load_mounted_session_ids_from_conn(conn, &workspace.id); + for row in load_all_session_rows_from_conn(conn, &workspace.id)? { + records.push(session_row_to_history_record( + &workspace, + &mounted_session_ids, + row, + )?); + } + } + Ok(records) +} + +fn load_mounted_session_ids_from_conn(conn: &Connection, workspace_id: &str) -> HashSet { + load_view_state_from_conn(conn, workspace_id) + .ok() + .map(|view_state| { + let mut ids = HashSet::new(); + collect_pane_session_ids(&view_state.pane_layout, &mut ids); + ids + }) + .unwrap_or_default() +} + fn build_snapshot_from_conn( conn: &Connection, workspace_id: &str, @@ -1175,6 +1299,11 @@ pub(crate) fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { payload TEXT NOT NULL, updated_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS app_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + payload TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); CREATE TABLE IF NOT EXISTS app_device_ui_state ( device_id TEXT PRIMARY KEY, payload TEXT NOT NULL, @@ -1193,6 +1322,12 @@ pub(crate) fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { "INSERT OR IGNORE INTO app_ui_state (id, payload, updated_at) VALUES (?1, ?2, ?3)", params![APP_UI_STATE_ROW_ID, payload, now_ts()], )?; + let app_settings_payload = + serde_json::to_string(&AppSettingsPayload::default()).unwrap_or_else(|_| "{}".to_string()); + conn.execute( + "INSERT OR IGNORE INTO app_settings (id, payload, updated_at) VALUES (?1, ?2, ?3)", + params![APP_SETTINGS_ROW_ID, app_settings_payload, now_ts()], + )?; conn.execute( "UPDATE workspace_terminals SET recoverable = 0, updated_at = ?1", params![now_ts()], @@ -1474,15 +1609,19 @@ pub(crate) fn archive_workspace_session( ) -> Result { with_db(state, |conn| { let row = load_session_row(conn, workspace_id, session_id)?; - let session = session_from_payload(&row.payload)?; - let archived_at = now_ts(); - conn.execute( - "UPDATE workspace_sessions SET archived_at = ?3, status = ?4 WHERE workspace_id = ?1 AND id = ?2", - params![workspace_id, session_id as i64, archived_at, status_label(&SessionStatus::Suspended)], - ) - .map_err(|e| e.to_string())?; + let mut session = session_from_payload(&row.payload)?; + let archived_at = row.archived_at.unwrap_or_else(now_ts); + session.status = SessionStatus::Suspended; + session.last_active_at = now_ts(); + persist_session_row( + conn, + workspace_id, + &session, + Some(archived_at), + row.sort_order, + )?; Ok(ArchiveEntry { - id: archived_at as u64, + id: archive_entry_id(session.id, archived_at), session_id: session.id, mode: session.mode.clone(), time: now_label(), @@ -1491,6 +1630,109 @@ pub(crate) fn archive_workspace_session( }) } +pub(crate) fn archive_workspace_sessions( + state: State<'_, AppState>, + workspace_id: &str, +) -> Result, String> { + with_db(state, |conn| { + let mut entries = Vec::new(); + for row in load_sessions_from_conn(conn, workspace_id, false)? { + let mut session = session_from_payload(&row.payload)?; + let archived_at = now_ts(); + session.status = SessionStatus::Suspended; + session.last_active_at = archived_at; + persist_session_row( + conn, + workspace_id, + &session, + Some(archived_at), + row.sort_order, + )?; + entries.push(ArchiveEntry { + id: archive_entry_id(session.id, archived_at), + session_id: session.id, + mode: session.mode.clone(), + time: now_label(), + snapshot: serde_json::to_value(session).map_err(|e| e.to_string())?, + }); + } + Ok(entries) + }) +} + +pub(crate) fn load_session_history_records( + state: State<'_, AppState>, +) -> Result, String> { + with_db(state, build_history_from_conn) +} + +pub(crate) fn restore_workspace_session( + state: State<'_, AppState>, + workspace_id: &str, + session_id: u64, +) -> Result { + with_db(state, |conn| { + let workspace = load_workspace_row(conn, workspace_id)?; + let row = load_session_row(conn, workspace_id, session_id)?; + let mounted_session_ids = load_mounted_session_ids_from_conn(conn, workspace_id); + let mut session = session_from_payload(&row.payload)?; + let already_active = + row.archived_at.is_none() && mounted_session_ids.contains(&session.id.to_string()); + if already_active { + return Ok(SessionRestoreResult { + session, + already_active: true, + }); + } + + let active_count = load_sessions_from_conn(conn, workspace_id, false)? + .into_iter() + .filter_map(|candidate| session_from_payload(&candidate.payload).ok()) + .filter(|candidate| { + candidate.id != session.id + && !matches!( + candidate.status, + SessionStatus::Suspended | SessionStatus::Queued + ) + }) + .count() as u32; + let next_status = if active_count >= workspace.idle_policy.max_active { + SessionStatus::Queued + } else { + SessionStatus::Idle + }; + session.status = next_status; + session.last_active_at = now_ts(); + let sort_order = min_active_sort_order(conn, workspace_id)? - 1; + persist_session_row(conn, workspace_id, &session, None, sort_order)?; + Ok(SessionRestoreResult { + session, + already_active: false, + }) + }) +} + +pub(crate) fn delete_workspace_session( + state: State<'_, AppState>, + workspace_id: &str, + session_id: u64, +) -> Result<(), String> { + with_db(state, |conn| { + load_session_row(conn, workspace_id, session_id)?; + 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_events WHERE workspace_id = ?1 AND session_id = ?2", + params![workspace_id, session_id.to_string()], + ) + .map_err(|e| e.to_string())?; + Ok(()) + }) +} + pub(crate) fn update_workspace_idle_policy( state: State<'_, AppState>, workspace_id: &str, @@ -1714,6 +1956,7 @@ pub(crate) fn append_session_stream( }) } +#[cfg(test)] pub(crate) fn set_session_status( state: State<'_, AppState>, workspace_id: &str, @@ -1735,6 +1978,35 @@ pub(crate) fn set_session_status( }) } +pub(crate) fn set_session_status_if_not_archived( + state: State<'_, AppState>, + workspace_id: &str, + session_id: u64, + status: SessionStatus, +) -> Result { + with_db(state, |conn| { + let row = match load_session_row(conn, workspace_id, session_id) { + Ok(row) => row, + Err(error) if error == "session_not_found" => return Ok(false), + Err(error) => return Err(error), + }; + if row.archived_at.is_some() { + return Ok(false); + } + let mut session = session_from_payload(&row.payload)?; + session.status = status; + session.last_active_at = now_ts(); + persist_session_row( + conn, + workspace_id, + &session, + row.archived_at, + row.sort_order, + )?; + Ok(true) + }) +} + pub(crate) fn set_session_claude_id( state: State<'_, AppState>, workspace_id: &str, diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 1538d13..915312c 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -51,14 +51,18 @@ pub(crate) use auth::{ pub(crate) use command::http::start_transport_server; #[cfg(test)] pub(crate) use infra::db::launch_workspace_record; +#[cfg(test)] +pub(crate) use infra::db::set_session_status; pub(crate) use infra::db::{ activate_workspace_ui, append_agent_lifecycle_event, append_session_stream, - append_workspace_terminal_output, archive_workspace_session, close_workspace_ui, - create_workspace_session, delete_workspace_terminal, init_db, launch_workspace_record_scoped, + append_workspace_terminal_output, archive_workspace_session, archive_workspace_sessions, + close_workspace_ui, create_workspace_session, delete_workspace_session, + delete_workspace_terminal, init_db, launch_workspace_record_scoped, list_workspace_ids_for_workspace_client, load_agent_lifecycle_events, load_session, - load_workspace_controller_lease, mark_active_sessions_interrupted_on_boot, - mark_workspace_client_detached, patch_workspace_view_state, persist_workspace_terminal, - save_workspace_controller_lease, set_session_claude_id, set_session_status, + load_session_history_records, load_workspace_controller_lease, + mark_active_sessions_interrupted_on_boot, mark_workspace_client_detached, + patch_workspace_view_state, persist_workspace_terminal, restore_workspace_session, + save_workspace_controller_lease, set_session_claude_id, set_session_status_if_not_archived, set_workspace_terminal_recoverable, switch_workspace_session, update_workbench_layout as persist_workbench_layout, update_workspace_idle_policy, update_workspace_session, upsert_workspace_attachment, @@ -78,23 +82,33 @@ pub(crate) use infra::support::{ }; pub(crate) use infra::time::{default_idle_policy, now_label, now_ts, status_label}; pub(crate) use models::{ - AgentEvent, AgentLifecycleEvent, AgentLifecycleHistoryEntry, AgentStartResult, ArchiveEntry, - ClaudeSlashSkillEntry, CommandAvailability, ExecTarget, FileNode, FilePreview, FilesystemEntry, + AgentEvent, AgentLifecycleEvent, AgentLifecycleHistoryEntry, AgentStartResult, + AppSettingsPayload, ArchiveEntry, ClaudeRuntimeProfile, ClaudeSlashSkillEntry, + CommandAvailability, ExecTarget, FileNode, FilePreview, FilesystemEntry, FilesystemListResponse, FilesystemRoot, GitChangeEntry, GitFileDiffPayload, GitStatus, - IdlePolicy, SessionInfo, SessionMessage, SessionMessageRole, SessionMode, SessionPatch, - SessionStatus, TerminalEvent, TerminalInfo, TransportEvent, WorkbenchBootstrap, - WorkbenchLayout, WorkbenchUiState, WorkspaceControllerLease, WorkspaceLaunchResult, - WorkspaceRuntimeSnapshot, WorkspaceRuntimeStateEvent, WorkspaceSnapshot, WorkspaceSource, - WorkspaceSourceKind, WorkspaceSummary, WorkspaceTree, WorkspaceViewPatch, WorkspaceViewState, - WorktreeDetail, WorktreeInfo, + IdlePolicy, SessionHistoryRecord, SessionInfo, SessionMessage, SessionMessageRole, SessionMode, + SessionPatch, SessionRestoreResult, SessionStatus, TerminalEvent, TerminalInfo, TransportEvent, + WorkbenchBootstrap, WorkbenchLayout, WorkbenchUiState, WorkspaceControllerLease, + WorkspaceLaunchResult, WorkspaceRuntimeSnapshot, WorkspaceRuntimeStateEvent, WorkspaceSnapshot, + WorkspaceSource, WorkspaceSourceKind, WorkspaceSummary, WorkspaceTree, WorkspaceViewPatch, + WorkspaceViewState, WorktreeDetail, WorktreeInfo, +}; +#[cfg(test)] +pub(crate) use models::{ + ClaudeSettingsPayload, ClaudeTargetOverrides, CompletionNotificationSettings, + GeneralSettingsPayload, TargetClaudeOverride, }; pub(crate) use runtime::{AppHandle, State}; pub(crate) use services::agent::{ - agent_resize, agent_send, agent_start, agent_stop, stop_workspace_agents, + agent_resize, agent_send, agent_start, agent_stop, stop_agent_runtime_without_status_update, + stop_workspace_agents, +}; +pub(crate) use services::app_settings::{ + app_settings_get, app_settings_update, load_or_default_app_settings, }; pub(crate) use services::claude::{ current_app_bin_for_target, current_hook_endpoint, ensure_claude_hook_settings, - run_claude_hook_helper, start_claude_hook_receiver, + resolve_claude_runtime_profile, run_claude_hook_helper, start_claude_hook_receiver, }; pub(crate) use services::filesystem::{ file_preview, file_save, filesystem_list, filesystem_roots, workspace_tree, @@ -110,9 +124,10 @@ pub(crate) use services::terminal::{ }; pub(crate) use services::workspace::{ activate_workspace_scoped, archive_session, close_workspace_scoped, create_session, - launch_workspace_internal_scoped, launch_workspace_scoped, session_update, switch_session, - update_idle_policy, update_workbench_layout_scoped, workbench_bootstrap_scoped, - workspace_snapshot, workspace_view_update, worktree_inspect, + delete_session, launch_workspace_internal_scoped, launch_workspace_scoped, + list_session_history, restore_session, session_update, switch_session, update_idle_policy, + update_workbench_layout_scoped, workbench_bootstrap_scoped, workspace_snapshot, + workspace_view_update, worktree_inspect, }; pub(crate) use services::workspace_runtime::{ assert_workspace_controller_can_mutate, register_workspace_client_connection, diff --git a/apps/server/src/models.rs b/apps/server/src/models.rs index 6353a47..db0e593 100644 --- a/apps/server/src/models.rs +++ b/apps/server/src/models.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -27,14 +29,150 @@ pub enum SessionStatus { Interrupted, } -#[derive(Clone, Serialize, Deserialize, Debug)] +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct IdlePolicy { pub enabled: bool, + #[serde(alias = "idleMinutes")] pub idle_minutes: u32, + #[serde(alias = "maxActive")] pub max_active: u32, pub pressure: bool, } +fn default_settings_locale() -> String { + "en".to_string() +} + +fn default_terminal_compatibility_mode() -> String { + "standard".to_string() +} + +fn default_completion_notifications_only_when_background() -> bool { + true +} + +fn default_completion_notifications_enabled() -> bool { + true +} + +fn default_idle_policy_settings() -> IdlePolicy { + IdlePolicy { + enabled: true, + idle_minutes: 10, + max_active: 3, + pressure: true, + } +} + +fn default_claude_executable() -> String { + "claude".to_string() +} + +fn default_json_object() -> Value { + Value::Object(Default::default()) +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(default)] +pub struct CompletionNotificationSettings { + #[serde(default = "default_completion_notifications_enabled")] + pub enabled: bool, + #[serde(default = "default_completion_notifications_only_when_background")] + #[serde(alias = "onlyWhenBackground")] + pub only_when_background: bool, +} + +impl Default for CompletionNotificationSettings { + fn default() -> Self { + Self { + enabled: default_completion_notifications_enabled(), + only_when_background: default_completion_notifications_only_when_background(), + } + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(default)] +pub struct GeneralSettingsPayload { + #[serde(default = "default_settings_locale")] + pub locale: String, + #[serde(default = "default_terminal_compatibility_mode")] + #[serde(alias = "terminalCompatibilityMode")] + pub terminal_compatibility_mode: String, + #[serde(alias = "completionNotifications")] + pub completion_notifications: CompletionNotificationSettings, + #[serde(default = "default_idle_policy_settings")] + #[serde(alias = "idlePolicy")] + pub idle_policy: IdlePolicy, +} + +impl Default for GeneralSettingsPayload { + fn default() -> Self { + Self { + locale: default_settings_locale(), + terminal_compatibility_mode: default_terminal_compatibility_mode(), + completion_notifications: CompletionNotificationSettings::default(), + idle_policy: default_idle_policy_settings(), + } + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(default)] +pub struct ClaudeRuntimeProfile { + #[serde(default = "default_claude_executable")] + pub executable: String, + #[serde(alias = "startupArgs")] + pub startup_args: Vec, + pub env: BTreeMap, + #[serde(default = "default_json_object")] + #[serde(alias = "settingsJson")] + pub settings_json: Value, + #[serde(default = "default_json_object")] + #[serde(alias = "globalConfigJson")] + pub global_config_json: Value, +} + +impl Default for ClaudeRuntimeProfile { + fn default() -> Self { + Self { + executable: default_claude_executable(), + startup_args: Vec::new(), + env: BTreeMap::new(), + settings_json: default_json_object(), + global_config_json: default_json_object(), + } + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] +#[serde(default)] +pub struct TargetClaudeOverride { + pub enabled: bool, + pub profile: ClaudeRuntimeProfile, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] +#[serde(default)] +pub struct ClaudeTargetOverrides { + pub native: Option, + pub wsl: Option, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] +#[serde(default)] +pub struct ClaudeSettingsPayload { + pub global: ClaudeRuntimeProfile, + pub overrides: ClaudeTargetOverrides, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] +#[serde(default)] +pub struct AppSettingsPayload { + pub general: GeneralSettingsPayload, + pub claude: ClaudeSettingsPayload, +} + #[derive(Clone, Serialize, Deserialize, Debug)] pub struct QueueTask { pub id: u64, @@ -121,6 +259,28 @@ pub struct ArchiveEntry { pub snapshot: Value, } +#[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, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct SessionRestoreResult { + pub session: SessionInfo, + pub already_active: bool, +} + #[derive(Clone, Serialize, Deserialize, Debug)] #[serde(rename_all = "snake_case")] pub enum WorkspaceSourceKind { diff --git a/apps/server/src/services/agent.rs b/apps/server/src/services/agent.rs index df051e0..f18a81e 100644 --- a/apps/server/src/services/agent.rs +++ b/apps/server/src/services/agent.rs @@ -3,6 +3,13 @@ use crate::*; const DEFAULT_PTY_COLS: u16 = 120; const DEFAULT_PTY_ROWS: u16 = 30; +#[derive(Default)] +struct AgentLifecycleFallbackState { + emitted_tool_started: bool, + emitted_turn_completed: bool, + claude_session_id: Option, +} + fn initial_pty_size(cols: Option, rows: Option) -> PtySize { PtySize { rows: rows.filter(|value| *value > 0).unwrap_or(DEFAULT_PTY_ROWS), @@ -12,6 +19,46 @@ fn initial_pty_size(cols: Option, rows: Option) -> PtySize { } } +fn fallback_agent_lifecycle_from_output( + state: &mut AgentLifecycleFallbackState, + text: &str, +) -> Option<(&'static str, &'static str, String)> { + if state.emitted_tool_started || text.trim().is_empty() { + return None; + } + state.emitted_tool_started = true; + let data = state + .claude_session_id + .as_deref() + .map(|session_id| { + json!({ + "source": "agent_process_output", + "session_id": session_id, + }) + }) + .unwrap_or_else(|| { + json!({ + "source": "agent_process_output", + }) + }) + .to_string(); + Some(("tool_started", "AgentProcessOutput", data)) +} + +fn fallback_agent_lifecycle_from_exit( + state: &mut AgentLifecycleFallbackState, +) -> Option<(&'static str, &'static str, String)> { + if state.emitted_turn_completed || !state.emitted_tool_started { + return None; + } + state.emitted_turn_completed = true; + Some(( + "turn_completed", + "AgentProcessExit", + r#"{"source":"agent_process_exit"}"#.to_string(), + )) +} + fn terminate_agent_runtime(runtime: Arc) { if let Ok(mut writer) = runtime.writer.lock() { *writer = None; @@ -25,11 +72,63 @@ fn terminate_agent_runtime(runtime: Arc) { } } +fn escape_agent_command_part(target: &ExecTarget, value: &str) -> String { + if matches!(target, ExecTarget::Wsl { .. }) { + return shell_escape(value); + } + + #[cfg(target_os = "windows")] + { + crate::infra::runtime::shell_escape_windows(value) + } + + #[cfg(not(target_os = "windows"))] + { + shell_escape(value) + } +} + +fn build_claude_launch_command( + target: &ExecTarget, + profile: &ClaudeRuntimeProfile, + claude_session_id: Option<&str>, +) -> String { + let mut parts = Vec::with_capacity(1 + profile.startup_args.len()); + parts.push(escape_agent_command_part(target, &profile.executable)); + parts.extend( + profile + .startup_args + .iter() + .map(|arg| escape_agent_command_part(target, arg)), + ); + build_claude_resume_command(&parts.join(" "), claude_session_id) +} + +fn take_agent_runtime( + workspace_id: &str, + session_id: &str, + state: State<'_, AppState>, +) -> Result>, String> { + let key = agent_key(workspace_id, session_id); + let mut agents = state.agents.lock().map_err(|e| e.to_string())?; + Ok(agents.remove(&key)) +} + +pub(crate) fn stop_agent_runtime_without_status_update( + workspace_id: &str, + session_id: &str, + state: State<'_, AppState>, +) -> Result<(), String> { + if let Some(runtime) = take_agent_runtime(workspace_id, session_id, state)? { + terminate_agent_runtime(runtime); + } + Ok(()) +} + pub(crate) struct AgentStartParams { pub(crate) workspace_id: String, pub(crate) session_id: String, pub(crate) provider: String, - pub(crate) command: String, pub(crate) cols: Option, pub(crate) rows: Option, } @@ -43,7 +142,6 @@ pub(crate) fn agent_start( workspace_id, session_id, provider, - command, cols, rows, } = params; @@ -61,10 +159,14 @@ pub(crate) fn agent_start( let (cwd, target) = workspace_access_context(state, &workspace_id)?; let stored_session = load_session(state, &workspace_id, session_id_num)?; let effective_claude_session_id = stored_session.claude_session_id.clone(); - let command = if provider == "claude" { - build_claude_resume_command(&command, effective_claude_session_id.as_deref()) + let (command, claude_profile) = if provider == "claude" { + let settings = load_or_default_app_settings(state)?; + let profile = resolve_claude_runtime_profile(&settings, &target); + let command = + build_claude_launch_command(&target, &profile, effective_claude_session_id.as_deref()); + (command, Some(profile)) } else { - command + return Err("unsupported_agent_provider".to_string()); }; let (program, args) = build_agent_pty_command(&target, &cwd, &command); @@ -89,7 +191,10 @@ pub(crate) fn agent_start( crate::infra::runtime::apply_unix_pty_env_defaults(&mut cmd, shell_env.as_deref()); } - if provider == "claude" { + if let Some(profile) = claude_profile.as_ref() { + for (key, value) in &profile.env { + cmd.env(key, value); + } ensure_claude_hook_settings(&cwd, &target)?; let app_bin = current_app_bin_for_target(&target)?; let hook_endpoint = current_hook_endpoint(&app)?; @@ -144,8 +249,13 @@ pub(crate) fn agent_start( let workspace_id_out = workspace_id.clone(); let session_out = session_id.clone(); let session_out_num = session_id_num; + let lifecycle_fallback_state = Arc::new(Mutex::new(AgentLifecycleFallbackState { + claude_session_id: effective_claude_session_id.clone(), + ..Default::default() + })); let app_handle = app.clone(); let state_handle = app.clone(); + let lifecycle_fallback_state_out = lifecycle_fallback_state.clone(); std::thread::spawn(move || { let mut reader = reader; let mut buf = [0u8; 4096]; @@ -157,6 +267,20 @@ pub(crate) fn agent_start( if text.is_empty() { continue; } + if let Ok(mut lifecycle_state) = lifecycle_fallback_state_out.lock() { + if let Some((kind, source_event, data)) = + fallback_agent_lifecycle_from_output(&mut lifecycle_state, &text) + { + emit_agent_lifecycle( + &app_handle, + &workspace_id_out, + &session_out, + kind, + source_event, + &data, + ); + } + } emit_agent( &app_handle, &workspace_id_out, @@ -174,16 +298,40 @@ pub(crate) fn agent_start( let app_handle = app.clone(); let state_handle = app.clone(); + let lifecycle_fallback_state_out = lifecycle_fallback_state.clone(); std::thread::spawn(move || { if let Ok(mut child) = runtime.child.lock() { let _ = child.wait(); } + if let Ok(mut lifecycle_state) = lifecycle_fallback_state_out.lock() { + if let Some((kind, source_event, data)) = + fallback_agent_lifecycle_from_exit(&mut lifecycle_state) + { + emit_agent_lifecycle( + &app_handle, + &workspace_id, + &session_id, + kind, + source_event, + &data, + ); + } + } emit_agent(&app_handle, &workspace_id, &session_id, "exit", "exited"); let state: State = state_handle.state(); - let _ = set_session_status(state, &workspace_id, session_id_num, SessionStatus::Idle); - if let Ok(mut agents) = state.agents.lock() { - agents.remove(&key); + let should_mark_idle = if let Ok(mut agents) = state.agents.lock() { + agents.remove(&key).is_some() + } else { + false }; + if should_mark_idle { + let _ = set_session_status_if_not_archived( + state, + &workspace_id, + session_id_num, + SessionStatus::Idle, + ); + } }); Ok(AgentStartResult { started: true }) @@ -239,16 +387,9 @@ pub(crate) fn agent_stop( session_id: String, state: State<'_, AppState>, ) -> Result<(), String> { - let key = agent_key(&workspace_id, &session_id); - let runtime = { - let mut agents = state.agents.lock().map_err(|e| e.to_string())?; - agents.remove(&key) - }; - if let Some(runtime) = runtime { - terminate_agent_runtime(runtime); - } + stop_agent_runtime_without_status_update(&workspace_id, &session_id, state)?; if let Ok(session_id_num) = session_id.parse::() { - let _ = set_session_status( + let _ = set_session_status_if_not_archived( state, &workspace_id, session_id_num, @@ -281,7 +422,7 @@ pub(crate) fn stop_workspace_agents(workspace_id: &str, state: State<'_, AppStat for (session_id, runtime) in runtimes { terminate_agent_runtime(runtime); if let Ok(session_id_num) = session_id.parse::() { - let _ = set_session_status( + let _ = set_session_status_if_not_archived( state, workspace_id, session_id_num, @@ -311,3 +452,61 @@ pub(crate) fn agent_resize( }) .map_err(|e| e.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fallback_agent_lifecycle_marks_first_output_as_tool_started_once() { + let mut state = AgentLifecycleFallbackState::default(); + + assert_eq!( + fallback_agent_lifecycle_from_output(&mut state, "fixture-running\n"), + Some(( + "tool_started", + "AgentProcessOutput", + r#"{"source":"agent_process_output"}"#.to_string(), + )), + ); + assert_eq!( + fallback_agent_lifecycle_from_output(&mut state, "fixture-still-running\n"), + None + ); + } + + #[test] + fn fallback_agent_lifecycle_carries_known_claude_session_id() { + let mut state = AgentLifecycleFallbackState { + claude_session_id: Some("claude-resume-known".to_string()), + ..Default::default() + }; + + assert_eq!( + fallback_agent_lifecycle_from_output(&mut state, "fixture-running\n"), + Some(( + "tool_started", + "AgentProcessOutput", + r#"{"session_id":"claude-resume-known","source":"agent_process_output"}"# + .to_string(), + )), + ); + } + + #[test] + fn fallback_agent_lifecycle_only_emits_completion_after_output_started() { + let mut state = AgentLifecycleFallbackState::default(); + assert_eq!(fallback_agent_lifecycle_from_exit(&mut state), None); + + let _ = fallback_agent_lifecycle_from_output(&mut state, "fixture-running\n"); + assert_eq!( + fallback_agent_lifecycle_from_exit(&mut state), + Some(( + "turn_completed", + "AgentProcessExit", + r#"{"source":"agent_process_exit"}"#.to_string(), + )), + ); + assert_eq!(fallback_agent_lifecycle_from_exit(&mut state), None); + } +} diff --git a/apps/server/src/services/app_settings.rs b/apps/server/src/services/app_settings.rs new file mode 100644 index 0000000..a6ff0d1 --- /dev/null +++ b/apps/server/src/services/app_settings.rs @@ -0,0 +1,714 @@ +use crate::infra::db::with_db; +use crate::*; +use std::fs; + +const APP_SETTINGS_ROW_ID: i64 = 1; + +fn default_app_settings() -> AppSettingsPayload { + AppSettingsPayload::default() +} + +fn ensure_app_settings_row(conn: &Connection) -> Result<(), String> { + conn.execute( + "INSERT OR IGNORE INTO app_settings (id, payload, updated_at) + VALUES (?1, ?2, ?3)", + params![ + APP_SETTINGS_ROW_ID, + serde_json::to_string(&default_app_settings()).map_err(|e| e.to_string())?, + now_ts(), + ], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +fn load_or_default_app_settings_from_conn(conn: &Connection) -> Result { + ensure_app_settings_row(conn)?; + let raw: String = conn + .query_row( + "SELECT payload FROM app_settings WHERE id = ?1", + params![APP_SETTINGS_ROW_ID], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + serde_json::from_str(&raw).map_err(|e| e.to_string()) +} + +fn resolve_claude_home_root(root_override: Option<&Path>) -> Option { + if let Some(root) = root_override { + return Some(root.to_path_buf()); + } + + if let Some(root) = std::env::var_os("CODER_STUDIO_CLAUDE_HOME") { + return Some(PathBuf::from(root)); + } + + #[cfg(test)] + { + None + } + + #[cfg(not(test))] + { + home_dir() + } +} + +#[derive(Default)] +struct ClaudeJsonSources { + settings_json: Option>, + config_json: Option>, + global_config_json: Option>, +} + +impl ClaudeJsonSources { + fn is_empty(&self) -> bool { + self.settings_json.is_none() + && self.config_json.is_none() + && self.global_config_json.is_none() + } +} + +fn parse_json_object_text(raw: &str) -> Option> { + match serde_json::from_str::(raw).ok()? { + Value::Object(value) => Some(value), + _ => None, + } +} + +fn read_json_object_file(path: &Path) -> Option> { + let raw = fs::read_to_string(path).ok()?; + parse_json_object_text(&raw) +} + +fn read_target_json_object_file(target: &ExecTarget, path: &str) -> Option> { + let raw = run_cmd(target, "", &["cat", path]).ok()?; + parse_json_object_text(&raw) +} + +fn load_native_claude_json_sources(root: &Path) -> ClaudeJsonSources { + ClaudeJsonSources { + settings_json: read_json_object_file(&root.join(".claude/settings.json")), + config_json: read_json_object_file(&root.join(".claude/config.json")), + global_config_json: read_json_object_file(&root.join(".claude.json")), + } +} + +fn load_wsl_claude_json_sources(target: &ExecTarget) -> Option { + let home = filesystem_home_for_target(target).ok()?; + let home = home.trim_end_matches('/'); + let render = |relative: &str| { + if home.is_empty() || home == "/" { + format!("/{}", relative.trim_start_matches('/')) + } else { + format!("{home}/{}", relative.trim_start_matches('/')) + } + }; + + let sources = ClaudeJsonSources { + settings_json: read_target_json_object_file(target, &render(".claude/settings.json")), + config_json: read_target_json_object_file(target, &render(".claude/config.json")), + global_config_json: read_target_json_object_file(target, &render(".claude.json")), + }; + + if sources.is_empty() { + None + } else { + Some(sources) + } +} + +fn merge_missing_env_value( + env: &mut std::collections::BTreeMap, + key: &str, + value: &str, +) { + let trimmed = value.trim(); + if trimmed.is_empty() { + return; + } + + match env.get(key) { + Some(existing) if !existing.trim().is_empty() => {} + _ => { + env.insert(key.to_string(), trimmed.to_string()); + } + } +} + +fn merge_missing_env_map( + env: &mut std::collections::BTreeMap, + source: &Map, +) { + for (key, value) in source { + if let Some(text) = value.as_str() { + merge_missing_env_value(env, key, text); + } + } +} + +fn merge_missing_json(target: &mut Value, source: &Value) { + match source { + Value::Object(source_map) => { + let Value::Object(target_map) = target else { + if target.is_null() { + *target = source.clone(); + } + return; + }; + for (key, source_value) in source_map { + match target_map.get_mut(key) { + Some(target_value) => merge_missing_json(target_value, source_value), + None => { + target_map.insert(key.clone(), source_value.clone()); + } + } + } + } + Value::Array(source_values) => { + if let Value::Array(target_values) = target { + if target_values.is_empty() { + *target_values = source_values.clone(); + } + } else if target.is_null() { + *target = source.clone(); + } + } + Value::String(source_value) => { + if let Value::String(target_value) = target { + if target_value.trim().is_empty() { + *target_value = source_value.clone(); + } + } else if target.is_null() { + *target = source.clone(); + } + } + _ => { + if target.is_null() { + *target = source.clone(); + } + } + } +} + +fn hydrate_runtime_profile_from_claude_sources( + profile: &ClaudeRuntimeProfile, + sources: &ClaudeJsonSources, +) -> ClaudeRuntimeProfile { + let mut hydrated = profile.clone(); + + if let Some(mut settings_json) = sources.settings_json.clone() { + if let Some(Value::Object(env_map)) = settings_json.remove("env") { + merge_missing_env_map(&mut hydrated.env, &env_map); + } + merge_missing_json(&mut hydrated.settings_json, &Value::Object(settings_json)); + } + + if let Some(config_json) = &sources.config_json { + if let Some(primary_api_key) = config_json.get("primaryApiKey").and_then(Value::as_str) { + merge_missing_env_value(&mut hydrated.env, "ANTHROPIC_API_KEY", primary_api_key); + } + } + + if let Some(global_config_json) = &sources.global_config_json { + merge_missing_json( + &mut hydrated.global_config_json, + &Value::Object(global_config_json.clone()), + ); + } + + hydrated +} + +fn hydrate_settings_from_claude_sources( + settings: &AppSettingsPayload, + native_sources: Option<&ClaudeJsonSources>, + wsl_sources: Option<&ClaudeJsonSources>, +) -> AppSettingsPayload { + let mut hydrated = settings.clone(); + + if let Some(sources) = native_sources { + hydrated.claude.global = + hydrate_runtime_profile_from_claude_sources(&hydrated.claude.global, sources); + } + + if let Some(sources) = wsl_sources { + let existing_override = hydrated.claude.overrides.wsl.clone(); + let mut wsl_override = existing_override.clone().unwrap_or_default(); + let next_profile = + hydrate_runtime_profile_from_claude_sources(&wsl_override.profile, sources); + if existing_override.is_some() || next_profile != wsl_override.profile { + wsl_override.profile = next_profile; + hydrated.claude.overrides.wsl = Some(wsl_override); + } + } + + hydrated +} + +fn hydrate_settings_from_claude_home( + settings: &AppSettingsPayload, + root_override: Option<&Path>, +) -> AppSettingsPayload { + let Some(root) = resolve_claude_home_root(root_override) else { + return settings.clone(); + }; + + let sources = load_native_claude_json_sources(&root); + hydrate_settings_from_claude_sources(settings, Some(&sources), None) +} + +fn load_or_default_app_settings_from_conn_hydrated( + conn: &Connection, +) -> Result { + let settings = + hydrate_settings_from_claude_home(&load_or_default_app_settings_from_conn(conn)?, None); + let wsl_target = ExecTarget::Wsl { distro: None }; + let wsl_sources = load_wsl_claude_json_sources(&wsl_target); + Ok(hydrate_settings_from_claude_sources( + &settings, + None, + wsl_sources.as_ref(), + )) +} + +fn save_app_settings_to_conn( + conn: &Connection, + settings: &AppSettingsPayload, +) -> Result { + ensure_app_settings_row(conn)?; + conn.execute( + "INSERT INTO app_settings (id, payload, updated_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at", + params![ + APP_SETTINGS_ROW_ID, + serde_json::to_string(settings).map_err(|e| e.to_string())?, + now_ts(), + ], + ) + .map_err(|e| e.to_string())?; + Ok(settings.clone()) +} + +fn should_replace_object_patch(path: &[String]) -> bool { + let path = path.iter().map(String::as_str).collect::>(); + matches!( + path.as_slice(), + ["claude", "global", "env"] + | ["claude", "global", "settings_json"] + | ["claude", "global", "global_config_json"] + | ["claude", "overrides", "native", "profile", "env"] + | ["claude", "overrides", "native", "profile", "settings_json"] + | [ + "claude", + "overrides", + "native", + "profile", + "global_config_json" + ] + | ["claude", "overrides", "wsl", "profile", "env"] + | ["claude", "overrides", "wsl", "profile", "settings_json"] + | [ + "claude", + "overrides", + "wsl", + "profile", + "global_config_json" + ] + ) +} + +fn merge_settings_value(current: &mut Value, patch: Value, path: &[String]) { + match patch { + Value::Object(patch_map) if should_replace_object_patch(path) => { + *current = Value::Object(patch_map); + } + Value::Object(patch_map) => { + if let Value::Object(current_map) = current { + for (key, value) in patch_map { + let mut next_path = path.to_vec(); + next_path.push(key.clone()); + if let Some(existing) = current_map.get_mut(&key) { + merge_settings_value(existing, value, &next_path); + } else { + current_map.insert(key, value); + } + } + } else { + *current = Value::Object(patch_map); + } + } + patch => { + *current = patch; + } + } +} + +fn normalize_settings_patch_key(path: &[String], key: &str) -> String { + let path = path.iter().map(String::as_str).collect::>(); + match path.as_slice() { + ["general"] => match key { + "terminalCompatibilityMode" => "terminal_compatibility_mode".to_string(), + "completionNotifications" => "completion_notifications".to_string(), + "idlePolicy" => "idle_policy".to_string(), + _ => key.to_string(), + }, + ["general", "completion_notifications"] => match key { + "onlyWhenBackground" => "only_when_background".to_string(), + _ => key.to_string(), + }, + ["general", "idle_policy"] => match key { + "idleMinutes" => "idle_minutes".to_string(), + "maxActive" => "max_active".to_string(), + _ => key.to_string(), + }, + ["claude", "global"] + | ["claude", "overrides", "native", "profile"] + | ["claude", "overrides", "wsl", "profile"] => match key { + "startupArgs" => "startup_args".to_string(), + "settingsJson" => "settings_json".to_string(), + "globalConfigJson" => "global_config_json".to_string(), + _ => key.to_string(), + }, + _ => key.to_string(), + } +} + +fn normalize_settings_patch_value(value: Value, path: &[String]) -> Value { + match value { + Value::Object(object) => { + let normalized = object + .into_iter() + .map(|(key, value)| { + let normalized_key = normalize_settings_patch_key(path, &key); + let mut next_path = path.to_vec(); + next_path.push(normalized_key.clone()); + ( + normalized_key, + normalize_settings_patch_value(value, &next_path), + ) + }) + .collect(); + Value::Object(normalized) + } + other => other, + } +} + +pub(crate) fn load_or_default_app_settings( + state: State<'_, AppState>, +) -> Result { + with_db(state, load_or_default_app_settings_from_conn_hydrated) +} + +pub(crate) fn app_settings_get(state: State<'_, AppState>) -> Result { + load_or_default_app_settings(state) +} + +fn app_settings_update_with_before_save_hook( + patch: Value, + state: State<'_, AppState>, + before_save: impl FnOnce() -> Result<(), String>, +) -> Result { + let normalized_patch = normalize_settings_patch_value(patch, &Vec::::new()); + with_db(state, |conn| { + let mut current = + serde_json::to_value(load_or_default_app_settings_from_conn_hydrated(conn)?) + .map_err(|e| e.to_string())?; + merge_settings_value(&mut current, normalized_patch, &[]); + before_save()?; + let merged: AppSettingsPayload = + serde_json::from_value(current).map_err(|e| e.to_string())?; + save_app_settings_to_conn(conn, &merged) + }) +} + +pub(crate) fn app_settings_update( + patch: Value, + state: State<'_, AppState>, +) -> Result { + app_settings_update_with_before_save_hook(patch, state, || Ok(())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::RuntimeHandle; + use std::fs; + use std::path::Path; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn test_app() -> AppHandle { + let (app, _shutdown_rx) = RuntimeHandle::new(); + let conn = Connection::open_in_memory().unwrap(); + init_db(&conn).unwrap(); + *app.state().db.lock().unwrap() = Some(conn); + app + } + + fn unique_temp_dir(name: &str) -> PathBuf { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("coder-studio-{name}-{ts}")) + } + + fn write_json(path: &Path, value: Value) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, serde_json::to_string_pretty(&value).unwrap()).unwrap(); + } + + #[test] + fn hydrate_settings_from_claude_home_imports_auth_and_existing_file_values() { + let root = unique_temp_dir("claude-settings-import"); + + write_json( + &root.join(".claude/settings.json"), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "auth-token-12345", + "ANTHROPIC_BASE_URL": "https://anthropic.example" + }, + "model": "sonnet", + "permissionMode": "auto" + }), + ); + write_json( + &root.join(".claude/config.json"), + json!({ + "primaryApiKey": "primary-api-key-12345" + }), + ); + write_json( + &root.join(".claude.json"), + json!({ + "showTurnDuration": true + }), + ); + + let hydrated = + hydrate_settings_from_claude_home(&AppSettingsPayload::default(), Some(root.as_path())); + + assert_eq!( + hydrated + .claude + .global + .env + .get("ANTHROPIC_API_KEY") + .map(String::as_str), + Some("primary-api-key-12345") + ); + assert_eq!( + hydrated + .claude + .global + .env + .get("ANTHROPIC_AUTH_TOKEN") + .map(String::as_str), + Some("auth-token-12345") + ); + assert_eq!( + hydrated + .claude + .global + .env + .get("ANTHROPIC_BASE_URL") + .map(String::as_str), + Some("https://anthropic.example") + ); + assert_eq!(hydrated.claude.global.settings_json["model"], "sonnet"); + assert_eq!( + hydrated.claude.global.settings_json["permissionMode"], + "auto" + ); + assert_eq!( + hydrated.claude.global.global_config_json["showTurnDuration"], + true + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn hydrate_settings_from_claude_home_preserves_backend_values_over_local_files() { + let root = unique_temp_dir("claude-settings-precedence"); + + write_json( + &root.join(".claude/settings.json"), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "auth-token-from-file", + "ANTHROPIC_BASE_URL": "https://file.example" + }, + "model": "file-model" + }), + ); + write_json( + &root.join(".claude/config.json"), + json!({ + "primaryApiKey": "api-key-from-file" + }), + ); + + let mut settings = AppSettingsPayload::default(); + settings + .claude + .global + .env + .insert("ANTHROPIC_API_KEY".into(), "api-key-from-backend".into()); + settings.claude.global.env.insert( + "ANTHROPIC_AUTH_TOKEN".into(), + "auth-token-from-backend".into(), + ); + settings.claude.global.settings_json = json!({ + "model": "backend-model" + }); + + let hydrated = hydrate_settings_from_claude_home(&settings, Some(root.as_path())); + + assert_eq!( + hydrated + .claude + .global + .env + .get("ANTHROPIC_API_KEY") + .map(String::as_str), + Some("api-key-from-backend") + ); + assert_eq!( + hydrated + .claude + .global + .env + .get("ANTHROPIC_AUTH_TOKEN") + .map(String::as_str), + Some("auth-token-from-backend") + ); + assert_eq!( + hydrated.claude.global.settings_json["model"], + "backend-model" + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn hydrate_settings_from_claude_sources_imports_wsl_values_into_wsl_override_profile() { + let hydrated = hydrate_settings_from_claude_sources( + &AppSettingsPayload::default(), + None, + Some(&ClaudeJsonSources { + settings_json: Some( + serde_json::from_value(json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "wsl-auth-token", + "ANTHROPIC_BASE_URL": "https://wsl.example" + }, + "model": "wsl-sonnet" + })) + .unwrap(), + ), + config_json: Some( + serde_json::from_value(json!({ + "primaryApiKey": "wsl-primary-api-key" + })) + .unwrap(), + ), + global_config_json: Some( + serde_json::from_value(json!({ + "showTurnDuration": true + })) + .unwrap(), + ), + }), + ); + + let wsl = hydrated + .claude + .overrides + .wsl + .expect("wsl override should be created"); + assert!(!wsl.enabled); + assert_eq!( + wsl.profile.env.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("wsl-primary-api-key") + ); + assert_eq!( + wsl.profile + .env + .get("ANTHROPIC_AUTH_TOKEN") + .map(String::as_str), + Some("wsl-auth-token") + ); + assert_eq!( + wsl.profile + .env + .get("ANTHROPIC_BASE_URL") + .map(String::as_str), + Some("https://wsl.example") + ); + assert_eq!(wsl.profile.settings_json["model"], "wsl-sonnet"); + assert_eq!(wsl.profile.global_config_json["showTurnDuration"], true); + assert_eq!(hydrated.claude.global.env.get("ANTHROPIC_API_KEY"), None); + } + + #[test] + fn app_settings_update_keeps_partial_updates_atomic() { + let app = test_app(); + let interleaved = Arc::new(AtomicBool::new(false)); + let env_patch = json!({ + "claude": { + "global": { + "env": { + "TEST_MARKER": "persisted-value" + } + } + } + }); + + app_settings_update_with_before_save_hook( + json!({ + "general": { + "locale": "zh" + } + }), + app.state(), + { + let app = app.clone(); + let interleaved = interleaved.clone(); + let env_patch = env_patch.clone(); + move || { + if let Ok(guard) = app.state().db.try_lock() { + drop(guard); + interleaved.store(true, Ordering::SeqCst); + app_settings_update(env_patch, app.state()).map(|_| ())?; + } + Ok(()) + } + }, + ) + .unwrap(); + + if !interleaved.load(Ordering::SeqCst) { + app_settings_update(env_patch, app.state()).unwrap(); + } + + let saved = load_or_default_app_settings(app.state()).unwrap(); + assert_eq!(saved.general.locale, "zh"); + assert_eq!( + saved + .claude + .global + .env + .get("TEST_MARKER") + .map(String::as_str), + Some("persisted-value") + ); + } +} diff --git a/apps/server/src/services/claude.rs b/apps/server/src/services/claude.rs index 08335c7..0fde6b0 100644 --- a/apps/server/src/services/claude.rs +++ b/apps/server/src/services/claude.rs @@ -27,6 +27,68 @@ fn respond_http(mut stream: TcpStream, status: &str, body: &str) { let _ = stream.flush(); } +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 { + let override_ = match target { + ExecTarget::Native => settings.claude.overrides.native.as_ref(), + ExecTarget::Wsl { .. } => settings.claude.overrides.wsl.as_ref(), + }; + + override_ + .filter(|override_| override_.enabled) + .map(|override_| merge_claude_runtime_profile(&settings.claude.global, &override_.profile)) + .unwrap_or_else(|| settings.claude.global.clone()) +} + fn parse_http_json(stream: &TcpStream) -> Result { let cloned = stream.try_clone().map_err(|e| e.to_string())?; let mut reader = BufReader::new(cloned); @@ -322,3 +384,90 @@ pub(crate) fn run_claude_hook_helper() { Ok(()) })(); } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[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"); + } +} diff --git a/apps/server/src/services/mod.rs b/apps/server/src/services/mod.rs index 7e02cb6..28a4ba9 100644 --- a/apps/server/src/services/mod.rs +++ b/apps/server/src/services/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod agent; +pub(crate) mod app_settings; pub(crate) mod claude; pub(crate) mod filesystem; pub(crate) mod git; diff --git a/apps/server/src/services/workspace.rs b/apps/server/src/services/workspace.rs index 4c4f167..a17faae 100644 --- a/apps/server/src/services/workspace.rs +++ b/apps/server/src/services/workspace.rs @@ -120,6 +120,7 @@ pub(crate) fn close_workspace_scoped( 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); @@ -185,10 +186,33 @@ pub(crate) fn archive_session( state: State<'_, AppState>, ) -> Result { let entry = archive_workspace_session(state, &workspace_id, session_id)?; - let _ = agent_stop(workspace_id.clone(), session_id.to_string(), state); + let _ = stop_agent_runtime_without_status_update(&workspace_id, &session_id.to_string(), state); Ok(entry) } +pub(crate) fn list_session_history( + state: State<'_, AppState>, +) -> Result, String> { + load_session_history_records(state) +} + +pub(crate) fn restore_session( + workspace_id: String, + session_id: u64, + state: State<'_, AppState>, +) -> Result { + restore_workspace_session(state, &workspace_id, session_id) +} + +pub(crate) fn delete_session( + workspace_id: String, + session_id: u64, + state: State<'_, AppState>, +) -> Result<(), String> { + let _ = stop_agent_runtime_without_status_update(&workspace_id, &session_id.to_string(), state); + delete_workspace_session(state, &workspace_id, session_id) +} + pub(crate) fn update_idle_policy( workspace_id: String, policy: IdlePolicy, @@ -304,4 +328,80 @@ mod tests { assert_eq!(lease.lease_expires_at, 0); assert_eq!(lease.takeover_request_id, None); } + + #[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.session.id, created.id); + assert!(!restored.already_active); + + 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 extra = create_session(workspace_id.clone(), SessionMode::Branch, app.state()).unwrap(); + let live_ids = workspace_snapshot(workspace_id.clone(), app.state()) + .unwrap() + .sessions + .into_iter() + .map(|session| session.id) + .collect::>(); + + close_workspace_scoped(workspace_id.clone(), None, None, app.state()).unwrap(); + + let history = list_session_history(app.state()).unwrap(); + let records = history + .into_iter() + .filter(|record| record.workspace_id == workspace_id) + .collect::>(); + assert_eq!(records.len(), live_ids.len()); + assert!(records.iter().all(|record| record.archived)); + assert!(records.iter().any(|record| record.session_id == extra.id)); + for live_id in live_ids { + assert!(records.iter().any(|record| record.session_id == live_id)); + } + } } diff --git a/apps/server/src/services/workspace_runtime.rs b/apps/server/src/services/workspace_runtime.rs index 92643e6..cbcdd00 100644 --- a/apps/server/src/services/workspace_runtime.rs +++ b/apps/server/src/services/workspace_runtime.rs @@ -38,6 +38,14 @@ fn transfer_controller( clear_takeover_request(lease); } +fn refresh_controller_lease(lease: &mut WorkspaceControllerLease, client_id: &str, now: i64) { + if lease.fencing_token == 0 && lease.controller_device_id.is_some() { + lease.fencing_token = 1; + } + lease.controller_client_id = Some(client_id.to_string()); + lease.lease_expires_at = now + WORKSPACE_CONTROLLER_LEASE_SECS; +} + fn finalize_takeover_if_due(lease: &mut WorkspaceControllerLease, now: i64) -> bool { let takeover_due = lease .takeover_deadline_at @@ -282,7 +290,9 @@ pub(crate) fn workspace_runtime_attach( let before = lease.clone(); finalize_takeover_if_due(&mut lease, now); - if !lease_alive(&lease, now) || same_controller(&lease, &device_id, &client_id) { + if same_controller(&lease, &device_id, &client_id) { + refresh_controller_lease(&mut lease, &client_id, now); + } else if !lease_alive(&lease, now) { transfer_controller(&mut lease, &device_id, &client_id, now); } @@ -317,8 +327,7 @@ pub(crate) fn workspace_controller_heartbeat( finalize_takeover_if_due(&mut lease, now); if same_controller(&lease, &device_id, &client_id) { - lease.controller_client_id = Some(client_id.clone()); - lease.lease_expires_at = now + WORKSPACE_CONTROLLER_LEASE_SECS; + refresh_controller_lease(&mut lease, &client_id, now); } else if !lease_alive(&lease, now) { transfer_controller(&mut lease, &device_id, &client_id, now); } @@ -352,8 +361,7 @@ pub(crate) fn workspace_controller_takeover( finalize_takeover_if_due(&mut lease, now); if same_controller(&lease, &device_id, &client_id) { - lease.lease_expires_at = now + WORKSPACE_CONTROLLER_LEASE_SECS; - lease.controller_client_id = Some(client_id.clone()); + refresh_controller_lease(&mut lease, &client_id, now); } else if !lease_alive(&lease, now) { transfer_controller(&mut lease, &device_id, &client_id, now); } else if lease.takeover_requested_by_device_id.as_deref() == Some(device_id.as_str()) { @@ -398,8 +406,7 @@ pub(crate) fn workspace_controller_reject_takeover( finalize_takeover_if_due(&mut lease, now); if same_controller(&lease, &device_id, &client_id) { - lease.controller_client_id = Some(client_id.clone()); - lease.lease_expires_at = now + WORKSPACE_CONTROLLER_LEASE_SECS; + refresh_controller_lease(&mut lease, &client_id, now); clear_takeover_request(&mut lease); } save_workspace_controller_lease(state, &lease)?; @@ -536,6 +543,72 @@ mod tests { assert_eq!(transferred.fencing_token, 2); } + #[test] + fn controller_reattach_keeps_pending_takeover_request() { + let app = test_app(); + let workspace_id = launch_test_workspace(&app, "/tmp/ws-runtime-reattach-takeover-test"); + + workspace_runtime_attach( + workspace_id.clone(), + "device-a".to_string(), + "client-a".to_string(), + app.clone(), + app.state(), + ) + .unwrap(); + + workspace_controller_takeover( + workspace_id.clone(), + "device-b".to_string(), + "client-b".to_string(), + app.clone(), + app.state(), + ) + .unwrap(); + + let reattached = workspace_runtime_attach( + workspace_id.clone(), + "device-a".to_string(), + "client-a".to_string(), + app.clone(), + app.state(), + ) + .unwrap(); + + assert_eq!( + reattached.controller.controller_device_id.as_deref(), + Some("device-a") + ); + assert_eq!( + reattached + .controller + .takeover_requested_by_device_id + .as_deref(), + Some("device-b") + ); + assert_eq!( + reattached + .controller + .takeover_requested_by_client_id + .as_deref(), + Some("client-b") + ); + assert!(reattached.controller.takeover_request_id.is_some()); + assert!(reattached.controller.takeover_deadline_at.is_some()); + + let lease = load_workspace_controller_lease(app.state(), &workspace_id).unwrap(); + assert_eq!( + lease.takeover_requested_by_device_id.as_deref(), + Some("device-b") + ); + assert_eq!( + lease.takeover_requested_by_client_id.as_deref(), + Some("client-b") + ); + assert!(lease.takeover_request_id.is_some()); + assert!(lease.takeover_deadline_at.is_some()); + } + #[test] fn workspace_runtime_attach_keeps_same_device_second_client_as_observer() { let app = test_app(); @@ -740,4 +813,80 @@ mod tests { assert_eq!(runtime.lifecycle_events[0].source_event, "PreToolUse"); assert_eq!(runtime.lifecycle_events[0].seq, 1); } + + #[test] + fn workspace_runtime_attach_keeps_created_session_view_and_claude_id() { + let app = test_app(); + let workspace_id = launch_test_workspace(&app, "/tmp/ws-runtime-session-view-test"); + let session = create_workspace_session(app.state(), &workspace_id, SessionMode::Branch) + .expect("session should be created"); + + patch_workspace_view_state( + app.state(), + &workspace_id, + WorkspaceViewPatch { + active_session_id: Some(session.id.to_string()), + active_pane_id: Some(format!("pane-{}", session.id)), + active_terminal_id: None, + pane_layout: Some(json!({ + "type": "leaf", + "id": format!("pane-{}", session.id), + "sessionId": session.id.to_string(), + })), + file_preview: None, + }, + ) + .expect("view state should be updated"); + update_workspace_session( + app.state(), + &workspace_id, + session.id, + SessionPatch { + title: None, + status: Some(SessionStatus::Interrupted), + mode: None, + auto_feed: None, + queue: None, + messages: None, + stream: None, + unread: None, + last_active_at: None, + claude_session_id: Some("claude-runtime-attach".to_string()), + }, + ) + .expect("session should be updated"); + + let runtime = workspace_runtime_attach( + workspace_id, + "device-a".to_string(), + "client-a".to_string(), + app.clone(), + app.state(), + ) + .expect("runtime attach should succeed"); + + let restored = runtime + .snapshot + .sessions + .iter() + .find(|candidate| candidate.id == session.id) + .expect("created session should be present"); + assert_eq!(restored.status, SessionStatus::Interrupted); + assert_eq!( + restored.claude_session_id.as_deref(), + Some("claude-runtime-attach") + ); + assert_eq!( + runtime.snapshot.view_state.active_session_id, + session.id.to_string() + ); + assert_eq!( + runtime.snapshot.view_state.active_pane_id, + format!("pane-{}", session.id) + ); + assert_eq!( + runtime.snapshot.view_state.pane_layout["sessionId"].as_str(), + Some(session.id.to_string().as_str()) + ); + } } diff --git a/apps/web/src/components/HistoryDrawer/HistoryDrawer.tsx b/apps/web/src/components/HistoryDrawer/HistoryDrawer.tsx new file mode 100644 index 0000000..bc04796 --- /dev/null +++ b/apps/web/src/components/HistoryDrawer/HistoryDrawer.tsx @@ -0,0 +1,153 @@ +import type { Translator } from "../../i18n.ts"; +import type { + SessionHistoryExpansionState, + SessionHistoryGroup, + SessionHistoryRecord, +} from "../../types/app.ts"; +import { ChevronDownIcon, ChevronRightIcon, HeaderCloseIcon } from "../icons.tsx"; +import { selectHistoryPrimaryActionBadge } from "../../features/workspace/session-history.ts"; + +type HistoryDrawerProps = { + open: boolean; + loading?: boolean; + groups: SessionHistoryGroup[]; + expandedGroups: SessionHistoryExpansionState; + onClose: () => void; + onToggleGroup: (workspaceId: string) => void; + onSelectRecord: (record: SessionHistoryRecord) => void; + onDeleteRecord: (record: SessionHistoryRecord) => void; + t: Translator; +}; + +const recordMetaLabel = (record: SessionHistoryRecord, t: Translator) => { + if (record.archived) return t("historyArchived"); + if (record.mounted) return t("historyLive"); + return t("historyDetached"); +}; + +const recordStateClassName = (record: SessionHistoryRecord) => { + if (record.archived) return "archived"; + if (record.mounted) return "live"; + return "detached"; +}; + +const primaryActionLabel = (record: SessionHistoryRecord, t: Translator) => { + const action = selectHistoryPrimaryActionBadge(record); + if (action === "restore") return t("historyRestore"); + if (action === "open") return t("historyOpen"); + return null; +}; + +export const HistoryDrawer = ({ + open, + loading = false, + groups, + expandedGroups, + onClose, + onToggleGroup, + onSelectRecord, + onDeleteRecord, + t, +}: HistoryDrawerProps) => ( +
+ +
+
+ {loading ? ( +
{t("loading")}
+ ) : groups.length === 0 ? ( +
{t("historyEmpty")}
+ ) : ( + groups.map((group) => { + const expanded = expandedGroups[group.workspaceId] ?? false; + + return ( +
+ + {expanded ? ( +
+ {group.records.map((record) => { + const actionLabel = primaryActionLabel(record, t); + + return ( +
+ + +
+ ); + })} +
+ ) : null} +
+ ); + }) + )} +
+ + +); + +export default HistoryDrawer; diff --git a/apps/web/src/components/HistoryDrawer/index.ts b/apps/web/src/components/HistoryDrawer/index.ts new file mode 100644 index 0000000..4eeff9f --- /dev/null +++ b/apps/web/src/components/HistoryDrawer/index.ts @@ -0,0 +1 @@ +export { HistoryDrawer } from "./HistoryDrawer.tsx"; diff --git a/apps/web/src/components/RuntimeValidationOverlay/RuntimeValidationOverlay.tsx b/apps/web/src/components/RuntimeValidationOverlay/RuntimeValidationOverlay.tsx index 8c8ec06..6af5bd1 100644 --- a/apps/web/src/components/RuntimeValidationOverlay/RuntimeValidationOverlay.tsx +++ b/apps/web/src/components/RuntimeValidationOverlay/RuntimeValidationOverlay.tsx @@ -1,5 +1,7 @@ +import { useEffect } from "react"; import type { Translator } from "../../i18n"; import type { ExecTarget } from "../../state/workbench"; +import { HeaderCloseIcon } from "../icons"; export type RuntimeRequirementId = "claude" | "git"; @@ -25,6 +27,7 @@ type RuntimeValidationOverlayProps = { runtimeLabel: string; validation: RuntimeValidationState; onUpdateTarget: (target: ExecTarget) => void; + onClose: () => void; onRetry: () => void; t: Translator; }; @@ -50,9 +53,25 @@ export const RuntimeValidationOverlay = ({ runtimeLabel, validation, onUpdateTarget, + onClose, onRetry, t, }: RuntimeValidationOverlayProps) => { + useEffect(() => { + if (!visible) return undefined; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, [visible, onClose]); + if (!visible) return null; const summaryDescription = validation.status === "failed" @@ -61,12 +80,24 @@ export const RuntimeValidationOverlay = ({ const retryDisabled = validation.status !== "failed"; return ( -
-
+
+
event.stopPropagation()}>
-
-

{t("runtimeCheckTitle")}

-

{t("runtimeCheckDescription")}

+
+
+

{t("runtimeCheckTitle")}

+

{t("runtimeCheckDescription")}

+
+
{canUseWsl && ( diff --git a/apps/web/src/components/Settings/ClaudeSettingsPanel.tsx b/apps/web/src/components/Settings/ClaudeSettingsPanel.tsx new file mode 100644 index 0000000..6d1875e --- /dev/null +++ b/apps/web/src/components/Settings/ClaudeSettingsPanel.tsx @@ -0,0 +1,922 @@ +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import type { HTMLAttributes } from "react"; +import type { Locale, Translator } from "../../i18n.ts"; +import { EyeIcon, EyeOffIcon } from "../icons.tsx"; +import type { + AppSettings, + ClaudeRuntimeProfile, + ClaudeSettingsScope, +} from "../../types/app.ts"; +import { + forceClaudeExecutableDefaults, + formatClaudeLaunchPreview, + getClaudeScopeProfile, + isClaudeScopeOverrideEnabled, + patchClaudeStructuredSettings, + replaceClaudeAdvancedJson, + setClaudeScopeOverrideEnabled, +} from "../../shared/app/claude-settings.ts"; + +const RESERVED_ENV_KEYS = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_CUSTOM_HEADERS", +] as const; + +const STARTUP_BOOLEAN_OPTIONS = [ + { + flag: "--dangerously-skip-permissions", + labelKey: "claudeDangerouslySkipPermissions", + helpKey: "claudeDangerouslySkipPermissionsHelp", + testId: "claude-flag-dangerously-skip-permissions", + }, + { + flag: "--allow-dangerously-skip-permissions", + labelKey: "claudeAllowDangerouslySkipPermissions", + helpKey: "claudeAllowDangerouslySkipPermissionsHelp", + testId: "claude-flag-allow-dangerously-skip-permissions", + }, + { + flag: "--verbose", + labelKey: "claudeVerbose", + helpKey: "claudeVerboseHelp", + testId: "claude-flag-verbose", + }, + { + flag: "--ide", + labelKey: "claudeIdeFlag", + helpKey: "claudeIdeHelp", + testId: "claude-flag-ide", + }, + { + flag: "--brief", + labelKey: "claudeBrief", + helpKey: "claudeBriefHelp", + testId: "claude-flag-brief", + }, + { + flag: "--bare", + labelKey: "claudeBare", + helpKey: "claudeBareHelp", + testId: "claude-flag-bare", + }, +] as const; + +const STARTUP_VALUE_OPTIONS = [ + { + flag: "--permission-mode", + labelKey: "claudePermissionModeFlag", + helpKey: "claudePermissionModeHelp", + testId: "claude-startup-permission-mode", + values: [ + { value: "", labelKey: "claudePermissionModeInherit" }, + { value: "default", labelKey: "claudePermissionModeDefaultOption" }, + { value: "plan", labelKey: "claudePermissionModePlanOption" }, + { value: "auto", labelKey: "claudePermissionModeAutoOption" }, + { value: "acceptEdits", labelKey: "claudePermissionModeAcceptEditsOption" }, + { value: "dontAsk", labelKey: "claudePermissionModeDontAskOption" }, + { value: "bypassPermissions", labelKey: "claudePermissionModeBypassPermissionsOption" }, + ], + }, +] as const; + +const BEHAVIOR_PERMISSION_MODE_OPTIONS = [ + { value: "", labelKey: "claudeSelectUnsetOption" }, + { value: "default", labelKey: "claudePermissionModeDefaultOption" }, + { value: "plan", labelKey: "claudePermissionModePlanOption" }, + { value: "auto", labelKey: "claudePermissionModeAutoOption" }, + { value: "acceptEdits", labelKey: "claudePermissionModeAcceptEditsOption" }, + { value: "dontAsk", labelKey: "claudePermissionModeDontAskOption" }, + { value: "bypassPermissions", labelKey: "claudePermissionModeBypassPermissionsOption" }, +] as const; + +const EFFORT_OPTIONS = [ + { value: "", labelKey: "claudeSelectUnsetOption" }, + { value: "low", labelKey: "claudeEffortLowOption" }, + { value: "medium", labelKey: "claudeEffortMediumOption" }, + { value: "high", labelKey: "claudeEffortHighOption" }, +] as const; + +const EDITOR_MODE_OPTIONS = [ + { value: "", labelKey: "claudeSelectUnsetOption" }, + { value: "default", labelKey: "claudeEditorModeDefaultOption" }, + { value: "vim", labelKey: "claudeEditorModeVimOption" }, +] as const; + +const STARTUP_BOOLEAN_FLAGS = STARTUP_BOOLEAN_OPTIONS.map((option) => option.flag); +const STARTUP_VALUE_FLAGS = STARTUP_VALUE_OPTIONS.map((option) => option.flag); +const STARTUP_STRUCTURED_FLAGS = [...STARTUP_BOOLEAN_FLAGS, ...STARTUP_VALUE_FLAGS]; + +const readJsonPath = (source: Record, path: string[]): unknown => { + let current: unknown = source; + for (const segment of path) { + if (!current || typeof current !== "object" || Array.isArray(current)) { + return undefined; + } + current = (current as Record)[segment]; + } + return current; +}; + +const cloneJson = (value: Record) => structuredClone(value); + +const removeJsonPath = (source: Record, path: string[]): Record => { + const next = cloneJson(source); + let current: Record = next; + for (let index = 0; index < path.length - 1; index += 1) { + const key = path[index]; + const child = current[key]; + if (!child || typeof child !== "object" || Array.isArray(child)) { + return next; + } + current = child as Record; + } + delete current[path[path.length - 1]]; + return next; +}; + +const setJsonPath = ( + source: Record, + path: string[], + value: unknown, +): Record => { + if ( + value === undefined + || value === null + || value === "" + || (Array.isArray(value) && value.length === 0) + ) { + return removeJsonPath(source, path); + } + + const next = cloneJson(source); + let current: Record = next; + for (let index = 0; index < path.length - 1; index += 1) { + const key = path[index]; + const child = current[key]; + if (!child || typeof child !== "object" || Array.isArray(child)) { + current[key] = {}; + } + current = current[key] as Record; + } + current[path[path.length - 1]] = value as never; + return next; +}; + +const readString = (value: unknown) => (typeof value === "string" ? value : ""); +const readBoolean = (value: unknown) => value === true; +const readNumber = (value: unknown) => (typeof value === "number" ? value : ""); + +const linesToList = (value: string) => value + .split("\n") + .map((entry) => entry.trim()) + .filter(Boolean); + +const listToLines = (value: string[]) => value.join("\n"); + +const envToText = (env: Record) => Object.entries(env) + .filter(([key]) => !RESERVED_ENV_KEYS.includes(key as typeof RESERVED_ENV_KEYS[number])) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + +const textToEnv = (value: string) => Object.fromEntries( + value + .split("\n") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const splitIndex = entry.indexOf("="); + if (splitIndex === -1) { + return [entry, ""] as const; + } + return [entry.slice(0, splitIndex).trim(), entry.slice(splitIndex + 1)] as const; + }) + .filter(([key]) => Boolean(key)), +); + +const stripFlags = ( + startupArgs: string[], + flags: string[], + valueFlags: string[] = [], +): string[] => { + const next: string[] = []; + for (let index = 0; index < startupArgs.length; index += 1) { + const current = startupArgs[index]; + if (!flags.includes(current)) { + next.push(current); + continue; + } + const takesValue = valueFlags.includes(current); + if ( + takesValue + && typeof startupArgs[index + 1] === "string" + && !startupArgs[index + 1].startsWith("--") + ) { + index += 1; + } + } + return next; +}; + +const readStandaloneFlag = (startupArgs: string[], flag: string) => startupArgs.includes(flag); + +const readFlagValues = (startupArgs: string[], flag: string) => { + const values: string[] = []; + for (let index = 0; index < startupArgs.length; index += 1) { + if (startupArgs[index] !== flag) continue; + const next = startupArgs[index + 1]; + if (typeof next === "string" && !next.startsWith("--")) { + values.push(next); + index += 1; + } + } + return values; +}; + +const readSingleFlagValue = (startupArgs: string[], flag: string) => readFlagValues(startupArgs, flag)[0] ?? ""; + +const formatJson = (value: Record) => JSON.stringify(value, null, 2); + +const parseJsonObject = (value: string): { data?: Record; error?: string } => { + try { + const parsed = JSON.parse(value || "{}"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { error: "JSON must be an object." }; + } + return { data: parsed as Record }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +}; + +type ClaudeSettingsPanelProps = { + locale: Locale; + settings: AppSettings; + onChange: (settings: AppSettings) => void; + t: Translator; +}; + +const ClaudeHelpTip = ({ help }: { help: string }) => ( + + + i + + {help} + +); + +const ClaudeFieldLabel = ({ + label, + help, +}: { + label: string; + help?: string; +}) => ( + + {label} + {help ? : null} + +); + +const ClaudeFieldCopy = ({ + label, + help, + meta, +}: { + label: string; + help?: string; + meta?: string; +}) => ( +
+ + {meta ? {meta} : null} +
+); + +const ClaudeInputField = ({ + label, + help, + meta, + value, + onChange, + placeholder, + type = "text", + testId, + inputMode, + min, + allowSecretReveal = false, + revealLabel, + concealLabel, + className = "", +}: { + label: string; + help?: string; + meta?: string; + value: string | number; + onChange: (value: string) => void; + placeholder?: string; + type?: "text" | "password" | "number"; + testId?: string; + inputMode?: HTMLAttributes["inputMode"]; + min?: number; + allowSecretReveal?: boolean; + revealLabel?: string; + concealLabel?: string; + className?: string; +}) => { + const inputId = useId(); + const [revealed, setRevealed] = useState(false); + const showSecretToggle = type === "password" && allowSecretReveal; + const effectiveType = showSecretToggle && revealed ? "text" : type; + + return ( +
+ +
+ onChange(event.target.value)} + placeholder={placeholder} + data-testid={testId} + /> + {showSecretToggle ? ( + + ) : null} +
+
+ ); +}; + +const ClaudeTextareaField = ({ + label, + help, + meta, + value, + onChange, + placeholder, + rows, + className = "", + testId, +}: { + label: string; + help?: string; + meta?: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + rows: number; + className?: string; + testId?: string; +}) => ( +