diff --git a/README.md b/README.md index 6a24c0a3..55f56d99 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ **Coder Studio, made for vibe coding.** -Run Claude Code and Codex in one workspace. Keep your terminal, files, Git view, and AI sessions available from any device. +An agentic workspace for real development. Run, inspect, and supervise coding agents with terminals, files, Git, sessions, and review in one browser workspace. + +Built-in support today: Claude Code and Codex. Your code and runtime stay on your machine. [![npm version](https://img.shields.io/npm/v/@spencer-kit/coder-studio.svg)](https://www.npmjs.com/package/@spencer-kit/coder-studio) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -21,17 +23,17 @@ Run Claude Code and Codex in one workspace. Keep your terminal, files, Git view, [![Workspace Preview](docs/help/assets/screenshot-desktop-workspace-full.png)](docs/help/assets/screenshot-desktop-workspace-full.png) -
Preview the full workspace layout built for vibe coding, supervision, and device switching.
+
Preview the full workspace layout built for agent runs, review, supervision, and device switching.
## Why It Feels Different -- **One browser workspace for vibe coding** — Keep terminal, files, Git, and agent sessions in one place. +- **One browser workspace for real agent work** — Keep terminals, files, Git, sessions, and review in one place. - **Built for device switching** — Start on desktop, continue on tablet, and check progress from your phone. -- **Objective-driven multi-step orchestration** — Let Supervisor steer long-running AI tasks so you do not have to babysit every turn, reduce repetitive manual prompting, and get more consistent outcomes. +- **Keep control local** — Your code and runtime stay on your machine. ## Why Coder Studio? -Vibe coding agents are powerful, but the raw workflow is still fragmented: +Vibe coding agents are fast, but real development still gets fragmented: - the agent runs in one terminal - files and diffs live in another editor @@ -46,7 +48,7 @@ Coder Studio turns that scattered workflow into one local browser workspace. | Long agent tasks | Watch a terminal or come back later and reconstruct context | Keep sessions, terminal output, files, and Git changes visible in one workspace | | Cross-device work | Use SSH, remote desktop, or rebuild context on another machine | Reopen the same local workspace from desktop, tablet, or phone | | Reviewing AI changes | Jump between terminal, editor, and Git tools | Inspect files and diffs beside the agent session | -| Multiple agents | Manage separate terminal windows and histories | Run Claude and Codex sessions side by side in one workspace | +| Multiple agents | Manage separate terminal windows and histories | Run built-in Claude Code and Codex sessions side by side in one workspace today | | Local-first control | Move work into a hosted IDE or cloud VM | Keep the runtime and project files on your own machine | ## Quick Start @@ -59,7 +61,7 @@ npm install -g @spencer-kit/coder-studio coder-studio open ``` -Your browser opens automatically. Select your project folder and start working with Claude Code or OpenAI Codex. +Your browser opens automatically. Select your project folder and start working with Claude Code or OpenAI Codex today. > **No AI CLI installed yet?** You can still browse files and use the terminal. Install Claude Code or Codex later when needed. @@ -81,7 +83,7 @@ Your browser opens automatically. Select your project folder and start working w ### AI-Assisted Coding -- Run Claude Code and Codex sessions side by side +- Run Claude Code and Codex sessions side by side today - Keep terminal, editor, Git, and supervisor state in one unified interface - Resume active AI work from another device without rebuilding context @@ -113,11 +115,12 @@ The same workspace URL works across all devices — interface adapts automatical |---------|-------------| | **Cross-Device Workspace** | Reopen the same coding environment from desktop, tablet, or phone without rebuilding context | | **Supervisor Loops** | Run objective-driven evaluation and follow-up cycles for long AI tasks with less manual babysitting | -| **Claude Code + Codex** | Use both agent CLIs inside one workspace instead of splitting your workflow across separate tools | -| **Unified Terminal, Editor, and Git** | Keep PTY terminals, Monaco editing, diffs, and changed files in one browser UI | +| **Built-in Agent Providers** | Use Claude Code and Codex inside one workspace today instead of splitting your workflow across separate tools | +| **Unified Terminal, Files, and Git** | Keep PTY terminals, Monaco editing, diffs, and changed files in one browser UI | +| **Reviewable AI Work** | Inspect changed files and diffs beside the session before trusting the result | | **Responsive Workspace UI** | Use layouts tuned for desktop, tablet, and mobile instead of a desktop-only interface squeezed onto small screens | | **Session Continuity** | Resume active sessions and keep AI work visible across device switches | -| **Local-First Runtime** | Keep code and runtime on your machine instead of relying on a cloud IDE | +| **Local Runtime Control** | Keep code and runtime on your machine instead of relying on a cloud IDE | --- @@ -150,7 +153,7 @@ The same workspace URL works across all devices — interface adapts automatical ## 👥 Who Should Use Coder Studio -- **AI Coding Power Users** — Daily Claude Code / Codex users who want better session management +- **Developers Running Coding Agents** — Want terminals, files, Git, sessions, and review in one place - **Multi-Device Developers** — Switch between office, home, and mobile devices frequently - **Developers Running Long AI Tasks** — Want Supervisor to keep multi-step work moving without constant babysitting - **Privacy-Conscious Developers** — Want code to stay on local machine, not cloud IDE diff --git a/README.zh-CN.md b/README.zh-CN.md index 184117cd..885c7c11 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,9 @@ **Coder Studio,生来就是 vibe coding。** -在同一个工作台里运行 Claude Code 和 Codex,让终端、文件、Git 视图和 AI 会话跟着你在不同设备间延续。 +面向真实开发的 agentic workspace。用一个浏览器工作区运行、检查和监督 coding agent,把终端、文件、Git、会话和代码审查放在一起。 + +当前内置支持:Claude Code 和 Codex。你的代码和运行时保留在自己的机器上。 [![npm version](https://img.shields.io/npm/v/@spencer-kit/coder-studio.svg)](https://www.npmjs.com/package/@spencer-kit/coder-studio) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -21,17 +23,17 @@ [![工作区预览](docs/help/assets/screenshot-desktop-workspace-full.png)](docs/help/assets/screenshot-desktop-workspace-full.png) -
预览这个为 vibe coding、Supervisor 监督和跨设备切换而设计的完整工作区布局。
+
预览这个为 Agent 运行、改动审查、Supervisor 监督和跨设备切换而设计的完整工作区布局。
## 为什么它不一样 -- **一个浏览器里完成 AI 编程工作流** — 把终端、文件、Git 和 AI 会话放到同一个工作台。 +- **一个浏览器里完成真实 Agent 工作流** — 把终端、文件、Git、会话和代码审查放到同一个工作台。 - **真正为设备切换而设计** — 在桌面端开始,在平板继续,用手机随时查看 Agent 进度。 -- **目标驱动的多轮调度** — 让 Supervisor 接管长任务推进,你不必全程盯守每一轮输出,减少机械重复的人工催促,并获得更稳定的执行效果。 +- **保留本地控制权** — 你的代码和运行时都留在自己的机器上。 ## 为什么选择 Coder Studio? -vibe coding agent 已经很强,但原始工作流仍然是割裂的: +vibe coding agent 很快,但真实开发里的工作流仍然是割裂的: - Agent 跑在一个终端里 - 文件和 diff 在另一个编辑器里 @@ -46,7 +48,7 @@ Coder Studio 把这些分散的环节收进同一个本地浏览器工作台。 | 长时间 Agent 任务 | 盯着终端,或者回来后重新拼上下文 | 会话、终端输出、文件和 Git 变更都在同一个工作区里 | | 跨设备继续 | SSH、远程桌面,或在另一台机器重新配置 | 桌面、平板、手机重新打开同一个本地工作区 | | 审阅 AI 改动 | 在终端、编辑器、Git 工具之间切换 | 在 Agent 会话旁边直接查看文件和 diff | -| 多 Agent 并行 | 多个终端窗口和历史记录分散管理 | Claude 和 Codex 会话在同一个工作区里并行管理 | +| 多 Agent 并行 | 多个终端窗口和历史记录分散管理 | 今天先把内置的 Claude Code 和 Codex 会话并行放进同一个工作区 | | 本地优先 | 把环境迁到云 IDE 或远程 VM | 运行时和项目文件留在自己的机器上 | ## 快速开始 @@ -81,7 +83,7 @@ coder-studio open ### AI 辅助编程 -- 并行运行 Claude Code 和 Codex 会话 +- 今天并行运行 Claude Code 和 Codex 会话 - 终端、编辑器、Git 和 Supervisor 状态统一在一个界面 - 切换设备后继续当前 AI 工作,不必重新建立上下文 @@ -113,11 +115,12 @@ coder-studio open |------|------| | **跨设备工作区** | 在桌面、平板和手机之间重新打开同一个编码环境,不必重新建立上下文 | | **Supervisor 监督循环** | 围绕目标运行评估与续推循环,减少长任务中的人工盯守 | -| **Claude Code + Codex** | 在同一个工作区里使用两套 Agent CLI,而不是把工作流拆散到多个工具中 | -| **终端、编辑器和 Git 一体化** | 在同一个浏览器界面里完成 PTY 终端、Monaco 编辑、diff 和变更查看 | +| **内置 Agent Provider** | 今天先在同一个工作区里使用 Claude Code 和 Codex,而不是把工作流拆散到多个工具中 | +| **终端、文件和 Git 一体化** | 在同一个浏览器界面里完成 PTY 终端、Monaco 编辑、diff 和变更查看 | +| **可审查的 AI 改动** | 先在 Agent 会话旁检查文件和 diff,再决定是否信任结果 | | **响应式工作区界面** | 提供面向桌面、平板和手机的布局,而不是把桌面界面硬塞进小屏幕 | | **会话连续性** | 切换设备后继续当前活跃会话,让 AI 工作保持可见 | -| **本地优先运行时** | 代码和运行时都留在你的机器上,不依赖云 IDE | +| **本地运行时控制** | 代码和运行时都留在你的机器上,不依赖云 IDE | --- @@ -150,7 +153,7 @@ coder-studio open ## 👥 谁适合使用 -- **AI 编程深度用户** — 每天使用 Claude Code / Codex,想要更好的会话管理 +- **运行 coding agent 的开发者** — 希望把终端、文件、Git、会话和代码审查放到同一个地方 - **多设备开发者** — 频繁在办公室、家和移动设备之间切换 - **运行长任务的开发者** — 希望由 Supervisor 持续推进多轮任务,而不是全程人工盯守 - **注重隐私的开发者** — 希望代码留在本地机器,不依赖云 IDE diff --git a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-3-workspace-intelligence.md b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-3-workspace-intelligence.md index d1facb58..b3c8c667 100644 --- a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-3-workspace-intelligence.md +++ b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-3-workspace-intelligence.md @@ -6,7 +6,7 @@ **Goal:** Add a project understanding layer that summarizes Git, package manager, framework, commands, docs, and agent instruction state for the active workspace. -**Architecture:** Server-side workspace inspection produces a typed `WorkspaceIntelligenceSummary`. Web UI consumes it through a command and renders a compact setup/context panel inside the workspace. +**Architecture:** Server-side workspace inspection produces a typed `WorkspaceIntelligenceSummary`. The command stays server-facing and is reused by later agent-instruction and context flows; no dedicated workspace UI panel is kept in this phase. **Tech Stack:** TypeScript, Node filesystem APIs, Zod, Vitest, React Testing Library. @@ -18,7 +18,6 @@ Includes: - Workspace inspection module. - `workspace.intelligence` command. -- Summary panel in the workspace UI. - Detection for Git, package managers, package scripts, common frameworks, README/docs, and `AGENTS.md`. Excludes: @@ -36,10 +35,6 @@ Excludes: - Create: `packages/server/src/__tests__/workspace/intelligence.test.ts` - Modify: `packages/server/src/commands/workspace.ts` - Create: `packages/server/src/__tests__/workspace-intelligence-command.test.ts` -- Create: `packages/web/src/features/workspace-intelligence/actions/use-workspace-intelligence.ts` -- Create: `packages/web/src/features/workspace-intelligence/components/workspace-intelligence-panel.tsx` -- Create: `packages/web/src/features/workspace-intelligence/components/workspace-intelligence-panel.test.tsx` -- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` ## Data Model @@ -96,15 +91,14 @@ export interface WorkspaceIntelligenceSummary { - [ ] Detect docs via `README.md` and top-level `docs/`. - [ ] Detect `AGENTS.md`. - [ ] Register `workspace.intelligence`. -- [ ] Add a desktop panel showing project type, commands, Git state, docs, and instruction state. -- [ ] Keep the panel action-oriented: show recommended commands and `AGENTS.md` state instead of a passive dashboard. +- [ ] Keep the command server-side and reuse it for later agent-instruction and context features. ## Acceptance Criteria - Opening a workspace can produce a stable typed summary. - Summary works for non-Git folders. - Summary works when `package.json` is missing. -- UI makes clear whether `AGENTS.md` exists. +- UI surfaces for `AGENTS.md` are deferred to the agent-instructions phase. - No provider-specific assumptions are required. ## Verification @@ -112,8 +106,7 @@ export interface WorkspaceIntelligenceSummary { ```bash pnpm exec vitest run \ packages/server/src/__tests__/workspace/intelligence.test.ts \ - packages/server/src/__tests__/workspace-intelligence-command.test.ts \ - packages/web/src/features/workspace-intelligence/components/workspace-intelligence-panel.test.tsx + packages/server/src/__tests__/workspace-intelligence-command.test.ts ``` Expected: all tests pass. @@ -124,4 +117,3 @@ Expected: all tests pass. git add packages/core packages/server packages/web/src/features/workspace-intelligence packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx git commit -m "feat: add workspace intelligence summary" ``` - diff --git a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-4-agent-instructions.md b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-4-agent-instructions.md index 498c53be..060759c9 100644 --- a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-4-agent-instructions.md +++ b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-4-agent-instructions.md @@ -6,7 +6,7 @@ **Goal:** Let users create, inspect, and edit universal project-level agent instructions through `AGENTS.md`. -**Architecture:** Build on Workspace Intelligence. The server generates deterministic `AGENTS.md` content from project facts; the web UI exposes create/edit actions without tying the feature to any single provider. +**Architecture:** Build on Workspace Intelligence. The server generates deterministic `AGENTS.md` content from project facts and exposes read/write/health commands. No separate workspace intelligence panel or dedicated frontend editor is part of this phase. **Tech Stack:** TypeScript, Node filesystem APIs, Zod, Vitest, React Testing Library. @@ -17,8 +17,7 @@ Includes: - `AGENTS.md` generation from workspace intelligence. -- Server commands to read, generate, and write instructions. -- UI panel for instruction state and editing. +- Server commands to read, generate, write, and inspect instructions. - Provider-specific notes section. - Instruction health checks. @@ -39,9 +38,6 @@ Excludes: - Create: `packages/server/src/commands/agent-instructions.ts` - Modify: `packages/server/src/commands/index.ts` - Create: `packages/server/src/__tests__/agent-instructions-command.test.ts` -- Create: `packages/web/src/features/agent-instructions/actions/use-agent-instructions.ts` -- Create: `packages/web/src/features/agent-instructions/components/agent-instructions-panel.tsx` -- Create: `packages/web/src/features/agent-instructions/components/agent-instructions-panel.test.tsx` ## Commands @@ -102,12 +98,7 @@ Omit command lines that are unknown rather than inserting placeholders. - has safety rules - [ ] Implement server commands using safe workspace path resolution. - [ ] Add command tests for missing workspace, missing file, generated content, write roundtrip, and health output. -- [ ] Add UI panel with actions: - - create from project context - - open existing instructions - - save changes - - show health issues -- [ ] Add tests for create, edit, and health states. +- [ ] Add command-only workspace tests for create, edit, and health states. ## Acceptance Criteria @@ -122,8 +113,7 @@ Omit command lines that are unknown rather than inserting placeholders. pnpm exec vitest run \ packages/server/src/__tests__/agent-instructions/generator.test.ts \ packages/server/src/__tests__/agent-instructions/health.test.ts \ - packages/server/src/__tests__/agent-instructions-command.test.ts \ - packages/web/src/features/agent-instructions/components/agent-instructions-panel.test.tsx + packages/server/src/__tests__/agent-instructions-command.test.ts ``` Expected: all tests pass. @@ -131,7 +121,6 @@ Expected: all tests pass. ## Suggested Commit ```bash -git add packages/core packages/server packages/web/src/features/agent-instructions +git add packages/core packages/server docs/superpowers/plans/2026-05-17-agentic-workspace-phase-4-agent-instructions.md git commit -m "feat: add universal agent instructions" ``` - diff --git a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-5-custom-agent-mvp.md b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-5-custom-agent-mvp.md index 80a80c8d..10ccfe33 100644 --- a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-5-custom-agent-mvp.md +++ b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-5-custom-agent-mvp.md @@ -6,7 +6,7 @@ **Goal:** Allow users to define a command-based custom coding agent and launch it like a built-in provider. -**Architecture:** Add persistent custom provider configs that are converted into runtime `ProviderDefinition` objects on the server. Keep the MVP limited to command-based PTY sessions and one-shot command sessions. +**Architecture:** Add persistent custom provider configs that are converted into runtime `ProviderDefinition` objects on the server. In the current execution scope, keep the MVP limited to command-based PTY sessions only and defer any separate one-shot runtime model. **Tech Stack:** TypeScript, SQLite repository pattern, Zod, Vitest, existing PTY session manager. @@ -18,17 +18,18 @@ Includes: - Custom provider storage. - Command-based custom provider definition builder. -- Settings UI for custom providers. - Launch support through existing `session.create`. - Basic command validation. Excludes: +- Frontend settings/editor UI for custom providers. Deferred to a later UX phase. - Marketplace. - Community provider import/export. - OAuth/auth setup. - Vendor-specific install diagnosis. - Complex output parsing. +- One-shot session runtime support beyond the existing PTY-based interactive launch path. ## Files @@ -43,16 +44,13 @@ Excludes: - Modify: `packages/server/src/commands/index.ts` - Modify: `packages/server/src/server.ts` - Create: `packages/server/src/__tests__/custom-provider-command.test.ts` -- Create: `packages/web/src/features/agent-providers/components/custom-provider-form.tsx` -- Create: `packages/web/src/features/agent-providers/components/custom-provider-form.test.tsx` -- Modify: `packages/web/src/features/settings/components/provider-settings.tsx` ## Data Model Add: ```ts -export type CustomProviderSessionMode = "interactive" | "one_shot"; +export type CustomProviderSessionMode = "interactive"; export interface CustomProviderConfig { id: string; @@ -99,14 +97,6 @@ CREATE TABLE IF NOT EXISTS custom_providers ( - [ ] Make `buildCommand` resolve cwd to workspace root. - [ ] Merge built-in registry and custom provider definitions in server command context. - [ ] Add command tests for create/update/delete/list. -- [ ] Add settings UI form with fields: - - display name - - command - - args - - env vars - - session mode - - startup prompt - - capabilities - [ ] Add launch test proving a custom provider can be selected and passed to `session.create`. ## Acceptance Criteria @@ -123,8 +113,7 @@ CREATE TABLE IF NOT EXISTS custom_providers ( pnpm exec vitest run \ packages/server/src/__tests__/custom-provider-repo.test.ts \ packages/server/src/__tests__/provider-runtime/custom-provider.test.ts \ - packages/server/src/__tests__/custom-provider-command.test.ts \ - packages/web/src/features/agent-providers/components/custom-provider-form.test.tsx + packages/server/src/__tests__/custom-provider-command.test.ts ``` Expected: all tests pass. @@ -132,7 +121,6 @@ Expected: all tests pass. ## Suggested Commit ```bash -git add packages/core packages/server packages/web/src/features/agent-providers packages/web/src/features/settings/components/provider-settings.tsx +git add packages/core packages/server git commit -m "feat: add command-based custom agents" ``` - diff --git a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-6-session-metadata.md b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-6-session-metadata.md index 48f00d94..fcb85106 100644 --- a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-6-session-metadata.md +++ b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-6-session-metadata.md @@ -24,7 +24,7 @@ Includes: Excludes: -- Full review UI. +- Full review UI. Deferred to a later UX phase. - Perfect attribution of file changes to a session. - Automatic test command execution. @@ -38,8 +38,6 @@ Excludes: - Create: `packages/server/src/commands/session-metadata.ts` - Modify: `packages/server/src/commands/index.ts` - Create: `packages/server/src/__tests__/session-metadata-command.test.ts` -- Modify: `packages/web/src/features/agent-panes/components/session-card.tsx` -- Modify: `packages/web/src/features/agent-panes/components/session-card.test.tsx` ## Data Model @@ -76,7 +74,6 @@ export interface AgentSessionVerificationRun { - [ ] Register `session.metadata.get`. - [ ] Register `session.verification.add`. - [ ] Add tests for non-Git workspaces, Git workspaces, and verification append. -- [ ] Update session card to show objective and baseline state when available. ## Acceptance Criteria @@ -91,8 +88,7 @@ export interface AgentSessionVerificationRun { pnpm exec vitest run \ packages/server/src/__tests__/session-metadata-repo.test.ts \ packages/server/src/__tests__/session-metadata-command.test.ts \ - packages/server/src/__tests__/session-commands.test.ts \ - packages/web/src/features/agent-panes/components/session-card.test.tsx + packages/server/src/__tests__/session-commands.test.ts ``` Expected: all tests pass. @@ -100,7 +96,6 @@ Expected: all tests pass. ## Suggested Commit ```bash -git add packages/core packages/server packages/web/src/features/agent-panes +git add packages/core packages/server git commit -m "feat: add agent session metadata" ``` - diff --git a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-7-ai-change-review.md b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-7-ai-change-review.md index 465002fb..b11b7db8 100644 --- a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-7-ai-change-review.md +++ b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-7-ai-change-review.md @@ -6,7 +6,7 @@ **Goal:** Add a review flow that shows changed files and diffs for an agent session using the session Git baseline. -**Architecture:** Use Git baseline metadata as the review anchor. Server commands compute changed files and diffs against baseline; web UI renders a session review panel beside the agent session. +**Architecture:** Use Git baseline metadata as the review anchor. Server commands compute changed files and diffs against baseline; frontend rendering is explicitly deferred in the current execution scope. **Tech Stack:** TypeScript, Git CLI helpers, Zod, Vitest, React Testing Library. @@ -18,12 +18,10 @@ Includes: - Review summary command. - Per-file diff command for session baseline. -- Session review panel. -- Verification checklist display. -- Manual "mark verification" action. Excludes: +- Session review panel and verification UI. Deferred to a later UX phase. - Automatic causal attribution. - Automatic accept/discard hunk UI. - LLM-generated review summary. @@ -37,10 +35,6 @@ Excludes: - Create: `packages/server/src/commands/session-review.ts` - Modify: `packages/server/src/commands/index.ts` - Create: `packages/server/src/__tests__/session-review-command.test.ts` -- Create: `packages/web/src/features/session-review/actions/use-session-review.ts` -- Create: `packages/web/src/features/session-review/components/session-review-panel.tsx` -- Create: `packages/web/src/features/session-review/components/session-review-panel.test.tsx` -- Modify: `packages/web/src/features/agent-panes/views/shared/session-card.tsx` ## Data Model @@ -69,18 +63,12 @@ Add: ## Tasks -- [ ] Implement changed file detection from `baselineGitHead` to working tree. -- [ ] Return a warning when baseline is missing. -- [ ] Return a warning when workspace is not a Git repo. -- [ ] Implement per-file diff against baseline. -- [ ] Register session review commands. -- [ ] Add UI panel showing: - - changed files - - selected diff - - verification runs - - baseline warnings -- [ ] Add action to manually add verification result using `session.verification.add`. -- [ ] Add tests for clean session, changed session, missing baseline, and non-Git workspace. +- [x] Implement changed file detection from `baselineGitHead` to working tree. +- [x] Return a warning when baseline is missing. +- [x] Return a warning when workspace is not a Git repo. +- [x] Implement per-file diff against baseline. +- [x] Register session review commands. +- [x] Add tests for clean session, changed session, missing baseline, and non-Git workspace. ## Acceptance Criteria @@ -94,8 +82,7 @@ Add: ```bash pnpm exec vitest run \ packages/server/src/__tests__/session-review/review.test.ts \ - packages/server/src/__tests__/session-review-command.test.ts \ - packages/web/src/features/session-review/components/session-review-panel.test.tsx + packages/server/src/__tests__/session-review-command.test.ts ``` Expected: all tests pass. @@ -103,7 +90,6 @@ Expected: all tests pass. ## Suggested Commit ```bash -git add packages/core packages/server packages/web/src/features/session-review packages/web/src/features/agent-panes +git add packages/core packages/server git commit -m "feat: add agent session change review" ``` - diff --git a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-8-context-attach-presets.md b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-8-context-attach-presets.md index 74f86480..9aa56058 100644 --- a/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-8-context-attach-presets.md +++ b/docs/superpowers/plans/2026-05-17-agentic-workspace-phase-8-context-attach-presets.md @@ -4,9 +4,9 @@ > > **Spec:** `docs/superpowers/specs/2026-05-17-agentic-workspace-platform-design.md` -**Goal:** Let users send workspace context to selected agents and add a lightweight preset foundation for future providers. +**Goal:** Add deterministic workspace-context package builders and a lightweight preset foundation for future providers. -**Architecture:** Build a provider-agnostic context package format. UI actions create text payloads from files, diffs, terminal output, project summary, or session review and send them to a selected existing or new session. +**Architecture:** Build a provider-agnostic context package format. In the current execution scope, server commands create deterministic text payloads from files, diffs, project summary, or session review. Frontend send actions are explicitly deferred. **Tech Stack:** TypeScript, React, existing terminal/session input actions, Vitest, React Testing Library. @@ -18,12 +18,12 @@ Includes: - Context package data model. - Server helpers for file/diff/project/session context. -- Frontend "Send to agent" actions. -- Send diff to another agent. - Preset provider metadata format, without installing presets by default. Excludes: +- Frontend "Send to agent" menus and routing UI. Deferred to a later UX phase. +- Automatic session input submission from generated context packages. - Real marketplace. - Remote registry downloads. - OAuth. @@ -38,11 +38,6 @@ Excludes: - Create: `packages/server/src/commands/agent-context.ts` - Modify: `packages/server/src/commands/index.ts` - Create: `packages/server/src/__tests__/agent-context-command.test.ts` -- Create: `packages/web/src/features/agent-context/actions/use-send-context-to-agent.ts` -- Create: `packages/web/src/features/agent-context/components/send-context-menu.tsx` -- Create: `packages/web/src/features/agent-context/components/send-context-menu.test.tsx` -- Modify: `packages/web/src/features/session-review/components/session-review-panel.tsx` -- Modify: `packages/web/src/features/code-editor/views/shared/code-editor-host.tsx` - Create: `packages/providers/src/presets.ts` - Create: `packages/providers/src/presets.test.ts` @@ -85,8 +80,8 @@ Add: ## Tasks -- [ ] Add context package types. -- [ ] Implement deterministic wrappers: +- [x] Add context package types. +- [x] Implement deterministic wrappers: ```text Context: [title] @@ -95,20 +90,13 @@ Source: [source] [body] ``` -- [ ] Implement context builders for file, diff, project summary, and session review. -- [ ] Add send action that can: - - append context to an existing session - - start a new session with context as draft -- [ ] Add UI menu actions: - - Send file to agent - - Send diff to agent - - Send review summary to agent - - Send project context to agent -- [ ] Add first preset metadata for future providers without exposing them as enabled providers: +- [x] Implement context builders for file, diff, project summary, and session review. +- [x] Add command coverage for deterministic context package generation only. +- [x] Add first preset metadata for future providers without exposing them as enabled providers: - Gemini CLI - Aider - OpenCode -- [ ] Add tests for context body shape and selected-agent routing. +- [x] Add tests for context body shape and preset metadata shape. ## Acceptance Criteria @@ -123,7 +111,6 @@ Source: [source] pnpm exec vitest run \ packages/server/src/__tests__/agent-context/context-package.test.ts \ packages/server/src/__tests__/agent-context-command.test.ts \ - packages/web/src/features/agent-context/components/send-context-menu.test.tsx \ packages/providers/src/presets.test.ts ``` @@ -132,7 +119,6 @@ Expected: all tests pass. ## Suggested Commit ```bash -git add packages/core packages/server packages/web/src/features/agent-context packages/web/src/features/session-review packages/web/src/features/code-editor packages/providers +git add packages/core packages/server packages/providers git commit -m "feat: attach workspace context to agents" ``` - diff --git a/docs/superpowers/plans/2026-05-25-welcome-first-session-activation.md b/docs/superpowers/plans/2026-05-25-welcome-first-session-activation.md new file mode 100644 index 00000000..7df48c42 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-welcome-first-session-activation.md @@ -0,0 +1,365 @@ +# Welcome First-Session Activation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the welcome page tell new users exactly what to do first so they open a workspace and understand that the next step is starting Claude Code or Codex inside it. + +**Architecture:** Keep the existing welcome-page shell, modal launch behavior, and settings navigation intact. Implement the change as a narrow welcome-page copy and structure update: add three action hints around the existing buttons, rewrite the localized hero and feature-card copy, then add lightweight CSS rules so the new instructional text reads clearly on desktop and mobile. + +**Tech Stack:** React 19, React Router, Jotai-powered locale switching, JSON locale files, shared CSS in `packages/web/src/styles/components.css`, Vitest, and Testing Library. + +**Spec reference:** `docs/superpowers/specs/2026-05-25-welcome-first-session-activation-design.md` + +--- + +## File Structure + +**Modify:** +- `packages/web/src/features/welcome/index.tsx` — add instructional hint copy around the existing primary and secondary actions +- `packages/web/src/features/welcome/index.test.tsx` — cover the new English and Chinese activation copy and the new hint nodes +- `packages/web/src/locales/en.json` — replace welcome hero / feature copy and add the three new hint keys +- `packages/web/src/locales/zh.json` — mirror the English copy model with concise Chinese guidance +- `packages/web/src/styles/components.css` — add lightweight classes for the new hint rows and mobile width overrides +- `packages/web/src/styles/components.theme.test.ts` — verify the new welcome hint selectors stay within the existing flat-shell design language + +**Testing commands used in this plan:** +- `pnpm --filter @coder-studio/web exec vitest run src/features/welcome/index.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/features/welcome/index.test.tsx src/styles/components.theme.test.ts` + +--- + +### Task 1: Update Welcome Copy And Hint Rendering + +**Files:** +- Modify: `packages/web/src/features/welcome/index.test.tsx` +- Modify: `packages/web/src/features/welcome/index.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write the failing welcome-page tests** + +In `packages/web/src/features/welcome/index.test.tsx`, replace the existing English copy test with the following block and insert the Chinese test immediately after it: + +```tsx + it("renders task-oriented English activation copy and action hints", () => { + const store = createStore(); + store.set(localeAtom, "en"); + + render( + + + + + + ); + + expect(screen.getByText("LOCAL AI CODING WORKSPACE")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { + name: "Open a workspace. Start an AI coding session.", + }) + ).toBeInTheDocument(); + expect( + screen.getByText( + "Choose a local project folder to get started. Inside the workspace, you can launch Claude Code or Codex in the same place where you edit files, inspect Git changes, and watch terminal output." + ) + ).toBeInTheDocument(); + expect(screen.getByText("Step 1: Open your project folder")).toBeInTheDocument(); + expect( + screen.getByText("Step 2 happens inside the workspace: start Claude or Codex.") + ).toBeInTheDocument(); + expect(screen.getByText("Need to configure providers first?")).toBeInTheDocument(); + expect(screen.getByText("Start Claude or Codex sessions")).toBeInTheDocument(); + expect(screen.getByText("Review code and Git side by side")).toBeInTheDocument(); + expect(screen.getByText("Run commands in the same workspace")).toBeInTheDocument(); + expect(document.querySelector(".welcome-card__hero")).toBeTruthy(); + expect(document.querySelector(".welcome-card__actions")).toBeTruthy(); + expect(document.querySelector(".welcome-card__features")).toBeTruthy(); + expect(document.querySelector(".welcome-actions-group")).toBeTruthy(); + + const openWorkspaceButton = screen.getByRole("button", { name: "Open Workspace" }); + const settingsButton = screen.getByRole("button", { name: "Settings" }); + const featureCards = Array.from(document.querySelectorAll(".welcome-feature")); + + expect(featureCards).toHaveLength(3); + expect( + openWorkspaceButton.querySelector('[data-icon-semantic="nav.newWorkspace"]') + ).toBeTruthy(); + expect(settingsButton.querySelector('[data-icon-semantic="nav.settings"]')).toBeTruthy(); + }); + + it("renders translated Chinese activation copy and action hints when locale is set to zh", () => { + const store = createStore(); + store.set(localeAtom, "zh"); + + render( + + + + + + ); + + expect(screen.getByText("本地 AI 编码工作台")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "先打开工作区,再启动 AI 编码会话" }) + ).toBeInTheDocument(); + expect( + screen.getByText( + "先选择一个本地项目目录。进入工作区后,你就可以在同一个界面里启动 Claude Code 或 Codex,同时查看文件、Git 变更和终端输出。" + ) + ).toBeInTheDocument(); + expect(screen.getByText("第 1 步:打开你的项目目录")).toBeInTheDocument(); + expect( + screen.getByText("第 2 步会在工作区里完成:启动 Claude 或 Codex。") + ).toBeInTheDocument(); + expect( + screen.getByText("如果你需要先配置 Provider,可以先去设置。") + ).toBeInTheDocument(); + expect(screen.getByText("启动 Claude 或 Codex 会话")).toBeInTheDocument(); + expect(screen.getByText("并排查看代码和 Git 变更")).toBeInTheDocument(); + expect(screen.getByText("在同一工作区运行命令")).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run the welcome-page test to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/welcome/index.test.tsx +``` + +Expected: +- FAIL because the current locale files still contain the old welcome copy +- FAIL because the current component does not render `welcome.primary_hint`, `welcome.secondary_hint`, or `welcome.settings_hint` + +- [ ] **Step 3: Implement the minimal welcome-page and locale changes** + +In `packages/web/src/features/welcome/index.tsx`, replace the current `.welcome-card__actions` block with: + +```tsx +
+
+

{t("welcome.primary_hint")}

+ + + +

{t("welcome.secondary_hint")}

+

{t("welcome.settings_hint")}

+ + +
+
+``` + +In `packages/web/src/locales/en.json`, replace the entire `welcome` object with: + +```json + "welcome": { + "kicker": "LOCAL AI CODING WORKSPACE", + "title": "Open a workspace. Start an AI coding session.", + "description": "Choose a local project folder to get started. Inside the workspace, you can launch Claude Code or Codex in the same place where you edit files, inspect Git changes, and watch terminal output.", + "primary_hint": "Step 1: Open your project folder", + "secondary_hint": "Step 2 happens inside the workspace: start Claude or Codex.", + "settings_hint": "Need to configure providers first?", + "features": { + "agent_first": { + "title": "Start Claude or Codex sessions", + "description": "Open a workspace first, then launch an AI session for that project." + }, + "git_tools": { + "title": "Review code and Git side by side", + "description": "Inspect files and changes next to the agent instead of switching between tools." + }, + "terminals": { + "title": "Run commands in the same workspace", + "description": "Use integrated terminals alongside your AI session when you need manual control." + } + } + }, +``` + +In `packages/web/src/locales/zh.json`, replace the entire `welcome` object with: + +```json + "welcome": { + "kicker": "本地 AI 编码工作台", + "title": "先打开工作区,再启动 AI 编码会话", + "description": "先选择一个本地项目目录。进入工作区后,你就可以在同一个界面里启动 Claude Code 或 Codex,同时查看文件、Git 变更和终端输出。", + "primary_hint": "第 1 步:打开你的项目目录", + "secondary_hint": "第 2 步会在工作区里完成:启动 Claude 或 Codex。", + "settings_hint": "如果你需要先配置 Provider,可以先去设置。", + "features": { + "agent_first": { + "title": "启动 Claude 或 Codex 会话", + "description": "先打开工作区,再为当前项目启动一个 AI 会话。" + }, + "git_tools": { + "title": "并排查看代码和 Git 变更", + "description": "在 Agent 旁边直接查看文件和改动,不用在多个工具之间来回切换。" + }, + "terminals": { + "title": "在同一工作区运行命令", + "description": "需要手动操作时,可以直接在集成终端里配合 AI 会话执行命令。" + } + } + }, +``` + +- [ ] **Step 4: Run the welcome-page test to verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/welcome/index.test.tsx +``` + +Expected: +- PASS for the updated English copy assertions +- PASS for the new Chinese copy assertions +- PASS for the existing modal-open, settings-navigation, and mobile-shell assertions + +- [ ] **Step 5: Commit the rendering and locale changes** + +Run: + +```bash +git add \ + packages/web/src/features/welcome/index.tsx \ + packages/web/src/features/welcome/index.test.tsx \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json +git commit -m "feat(web): clarify welcome first-session activation copy" +``` + +--- + +### Task 2: Add Lightweight Welcome Hint Styling + +**Files:** +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Modify: `packages/web/src/styles/components.css` + +- [ ] **Step 1: Write the failing style test** + +In `packages/web/src/styles/components.theme.test.ts`, insert the following test immediately after `it("keeps auth and welcome shells on flat page surfaces", () => { ... })`: + +```ts + it("styles welcome activation hints as supporting copy around the primary action", () => { + const actionsGroup = getLastRuleBlock(".welcome-actions-group"); + const stepHint = getLastRuleBlock(".welcome-step-hint"); + const stepDetail = getLastRuleBlock(".welcome-step-detail"); + const settingsHint = getLastRuleBlock(".welcome-settings-hint"); + const mobileStepDetail = getLastRuleBlock(".welcome-card--mobile .welcome-step-detail"); + + expect(actionsGroup).toContain("align-items: flex-start"); + expect(stepHint).toContain("width: 100%"); + expect(stepHint).toContain("text-transform: uppercase"); + expect(stepHint).toContain("color: var(--text-ter)"); + expect(stepDetail).toContain("max-width: 440px"); + expect(stepDetail).toContain("color: var(--text-secondary)"); + expect(settingsHint).toContain("padding-top: var(--sp-2)"); + expect(settingsHint).toContain("color: var(--text-ter)"); + expect(mobileStepDetail).toContain("max-width: none"); + }); +``` + +- [ ] **Step 2: Run the style test to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: +- FAIL because `.welcome-step-hint`, `.welcome-step-detail`, and `.welcome-settings-hint` do not exist yet +- FAIL because `.welcome-actions-group` still uses `align-items: center` + +- [ ] **Step 3: Implement the minimal CSS** + +In `packages/web/src/styles/components.css`, replace the existing `.welcome-actions-group` rule and add the new welcome hint rules directly below it: + +```css +.welcome-actions-group { + display: flex; + align-items: flex-start; + gap: var(--sp-3); + flex-wrap: wrap; +} + +.welcome-step-hint, +.welcome-step-detail, +.welcome-settings-hint { + width: 100%; + margin: 0; +} + +.welcome-step-hint { + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-ter); +} + +.welcome-step-detail { + max-width: 440px; + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); + color: var(--text-secondary); +} + +.welcome-settings-hint { + padding-top: var(--sp-2); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); + color: var(--text-ter); +} +``` + +In the existing mobile welcome section of `packages/web/src/styles/components.css`, add this block immediately after `.welcome-card--mobile .welcome-body`: + +```css + .welcome-card--mobile .welcome-step-detail, + .welcome-card--mobile .welcome-settings-hint { + max-width: none; + } +``` + +- [ ] **Step 4: Run the focused verification suite** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/welcome/index.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: +- PASS for the welcome-page rendering tests +- PASS for the new CSS rule assertions +- PASS for the existing flat-shell assertions in `components.theme.test.ts` + +- [ ] **Step 5: Commit the CSS changes** + +Run: + +```bash +git add \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts +git commit -m "style(web): support welcome activation hints" +``` diff --git a/docs/superpowers/plans/2026-05-26-welcome-step-first-redesign.md b/docs/superpowers/plans/2026-05-26-welcome-step-first-redesign.md new file mode 100644 index 00000000..eda80ebc --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-welcome-step-first-redesign.md @@ -0,0 +1,53 @@ +# Welcome Step-First Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign the welcome page so the two-step workspace flow dominates the screen, desktop actions no longer wrap awkwardly, and short mobile screens can scroll. + +**Architecture:** Keep shared welcome shell primitives for auth/not-found intact, then layer a welcome-page-specific layout on top. Update tests first to lock the new DOM and layout contract before changing JSX, copy, and CSS. + +**Tech Stack:** React 19, React Router, Jotai, CSS tokens, Vitest, Testing Library + +--- + +### Task 1: Lock the new welcome-page contract in tests + +**Files:** +- Modify: `packages/web/src/features/welcome/index.test.tsx` +- Test: `packages/web/src/features/welcome/index.test.tsx` + +- [ ] Add assertions for a step-first layout with a dedicated workflow section and compact supporting summary. +- [ ] Verify the new test fails before implementation. +- [ ] Keep modal-open and settings-navigation coverage intact. + +### Task 2: Implement the step-first welcome layout + +**Files:** +- Modify: `packages/web/src/features/welcome/index.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] Replace the action-wrap layout with a structured hero, workflow, and secondary summary. +- [ ] Keep the primary action opening the workspace launch modal. +- [ ] Keep the secondary action navigating to settings. +- [ ] Reduce welcome-page supporting content from prominent cards to compact summary items. + +### Task 3: Rework welcome styling for desktop and short mobile screens + +**Files:** +- Modify: `packages/web/src/styles/components.css` +- Test: `packages/web/src/styles/components.theme.test.ts` + +- [ ] Add a welcome-page-specific grid layout for desktop. +- [ ] Add a scrollable mobile shell with tighter spacing and a single-column workflow. +- [ ] Preserve shared auth/not-found shells by limiting changes to welcome-page-specific selectors where possible. +- [ ] Update theme/style assertions for the mobile scroll contract. + +### Task 4: Verify targeted regression coverage + +**Files:** +- Test: `packages/web/src/features/welcome/index.test.tsx` +- Test: `packages/web/src/styles/components.theme.test.ts` + +- [ ] Run `pnpm --filter @coder-studio/web test -- src/features/welcome/index.test.tsx src/styles/components.theme.test.ts` +- [ ] Confirm the updated welcome page behavior and style contract pass together. diff --git a/docs/superpowers/specs/2026-05-25-welcome-first-session-activation-design.md b/docs/superpowers/specs/2026-05-25-welcome-first-session-activation-design.md new file mode 100644 index 00000000..aaa25af8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-welcome-first-session-activation-design.md @@ -0,0 +1,283 @@ +# Welcome First-Session Activation Design + +> Status: Draft +> Date: 2026-05-25 +> Scope: `packages/web/src/features/welcome/index.tsx`, `packages/web/src/features/welcome/index.test.tsx`, `packages/web/src/locales/en.json`, `packages/web/src/locales/zh.json`, `packages/web/src/styles/components.css` + +## Goal + +Improve first-session activation for new users by making the welcome page explain the first successful path clearly: + +- open a workspace +- understand that the next step is to start Claude Code or Codex inside that workspace + +This work targets comprehension before environment repair, cross-device continuation, or Supervisor differentiation. + +## Problem + +The current welcome page is structurally simple but still behaves more like a product-introduction surface than a first-task surface. + +Current issues: + +- the hero copy emphasizes product positioning more than the next user action +- `Open Workspace` is the right primary CTA, but the page does not make the two-step path explicit +- `Settings` is available, but the page does not explain when a user should choose it +- the three feature cards describe capabilities broadly instead of telling the user what they can do immediately after opening a workspace + +For the target outcome in this iteration, the failure mode is not “the product lacks onboarding.” The failure mode is “a new user lands here and does not immediately understand what to click first.” + +## Decision + +Adopt a task-oriented welcome page that keeps the existing direct launch flow while changing the page from a brand-forward intro surface into a first-task activation surface. + +The welcome page should communicate this path explicitly: + +`Open Workspace -> Enter workspace -> Start Claude or Codex session` + +This remains an inline-first activation change. + +It does **not** introduce: + +- a dedicated onboarding route +- a wizard or forced first-run flow +- tooltip tours +- first-run persistence state +- changes to workspace launch behavior + +## Why This Approach + +Three implementation directions were considered: + +### 1. Copy-only polish + +Pros: + +- smallest change +- lowest implementation risk + +Cons: + +- likely too weak to fix the main comprehension problem +- does not create a clear first-step / second-step mental model + +### 2. Task-oriented welcome page rewrite + +Pros: + +- directly addresses the “what do I click first?” problem +- keeps healthy flows uninterrupted +- requires only welcome-page copy, layout, and test updates +- aligns with the existing conversion-first activation direction + +Cons: + +- does not help users who become confused after entering the workspace + +### 3. Dedicated onboarding interaction + +Pros: + +- strongest prompt strength + +Cons: + +- adds ceremony to a flow that already has a correct primary action +- introduces first-run state and more behavioral surface area +- conflicts with the inline-first activation direction already defined elsewhere + +Chosen approach: **2. Task-oriented welcome page rewrite** + +## Experience Principles + +### One Primary Action + +The page should continue to optimize for a single first action: `Open Workspace`. + +### Explain The Immediate Next Step + +The page should tell the user what happens after the workspace opens so that the first action feels purposeful, not blind. + +### Do Not Interrupt Healthy Users + +Users who already know the flow should still be able to click the button and proceed immediately. + +### Keep Settings Secondary + +`Settings` should remain available, but only as a recovery or preparation path for users who already know they need provider configuration. + +### Sell Outcomes, Not Product Breadth + +The three supporting cards should emphasize what the user can do right after activation instead of advertising broad platform features. + +## Proposed Page Structure + +The existing welcome page keeps the same overall shell: + +- hero section +- action section +- three-card supporting section + +The change is in information hierarchy and content, not in route structure or major interaction design. + +### Hero + +The hero should answer: + +- what is this surface for +- what is the first action +- what happens next + +Required copy direction: + +- kicker becomes product-category oriented, not slogan oriented +- title becomes action-oriented +- description explains that the user first chooses a local project folder and then starts Claude Code or Codex inside that workspace + +### Action Section + +The action section becomes the page’s center of gravity. + +Required content: + +- a short pre-CTA hint: `Step 1` +- the existing `Open Workspace` button as the only primary action +- a short post-CTA hint: `Step 2 happens inside the workspace` +- a secondary settings hint explaining when `Settings` is relevant +- the existing `Settings` button as a secondary action + +This creates guidance without adding any new step enforcement. + +### Supporting Cards + +Keep exactly three cards to avoid visual churn and preserve the existing structure. + +Required card themes: + +1. start Claude or Codex sessions +2. review files and Git changes beside the agent +3. run commands in the same workspace + +These cards should describe immediate, post-activation outcomes instead of broad product capabilities. + +## Copy Model + +The recommended content model is: + +- `welcome.kicker` +- `welcome.title` +- `welcome.description` +- `welcome.primary_hint` +- `welcome.secondary_hint` +- `welcome.settings_hint` +- existing `action.open_workspace` +- existing `action.settings` +- updated `welcome.features.*` + +The new hint keys should remain short and literal. They are instructional copy, not marketing copy. + +## Layout And Styling + +This design intentionally avoids a new visual shell. The welcome page already has a stable structure and recent welcome/auth work has established the flat entry-page direction. + +Required layout changes: + +- keep the current hero / actions / features stacking +- add the new hint lines inside the existing action group +- visually separate: + - pre-CTA hint + - primary CTA + - post-CTA hint + - settings hint + - secondary action + +Styling guidelines: + +- `primary_hint` should be more noticeable than generic body copy, but lower than the page title +- `secondary_hint` and `settings_hint` should use secondary text treatment +- mobile should keep the same reading order without introducing a new layout mode + +Expected CSS additions are small utility classes rather than a welcome-page redesign. + +## Behavior + +No interaction behavior changes are required. + +Specifically: + +- `Open Workspace` still opens `WorkspaceLaunchModal` +- `Settings` still navigates to `/settings` +- no local storage state is introduced +- no first-run detection is introduced +- no workspace-launch modal changes are required in this iteration + +## File-Level Impact + +### `packages/web/src/features/welcome/index.tsx` + +- add the three new hint text nodes +- keep the existing button behavior +- keep the existing feature-card count and icon semantics + +### `packages/web/src/locales/en.json` + +- replace existing welcome hero copy +- replace feature-card copy +- add `welcome.primary_hint` +- add `welcome.secondary_hint` +- add `welcome.settings_hint` + +### `packages/web/src/locales/zh.json` + +- mirror the English content model with concise Chinese instructional copy + +### `packages/web/src/styles/components.css` + +- add lightweight classes for the new hints +- keep the rest of the welcome shell stable + +### `packages/web/src/features/welcome/index.test.tsx` + +- update copy assertions for the new hero text +- assert the presence of the three new hint lines +- preserve existing structural assertions around hero, actions, and features + +## Out Of Scope + +This iteration does not include: + +- post-workspace guidance inside the workspace +- provider recommendation logic +- provider install or auth guidance changes +- diagnostics changes +- analytics infrastructure changes +- mobile continuation or Supervisor onboarding +- return-user summary behavior + +## Success Criteria + +This iteration should make the first activation path easier to understand before any deeper onboarding work is considered. + +Signals to watch after implementation: + +- higher rate of `Open Workspace` clicks from the welcome page +- shorter time from welcome page render to workspace-launch modal open +- lower likelihood that new users detour into `Settings` as their first action +- faster progression from welcome page to the first provider launch attempt + +## Testing + +The change should be covered by targeted welcome-page tests: + +- English copy rendering +- mobile class retention +- `Open Workspace` button still opens the modal +- `Settings` button still navigates correctly +- new hint lines render in the action section + +Styling work should stay small enough that targeted component/theme assertions are only updated if the new classes require them. + +## Open Questions + +No blocking product questions remain for this MVP. + +The only deferred question is whether a second-stage in-workspace hint is needed after this welcome-page pass ships. That should be decided from user behavior after the current iteration, not folded into the same scope now. diff --git a/docs/superpowers/specs/2026-05-26-welcome-screenshot-layout-refinement-design.md b/docs/superpowers/specs/2026-05-26-welcome-screenshot-layout-refinement-design.md new file mode 100644 index 00000000..140131a7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-welcome-screenshot-layout-refinement-design.md @@ -0,0 +1,147 @@ +# Welcome Screenshot Layout Refinement + +## Goal + +Refine the existing welcome page redesign using real desktop and mobile screenshots so the first-run flow is clearer, shorter, and more balanced across screen sizes. + +This refinement keeps the same product message: + +1. Open a workspace +2. Start an AI coding session inside it + +The change focuses on layout hierarchy, action placement, and responsive density rather than new functionality. + +## Evidence From Current Screenshots + +The current welcome scene was captured from the existing `ui-preview` welcome route on both desktop and mobile. + +### Desktop Findings + +- The hero copy column feels visually heavier than the workflow column. +- Step 1 and Step 2 render in a single vertical stack, so the workflow does not feel like the main object on wide screens. +- The settings action lives inside the Step 2 card, which makes it look like part of the main workflow instead of optional setup help. +- The lower support section is relatively prominent compared with the step flow. +- The card leaves unused horizontal space that can be reassigned to the two core steps. + +### Mobile Findings + +- The first screen spends too much height on the hero before the user reaches the full workflow. +- Step cards are readable, but the support section still competes for attention after the flow. +- The settings action consumes large step-card space even though it is secondary. +- The screen can scroll, but the information density still makes short devices feel long and top-heavy. + +## Chosen Approach + +Use a step-priority layout with responsive step grouping. + +### Why This Approach + +- It preserves the current welcome-page architecture and copy model. +- It addresses the user’s explicit requirement that desktop Step 1 and Step 2 sit on one row while mobile stacks them vertically. +- It separates the settings action from the main workflow without introducing extra navigation complexity. +- It reduces mobile perceived length by demoting secondary content instead of deleting useful context. + +## Layout Design + +### Desktop + +- Keep the landing card as a two-column shell: + - left: kicker, title, short description + - right: workflow section +- Change the workflow body to a two-column steps grid: + - Step 1 card on the left + - Step 2 card on the right +- Move the settings action out of Step 2 and place it below the steps as a separate, low-emphasis support row. +- Keep the support summary below the main grid, but visually lighter than the workflow cards. + +### Mobile + +- Keep a single-column shell. +- Reduce hero perceived height through tighter spacing and shorter line-length presentation. +- Stack Step 1 and Step 2 vertically. +- Place the settings action below the step stack as a standalone secondary row. +- Keep the support items below the workflow as compact summary cards. + +## Information Hierarchy + +The page should read in this order: + +1. What this page is for +2. Step 1: open a workspace +3. Step 2: start Claude or Codex +4. Optional settings/setup help +5. Small supporting reasons to use the product here + +The settings action must no longer appear to be a required part of Step 2. + +## Component-Level Changes + +### Welcome Page Structure + +- Keep `welcome-card__hero` for the left/top explanatory block. +- Keep `welcome-flow` as the workflow container. +- Change `welcome-flow__steps` into: + - desktop: two columns + - mobile: one column +- Add a dedicated secondary settings/support block directly under the steps. +- Keep the lower support section, but treat it as compact context rather than feature marketing. + +### Settings Placement + +- The settings CTA remains a button that navigates to `/settings`. +- It moves into its own layout block with its own hint copy. +- It should visually read as optional preparation help, not a main-step action. + +## Styling Contract + +### Desktop Contract + +- The main welcome card remains width-constrained and centered. +- `welcome-flow__steps` uses a two-column grid. +- Both step cards share equal visual weight. +- The settings support row spans the workflow width below the two step cards. +- The support summary remains below the main top grid and should not overpower the workflow. + +### Mobile Contract + +- The container remains vertically scrollable. +- The card sizes to content and does not trap overflow. +- The hero, steps, settings row, and support summary stack in one column. +- Primary CTA remains full-width within Step 1. +- Settings CTA is full-width in its own secondary block. + +## Copy Strategy + +- Reuse the existing title, description, step copy, support copy, and action labels where possible. +- Keep the current settings hint copy, but associate it with the standalone settings block instead of Step 2. +- Avoid adding new marketing copy unless needed for layout clarity. + +## Testing + +### Component Tests + +- Assert that the welcome page renders two step cards inside the workflow. +- Assert that the settings button is no longer inside the Step 2 card. +- Assert that the standalone settings support block exists. +- Preserve modal opening and settings navigation behavior coverage. + +### Style Tests + +- Assert that `welcome-flow__steps` is a grid and includes an explicit desktop two-column layout. +- Assert that the mobile welcome layout remains single-column and scrollable. +- Assert that the mobile support list remains one column. + +### Visual Verification + +- Capture welcome screenshots again from `e2e-ui` for both desktop and mobile after implementation. +- Compare post-change screenshots against the current captured baseline to confirm: + - desktop steps sit side by side + - settings is separated from Step 2 + - mobile flow reaches the core actions sooner + +## Out of Scope + +- No new welcome-page functionality +- No auth-page redesign +- No not-found-page redesign +- No changes to the workspace launch modal flow diff --git a/docs/superpowers/specs/2026-05-26-welcome-step-first-redesign-design.md b/docs/superpowers/specs/2026-05-26-welcome-step-first-redesign-design.md new file mode 100644 index 00000000..d321993a --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-welcome-step-first-redesign-design.md @@ -0,0 +1,63 @@ +# Welcome Step-First Redesign + +## Goal + +Refocus the welcome page around the two actions a new user must understand immediately: + +1. Open a local workspace +2. Start an AI session inside that workspace + +The redesign must fix the desktop action row wrapping bug and make short mobile screens usable without losing access to lower content. + +## Problems + +- Desktop layout treats hints, buttons, and helper copy as one wrapping flex group, so copy and actions visually collide. +- Mobile layout gives equal weight to hero copy, action hints, and feature cards, which makes the screen too tall for short devices. +- The app shell uses `overflow: hidden`, so the welcome screen must provide its own scroll container. + +## Design + +### Information Hierarchy + +- Keep the kicker, title, and short description at the top. +- Promote the two core steps into the main visual structure. +- Treat settings guidance and “why use it here” content as secondary support. +- Replace the large feature-card row with a lighter supporting summary. + +### Desktop + +- Use a two-column landing card: + - Left column: kicker, title, short description. + - Right column: the two-step workflow. +- Each step gets: + - step number / eyebrow + - short title + - short detail + - relevant action when applicable +- Add a secondary strip below the main grid with compact supporting bullets. + +### Mobile + +- Collapse to a single column. +- Keep hero copy shorter in perceived height with tighter spacing. +- Render the two-step workflow directly under the hero. +- Show only compact supporting bullets below the workflow. +- Make the welcome container itself scrollable with safe-area-aware padding. + +## Content Strategy + +- Reuse the existing “Open Workspace” and “Settings” actions. +- Keep the first step focused on opening the local project folder. +- Keep the second step focused on launching Claude or Codex after the workspace opens. +- Use the existing Git and terminal benefits as the smaller supporting bullets. + +## Implementation Notes + +- Add welcome-page-specific classes instead of heavily changing shared `welcome-card` behavior used by auth and not-found screens. +- Update locale strings to support step titles/details and a compact secondary section title. +- Update tests to assert the new structure instead of the old feature-card-heavy layout. + +## Verification + +- Welcome page tests should cover desktop structure, mobile structure, locale rendering, modal opening, and settings navigation. +- Theme/style tests should verify the mobile shell remains vertically stacked and scrollable. diff --git a/docs/superpowers/specs/2026-05-26-workspace-panel-editor-unification-design.md b/docs/superpowers/specs/2026-05-26-workspace-panel-editor-unification-design.md new file mode 100644 index 00000000..1e449bb1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-workspace-panel-editor-unification-design.md @@ -0,0 +1,357 @@ +# Workspace 文件管理面板编辑器化统一设计 + +> Status: Draft +> Date: 2026-05-26 +> Scope: `packages/web/src/features/workspace/views/shared/*`, `packages/web/src/features/workspace/views/mobile/*`, `packages/web/src/styles/components.css`, `packages/web/src/styles/components.theme.test.ts` + +## 目标 + +统一当前 workspace 文件管理面板在桌面端和移动端的视觉语言,让 `Explorer`、`Search`、`Source Control` 三个面板更像同一套专业编辑器 workbench,而不是三个独立产品。 + +本轮目标: + +- 保留现有桌面端 `Activity Bar + Sidebar View` 的信息架构 +- 保留移动端 `Explorer / Search / Git` 三视图切换模型 +- 收敛三块面板的圆角、边距、标题区、输入框、列表行、状态样式 +- 将整体气质收敛到 `小圆角 / 硬朗 / 克制 / 高扫描效率` +- 保证浅色、深色和高对比主题下都继续走现有 token 体系 + +本轮不做: + +- 不增加新的面板能力或 Git 工作流 +- 不重做桌面端信息架构 +- 不引入页面私有主题或绕过 token 的硬编码颜色体系 +- 不把移动端做成另一套更圆、更软的移动 App 风格 + +## 相关背景 + +当前代码和已有设计已经完成了几件正确的事: + +- 桌面端已经是 `Activity Bar + Explorer / Search / Source Control` +- `Search` 已经有独立内容搜索面板 +- 移动端已经接入 `Explorer / Search / Source Control` +- 文件树、搜索结果、Git 列表都已经有独立共享组件 + +但视觉语言仍然没有完全统一: + +- `Explorer` 更像树控件 +- `Search` 更像独立搜索工具 +- `Git` 仍有较重的表单和局部卡片感 +- 移动端虽然已扁平化,但和桌面端还不是完全同一套 panel grammar + +用户已确认的方向: + +- 整体采用小圆角、硬朗设计风格 +- 三块面板必须统一成一套视觉系统 +- 选中态不要左侧强调条,改为更完整、更干净的块级高亮 +- 需要特别注意视觉规范与主题一致性 + +## 设计结论 + +采用 `Workbench 统一化` 方向,并向更硬朗的编辑器工具面板靠拢。 + +核心原则: + +- 信息架构保持不变,主要改视觉系统 +- 共享一套 panel primitive,而不是为每个面板单独修样式 +- 桌面端优先保证扫描效率与专业感 +- 移动端保持同一套语言,只放大热区,不改变语气 +- 所有背景、边框、选中、hover、focus 都必须走语义 token + +## 统一视觉系统 + +## 1. 圆角规范 + +整体使用现有共享 radius token,不新增“文件管理面板专用圆角”。 + +约束: + +- 输入框、列表行、面板内工具按钮使用小圆角 +- 面板容器和分组容器使用中等偏小圆角 +- 不使用大胶囊、超大卡片圆角、消费型圆按钮语言 +- 状态 chip 可继续保留胶囊型 radius,但只用于状态,不扩散到面板主体 + +具体落点: + +- `Explorer / Search / Git` 的输入框、行项、行内按钮统一靠拢到现有 `radius-control-sm / radius-panel / radius-md` 体系 +- 移动端 `mobile-files-sheet` 内容面板继续使用共享 radius token,不引入大圆角容器 + +## 2. 间距与密度 + +三块面板统一成相同的密度节奏。 + +桌面端: + +- 标题区高度统一到紧凑工具面板密度 +- 列表行维持紧凑扫描节奏 +- 分组块之间的间距小于普通页面卡片系统 + +移动端: + +- 保持相同视觉节奏,但将行高和点击热区放大到触控可用范围 +- 不因触控而放大圆角或加重卡片感 + +统一结果: + +- `header / section / input / row / inline action` 的边距关系一致 +- `Search` 不再比 `Explorer` 更像表单 +- `Git` 不再比另外两块更像卡片式工具区 + +## 3. 面板层级 + +整体层级从 “多层壳卡片” 收敛为 “连续工具面”。 + +规则: + +- 主面板依赖细边框、浅层背景和分隔线建立结构 +- 禁止使用厚阴影、强渐变、明显浮起卡片层级 +- 同一面板内,内容层级优先于容器层级 + +这意味着: + +- 用户先看到文件、搜索结果、变更列表 +- 而不是先看到包住这些内容的“卡片” + +## 4. 交互态规范 + +### Hover + +- 使用单层轻背景变化 +- 不使用营销式高亮或重阴影 + +### Focus + +- 必须沿用现有 control focus ring token +- 输入框、可点击 row、工具按钮使用同一套 focus 表达 + +### Selected + +这是本轮的明确决策点。 + +不采用: + +- 左侧竖条强调 +- 选中时通过额外占位改变内容起始位置 + +采用: + +- 完整块级高亮 +- 低饱和选中背景 +- 同色系轻边框或非常轻的内高光 +- 与 hover、focus 能共存但不互相打架 + +目标效果: + +- 更像编辑器侧栏里的当前项 +- 更少后台列表或数据表格感 +- `Explorer`、`Search Match`、`Git Change Row` 共用同一类选中语义 + +## 主题与视觉规范约束 + +本轮必须遵守现有主题系统,不允许为赶效果直接写死颜色。 + +### 1. Surface + +工作区面板背景必须继续走: + +- `--workspace-sidebar-surface` +- `--workspace-activitybar-surface` +- `--workspace-content-surface` +- 已有 `component-mix` surface token + +禁止: + +- 直接写死浅灰或深灰面板色 +- 新增与现有 theme pipeline 脱节的 bespoke surface + +### 2. Border / Hover / Selected + +边框、hover、选中态都必须继续走现有语义 token 组合。 + +优先使用: + +- `--border-default` +- `--surface-hover` +- `--state-selected-bg` +- `--state-selected-border` +- 已有 `component-mix-status-info-fg-*` 和 `component-mix-surface-*` 体系 + +允许为本轮补充更准确的语义 token 映射,但不允许绕过 token 直接写死十六进制颜色。 + +### 3. Radius + +必须继续走共享 radius token。 + +本轮不接受: + +- `999px` 扩散到普通 panel control +- `12px / 14px / 16px` 大圆角随意混用 +- 桌面与移动端各用一套完全不同的 radius 语言 + +### 4. Theme-sensitive testing + +`components.theme.test.ts` 需要补充或更新断言,保证: + +- workspace sidebar surface 仍走语义 surface token +- 桌面端和移动端文件面板仍走共享 radius token +- 选中态不再依赖左侧 border-left 方案 +- 搜索输入、文件树行、Git 列表行的视觉约束可以被测试捕获 + +## 三个面板的具体设计 + +## Explorer + +`Explorer` 需要成为最基础的 panel grammar 来源。 + +保留: + +- `Open Editors` +- `Workspace` +- 新建文件 / 新建文件夹 / 折叠操作 +- 文件树已有展开、打开、上下文菜单能力 + +改动: + +- `Open Editors` 行项、文件树行项、行内操作按钮统一到同一套 row/button 体系 +- 文件树搜索框若出现在对应模式中,必须与 `Search` 面板输入框同源 +- section header、action icon、row active/hover 语义成为另外两块面板的基准 + +目标: + +- Explorer 看起来不是“树控件样式集合” +- 而是整个 sidebar design system 的主参考 + +## Search + +`Search` 保留现有内容搜索能力,但视觉上必须向 `Explorer` 靠拢。 + +改动重点: + +- 搜索输入框改成与 Quick Jump / Explorer 输入同一档工具输入框 +- 分组头与 match row 使用与文件树行一致的层级语言 +- 文件组、路径、匹配行不再像独立搜索结果卡片 +- match 行选中态改成块级高亮,不再出现类似独立列表条目的割裂感 + +视觉目标: + +- 像编辑器内的内容搜索面板 +- 不是通用搜索页塞进 sidebar + +## Source Control / Git + +`Git` 面板的视觉问题最明显,因为它同时包含: + +- commit 输入 +- 变更列表 +- worktree 列表 +- 历史列表 + +本轮要求: + +- `commit` 区块的控件语言向工具面板收敛,降低“表单区域”感 +- `changes / worktrees / history` 三块与 Explorer section header 同构 +- Git 列表行和 Search / Explorer 的 row grammar 统一 +- 行内操作按钮、hover、active、selected 一律走同一套轻量表达 + +特别说明: + +- Git 状态色仍保留状态表达职责 +- 但状态色不能成为额外的容器装饰系统 + +## 桌面端设计 + +桌面端保持当前布局模型: + +- 左侧 `Activity Bar` +- 右侧 sidebar content + +本轮主要做: + +- 统一 `workspace-sidebar-view` 的 header/body grammar +- 统一三块 view 的顶部工具栏高度、标题样式、按钮尺寸 +- 统一列表容器、结果容器、commit 区块的工具面语言 + +桌面端目标关键词: + +- dense +- inspectable +- editor-like +- text-first + +## 移动端设计 + +移动端继续保留: + +- 顶部三视图切换 +- Explorer / Search / Git 的独立内容区 + +但必须与桌面端共享同一套工具面板语言。 + +规则: + +- 顶部 tab 继续使用扁平切换,不回退到胶囊 +- 激活态可用细下划线或细底部强调,但不使用厚块状填充 +- 内容区 panel 使用与桌面端一致的 header / input / row grammar +- 仅提升点击热区,不提升装饰性 + +移动端目标: + +- 视觉上仍然像桌面编辑器的移动映射 +- 不是另一套消费型移动 UI + +## 实现边界 + +预计主要涉及: + +- `packages/web/src/features/workspace/views/shared/explorer-panel.tsx` +- `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` +- `packages/web/src/features/workspace/views/shared/search-panel.tsx` +- `packages/web/src/features/workspace/views/shared/git-panel.tsx` +- `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx` +- `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.tsx` +- `packages/web/src/styles/components.css` +- `packages/web/src/styles/components.theme.test.ts` +- 对应结构和交互测试 + +本轮优先做共享样式层收敛,不建议先分别修三个面板,否则容易再次分叉。 + +## 实施顺序 + +1. 在共享 sidebar 样式层抽出统一的 panel primitives +2. 先对齐桌面端 `Explorer / Search / Git` 的 header、input、row、section +3. 再映射到移动端 `mobile-files-sheet` 三视图 +4. 最后补充主题约束测试与结构测试 + +## 测试策略 + +需要更新或新增的测试重点: + +1. 样式约束测试 + - 文件树 row 选中态不再依赖 `border-left` + - Search match / group row / Git row 的选中态统一到块级高亮表达 + - 搜索输入、Git 输入、Explorer 输入共用紧凑工具输入风格 + - 桌面端和移动端继续使用共享 radius token +2. 结构测试 + - 现有三块面板结构不因视觉收敛而破坏可操作性 + - 移动端 tab 切换、文件打开、Git preview 等行为保持不变 +3. 主题验证 + - 浅色、深色、高对比主题下的面板 surface、selected、hover 仍走语义 token + +## 验收标准 + +- Explorer / Search / Git 在桌面端和移动端表现为同一套设计语言 +- 普通面板控件全面收敛到小圆角、硬朗、克制的工具面板风格 +- 选中态不再使用左侧强调条 +- 移动端视觉不再像另一套产品,只是在热区尺寸上适配触控 +- 主题切换后不出现脱离 token 的颜色或明暗冲突 +- `components.theme.test.ts` 能明确约束上述关键视觉决策 + +## 已确认设计结论 + +本设计已通过一次可视化稿确认,当前锁定方向为: + +- V2 选中态:去掉左侧强调边,改用整块高亮 +- 整体风格:小圆角、硬朗、统一 workbench +- 视觉规范:严格服从现有主题与 token 系统 + +后续实现如需偏离上述三点,必须重新评审。 diff --git a/docs/wiki/Agent-Providers.md b/docs/wiki/Agent-Providers.md new file mode 100644 index 00000000..401ee7ec --- /dev/null +++ b/docs/wiki/Agent-Providers.md @@ -0,0 +1,64 @@ +# Agent Providers + +Agent providers are the coding-agent runtimes that Coder Studio can launch inside the workspace. + +The important idea is that the workspace should not be defined by only one vendor or one CLI forever. Providers can change over time. The workspace surfaces around them should stay useful. + +## Built-In Providers Today + +Current built-in support covers: + +- Claude Code +- OpenAI Codex + +These are the providers available in today's product. They run through their local CLIs, and Coder Studio gives them a browser workspace with terminals, files, Git, sessions, and review surfaces around the run. + +## Why Provider-Agnostic Positioning Matters + +Coder Studio is not trying to become a wrapper for exactly two agent CLIs forever. + +The longer-term product promise is simpler: + +- keep the workspace useful even as agent tools change +- let users compare or supervise different agents in one place +- avoid rebuilding the whole product story around a single provider brand + +That positioning does not mean every provider already exists in the product. It means the workspace direction is broader than today's built-in list. + +## Future Presets + +Future preset providers are roadmap items, not current built-in support. + +The idea is to offer pre-filled provider metadata for common coding-agent tools so users do not have to start from scratch when support expands. Example preset candidates include: + +- Gemini CLI +- Aider +- OpenCode + +This page is not claiming that those providers are already enabled today. + +## Custom Command Providers + +Custom command providers are also a roadmap item. + +The goal is to let users connect their own local coding-agent commands to the same workspace model. That could cover internal tools, local scripts, or company-specific agents. + +Custom command providers are not part of the current built-in release. + +## Non-Goals + +The provider roadmap does not imply: + +- a provider marketplace today +- cloud agent orchestration +- vendor-specific OAuth or auth setup flows +- deep install diagnosis for every agent tool +- a promise that every provider will support identical capabilities on day one + +## Current Reality + +If you are using Coder Studio today, the accurate summary is: + +- built-in providers today are Claude Code and OpenAI Codex +- the workspace direction is broader than those two providers +- your code and runtime still stay on your own machine diff --git a/docs/wiki/Coder-Studio-vs-Warp.md b/docs/wiki/Coder-Studio-vs-Warp.md new file mode 100644 index 00000000..4c39ae70 --- /dev/null +++ b/docs/wiki/Coder-Studio-vs-Warp.md @@ -0,0 +1,52 @@ +# Coder Studio vs Warp + +The simplest frame is: + +```text +Warp is the agentic terminal. Coder Studio is the agentic workspace. +``` + +That distinction is more useful than treating the two products as direct substitutes for every job. + +## Where Warp Starts + +Warp starts from the terminal and grows outward into agentic development. That makes it strong when your main workflow is terminal-first and you want the terminal itself to become smarter, faster, and more agent-aware. + +## Where Coder Studio Starts + +Coder Studio starts from the browser workspace around the agent run. + +Its focus is to keep these surfaces visible together: + +- terminals +- files +- Git changes +- sessions +- review +- cross-device supervision + +The product is less about rebuilding the terminal from scratch and more about keeping agent work inspectable across a broader workspace. + +## Practical Difference + +Warp is strongest when you want the terminal to be the center of the experience. + +Coder Studio is strongest when you want: + +- long-running agent sessions visible in a browser workspace +- files and Git changes beside the agent run +- review before trusting generated code +- the same workspace available from desktop, tablet, or phone + +## Built-In Provider Reality + +Today, Coder Studio's built-in providers are Claude Code and OpenAI Codex. The agentic workspace direction is broader than that, but the current product should still be described accurately. + +## They Can Be Complementary + +This is not an argument that one tool must replace the other. + +You might prefer Warp for terminal-first work and Coder Studio for inspectable, cross-device agent sessions around a local project. The key difference is the center of gravity: + +- Warp centers the terminal +- Coder Studio centers the workspace around the agent diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index dd4b32f1..6119074c 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -1,8 +1,10 @@ # Coder Studio -Coder Studio is a local-first, browser-based vibe coding workspace. It brings Claude Code, OpenAI Codex, terminals, files, Git, and long-running agent sessions into one interface that you can reopen from desktop, tablet, or phone. +Coder Studio is an agentic workspace for real development. It lets you run, inspect, and supervise coding agents with terminals, files, Git, sessions, and review in one browser workspace. -In current vibe coding language, Coder Studio sits between "vibe coding" and a full engineering harness: you can express intent in natural language, but the workspace keeps the files, terminals, diffs, sessions, and verification loop visible. +Built-in support today covers Claude Code and OpenAI Codex. The broader direction is a workspace that can bring more coding agents together over time, while your code and runtime stay on your machine. + +In current vibe coding language, Coder Studio sits between raw vibe coding and a full engineering harness: you can express intent in natural language, but the workspace keeps terminals, files, diffs, sessions, and review visible. ## Start Here @@ -11,11 +13,11 @@ npm install -g @spencer-kit/coder-studio coder-studio open ``` -Then select a project folder and start a Claude or Codex session. +Then select a project folder and start a Claude or Codex session today. ## What It Is For -- Run vibe coding agents without losing sight of files, terminal output, and Git changes. +- Run coding agents without losing sight of files, terminal output, Git changes, and review. - Keep long-running sessions available from another device. - Check agent progress from a phone without using remote desktop. - Keep your runtime and project files on your own machine. @@ -25,7 +27,7 @@ Then select a project folder and start a Claude or Codex session. | If you use... | It is great for... | Coder Studio adds... | |---------------|--------------------|----------------------| | Vibe coding tools | Fast intent-to-code iteration | A local workspace harness for review, terminal output, Git diffs, and verification | -| Claude Code / Codex CLI | Running agents in a terminal | A browser workspace with files, Git, terminals, sessions, and mobile access | +| Claude Code / Codex CLI | Running today's built-in agents in a terminal | A browser workspace with files, Git, terminals, sessions, review, and mobile access | | Cursor / VS Code | Interactive editing | Long-running AI sessions that remain visible across devices | | Cloud IDEs | Hosted remote development | A local-first runtime on your own machine | | SSH / remote desktop | Remote machine access | A responsive workspace UI instead of mirroring a desktop | @@ -34,6 +36,9 @@ Then select a project folder and start a Claude or Codex session. - [Quick Start](Quick-Start.md) - [Why Coder Studio](Why-Coder-Studio.md) +- [What Is an Agentic Workspace](What-is-an-Agentic-Workspace.md) +- [Agent Providers](Agent-Providers.md) +- [Coder Studio vs Warp](Coder-Studio-vs-Warp.md) - [AI Coding Terms](AI-Coding-Terms.md) - [Mobile and Remote Access](Mobile-and-Remote-Access.md) - [Security and Privacy](Security-and-Privacy.md) diff --git a/docs/wiki/README.md b/docs/wiki/README.md index 029292ce..415e0303 100644 --- a/docs/wiki/README.md +++ b/docs/wiki/README.md @@ -26,6 +26,9 @@ GitHub only creates `.wiki.git` after the repository Wiki is initialized o - [Home](Home.md) - [Quick Start](Quick-Start.md) - [Why Coder Studio](Why-Coder-Studio.md) +- [What Is an Agentic Workspace](What-is-an-Agentic-Workspace.md) +- [Agent Providers](Agent-Providers.md) +- [Coder Studio vs Warp](Coder-Studio-vs-Warp.md) - [AI Coding Terms](AI-Coding-Terms.md) - [Mobile and Remote Access](Mobile-and-Remote-Access.md) - [Security and Privacy](Security-and-Privacy.md) diff --git a/docs/wiki/What-is-an-Agentic-Workspace.md b/docs/wiki/What-is-an-Agentic-Workspace.md new file mode 100644 index 00000000..4859f05c --- /dev/null +++ b/docs/wiki/What-is-an-Agentic-Workspace.md @@ -0,0 +1,67 @@ +# What Is an Agentic Workspace + +An agentic workspace is the environment around a coding agent, not just the agent itself. It gives the agent room to run, and it gives the human enough visibility to inspect what happened. + +For real development, that surrounding workspace matters. Fast intent-to-code loops are useful, but they are not enough on their own when a task touches project files, verification, and review. + +## What Belongs In An Agentic Workspace + +An agentic workspace should keep the core engineering surfaces close to the agent: + +- terminal output +- files +- Git status and diffs +- session history +- verification commands +- review checkpoints +- cross-device visibility when a task keeps running + +The point is not just to launch an agent. The point is to keep the execution loop inspectable while the agent works. + +## Why A Terminal Alone Is Not Enough + +A terminal is a strong runtime surface, but it is only one part of the workflow. + +When an agent works only in a terminal: + +- output scrolls away quickly +- changed files and diffs live somewhere else +- verification often happens in separate shell tabs +- long-running work is harder to inspect away from the desk + +That terminal-first flow is fast, but it can make agent work feel opaque. + +## Why An Editor Alone Is Not Enough + +An editor shows the files, but it does not automatically show the full execution loop behind those files. + +For agentic work, you also need to see: + +- what the agent ran +- what the terminal reported +- what changed in Git +- whether verification passed +- whether a human has reviewed the result + +Without that surrounding context, the final files can look cleaner than the actual process that produced them. + +## Why Review Matters + +Generated code can look plausible and still be wrong, incomplete, or risky. + +Review matters because it keeps a human in the loop: + +- inspect changed files +- read the diff +- run or confirm verification +- decide whether the result is ready + +That is the core promise behind inspectable vibe coding. Speed still matters, but the work should stay reviewable. + +## Where Coder Studio Fits + +Coder Studio is designed as an agentic workspace for real development. + +Today, built-in support covers Claude Code and OpenAI Codex. The larger direction is a workspace that can bring more coding agents together over time while keeping the same engineering surfaces visible: terminals, files, Git, sessions, review, and cross-device supervision. + +Your code and runtime stay on your machine. diff --git a/docs/wiki/Why-Coder-Studio.md b/docs/wiki/Why-Coder-Studio.md index 99dabc8e..b6483067 100644 --- a/docs/wiki/Why-Coder-Studio.md +++ b/docs/wiki/Why-Coder-Studio.md @@ -1,8 +1,10 @@ # Why Coder Studio -Coder Studio is not trying to replace every editor or every terminal. It is built for developers who run vibe coding agents and want a persistent, inspectable workspace around those agents. +Coder Studio is not trying to replace every editor or every terminal. It is built as an agentic workspace for developers who run coding agents and want a persistent, inspectable engineering loop around those agents. -Another way to frame it: Coder Studio is an agentic coding harness. It does not just ask an AI to write code. It keeps the surrounding engineering loop visible: commands, terminal output, files, Git diffs, sessions, mobile access, and human review. +Built-in support today is Claude Code and Codex. The workspace framing is broader than those two CLIs: terminals, files, Git, sessions, review, and cross-device visibility should stay useful as more coding agents arrive over time. + +Another way to frame it: Coder Studio is the workspace around agentic coding. It does not just ask an AI to write code. It keeps the surrounding engineering loop visible: commands, terminal output, files, Git diffs, sessions, mobile access, and human review. Vibe coding agents are powerful, but the raw workflow is still fragmented: @@ -19,7 +21,7 @@ Coder Studio turns that scattered workflow into one local browser workspace. | Long agent tasks | Watch a terminal or come back later and reconstruct context | Keep sessions, terminal output, files, and Git changes visible in one workspace | | Cross-device work | Use SSH, remote desktop, or rebuild context on another machine | Reopen the same local workspace from desktop, tablet, or phone | | Reviewing AI changes | Jump between terminal, editor, and Git tools | Inspect files and diffs beside the agent session | -| Multiple agents | Manage separate terminal windows and histories | Run Claude and Codex sessions side by side in one workspace | +| Multiple agents | Manage separate terminal windows and histories | Run built-in Claude Code and Codex sessions side by side in one workspace today | | Local-first control | Move work into a hosted IDE or cloud VM | Keep the runtime and project files on your own machine | ## Compared With Vibe Coding Tools @@ -38,7 +40,7 @@ Use vibe coding for momentum. Use Coder Studio when you want that momentum insid ## Compared With Claude Code Or Codex CLI -The CLIs are the agent runtime. Coder Studio wraps them in a browser workspace. +Those CLIs are today's built-in agent runtimes. Coder Studio wraps them in a browser workspace. Coder Studio adds: @@ -85,7 +87,7 @@ On a phone or tablet, a responsive workspace is easier to inspect than a mirrore Coder Studio is a good fit if you: -- Use Claude Code or Codex frequently +- Use Claude Code or Codex today and want a workspace that can grow with broader agent workflows - Run long agent tasks - Need to check progress away from the desk - Want project files and terminals in one browser UI diff --git a/packages/core/src/domain/types.test.ts b/packages/core/src/domain/types.test.ts index d270aa85..b1cd466d 100644 --- a/packages/core/src/domain/types.test.ts +++ b/packages/core/src/domain/types.test.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from "vitest"; -import type { SessionState } from "./types"; +import type { AgentContextKind, CustomProviderSessionMode, SessionState } from "./types"; import { deriveSessionTitle, SESSION_TITLE_MAX_LENGTH } from "./types"; describe("deriveSessionTitle", () => { @@ -42,3 +42,17 @@ describe("SessionState", () => { >(); }); }); + +describe("CustomProviderSessionMode", () => { + it("currently only allows interactive PTY-backed custom providers", () => { + expectTypeOf().toEqualTypeOf<"interactive">(); + }); +}); + +describe("AgentContextKind", () => { + it("covers the backend context package variants", () => { + expectTypeOf().toEqualTypeOf< + "file" | "selection" | "git_diff" | "terminal_output" | "project_summary" | "session_review" + >(); + }); +}); diff --git a/packages/core/src/domain/types.ts b/packages/core/src/domain/types.ts index a464d1b4..7245f053 100644 --- a/packages/core/src/domain/types.ts +++ b/packages/core/src/domain/types.ts @@ -45,6 +45,145 @@ export interface WorkspaceLastViewedTarget { updatedAt: number; } +export interface WorkspaceIntelligenceRecommendedCommand { + key: "dev" | "test" | "build" | "lint"; + command: string; + source: "package_json" | "makefile" | "detected"; +} + +export interface WorkspaceIntelligenceSummary { + workspaceId: string; + rootPath: string; + git: { + isRepo: boolean; + branch?: string; + }; + packageManager?: "npm" | "pnpm" | "yarn" | "bun"; + frameworks: string[]; + scripts: { + dev?: string; + test?: string; + build?: string; + lint?: string; + }; + recommendedCommands: WorkspaceIntelligenceRecommendedCommand[]; + docs: Array<{ + path: string; + kind: "readme" | "docs"; + }>; + agentInstructions: { + exists: boolean; + path: ".coder-studio/AGENTS.md"; + }; +} + +export interface AgentInstructionsDocument { + path: ".coder-studio/AGENTS.md"; + exists: boolean; + content: string; + baseHash?: string; +} + +export interface AgentInstructionsHealthIssue { + code: + | "missing_document" + | "missing_project_overview" + | "missing_development_commands" + | "missing_working_rules" + | "missing_review_expectations" + | "missing_safety_rules" + | "missing_provider_notes"; + message: string; +} + +export interface AgentInstructionsHealthChecks { + projectOverview: boolean; + developmentCommands: boolean; + workingRules: boolean; + reviewExpectations: boolean; + safetyRules: boolean; + providerNotes: boolean; +} + +export interface AgentInstructionsHealth { + path: ".coder-studio/AGENTS.md"; + exists: boolean; + status: "healthy" | "warning" | "missing"; + checks: AgentInstructionsHealthChecks; + issues: AgentInstructionsHealthIssue[]; +} + +export type CustomProviderSessionMode = "interactive"; + +export interface CustomProviderConfig { + id: string; + displayName: string; + command: string; + args: string[]; + env: Record; + cwdMode: "workspace_root"; + sessionMode: CustomProviderSessionMode; + startupPrompt?: string; + capabilities: import("../provider/definition").ProviderCapabilityDescriptor[]; + createdAt: number; + updatedAt: number; +} + +export interface AgentSessionVerificationRun { + id: string; + command: string; + status: "passed" | "failed" | "unknown"; + exitCode?: number; + summary?: string; + createdAt: number; +} + +export interface AgentSessionMetadata { + sessionId: string; + workspaceId: string; + providerId: string; + objective?: string; + baselineGitHead?: string; + baselineCapturedAt?: number; + verificationRuns: AgentSessionVerificationRun[]; +} + +export interface SessionReviewWarning { + code: "missing_baseline" | "not_git_repo"; + message: string; +} + +export interface SessionReviewSummary { + sessionId: string; + workspaceId: string; + baselineGitHead?: string; + changedFiles: GitFileChange[]; + verificationRuns: AgentSessionVerificationRun[]; + warnings: SessionReviewWarning[]; +} + +export type AgentContextKind = + | "file" + | "selection" + | "git_diff" + | "terminal_output" + | "project_summary" + | "session_review"; + +export interface AgentContextPackage { + id: string; + kind: AgentContextKind; + title: string; + body: string; + source: { + workspaceId: string; + path?: string; + sessionId?: string; + terminalId?: string; + }; + createdAt: number; +} + export interface Terminal { id: string; workspaceId: string; diff --git a/packages/core/src/provider/definition.ts b/packages/core/src/provider/definition.ts index 7c29dd64..5f4cd331 100644 --- a/packages/core/src/provider/definition.ts +++ b/packages/core/src/provider/definition.ts @@ -27,16 +27,43 @@ export interface SupervisorEvalCommandRequest { outputFile?: string; } +export type ProviderKind = "built_in" | "preset" | "custom"; + +export type ProviderCapabilityKey = + | "interactive_session" + | "supervisor_eval" + | "idle_detection" + | "context_attach" + | "review"; + +export interface ProviderCapabilityDescriptor { + key: ProviderCapabilityKey; + supported: boolean; + label: string; +} + +export interface ProviderListItem { + id: string; + displayName: string; + badge: string; + kind: ProviderKind; + capability: "full" | "limited" | "unsupported"; + capabilities: ProviderCapabilityDescriptor[]; + requiredCommands: string[]; +} + export interface ProviderDefinition { // Metadata id: string; displayName: string; badge: string; + kind: ProviderKind; /** * Declarative label for UI badges and docs only. * Runtime behavior must read hooks/events directly. */ capability: "full" | "limited" | "unsupported"; + capabilities: ProviderCapabilityDescriptor[]; install: ProviderInstallMetadata; // Command construction diff --git a/packages/providers/src/claude/definition.ts b/packages/providers/src/claude/definition.ts index 4941e9ac..3a9e60ed 100644 --- a/packages/providers/src/claude/definition.ts +++ b/packages/providers/src/claude/definition.ts @@ -71,7 +71,15 @@ export const claudeDefinition: ProviderDefinition = { id: "claude", displayName: "Claude Code", badge: "Claude", + kind: "built_in", capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], install: claudeInstallMetadata, // ===== Command construction ===== diff --git a/packages/providers/src/codex/definition.ts b/packages/providers/src/codex/definition.ts index 498b382e..54e12f2d 100644 --- a/packages/providers/src/codex/definition.ts +++ b/packages/providers/src/codex/definition.ts @@ -71,7 +71,15 @@ export const codexDefinition: ProviderDefinition = { id: "codex", displayName: "Codex", badge: "Codex", + kind: "built_in", capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], install: codexInstallMetadata, // ===== Command construction ===== diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index d0009da8..df2bd9c9 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -15,6 +15,7 @@ export { isValidSessionId, sessionIdPatterns, } from "./codex/stdout-heuristics.js"; +export { getProviderPresets, type ProviderPresetMetadata, providerPresets } from "./presets.js"; // Provider registry export { getAllProviderIds, @@ -22,4 +23,5 @@ export { getProvidersByCapability, isValidProviderId, providerRegistry, + toProviderListItem, } from "./registry.js"; diff --git a/packages/providers/src/presets.test.ts b/packages/providers/src/presets.test.ts new file mode 100644 index 00000000..1acbf406 --- /dev/null +++ b/packages/providers/src/presets.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { getProviderPresets, providerPresets } from "./presets.js"; +import { providerRegistry } from "./registry.js"; + +describe("provider presets", () => { + it("defines preset metadata for future providers", () => { + expect(providerPresets).toEqual([ + expect.objectContaining({ + id: "gemini-cli", + displayName: "Gemini CLI", + kind: "preset", + command: "gemini", + requiredCommands: ["gemini"], + }), + expect.objectContaining({ + id: "aider", + displayName: "Aider", + kind: "preset", + command: "aider", + requiredCommands: ["aider"], + }), + expect.objectContaining({ + id: "opencode", + displayName: "OpenCode", + kind: "preset", + command: "opencode", + requiredCommands: ["opencode"], + }), + ]); + }); + + it("returns stable copies and keeps presets out of the active provider registry", () => { + const first = getProviderPresets(); + const second = getProviderPresets(); + + expect(first).toEqual(second); + expect(first).not.toBe(second); + expect(first[0]).not.toBe(second[0]); + + const activeProviderIds = new Set(providerRegistry.map((provider) => provider.id)); + for (const preset of first) { + expect(activeProviderIds.has(preset.id)).toBe(false); + } + }); +}); diff --git a/packages/providers/src/presets.ts b/packages/providers/src/presets.ts new file mode 100644 index 00000000..7a81c688 --- /dev/null +++ b/packages/providers/src/presets.ts @@ -0,0 +1,85 @@ +import type { ProviderCapabilityDescriptor } from "@coder-studio/core"; + +export interface ProviderPresetMetadata { + id: string; + displayName: string; + kind: "preset"; + description: string; + command: string; + args: string[]; + env: Record; + cwdMode: "workspace_root"; + sessionMode: "interactive"; + startupPrompt?: string; + capabilities: ProviderCapabilityDescriptor[]; + requiredCommands: string[]; +} + +function cloneCapabilities( + capabilities: ProviderCapabilityDescriptor[] +): ProviderCapabilityDescriptor[] { + return capabilities.map((capability) => ({ ...capability })); +} + +const defaultCapabilities: ProviderCapabilityDescriptor[] = [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "context_attach", supported: true, label: "Context attach" }, + { key: "review", supported: true, label: "Review" }, +]; + +export const providerPresets: ProviderPresetMetadata[] = [ + { + id: "gemini-cli", + displayName: "Gemini CLI", + kind: "preset", + description: + "Preset metadata for launching Google's Gemini CLI through the custom provider flow.", + command: "gemini", + args: [], + env: {}, + cwdMode: "workspace_root", + sessionMode: "interactive", + startupPrompt: "Follow the workspace instructions and explain important tradeoffs.", + capabilities: cloneCapabilities(defaultCapabilities), + requiredCommands: ["gemini"], + }, + { + id: "aider", + displayName: "Aider", + kind: "preset", + description: + "Preset metadata for launching Aider as a workspace-root interactive coding agent.", + command: "aider", + args: [], + env: {}, + cwdMode: "workspace_root", + sessionMode: "interactive", + startupPrompt: "Review the current workspace state before making edits.", + capabilities: cloneCapabilities(defaultCapabilities), + requiredCommands: ["aider"], + }, + { + id: "opencode", + displayName: "OpenCode", + kind: "preset", + description: "Preset metadata for launching OpenCode from the workspace root.", + command: "opencode", + args: [], + env: {}, + cwdMode: "workspace_root", + sessionMode: "interactive", + startupPrompt: "Use the repository instructions and verify changes before finishing.", + capabilities: cloneCapabilities(defaultCapabilities), + requiredCommands: ["opencode"], + }, +]; + +export function getProviderPresets(): ProviderPresetMetadata[] { + return providerPresets.map((preset) => ({ + ...preset, + args: [...preset.args], + env: { ...preset.env }, + capabilities: cloneCapabilities(preset.capabilities), + requiredCommands: [...preset.requiredCommands], + })); +} diff --git a/packages/providers/src/registry.test.ts b/packages/providers/src/registry.test.ts index 24dcd4e5..14eb39a6 100644 --- a/packages/providers/src/registry.test.ts +++ b/packages/providers/src/registry.test.ts @@ -5,6 +5,7 @@ import { getProvidersByCapability, isValidProviderId, providerRegistry, + toProviderListItem, } from "../src/registry.js"; describe("Provider Registry", () => { @@ -91,4 +92,56 @@ describe("Provider Registry", () => { expect(unsupportedProviders.length).toBe(0); }); }); + + describe("toProviderListItem", () => { + it("returns a safe provider DTO for Claude", () => { + const provider = getProviderById("claude"); + expect(provider).toBeDefined(); + + const item = toProviderListItem(provider!); + + expect(item).toEqual({ + id: "claude", + displayName: "Claude Code", + badge: "Claude", + kind: "built_in", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], + requiredCommands: ["claude"], + }); + }); + + it("returns a safe provider DTO for Codex without executable internals", () => { + const provider = getProviderById("codex"); + expect(provider).toBeDefined(); + + const item = toProviderListItem(provider!); + + expect(item).toEqual({ + id: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], + requiredCommands: ["codex"], + }); + expect("buildCommand" in item).toBe(false); + expect("install" in item).toBe(false); + expect("configSchema" in item).toBe(false); + expect("defaultConfig" in item).toBe(false); + }); + }); }); diff --git a/packages/providers/src/registry.ts b/packages/providers/src/registry.ts index 81198a70..ad9550c2 100644 --- a/packages/providers/src/registry.ts +++ b/packages/providers/src/registry.ts @@ -1,4 +1,4 @@ -import type { ProviderDefinition } from "@coder-studio/core"; +import type { ProviderDefinition, ProviderListItem } from "@coder-studio/core"; import { claudeDefinition } from "./claude/definition.js"; import { codexDefinition } from "./codex/definition.js"; @@ -15,6 +15,21 @@ import { codexDefinition } from "./codex/definition.js"; */ export const providerRegistry: ProviderDefinition[] = [claudeDefinition, codexDefinition]; +/** + * Convert an internal provider definition into a frontend-safe list item. + */ +export function toProviderListItem(provider: ProviderDefinition): ProviderListItem { + return { + id: provider.id, + displayName: provider.displayName, + badge: provider.badge, + kind: provider.kind, + capability: provider.capability, + capabilities: provider.capabilities.map((capability) => ({ ...capability })), + requiredCommands: [...provider.requiredCommands], + }; +} + /** * Get provider by ID */ diff --git a/packages/server/src/__tests__/agent-context-command.test.ts b/packages/server/src/__tests__/agent-context-command.test.ts new file mode 100644 index 00000000..24a56d41 --- /dev/null +++ b/packages/server/src/__tests__/agent-context-command.test.ts @@ -0,0 +1,224 @@ +import { execFile } from "child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { promisify } from "util"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventBus } from "../bus/event-bus.js"; +import { SessionMetadataRepo } from "../storage/repositories/session-metadata-repo.js"; +import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js"; +import { + AGENT_INSTRUCTIONS_RELATIVE_PATH, + WORKSPACE_STATE_DIR, +} from "../workspace/workspace-state.js"; +import type { CommandContext } from "../ws/dispatch.js"; +import { dispatch } from "../ws/dispatch.js"; +import "../commands/agent-context.js"; + +const execFileAsync = promisify(execFile); + +describe("agent context commands", () => { + let repoDir: string; + let stateDir: string; + let metadataRepo: SessionMetadataRepo; + let workspaceRepo: WorkspaceRepo; + let ctx: CommandContext & { sessionMetadataRepo: SessionMetadataRepo }; + + beforeEach(async () => { + repoDir = await mkdtemp(join(tmpdir(), "agent-context-command-")); + stateDir = await mkdtemp(join(tmpdir(), "agent-context-command-state-")); + workspaceRepo = new WorkspaceRepo({ + filePath: join(stateDir, "workspaces.json"), + }); + workspaceRepo.create({ + id: "ws-1", + path: repoDir, + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { leftPanelWidth: 1, bottomPanelHeight: 1, focusMode: false }, + }); + metadataRepo = new SessionMetadataRepo({ + workspaceRepo, + }); + + await execFileAsync("git", ["init"], { cwd: repoDir }); + await execFileAsync("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: repoDir }); + await writeFile(join(repoDir, "sample.ts"), "export const value = 1;\n"); + await writeFile( + join(repoDir, "package.json"), + JSON.stringify( + { + dependencies: { react: "^19.0.0" }, + scripts: { test: "vitest run" }, + }, + null, + 2 + ) + ); + await writeFile(join(repoDir, "README.md"), "# Demo\n"); + await mkdir(join(repoDir, "docs"), { recursive: true }); + await mkdir(join(repoDir, WORKSPACE_STATE_DIR), { recursive: true }); + await writeFile(join(repoDir, AGENT_INSTRUCTIONS_RELATIVE_PATH), "# Project\n"); + await execFileAsync("git", ["add", "."], { cwd: repoDir }); + await execFileAsync("git", ["commit", "-m", "Initial commit"], { cwd: repoDir }); + const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: repoDir }); + + metadataRepo.upsert({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + baselineGitHead: stdout.trim(), + baselineCapturedAt: 1, + verificationRuns: [], + }); + + ctx = { + workspaceMgr: { + get(id: string) { + return id === "ws-1" + ? { + id, + path: repoDir, + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { leftPanelWidth: 1, bottomPanelHeight: 1, focusMode: false }, + } + : undefined; + }, + } as never, + sessionMgr: {} as never, + terminalMgr: {} as never, + eventBus: new EventBus(), + broadcaster: { broadcast: vi.fn() } as never, + db: {} as never, + providerRegistry: [], + fencingMgr: {} as never, + supervisorMgr: {} as never, + autoFetch: {} as never, + activationMgr: {} as never, + sessionMetadataRepo: metadataRepo, + }; + }); + + afterEach(async () => { + await rm(stateDir, { recursive: true, force: true }); + await rm(repoDir, { recursive: true, force: true }); + }); + + it("returns file context through dispatch", async () => { + const result = await dispatch( + { + kind: "command", + id: "agent-context-file", + op: "agentContext.fromFile", + args: { + workspaceId: "ws-1", + path: "README.md", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + kind: "file", + title: "File: README.md", + source: { + workspaceId: "ws-1", + path: "README.md", + }, + }); + expect(result.data).toHaveProperty("id"); + expect(result.data).toHaveProperty("createdAt"); + }); + + it("returns diff context through dispatch", async () => { + await writeFile(join(repoDir, "sample.ts"), "export const value = 2;\n"); + + const result = await dispatch( + { + kind: "command", + id: "agent-context-diff", + op: "agentContext.fromDiff", + args: { + sessionId: "sess-1", + path: "sample.ts", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + kind: "git_diff", + title: "Git Diff: sample.ts", + source: { + workspaceId: "ws-1", + sessionId: "sess-1", + path: "sample.ts", + }, + }); + expect((result.data as { body: string }).body).toContain("+export const value = 2;"); + }); + + it("returns project summary context through dispatch", async () => { + const result = await dispatch( + { + kind: "command", + id: "agent-context-project", + op: "agentContext.fromProjectSummary", + args: { + workspaceId: "ws-1", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + kind: "project_summary", + title: "Project Summary", + source: { + workspaceId: "ws-1", + }, + }); + expect((result.data as { body: string }).body).toContain("Git: repository detected"); + }); + + it("returns session review context through dispatch", async () => { + await writeFile(join(repoDir, "sample.ts"), "export const value = 2;\n"); + metadataRepo.addVerificationRun("sess-1", { + id: "verify-1", + command: "pnpm test", + status: "passed", + createdAt: 44, + }); + + const result = await dispatch( + { + kind: "command", + id: "agent-context-review", + op: "agentContext.fromSessionReview", + args: { + sessionId: "sess-1", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + kind: "session_review", + title: "Session Review: sess-1", + source: { + workspaceId: "ws-1", + sessionId: "sess-1", + }, + }); + expect((result.data as { body: string }).body).toContain("- modified: sample.ts"); + expect((result.data as { body: string }).body).toContain("- passed: pnpm test"); + }); +}); diff --git a/packages/server/src/__tests__/agent-context/context-package.test.ts b/packages/server/src/__tests__/agent-context/context-package.test.ts new file mode 100644 index 00000000..beefebc3 --- /dev/null +++ b/packages/server/src/__tests__/agent-context/context-package.test.ts @@ -0,0 +1,227 @@ +import { execFile } from "child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { promisify } from "util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + buildDiffContextPackage, + buildFileContextPackage, + buildProjectSummaryContextPackage, + buildSessionReviewContextPackage, +} from "../../agent-context/context-package.js"; +import { SessionMetadataRepo } from "../../storage/repositories/session-metadata-repo.js"; +import { WorkspaceRepo } from "../../storage/repositories/workspace-repo.js"; +import { + AGENT_INSTRUCTIONS_RELATIVE_PATH, + WORKSPACE_STATE_DIR, +} from "../../workspace/workspace-state.js"; + +const execFileAsync = promisify(execFile); + +describe("agent context package builders", () => { + let metadataRepo: SessionMetadataRepo; + let repoDir: string; + let stateDir: string; + let workspaceRepo: WorkspaceRepo; + + beforeEach(async () => { + repoDir = await mkdtemp(join(tmpdir(), "agent-context-")); + stateDir = await mkdtemp(join(tmpdir(), "agent-context-state-")); + workspaceRepo = new WorkspaceRepo({ + filePath: join(stateDir, "workspaces.json"), + }); + workspaceRepo.create({ + id: "ws-1", + path: repoDir, + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { leftPanelWidth: 1, bottomPanelHeight: 1, focusMode: false }, + }); + metadataRepo = new SessionMetadataRepo({ + workspaceRepo, + }); + + await execFileAsync("git", ["init"], { cwd: repoDir }); + await execFileAsync("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: repoDir }); + await writeFile(join(repoDir, "sample.ts"), "export const value = 1;\n"); + await writeFile( + join(repoDir, "package.json"), + JSON.stringify( + { + dependencies: { + react: "^19.0.0", + }, + devDependencies: { + vite: "^7.0.0", + }, + scripts: { + dev: "vite", + test: "vitest run", + build: "vite build", + lint: "eslint .", + }, + }, + null, + 2 + ) + ); + await writeFile(join(repoDir, "pnpm-lock.yaml"), "lockfileVersion: 9.0\n"); + await writeFile(join(repoDir, "README.md"), "# Demo\n"); + await mkdir(join(repoDir, "docs"), { recursive: true }); + await mkdir(join(repoDir, WORKSPACE_STATE_DIR), { recursive: true }); + await writeFile(join(repoDir, AGENT_INSTRUCTIONS_RELATIVE_PATH), "# Project\n"); + await execFileAsync("git", ["add", "."], { cwd: repoDir }); + await execFileAsync("git", ["commit", "-m", "Initial commit"], { cwd: repoDir }); + + const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: repoDir }); + + metadataRepo.upsert({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + baselineGitHead: stdout.trim(), + baselineCapturedAt: 1, + verificationRuns: [], + }); + }); + + afterEach(async () => { + await rm(stateDir, { recursive: true, force: true }); + await rm(repoDir, { recursive: true, force: true }); + }); + + it("builds a deterministic file context package", async () => { + const pkg = await buildFileContextPackage( + { + workspaceId: "ws-1", + workspacePath: repoDir, + path: "README.md", + }, + { + createId: () => "ctx-file-1", + now: () => 111, + } + ); + + expect(pkg).toEqual({ + id: "ctx-file-1", + kind: "file", + title: "File: README.md", + body: "Context: File: README.md\nSource: workspace=ws-1 path=README.md\n\n# Demo\n", + source: { + workspaceId: "ws-1", + path: "README.md", + }, + createdAt: 111, + }); + }); + + it("builds a deterministic diff context package from a session baseline", async () => { + await writeFile(join(repoDir, "sample.ts"), "export const value = 2;\n"); + + const pkg = await buildDiffContextPackage( + { + sessionId: "sess-1", + path: "sample.ts", + workspacePath: repoDir, + metadataRepo, + }, + { + createId: () => "ctx-diff-1", + now: () => 222, + } + ); + + expect(pkg.kind).toBe("git_diff"); + expect(pkg.title).toBe("Git Diff: sample.ts"); + expect(pkg.source).toEqual({ + workspaceId: "ws-1", + path: "sample.ts", + sessionId: "sess-1", + }); + expect(pkg.createdAt).toBe(222); + expect(pkg.body).toContain("Context: Git Diff: sample.ts"); + expect(pkg.body).toContain("Source: workspace=ws-1 session=sess-1 path=sample.ts"); + expect(pkg.body).toContain("-export const value = 1;"); + expect(pkg.body).toContain("+export const value = 2;"); + }); + + it("builds a deterministic project summary context package", async () => { + const pkg = await buildProjectSummaryContextPackage( + { + workspaceId: "ws-1", + workspacePath: repoDir, + }, + { + createId: () => "ctx-project-1", + now: () => 333, + } + ); + + expect(pkg).toEqual({ + id: "ctx-project-1", + kind: "project_summary", + title: "Project Summary", + body: [ + "Context: Project Summary", + "Source: workspace=ws-1", + "", + "Git: repository detected", + "Package manager: pnpm", + "Frameworks: React, Vite, Node", + "Recommended commands:", + "- dev: pnpm dev", + "- test: pnpm test", + "- build: pnpm build", + "- lint: pnpm lint", + "Docs:", + "- README.md", + "- docs", + `Agent instructions: ${AGENT_INSTRUCTIONS_RELATIVE_PATH} present`, + ].join("\n"), + source: { + workspaceId: "ws-1", + }, + createdAt: 333, + }); + }); + + it("builds a deterministic session review context package", async () => { + await writeFile(join(repoDir, "sample.ts"), "export const value = 2;\n"); + metadataRepo.addVerificationRun("sess-1", { + id: "verify-1", + command: "pnpm test", + status: "passed", + createdAt: 44, + }); + + const pkg = await buildSessionReviewContextPackage( + { + sessionId: "sess-1", + workspacePath: repoDir, + metadataRepo, + }, + { + createId: () => "ctx-review-1", + now: () => 444, + } + ); + + expect(pkg.kind).toBe("session_review"); + expect(pkg.title).toBe("Session Review: sess-1"); + expect(pkg.source).toEqual({ + workspaceId: "ws-1", + sessionId: "sess-1", + }); + expect(pkg.createdAt).toBe(444); + expect(pkg.body).toContain("Context: Session Review: sess-1"); + expect(pkg.body).toContain("Source: workspace=ws-1 session=sess-1"); + expect(pkg.body).toContain("Changed files:"); + expect(pkg.body).toContain("- modified: sample.ts"); + expect(pkg.body).toContain("Verification runs:"); + expect(pkg.body).toContain("- passed: pnpm test"); + }); +}); diff --git a/packages/server/src/__tests__/agent-instructions-command.test.ts b/packages/server/src/__tests__/agent-instructions-command.test.ts new file mode 100644 index 00000000..340235b2 --- /dev/null +++ b/packages/server/src/__tests__/agent-instructions-command.test.ts @@ -0,0 +1,264 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { EventBus } from "../bus/event-bus.js"; +import { + AGENT_INSTRUCTIONS_RELATIVE_PATH, + WORKSPACE_STATE_DIR, +} from "../workspace/workspace-state.js"; +import type { CommandContext } from "../ws/dispatch.js"; +import { dispatch } from "../ws/dispatch.js"; +import "../commands/workspace.js"; +import "../commands/agent-instructions.js"; + +describe("agentInstructions commands", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs.map(async (dir) => { + try { + await import("node:fs/promises").then(({ rm }) => + rm(dir, { recursive: true, force: true }) + ); + } catch { + // Ignore temp cleanup failures in tests. + } + }) + ); + }); + + function createContext(rootPath: string | null): CommandContext { + return { + workspaceMgr: { + get(id: string) { + if (id !== "ws-1" || !rootPath) { + return undefined; + } + + return { + id, + path: rootPath, + targetRuntime: "native", + openedAt: Date.now(), + lastActiveAt: Date.now(), + uiState: { + leftPanelWidth: 320, + bottomPanelHeight: 240, + focusMode: false, + }, + }; + }, + }, + sessionMgr: {} as never, + terminalMgr: {} as never, + eventBus: new EventBus(), + broadcaster: {} as never, + db: {} as never, + providerRegistry: [], + fencingMgr: {} as never, + supervisorMgr: {} as never, + autoFetch: {} as never, + activationMgr: { + getLease: () => ({ wsClientId: "test-client" }), + }, + } as unknown as CommandContext; + } + + it("returns workspace_not_found for missing workspaces", async () => { + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-missing-workspace", + op: "agentInstructions.read", + args: { + workspaceId: "missing", + }, + }, + createContext(null) + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("workspace_not_found"); + }); + + it("reads a missing AGENTS.md without inventing content", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-read-")); + tempDirs.push(rootPath); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-read-missing", + op: "agentInstructions.read", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath) + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + path: AGENT_INSTRUCTIONS_RELATIVE_PATH, + exists: false, + content: "", + }); + }); + + it("generates content from workspace intelligence and omits absent commands", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-generate-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, ".git"), { recursive: true }); + await writeFile(join(rootPath, ".git", "HEAD"), "ref: refs/heads/main\n"); + await writeFile( + join(rootPath, "package.json"), + JSON.stringify({ + scripts: { + dev: "vite", + }, + devDependencies: { + vite: "^7.0.0", + }, + }) + ); + await writeFile(join(rootPath, "pnpm-lock.yaml"), "lockfileVersion: 9.0\n"); + await writeFile(join(rootPath, "README.md"), "# Repo\n"); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-generate-1", + op: "agentInstructions.generate", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath) + ); + + expect(result.ok).toBe(true); + expect((result.data as { content: string }).content).toContain("## Project Overview"); + expect((result.data as { content: string }).content).toContain("- Dev: `pnpm dev`"); + expect((result.data as { content: string }).content).not.toContain("- Test:"); + expect((result.data as { content: string }).content).not.toContain("- Build:"); + expect((result.data as { content: string }).content).not.toContain("- Lint:"); + }); + + it("writes and reads AGENTS.md roundtrip", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-write-")); + tempDirs.push(rootPath); + + const content = [ + "# Agent Instructions", + "", + "## Project Overview", + "", + "- Git branch: main", + "", + "## Development Commands", + "", + "- Dev: `pnpm dev`", + "", + "## Working Rules", + "", + "- Keep changes focused on the requested task.", + "- Do not revert user changes unless explicitly asked.", + "- Prefer the project's existing patterns.", + "- Run the relevant verification command before reporting completion.", + "", + "## Review Expectations", + "", + "- Summarize changed files.", + "- Report verification commands and results.", + "- Call out risks, skipped tests, and assumptions.", + "", + "## Provider Notes", + "", + "- Claude Code: use the project rules above.", + "- Codex: use the project rules above.", + "", + ].join("\n"); + + const writeResult = await dispatch( + { + kind: "command", + id: "agent-instructions-write-1", + op: "agentInstructions.write", + args: { + workspaceId: "ws-1", + content, + }, + }, + createContext(rootPath) + ); + + expect(writeResult.ok).toBe(true); + + const readResult = await dispatch( + { + kind: "command", + id: "agent-instructions-read-1", + op: "agentInstructions.read", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath) + ); + + expect(readResult.ok).toBe(true); + expect(readResult.data).toMatchObject({ + path: AGENT_INSTRUCTIONS_RELATIVE_PATH, + exists: true, + content, + }); + }); + + it("reports health for incomplete AGENTS.md content", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-health-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, WORKSPACE_STATE_DIR), { recursive: true }); + await writeFile( + join(rootPath, AGENT_INSTRUCTIONS_RELATIVE_PATH), + [ + "# Agent Instructions", + "", + "## Project Overview", + "", + "- Git branch: main", + "", + "## Development Commands", + "", + "- Dev: `pnpm dev`", + "", + "## Working Rules", + "", + "- Keep changes focused on the requested task.", + "", + "## Provider Notes", + "", + "- Claude Code: use the project rules above.", + "", + ].join("\n") + ); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-health-1", + op: "agentInstructions.health", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath) + ); + + expect(result.ok).toBe(true); + expect((result.data as { status: string }).status).toBe("warning"); + }); +}); diff --git a/packages/server/src/__tests__/agent-instructions/generator.test.ts b/packages/server/src/__tests__/agent-instructions/generator.test.ts new file mode 100644 index 00000000..56f646a0 --- /dev/null +++ b/packages/server/src/__tests__/agent-instructions/generator.test.ts @@ -0,0 +1,72 @@ +import type { WorkspaceIntelligenceSummary } from "@coder-studio/core"; +import { describe, expect, it } from "vitest"; +import { buildAgentInstructionsMarkdown } from "../../agent-instructions/generator.js"; +import { AGENT_INSTRUCTIONS_RELATIVE_PATH } from "../../workspace/workspace-state.js"; + +describe("buildAgentInstructionsMarkdown", () => { + it("builds a deterministic AGENTS.md document from workspace intelligence", () => { + const summary: WorkspaceIntelligenceSummary = { + workspaceId: "ws-1", + rootPath: "/repo", + git: { + isRepo: true, + branch: "main", + }, + packageManager: "pnpm", + frameworks: ["React"], + scripts: { + dev: "vite", + test: "vitest run", + build: undefined, + lint: undefined, + }, + recommendedCommands: [ + { key: "dev", command: "pnpm dev", source: "package_json" }, + { key: "test", command: "pnpm test", source: "package_json" }, + ], + docs: [{ path: "README.md", kind: "readme" }], + agentInstructions: { + exists: false, + path: AGENT_INSTRUCTIONS_RELATIVE_PATH, + }, + }; + + expect(buildAgentInstructionsMarkdown(summary)).toBe( + [ + "# Agent Instructions", + "", + "## Project Overview", + "", + "- Git branch: main", + "- Package manager: pnpm", + "- Frameworks: React", + "- Docs: README.md", + `- ${AGENT_INSTRUCTIONS_RELATIVE_PATH}: missing`, + "", + "## Development Commands", + "", + "- Dev: `pnpm dev`", + "- Test: `pnpm test`", + "", + "## Working Rules", + "", + "- Keep changes focused on the requested task.", + "- Do not revert user changes unless explicitly asked.", + "- Prefer the project's existing patterns.", + "- Run the relevant verification command before reporting completion.", + "", + "## Review Expectations", + "", + "- Summarize changed files.", + "- Report verification commands and results.", + "- Call out risks, skipped tests, and assumptions.", + "", + "## Provider Notes", + "", + "- Claude Code: use the project rules above.", + "- Codex: use the project rules above.", + "", + ].join("\n") + ); + }); +}); diff --git a/packages/server/src/__tests__/agent-instructions/health.test.ts b/packages/server/src/__tests__/agent-instructions/health.test.ts new file mode 100644 index 00000000..b5fd5823 --- /dev/null +++ b/packages/server/src/__tests__/agent-instructions/health.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { evaluateAgentInstructionsMarkdown } from "../../agent-instructions/health.js"; + +describe("evaluateAgentInstructionsMarkdown", () => { + it("flags missing instruction sections", () => { + const result = evaluateAgentInstructionsMarkdown( + [ + "# Agent Instructions", + "", + "## Project Overview", + "", + "- Git branch: main", + "", + "## Development Commands", + "", + "- Dev: `pnpm dev`", + "", + "## Working Rules", + "", + "- Keep changes focused on the requested task.", + "", + "## Provider Notes", + "", + "- Claude Code: use the project rules above.", + "", + ].join("\n") + ); + + expect(result.status).toBe("warning"); + expect(result.checks).toEqual({ + projectOverview: true, + developmentCommands: true, + workingRules: true, + reviewExpectations: false, + safetyRules: false, + providerNotes: true, + }); + expect(result.issues.map((issue) => issue.code)).toEqual([ + "missing_review_expectations", + "missing_safety_rules", + ]); + }); + + it("accepts a complete generated document", () => { + const result = evaluateAgentInstructionsMarkdown( + [ + "# Agent Instructions", + "", + "## Project Overview", + "", + "- Git branch: main", + "- Package manager: pnpm", + "", + "## Development Commands", + "", + "- Dev: `pnpm dev`", + "", + "## Working Rules", + "", + "- Keep changes focused on the requested task.", + "- Do not revert user changes unless explicitly asked.", + "- Prefer the project's existing patterns.", + "- Run the relevant verification command before reporting completion.", + "", + "## Review Expectations", + "", + "- Summarize changed files.", + "- Report verification commands and results.", + "- Call out risks, skipped tests, and assumptions.", + "", + "## Provider Notes", + "", + "- Claude Code: use the project rules above.", + "- Codex: use the project rules above.", + "", + ].join("\n") + ); + + expect(result.status).toBe("healthy"); + expect(result.issues).toEqual([]); + }); +}); diff --git a/packages/server/src/__tests__/custom-provider-command.test.ts b/packages/server/src/__tests__/custom-provider-command.test.ts new file mode 100644 index 00000000..0abe64b2 --- /dev/null +++ b/packages/server/src/__tests__/custom-provider-command.test.ts @@ -0,0 +1,194 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ProviderDefinition } from "@coder-studio/core"; +import { providerRegistry } from "@coder-studio/providers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventBus } from "../bus/event-bus.js"; +import { CustomProviderRepo } from "../storage/repositories/custom-provider-repo.js"; +import type { CommandContext } from "../ws/dispatch.js"; +import { dispatch } from "../ws/dispatch.js"; +import "../commands/provider.js"; +import "../commands/custom-provider.js"; + +describe("customProvider commands", () => { + let tempDir: string; + let ctx: CommandContext & { customProviderRepo: CustomProviderRepo }; + let registry: ProviderDefinition[]; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "custom-provider-command-")); + registry = [...providerRegistry]; + ctx = { + workspaceMgr: {} as never, + sessionMgr: {} as never, + terminalMgr: {} as never, + eventBus: new EventBus(), + broadcaster: { broadcast: vi.fn() } as never, + db: {} as never, + providerRegistry: registry, + fencingMgr: {} as never, + supervisorMgr: {} as never, + autoFetch: {} as never, + activationMgr: {} as never, + customProviderRepo: new CustomProviderRepo({ + filePath: join(tempDir, "custom-providers.json"), + }), + setProviderRegistry: (providers) => { + registry = providers; + ctx.providerRegistry = providers; + }, + }; + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("creates, lists, updates, and deletes custom providers through dispatch", async () => { + const created = await dispatch( + { + kind: "command", + id: "custom-provider-create", + op: "customProvider.create", + args: { + id: "review-bot", + displayName: "Review Bot", + command: "review-bot", + args: ["--stdio"], + env: { REVIEW_MODE: "strict" }, + cwdMode: "workspace_root", + sessionMode: "interactive", + startupPrompt: "Review the diff before answering.", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "review", supported: true, label: "Review" }, + ], + }, + }, + ctx + ); + + expect(created.ok).toBe(true); + expect(created.data).toMatchObject({ + id: "review-bot", + kind: "custom", + requiredCommands: ["review-bot"], + }); + + const listed = await dispatch( + { + kind: "command", + id: "provider-list-custom", + op: "provider.list", + args: {}, + }, + ctx + ); + + expect(listed.ok).toBe(true); + expect(listed.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "review-bot", + displayName: "Review Bot", + kind: "custom", + requiredCommands: ["review-bot"], + }), + ]) + ); + + const customListed = await dispatch( + { + kind: "command", + id: "custom-provider-list", + op: "customProvider.list", + args: {}, + }, + ctx + ); + + expect(customListed.ok).toBe(true); + expect(customListed.data).toEqual([ + expect.objectContaining({ + id: "review-bot", + kind: "custom", + }), + ]); + + const updated = await dispatch( + { + kind: "command", + id: "custom-provider-update", + op: "customProvider.update", + args: { + id: "review-bot", + displayName: "Review Bot 2", + command: "review-bot", + args: ["--stdio", "--model", "fast"], + env: { REVIEW_MODE: "fast" }, + cwdMode: "workspace_root", + sessionMode: "interactive", + startupPrompt: "Use the fast model.", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "review", supported: true, label: "Review" }, + ], + }, + }, + ctx + ); + + expect(updated.ok).toBe(true); + expect(updated.data).toMatchObject({ + id: "review-bot", + displayName: "Review Bot 2", + }); + expect(ctx.customProviderRepo.get("review-bot")).toMatchObject({ + displayName: "Review Bot 2", + args: ["--stdio", "--model", "fast"], + }); + + const removed = await dispatch( + { + kind: "command", + id: "custom-provider-delete", + op: "customProvider.delete", + args: { + id: "review-bot", + }, + }, + ctx + ); + + expect(removed.ok).toBe(true); + expect(ctx.customProviderRepo.get("review-bot")).toBeUndefined(); + expect(ctx.providerRegistry.find((provider) => provider.id === "review-bot")).toBeUndefined(); + }); + + it("rejects an empty command", async () => { + const result = await dispatch( + { + kind: "command", + id: "custom-provider-invalid", + op: "customProvider.create", + args: { + id: "invalid-provider", + displayName: "Invalid", + command: " ", + args: [], + env: {}, + cwdMode: "workspace_root", + sessionMode: "interactive", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + ], + }, + }, + ctx + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("validation_error"); + }); +}); diff --git a/packages/server/src/__tests__/custom-provider-repo.test.ts b/packages/server/src/__tests__/custom-provider-repo.test.ts new file mode 100644 index 00000000..fcf526f9 --- /dev/null +++ b/packages/server/src/__tests__/custom-provider-repo.test.ts @@ -0,0 +1,174 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { CustomProviderRepo } from "../storage/repositories/custom-provider-repo.js"; + +describe("CustomProviderRepo", () => { + let tempDir: string; + let filePath: string; + let repo: CustomProviderRepo; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "custom-provider-repo-")); + filePath = join(tempDir, "custom-providers.json"); + repo = new CustomProviderRepo({ + filePath, + }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("constructs with a filePath option object", () => { + const constructed = new CustomProviderRepo({ filePath }); + + expect(constructed).toBeInstanceOf(CustomProviderRepo); + }); + + it("rehydrates a provider from disk in a fresh repo instance", () => { + repo.set({ + id: "review-bot", + displayName: "Review Bot", + command: "review-bot", + args: ["--stdio"], + env: { REVIEW_MODE: "strict" }, + cwdMode: "workspace_root", + sessionMode: "interactive", + startupPrompt: "Review the diff before answering.", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "review", supported: true, label: "Review" }, + ], + createdAt: 100, + updatedAt: 100, + }); + + const reloadedRepo = new CustomProviderRepo({ filePath }); + + expect(reloadedRepo.get("review-bot")).toEqual({ + id: "review-bot", + displayName: "Review Bot", + command: "review-bot", + args: ["--stdio"], + env: { REVIEW_MODE: "strict" }, + cwdMode: "workspace_root", + sessionMode: "interactive", + startupPrompt: "Review the diff before answering.", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "review", supported: true, label: "Review" }, + ], + createdAt: 100, + updatedAt: 100, + }); + }); + + it("lists rehydrated providers by updated order and preserves createdAt on overwrite", () => { + repo.set({ + id: "alpha", + displayName: "Alpha", + command: "alpha", + args: [], + env: {}, + cwdMode: "workspace_root", + sessionMode: "interactive", + capabilities: [{ key: "interactive_session", supported: true, label: "Interactive session" }], + createdAt: 10, + updatedAt: 10, + }); + repo.set({ + id: "beta", + displayName: "Beta", + command: "beta", + args: [], + env: {}, + cwdMode: "workspace_root", + sessionMode: "interactive", + capabilities: [{ key: "interactive_session", supported: true, label: "Interactive session" }], + createdAt: 20, + updatedAt: 20, + }); + + repo.set({ + id: "alpha", + displayName: "Alpha 2", + command: "alpha2", + args: ["--fast"], + env: { MODE: "2" }, + cwdMode: "workspace_root", + sessionMode: "interactive", + capabilities: [{ key: "interactive_session", supported: true, label: "Interactive session" }], + createdAt: 999, + updatedAt: 30, + }); + + const reloadedRepo = new CustomProviderRepo({ filePath }); + + expect(reloadedRepo.list()).toEqual([ + { + id: "alpha", + displayName: "Alpha 2", + command: "alpha2", + args: ["--fast"], + env: { MODE: "2" }, + cwdMode: "workspace_root", + sessionMode: "interactive", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + ], + createdAt: 10, + updatedAt: 30, + }, + { + id: "beta", + displayName: "Beta", + command: "beta", + args: [], + env: {}, + cwdMode: "workspace_root", + sessionMode: "interactive", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + ], + createdAt: 20, + updatedAt: 20, + }, + ]); + }); + + it("persists deletions so a fresh repo instance keeps only remaining providers", () => { + repo.set({ + id: "keep", + displayName: "Keep", + command: "keep", + args: [], + env: {}, + cwdMode: "workspace_root", + sessionMode: "interactive", + capabilities: [{ key: "interactive_session", supported: true, label: "Interactive session" }], + createdAt: 1, + updatedAt: 1, + }); + repo.set({ + id: "drop", + displayName: "Drop", + command: "drop", + args: [], + env: {}, + cwdMode: "workspace_root", + sessionMode: "interactive", + capabilities: [{ key: "interactive_session", supported: true, label: "Interactive session" }], + createdAt: 2, + updatedAt: 2, + }); + + repo.delete("drop"); + + const reloadedRepo = new CustomProviderRepo({ filePath }); + + expect(reloadedRepo.get("drop")).toBeUndefined(); + expect(reloadedRepo.list().map((provider) => provider.id)).toEqual(["keep"]); + }); +}); diff --git a/packages/server/src/__tests__/provider-list.test.ts b/packages/server/src/__tests__/provider-list.test.ts new file mode 100644 index 00000000..64096ab6 --- /dev/null +++ b/packages/server/src/__tests__/provider-list.test.ts @@ -0,0 +1,119 @@ +import { providerRegistry } from "@coder-studio/providers"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { CommandContext } from "../ws/dispatch.js"; +import { dispatch } from "../ws/dispatch.js"; +import "../commands/provider.js"; + +describe("provider.list command", () => { + let ctx: CommandContext; + + beforeEach(() => { + ctx = { + workspaceMgr: {} as never, + sessionMgr: {} as never, + terminalMgr: {} as never, + eventBus: {} as never, + broadcaster: {} as never, + db: {} as never, + providerRegistry, + fencingMgr: {} as never, + supervisorMgr: {} as never, + autoFetch: {} as never, + activationMgr: {} as never, + }; + }); + + it("returns built-in provider DTOs through dispatch", async () => { + const result = await dispatch( + { + kind: "command", + id: "provider-list-1", + op: "provider.list", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual([ + { + id: "claude", + displayName: "Claude Code", + badge: "Claude", + kind: "built_in", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], + requiredCommands: ["claude"], + }, + { + id: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], + requiredCommands: ["codex"], + }, + ]); + }); + + it("includes custom providers already present in the command context registry", async () => { + ctx.providerRegistry = [ + ...providerRegistry, + { + id: "review-bot", + displayName: "Review Bot", + badge: "Custom", + kind: "custom", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "review", supported: true, label: "Review" }, + ], + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + strategies: {}, + }, + buildCommand: () => ({ argv: ["review-bot"], cwd: "/tmp", env: {} }), + configSchema: { parse: (value: unknown) => value } as never, + defaultConfig: {}, + requiredCommands: ["review-bot"], + }, + ]; + + const result = await dispatch( + { + kind: "command", + id: "provider-list-custom", + op: "provider.list", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "review-bot", + kind: "custom", + requiredCommands: ["review-bot"], + }), + ]) + ); + }); +}); diff --git a/packages/server/src/__tests__/provider-runtime/custom-provider.test.ts b/packages/server/src/__tests__/provider-runtime/custom-provider.test.ts new file mode 100644 index 00000000..d4c60b98 --- /dev/null +++ b/packages/server/src/__tests__/provider-runtime/custom-provider.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { buildCustomProviderDefinition } from "../../provider-runtime/custom-provider.js"; + +describe("buildCustomProviderDefinition", () => { + it("builds a runtime provider with first-token command requirements and workspace cwd", () => { + const provider = buildCustomProviderDefinition({ + id: "review-bot", + displayName: "Review Bot", + command: "review-bot", + args: ["--stdio"], + env: { REVIEW_MODE: "strict" }, + cwdMode: "workspace_root", + sessionMode: "interactive", + startupPrompt: "Review the diff before answering.", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "review", supported: true, label: "Review" }, + ], + createdAt: 100, + updatedAt: 200, + }); + + expect(provider.id).toBe("review-bot"); + expect(provider.kind).toBe("custom"); + expect(provider.badge).toBe("Custom"); + expect(provider.requiredCommands).toEqual(["review-bot"]); + expect(provider.capability).toBe("full"); + expect( + provider.buildCommand({}, { sessionId: "sess-1", workspacePath: "/tmp/workspace" }) + ).toEqual({ + argv: ["review-bot", "--stdio"], + cwd: "/tmp/workspace", + env: { + REVIEW_MODE: "strict", + CODER_STUDIO_SESSION_ID: "sess-1", + }, + }); + }); + + it("downgrades unsupported capability when interactive session is absent", () => { + const provider = buildCustomProviderDefinition({ + id: "batch-review", + displayName: "Batch Review", + command: "batch-review", + args: [], + env: {}, + cwdMode: "workspace_root", + sessionMode: "interactive", + capabilities: [{ key: "review", supported: true, label: "Review" }], + createdAt: 1, + updatedAt: 1, + }); + + expect(provider.capability).toBe("unsupported"); + }); +}); diff --git a/packages/server/src/__tests__/server-provider-install-wiring.test.ts b/packages/server/src/__tests__/server-provider-install-wiring.test.ts index ac5e4682..a4d42c87 100644 --- a/packages/server/src/__tests__/server-provider-install-wiring.test.ts +++ b/packages/server/src/__tests__/server-provider-install-wiring.test.ts @@ -16,6 +16,7 @@ vi.mock("node:child_process", async () => { }); import { createServer, type Server } from "../server.js"; +import { dispatch } from "../ws/dispatch.js"; describe("createServer provider install wiring", () => { let server: Server | undefined; @@ -159,4 +160,58 @@ describe("createServer provider install wiring", () => { expect.objectContaining({ shell: false, windowsHide: true }) ); }); + + it("keeps provider.install.start wired after creating a custom provider", async () => { + stateDir = mkdtempSync(join(tmpdir(), "coder-studio-server-custom-provider-install-")); + server = await createServer({ + stateDir, + host: "127.0.0.1", + port: 0, + }); + + const ctx = server.__test__!.commandContext; + + const created = await dispatch( + { + kind: "command", + id: "custom-provider-create", + op: "customProvider.create", + args: { + id: "review-bot", + displayName: "Review Bot", + command: "review-bot", + args: ["--stdio"], + env: { REVIEW_MODE: "strict" }, + cwdMode: "workspace_root", + sessionMode: "interactive", + startupPrompt: "Review the diff before answering.", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "review", supported: true, label: "Review" }, + ], + }, + }, + ctx + ); + + expect(created.ok).toBe(true); + + const install = await dispatch( + { + kind: "command", + id: "custom-provider-install-start", + op: "provider.install.start", + args: { + providerId: "review-bot", + }, + }, + ctx + ); + + expect(install.ok).toBe(true); + expect(install.data).toMatchObject({ + providerId: "review-bot", + status: "failed", + }); + }); }); diff --git a/packages/server/src/__tests__/session-commands.test.ts b/packages/server/src/__tests__/session-commands.test.ts index f11282af..89b70efd 100644 --- a/packages/server/src/__tests__/session-commands.test.ts +++ b/packages/server/src/__tests__/session-commands.test.ts @@ -5,9 +5,11 @@ import type { ProviderDefinition } from "@coder-studio/core"; import { providerRegistry } from "@coder-studio/providers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EventBus } from "../bus/event-bus.js"; +import { buildCustomProviderDefinition } from "../provider-runtime/custom-provider.js"; import { SessionManager } from "../session/manager.js"; import type { SessionDatabase } from "../session/types.js"; import { ProviderConfigRepo } from "../storage/repositories/provider-config-repo.js"; +import { SessionMetadataRepo } from "../storage/repositories/session-metadata-repo.js"; import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js"; import type { TerminalManager } from "../terminal/manager.js"; import { WorkspaceManager } from "../workspace/manager.js"; @@ -15,7 +17,6 @@ import type { CommandContext } from "../ws/dispatch.js"; import { dispatch } from "../ws/dispatch.js"; import type { Broadcaster } from "../ws/hub.js"; -// Import command handlers to register them import "../commands/workspace.js"; import "../commands/session.js"; @@ -23,16 +24,6 @@ describe("Session Commands", () => { const broadcaster = { broadcast: () => {} } satisfies Broadcaster; const createProviderConfigRepo = (filePath: string) => new ProviderConfigRepo({ filePath }) as Pick as ProviderConfigRepo; - const terminalMgrStub = { - create: () => ({ id: "terminal-1" }), - kill: async () => {}, - close: async () => {}, - } as unknown as TerminalManager; - const sessionDbStub = { - insert: () => {}, - update: () => {}, - delete: () => {}, - } as unknown as SessionDatabase; let ctx: CommandContext; let eventBus: EventBus; @@ -40,18 +31,37 @@ describe("Session Commands", () => { let sessionMgr: SessionManager; let stateDir: string; let tempDirs: string[]; + let sessionMetadataRepo: SessionMetadataRepo; + let workspaceRepo: WorkspaceRepo; + let terminalMgrStub: TerminalManager; + let sessionDbStub: SessionDatabase; beforeEach(() => { - // Create event bus eventBus = new EventBus(); stateDir = mkdtempSync(join(tmpdir(), "session-command-state-")); const providerConfigRepo = createProviderConfigRepo(join(stateDir, "provider-configs.json")); + workspaceRepo = new WorkspaceRepo({ + filePath: join(stateDir, "workspaces.json"), + }); + sessionMetadataRepo = new SessionMetadataRepo({ + workspaceRepo, + }); + terminalMgrStub = { + create: () => ({ id: "terminal-1" }), + kill: async () => {}, + close: async () => {}, + } as unknown as TerminalManager; + sessionDbStub = { + insert: () => {}, + update: () => {}, + findById: () => undefined, + findByWorkspaceId: () => [], + listHydratable: () => [], + delete: () => {}, + }; - // Create managers workspaceMgr = new WorkspaceManager({ - workspaceRepo: new WorkspaceRepo({ - filePath: join(stateDir, "workspaces.json"), - }), + workspaceRepo, eventBus, }); sessionMgr = new SessionManager({ @@ -63,17 +73,17 @@ describe("Session Commands", () => { providerConfigRepo, }); - // Create context with required dependencies ctx = { workspaceMgr, sessionMgr, - terminalMgr: {}, + terminalMgr: {} as never, eventBus, broadcaster, providerRegistry: [], - fencingMgr: {}, - supervisorMgr: {}, + fencingMgr: {} as never, + supervisorMgr: {} as never, providerConfigRepo, + sessionMetadataRepo, } as unknown as CommandContext; tempDirs = []; }); @@ -152,6 +162,131 @@ describe("Session Commands", () => { rmSync(testDir, { recursive: true, force: true }); } }); + + it("launches a custom provider through the existing session.create flow", async () => { + const testDir = join(tmpdir(), `coder-studio-custom-provider-session-${Date.now()}`); + mkdirSync(join(testDir, ".git"), { recursive: true }); + writeFileSync(join(testDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + + const customProvider = buildCustomProviderDefinition({ + id: "review-bot", + displayName: "Review Bot", + command: "review-bot", + args: ["--stdio"], + env: { REVIEW_MODE: "strict" }, + cwdMode: "workspace_root", + sessionMode: "interactive", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "review", supported: true, label: "Review" }, + ], + startupPrompt: "Review before responding.", + createdAt: 100, + updatedAt: 100, + }); + + ctx.providerRegistry = [...providerRegistry, customProvider] as ProviderDefinition[]; + ctx.providerRuntimeDeps = { + commandExists: async (command: string) => command === "review-bot", + }; + + try { + const openResult = await dispatch( + { + kind: "command", + id: "workspace-custom-provider", + op: "workspace.open", + args: { path: testDir }, + }, + ctx + ); + + expect(openResult.ok).toBe(true); + + const result = await dispatch( + { + kind: "command", + id: "session-custom-provider", + op: "session.create", + args: { + workspaceId: openResult.data!.id, + providerId: "review-bot", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + providerId: "review-bot", + capability: "full", + state: "starting", + }); + expect(sessionMetadataRepo.get(result.data!.id)).toMatchObject({ + sessionId: result.data!.id, + workspaceId: openResult.data!.id, + providerId: "review-bot", + objective: undefined, + baselineGitHead: undefined, + verificationRuns: [], + }); + } finally { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it("captures session objective and git baseline metadata when available", async () => { + const testDir = join(tmpdir(), `coder-studio-session-metadata-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + mkdirSync(join(testDir, ".git"), { recursive: true }); + writeFileSync(join(testDir, ".git", "HEAD"), "0123456789abcdef0123456789abcdef01234567\n"); + + ctx.providerRegistry = providerRegistry as ProviderDefinition[]; + ctx.providerRuntimeDeps = { + commandExists: async (command: string) => command === "claude", + }; + + try { + const openResult = await dispatch( + { + kind: "command", + id: "workspace-metadata", + op: "workspace.open", + args: { path: testDir }, + }, + ctx + ); + + expect(openResult.ok).toBe(true); + + const result = await dispatch( + { + kind: "command", + id: "session-metadata", + op: "session.create", + args: { + workspaceId: openResult.data!.id, + providerId: "claude", + draft: "Fix the build and run focused verification", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(sessionMetadataRepo.get(result.data!.id)).toMatchObject({ + sessionId: result.data!.id, + workspaceId: openResult.data!.id, + providerId: "claude", + objective: "Fix the build and run focused verification", + baselineGitHead: "0123456789abcdef0123456789abcdef01234567", + baselineCapturedAt: expect.any(Number), + verificationRuns: [], + }); + } finally { + rmSync(testDir, { recursive: true, force: true }); + } + }); }); describe("session.stop", () => { @@ -188,6 +323,51 @@ describe("Session Commands", () => { expect(result.ok).toBe(false); }); + + it("deletes session metadata when removing an ended session", async () => { + const workspacePath = mkdtempSync(join(tmpdir(), "coder-studio-remove-metadata-")); + tempDirs.push(workspacePath); + const workspace = await workspaceMgr.open({ path: workspacePath }); + sessionMetadataRepo.upsert({ + sessionId: "sess-ended", + workspaceId: workspace.id, + providerId: "codex", + verificationRuns: [], + }); + + const deleteSpy = vi.spyOn(sessionMgr, "delete").mockImplementation(() => {}); + vi.spyOn(sessionMgr, "get").mockImplementation((sessionId: string) => + sessionId === "sess-ended" + ? ({ + id: "sess-ended", + workspaceId: workspace.id, + terminalId: "term-ended", + providerId: "codex", + capability: "full", + state: "ended", + startedAt: 1, + lastActiveAt: 1, + endedAt: 2, + } as const) + : undefined + ); + + const result = await dispatch( + { + kind: "command", + id: "test-id-remove-metadata", + op: "session.remove", + args: { + sessionId: "sess-ended", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(deleteSpy).toHaveBeenCalledWith("sess-ended"); + expect(sessionMetadataRepo.get("sess-ended")).toBeUndefined(); + }); }); describe("session.close", () => { @@ -306,6 +486,52 @@ describe("Session Commands", () => { ], }); }); + + it("deletes session metadata when closing an ended session", async () => { + const workspacePath = mkdtempSync(join(tmpdir(), "coder-studio-close-metadata-")); + tempDirs.push(workspacePath); + const workspace = await workspaceMgr.open({ path: workspacePath }); + sessionMetadataRepo.upsert({ + sessionId: "sess-meta", + workspaceId: workspace.id, + providerId: "codex", + verificationRuns: [], + }); + + const deleteSpy = vi.spyOn(sessionMgr, "delete").mockImplementation(() => {}); + vi.spyOn(sessionMgr, "get").mockImplementation((sessionId: string) => + sessionId === "sess-meta" + ? ({ + id: "sess-meta", + workspaceId: workspace.id, + terminalId: "term-meta", + providerId: "codex", + capability: "full", + state: "ended", + startedAt: 1, + lastActiveAt: 1, + endedAt: 2, + } as const) + : undefined + ); + + const result = await dispatch( + { + kind: "command", + id: "test-id-close-metadata", + op: "session.close", + args: { + sessionId: "sess-meta", + paneDisposition: "draft", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(deleteSpy).toHaveBeenCalledWith("sess-meta"); + expect(sessionMetadataRepo.get("sess-meta")).toBeUndefined(); + }); }); describe("session.resume", () => { diff --git a/packages/server/src/__tests__/session-metadata-command.test.ts b/packages/server/src/__tests__/session-metadata-command.test.ts new file mode 100644 index 00000000..0008e70c --- /dev/null +++ b/packages/server/src/__tests__/session-metadata-command.test.ts @@ -0,0 +1,122 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventBus } from "../bus/event-bus.js"; +import { SessionMetadataRepo } from "../storage/repositories/session-metadata-repo.js"; +import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js"; +import type { CommandContext } from "../ws/dispatch.js"; +import { dispatch } from "../ws/dispatch.js"; +import "../commands/session-metadata.js"; + +describe("session metadata commands", () => { + let tempDir: string; + let workspacePath: string; + let workspaceRepo: WorkspaceRepo; + let metadataRepo: SessionMetadataRepo; + let ctx: CommandContext & { sessionMetadataRepo: SessionMetadataRepo }; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "session-metadata-command-")); + workspacePath = join(tempDir, "workspace"); + await mkdir(workspacePath, { recursive: true }); + workspaceRepo = new WorkspaceRepo({ + filePath: join(tempDir, "workspaces.json"), + }); + workspaceRepo.create({ + id: "ws-1", + path: workspacePath, + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { leftPanelWidth: 1, bottomPanelHeight: 1, focusMode: false }, + }); + metadataRepo = new SessionMetadataRepo({ + workspaceRepo, + }); + metadataRepo.upsert({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + objective: "Fix the lint errors", + baselineGitHead: "abc123", + baselineCapturedAt: 100, + verificationRuns: [], + }); + + ctx = { + workspaceMgr: {} as never, + sessionMgr: {} as never, + terminalMgr: {} as never, + eventBus: new EventBus(), + broadcaster: { broadcast: vi.fn() } as never, + db: {} as never, + providerRegistry: [], + fencingMgr: {} as never, + supervisorMgr: {} as never, + autoFetch: {} as never, + activationMgr: {} as never, + sessionMetadataRepo: metadataRepo, + }; + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("returns stored metadata", async () => { + const result = await dispatch( + { + kind: "command", + id: "session-metadata-get", + op: "session.metadata.get", + args: { + sessionId: "sess-1", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + objective: "Fix the lint errors", + baselineGitHead: "abc123", + baselineCapturedAt: 100, + verificationRuns: [], + }); + }); + + it("appends verification runs through dispatch", async () => { + const added = await dispatch( + { + kind: "command", + id: "session-verification-add", + op: "session.verification.add", + args: { + sessionId: "sess-1", + command: "pnpm lint", + status: "passed", + exitCode: 0, + summary: "lint clean", + }, + }, + ctx + ); + + expect(added.ok).toBe(true); + expect(added.data).toMatchObject({ + sessionId: "sess-1", + verificationRuns: [ + expect.objectContaining({ + command: "pnpm lint", + status: "passed", + exitCode: 0, + summary: "lint clean", + }), + ], + }); + }); +}); diff --git a/packages/server/src/__tests__/session-metadata-repo.test.ts b/packages/server/src/__tests__/session-metadata-repo.test.ts new file mode 100644 index 00000000..597809c8 --- /dev/null +++ b/packages/server/src/__tests__/session-metadata-repo.test.ts @@ -0,0 +1,160 @@ +import { mkdir, mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SessionMetadataRepo } from "../storage/repositories/session-metadata-repo.js"; +import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js"; + +describe("SessionMetadataRepo", () => { + let tempDir: string; + let workspacePath: string; + let workspaceRepo: WorkspaceRepo; + let repo: SessionMetadataRepo; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "session-metadata-repo-")); + workspacePath = join(tempDir, "workspace"); + await mkdir(workspacePath, { recursive: true }); + workspaceRepo = new WorkspaceRepo({ + filePath: join(tempDir, "workspaces.json"), + }); + workspaceRepo.create({ + id: "ws-1", + path: workspacePath, + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { leftPanelWidth: 1, bottomPanelHeight: 1, focusMode: false }, + }); + repo = new SessionMetadataRepo({ + workspaceRepo, + }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("constructs with a workspaceRepo option object", () => { + const constructed = new SessionMetadataRepo({ workspaceRepo }); + + expect(constructed).toBeInstanceOf(SessionMetadataRepo); + }); + + it("stores session metadata under .coder-studio", async () => { + repo.upsert({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + objective: "Fix the failing tests", + baselineGitHead: "abc123", + baselineCapturedAt: 1000, + verificationRuns: [], + }); + + await expect( + stat(join(workspacePath, ".coder-studio", "session-metadata.json")) + ).resolves.toBeDefined(); + }); + + it("rehydrates session metadata without verification runs in a fresh repo instance", () => { + repo.upsert({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + objective: "Fix the failing tests", + baselineGitHead: "abc123", + baselineCapturedAt: 1000, + verificationRuns: [], + }); + + const reloadedRepo = new SessionMetadataRepo({ workspaceRepo }); + + expect(reloadedRepo.get("sess-1")).toEqual({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + objective: "Fix the failing tests", + baselineGitHead: "abc123", + baselineCapturedAt: 1000, + verificationRuns: [], + }); + }); + + it("rehydrates appended verification runs in created order in a fresh repo instance", () => { + repo.upsert({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + verificationRuns: [], + }); + + repo.addVerificationRun("sess-1", { + id: "verify-1", + command: "pnpm test", + status: "failed", + exitCode: 1, + summary: "2 tests failing", + createdAt: 100, + }); + repo.addVerificationRun("sess-1", { + id: "verify-2", + command: "pnpm test", + status: "passed", + exitCode: 0, + summary: "all green", + createdAt: 200, + }); + + const reloadedRepo = new SessionMetadataRepo({ workspaceRepo }); + + expect(reloadedRepo.get("sess-1")?.verificationRuns).toEqual([ + { + id: "verify-1", + command: "pnpm test", + status: "failed", + exitCode: 1, + summary: "2 tests failing", + createdAt: 100, + }, + { + id: "verify-2", + command: "pnpm test", + status: "passed", + exitCode: 0, + summary: "all green", + createdAt: 200, + }, + ]); + }); + + it("finds metadata across registered workspaces by session id", async () => { + const otherWorkspacePath = join(tempDir, "workspace-2"); + await mkdir(otherWorkspacePath, { recursive: true }); + workspaceRepo.create({ + id: "ws-2", + path: otherWorkspacePath, + targetRuntime: "native", + openedAt: 2, + lastActiveAt: 2, + uiState: { leftPanelWidth: 1, bottomPanelHeight: 1, focusMode: false }, + }); + + repo.upsert({ + sessionId: "sess-2", + workspaceId: "ws-2", + providerId: "codex", + verificationRuns: [], + }); + + expect(repo.get("sess-2")).toMatchObject({ + sessionId: "sess-2", + workspaceId: "ws-2", + providerId: "codex", + verificationRuns: [], + }); + await expect( + stat(join(otherWorkspacePath, ".coder-studio", "session-metadata.json")) + ).resolves.toBeDefined(); + }); +}); diff --git a/packages/server/src/__tests__/session-review-command.test.ts b/packages/server/src/__tests__/session-review-command.test.ts new file mode 100644 index 00000000..9542a07d --- /dev/null +++ b/packages/server/src/__tests__/session-review-command.test.ts @@ -0,0 +1,136 @@ +import { execFile } from "child_process"; +import { mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { promisify } from "util"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventBus } from "../bus/event-bus.js"; +import { SessionMetadataRepo } from "../storage/repositories/session-metadata-repo.js"; +import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js"; +import type { CommandContext } from "../ws/dispatch.js"; +import { dispatch } from "../ws/dispatch.js"; +import "../commands/session-review.js"; + +const execFileAsync = promisify(execFile); + +describe("session review commands", () => { + let repoDir: string; + let stateDir: string; + let metadataRepo: SessionMetadataRepo; + let workspaceRepo: WorkspaceRepo; + let ctx: CommandContext & { sessionMetadataRepo: SessionMetadataRepo }; + + beforeEach(async () => { + repoDir = await mkdtemp(join(tmpdir(), "session-review-command-")); + stateDir = await mkdtemp(join(tmpdir(), "session-review-command-state-")); + workspaceRepo = new WorkspaceRepo({ + filePath: join(stateDir, "workspaces.json"), + }); + workspaceRepo.create({ + id: "ws-1", + path: repoDir, + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { leftPanelWidth: 1, bottomPanelHeight: 1, focusMode: false }, + }); + metadataRepo = new SessionMetadataRepo({ + workspaceRepo, + }); + + await execFileAsync("git", ["init"], { cwd: repoDir }); + await execFileAsync("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: repoDir }); + await writeFile(join(repoDir, "sample.ts"), "export const value = 1;\n"); + await execFileAsync("git", ["add", "."], { cwd: repoDir }); + await execFileAsync("git", ["commit", "-m", "Initial commit"], { cwd: repoDir }); + const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: repoDir }); + metadataRepo.upsert({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + baselineGitHead: stdout.trim(), + baselineCapturedAt: 1, + verificationRuns: [], + }); + + ctx = { + workspaceMgr: { + get(id: string) { + return id === "ws-1" + ? { + id, + path: repoDir, + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { leftPanelWidth: 1, bottomPanelHeight: 1, focusMode: false }, + } + : undefined; + }, + } as never, + sessionMgr: {} as never, + terminalMgr: {} as never, + eventBus: new EventBus(), + broadcaster: { broadcast: vi.fn() } as never, + db: {} as never, + providerRegistry: [], + fencingMgr: {} as never, + supervisorMgr: {} as never, + autoFetch: {} as never, + activationMgr: {} as never, + sessionMetadataRepo: metadataRepo, + }; + }); + + afterEach(async () => { + await rm(stateDir, { recursive: true, force: true }); + await rm(repoDir, { recursive: true, force: true }); + }); + + it("returns summary through dispatch", async () => { + await writeFile(join(repoDir, "sample.ts"), "export const value = 2;\n"); + + const result = await dispatch( + { + kind: "command", + id: "session-review-summary", + op: "sessionReview.summary", + args: { + sessionId: "sess-1", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + sessionId: "sess-1", + workspaceId: "ws-1", + changedFiles: [{ path: "sample.ts", status: "modified" }], + }); + }); + + it("returns diff through dispatch", async () => { + await writeFile(join(repoDir, "sample.ts"), "export const value = 2;\n"); + + const result = await dispatch( + { + kind: "command", + id: "session-review-diff", + op: "sessionReview.diff", + args: { + sessionId: "sess-1", + path: "sample.ts", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ + path: "sample.ts", + diff: expect.stringContaining("+export const value = 2;"), + }); + }); +}); diff --git a/packages/server/src/__tests__/session-review/review.test.ts b/packages/server/src/__tests__/session-review/review.test.ts new file mode 100644 index 00000000..e018b4a2 --- /dev/null +++ b/packages/server/src/__tests__/session-review/review.test.ts @@ -0,0 +1,136 @@ +import { execFile } from "child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { promisify } from "util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { buildSessionReviewSummary, getSessionReviewDiff } from "../../session-review/review.js"; +import { SessionMetadataRepo } from "../../storage/repositories/session-metadata-repo.js"; +import { WorkspaceRepo } from "../../storage/repositories/workspace-repo.js"; + +const execFileAsync = promisify(execFile); + +describe("session review", () => { + let repo: SessionMetadataRepo; + let repoDir: string; + let stateDir: string; + let workspaceRepo: WorkspaceRepo; + + beforeEach(async () => { + repoDir = await mkdtemp(join(tmpdir(), "session-review-")); + stateDir = await mkdtemp(join(tmpdir(), "session-review-state-")); + workspaceRepo = new WorkspaceRepo({ + filePath: join(stateDir, "workspaces.json"), + }); + workspaceRepo.create({ + id: "ws-1", + path: repoDir, + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { leftPanelWidth: 1, bottomPanelHeight: 1, focusMode: false }, + }); + repo = new SessionMetadataRepo({ + workspaceRepo, + }); + + await execFileAsync("git", ["init"], { cwd: repoDir }); + await execFileAsync("git", ["config", "user.name", "Test"], { cwd: repoDir }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: repoDir }); + await writeFile(join(repoDir, "sample.ts"), "export const value = 1;\n"); + await execFileAsync("git", ["add", "."], { cwd: repoDir }); + await execFileAsync("git", ["commit", "-m", "Initial commit"], { cwd: repoDir }); + + const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: repoDir }); + const baseline = stdout.trim(); + repo.upsert({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + baselineGitHead: baseline, + baselineCapturedAt: 1, + verificationRuns: [], + }); + }); + + afterEach(async () => { + await rm(stateDir, { recursive: true, force: true }); + await rm(repoDir, { recursive: true, force: true }); + }); + + it("returns changed files since the stored baseline", async () => { + await writeFile(join(repoDir, "sample.ts"), "export const value = 2;\n"); + await writeFile(join(repoDir, "new-file.ts"), "export const next = true;\n"); + + const summary = await buildSessionReviewSummary({ + sessionId: "sess-1", + workspacePath: repoDir, + metadataRepo: repo, + }); + + expect(summary.changedFiles).toEqual([ + { path: "sample.ts", status: "modified" }, + { path: "new-file.ts", status: "untracked" }, + ]); + expect(summary.warnings).toEqual([]); + }); + + it("returns a warning when baseline is missing", async () => { + repo.delete("sess-1"); + repo.upsert({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + verificationRuns: [], + }); + + const summary = await buildSessionReviewSummary({ + sessionId: "sess-1", + workspacePath: repoDir, + metadataRepo: repo, + }); + + expect(summary.changedFiles).toEqual([]); + expect(summary.warnings).toEqual([ + { + code: "missing_baseline", + message: "Session baseline is missing.", + }, + ]); + }); + + it("returns a warning for non-git workspaces", async () => { + const plainDir = await mkdtemp(join(tmpdir(), "session-review-plain-")); + try { + const summary = await buildSessionReviewSummary({ + sessionId: "sess-1", + workspacePath: plainDir, + metadataRepo: repo, + }); + + expect(summary.changedFiles).toEqual([]); + expect(summary.warnings).toEqual([ + { + code: "not_git_repo", + message: "Workspace is not a Git repository.", + }, + ]); + } finally { + await rm(plainDir, { recursive: true, force: true }); + } + }); + + it("returns a per-file diff against baseline", async () => { + await writeFile(join(repoDir, "sample.ts"), "export const value = 2;\n"); + + const diff = await getSessionReviewDiff({ + sessionId: "sess-1", + workspacePath: repoDir, + metadataRepo: repo, + path: "sample.ts", + }); + + expect(diff).toContain("-export const value = 1;"); + expect(diff).toContain("+export const value = 2;"); + }); +}); diff --git a/packages/server/src/__tests__/workspace-close-state-cleanup.test.ts b/packages/server/src/__tests__/workspace-close-state-cleanup.test.ts index 70258f04..92d86e7e 100644 --- a/packages/server/src/__tests__/workspace-close-state-cleanup.test.ts +++ b/packages/server/src/__tests__/workspace-close-state-cleanup.test.ts @@ -4,6 +4,8 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createServer, type Server } from "../server.js"; import { SessionRepo, TerminalRepo } from "../storage/index.js"; +import { SessionMetadataRepo } from "../storage/repositories/session-metadata-repo.js"; +import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js"; import { dispatch } from "../ws/dispatch.js"; import "../commands/workspace.js"; @@ -56,6 +58,12 @@ describe("workspace close state cleanup", () => { const sessionRepo = new SessionRepo({ filePath: join(stateDir, "state", "sessions.json"), }); + const workspaceRepo = new WorkspaceRepo({ + filePath: join(stateDir, "state", "workspaces.json"), + }); + const sessionMetadataRepo = new SessionMetadataRepo({ + workspaceRepo, + }); terminalRepo.insert({ id: "term-close", @@ -87,6 +95,12 @@ describe("workspace close state cleanup", () => { title: null, draft: null, }); + sessionMetadataRepo.upsert({ + sessionId: "sess-close", + workspaceId, + providerId: "claude", + verificationRuns: [], + }); const closeResult = await dispatch( { @@ -102,5 +116,6 @@ describe("workspace close state cleanup", () => { expect(terminalRepo.listByWorkspace(workspaceId)).toEqual([]); expect(sessionRepo.listByWorkspace(workspaceId)).toEqual([]); + expect(sessionMetadataRepo.get("sess-close")).toBeUndefined(); }); }); diff --git a/packages/server/src/__tests__/workspace-intelligence-command.test.ts b/packages/server/src/__tests__/workspace-intelligence-command.test.ts new file mode 100644 index 00000000..d253ac63 --- /dev/null +++ b/packages/server/src/__tests__/workspace-intelligence-command.test.ts @@ -0,0 +1,117 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { AGENT_INSTRUCTIONS_RELATIVE_PATH } from "../workspace/workspace-state.js"; +import type { CommandContext } from "../ws/dispatch.js"; +import { dispatch } from "../ws/dispatch.js"; +import "../commands/workspace.js"; + +describe("workspace.intelligence command", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs.map(async (dir) => { + try { + await import("node:fs/promises").then(({ rm }) => + rm(dir, { recursive: true, force: true }) + ); + } catch { + // Ignore temp cleanup failures in tests. + } + }) + ); + }); + + it("returns a typed workspace summary through dispatch", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "workspace-intelligence-command-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, ".git"), { recursive: true }); + await writeFile(join(rootPath, ".git", "HEAD"), "ref: refs/heads/main\n"); + await writeFile( + join(rootPath, "package.json"), + JSON.stringify({ + scripts: { + dev: "vite", + }, + devDependencies: { + vite: "^7.0.0", + }, + }) + ); + await writeFile(join(rootPath, "README.md"), "# Repo\n"); + + const ctx = { + workspaceMgr: { + get(id: string) { + if (id !== "ws-1") { + return undefined; + } + + return { + id, + path: rootPath, + targetRuntime: "native", + openedAt: Date.now(), + lastActiveAt: Date.now(), + uiState: { + leftPanelWidth: 320, + bottomPanelHeight: 240, + focusMode: false, + }, + }; + }, + }, + sessionMgr: {} as never, + terminalMgr: {} as never, + eventBus: {} as never, + broadcaster: {} as never, + db: {} as never, + providerRegistry: [], + fencingMgr: {} as never, + supervisorMgr: {} as never, + autoFetch: {} as never, + activationMgr: { + getLease: () => ({ wsClientId: "test-client" }), + }, + } as unknown as CommandContext; + + const result = await dispatch( + { + kind: "command", + id: "workspace-intelligence-1", + op: "workspace.intelligence", + args: { + workspaceId: "ws-1", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ + workspaceId: "ws-1", + rootPath, + git: { + isRepo: true, + branch: "main", + }, + packageManager: "npm", + frameworks: ["Vite", "Node"], + scripts: { + dev: "vite", + test: undefined, + build: undefined, + lint: undefined, + }, + recommendedCommands: [{ key: "dev", command: "npm run dev", source: "package_json" }], + docs: [{ path: "README.md", kind: "readme" }], + agentInstructions: { + exists: false, + path: AGENT_INSTRUCTIONS_RELATIVE_PATH, + }, + }); + }); +}); diff --git a/packages/server/src/__tests__/workspace/intelligence.test.ts b/packages/server/src/__tests__/workspace/intelligence.test.ts new file mode 100644 index 00000000..2228a822 --- /dev/null +++ b/packages/server/src/__tests__/workspace/intelligence.test.ts @@ -0,0 +1,152 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { inspectWorkspaceIntelligence } from "../../workspace/intelligence.js"; +import { + AGENT_INSTRUCTIONS_RELATIVE_PATH, + WORKSPACE_STATE_DIR, +} from "../../workspace/workspace-state.js"; + +describe("inspectWorkspaceIntelligence", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs.map(async (dir) => { + try { + await import("node:fs/promises").then(({ rm }) => + rm(dir, { recursive: true, force: true }) + ); + } catch { + // Ignore temp cleanup failures in tests. + } + }) + ); + }); + + it("summarizes git, package scripts, frameworks, docs, and AGENTS.md", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "workspace-intelligence-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, ".git"), { recursive: true }); + await writeFile(join(rootPath, ".git", "HEAD"), "ref: refs/heads/feature/agentic\n"); + await writeFile( + join(rootPath, "package.json"), + JSON.stringify( + { + dependencies: { + react: "^19.0.0", + }, + devDependencies: { + vite: "^7.0.0", + }, + scripts: { + dev: "vite", + test: "vitest run", + build: "vite build", + lint: "eslint .", + }, + }, + null, + 2 + ) + ); + await writeFile(join(rootPath, "pnpm-lock.yaml"), "lockfileVersion: 9.0\n"); + await writeFile(join(rootPath, "pnpm-workspace.yaml"), "packages:\n - packages/*\n"); + await writeFile(join(rootPath, "README.md"), "# Workspace\n"); + await mkdir(join(rootPath, "docs"), { recursive: true }); + await mkdir(join(rootPath, WORKSPACE_STATE_DIR), { recursive: true }); + await writeFile(join(rootPath, AGENT_INSTRUCTIONS_RELATIVE_PATH), "# Instructions\n"); + + const summary = await inspectWorkspaceIntelligence({ + workspaceId: "ws-1", + rootPath, + }); + + expect(summary).toEqual({ + workspaceId: "ws-1", + rootPath, + git: { + isRepo: true, + branch: "feature/agentic", + }, + packageManager: "pnpm", + frameworks: ["React", "Vite", "Node", "Monorepo"], + scripts: { + dev: "vite", + test: "vitest run", + build: "vite build", + lint: "eslint .", + }, + recommendedCommands: [ + { key: "dev", command: "pnpm dev", source: "package_json" }, + { key: "test", command: "pnpm test", source: "package_json" }, + { key: "build", command: "pnpm build", source: "package_json" }, + { key: "lint", command: "pnpm lint", source: "package_json" }, + ], + docs: [ + { path: "README.md", kind: "readme" }, + { path: "docs", kind: "docs" }, + ], + agentInstructions: { + exists: true, + path: AGENT_INSTRUCTIONS_RELATIVE_PATH, + }, + }); + }); + + it("handles non-git folders without package.json", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "workspace-intelligence-plain-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, "docs"), { recursive: true }); + + const summary = await inspectWorkspaceIntelligence({ + workspaceId: "ws-plain", + rootPath, + }); + + expect(summary).toEqual({ + workspaceId: "ws-plain", + rootPath, + git: { + isRepo: false, + }, + packageManager: undefined, + frameworks: [], + scripts: { + dev: undefined, + test: undefined, + build: undefined, + lint: undefined, + }, + recommendedCommands: [], + docs: [{ path: "docs", kind: "docs" }], + agentInstructions: { + exists: false, + path: AGENT_INSTRUCTIONS_RELATIVE_PATH, + }, + }); + }); + + it("reads branch metadata from a worktree-style .git file", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "workspace-intelligence-worktree-")); + tempDirs.push(rootPath); + + const gitDir = join(rootPath, ".git-data", "worktrees", "feature-agentic"); + await mkdir(gitDir, { recursive: true }); + await writeFile(join(gitDir, "HEAD"), "ref: refs/heads/review/phase-3\n"); + await writeFile(join(rootPath, ".git"), "gitdir: .git-data/worktrees/feature-agentic\n"); + + const summary = await inspectWorkspaceIntelligence({ + workspaceId: "ws-worktree", + rootPath, + }); + + expect(summary.git).toEqual({ + isRepo: true, + branch: "review/phase-3", + }); + }); +}); diff --git a/packages/server/src/agent-context/context-package.ts b/packages/server/src/agent-context/context-package.ts new file mode 100644 index 00000000..5ca77c3d --- /dev/null +++ b/packages/server/src/agent-context/context-package.ts @@ -0,0 +1,237 @@ +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import type { AgentContextPackage } from "@coder-studio/core"; +import { resolveSafe } from "../fs/file-io.js"; +import { buildSessionReviewSummary, getSessionReviewDiff } from "../session-review/review.js"; +import { SessionMetadataRepo } from "../storage/repositories/session-metadata-repo.js"; +import { inspectWorkspaceIntelligence } from "../workspace/intelligence.js"; + +interface ContextPackageOptions { + createId?: () => string; + now?: () => number; +} + +interface FileContextInput { + workspaceId: string; + workspacePath: string; + path: string; +} + +interface ProjectSummaryContextInput { + workspaceId: string; + workspacePath: string; +} + +interface SessionScopedContextInput { + sessionId: string; + workspacePath: string; + metadataRepo: SessionMetadataRepo; +} + +interface DiffContextInput extends SessionScopedContextInput { + path: string; +} + +function resolveOptions( + options: ContextPackageOptions | undefined +): Required { + return { + createId: options?.createId ?? randomUUID, + now: options?.now ?? Date.now, + }; +} + +function formatSource(source: AgentContextPackage["source"]): string { + const parts = [`workspace=${source.workspaceId}`]; + + if (source.sessionId) { + parts.push(`session=${source.sessionId}`); + } + + if (source.path) { + parts.push(`path=${source.path}`); + } + + if (source.terminalId) { + parts.push(`terminal=${source.terminalId}`); + } + + return parts.join(" "); +} + +function wrapContext(title: string, source: AgentContextPackage["source"], body: string): string { + return `Context: ${title}\nSource: ${formatSource(source)}\n\n${body}`; +} + +function createContextPackage( + kind: AgentContextPackage["kind"], + title: string, + source: AgentContextPackage["source"], + body: string, + options?: ContextPackageOptions +): AgentContextPackage { + const resolved = resolveOptions(options); + return { + id: resolved.createId(), + kind, + title, + body: wrapContext(title, source, body), + source, + createdAt: resolved.now(), + }; +} + +function requireSessionMetadata( + metadataRepo: SessionMetadataRepo, + sessionId: string +): { sessionId: string; workspaceId: string } { + const metadata = metadataRepo.get(sessionId); + if (!metadata) { + throw { + code: "session_metadata_not_found", + message: `Session metadata not found: ${sessionId}`, + }; + } + + return metadata; +} + +function buildProjectSummaryBody( + summary: Awaited> +): string { + const lines = [ + `Git: ${summary.git.isRepo ? "repository detected" : "no repository detected"}`, + `Package manager: ${summary.packageManager ?? "unknown"}`, + `Frameworks: ${summary.frameworks.length > 0 ? summary.frameworks.join(", ") : "none"}`, + ]; + + if (summary.recommendedCommands.length > 0) { + lines.push("Recommended commands:"); + for (const command of summary.recommendedCommands) { + lines.push(`- ${command.key}: ${command.command}`); + } + } else { + lines.push("Recommended commands: none"); + } + + if (summary.docs.length > 0) { + lines.push("Docs:"); + for (const doc of summary.docs) { + lines.push(`- ${doc.path}`); + } + } else { + lines.push("Docs: none"); + } + + lines.push( + `Agent instructions: ${summary.agentInstructions.path} ${ + summary.agentInstructions.exists ? "present" : "missing" + }` + ); + + return lines.join("\n"); +} + +export async function buildFileContextPackage( + input: FileContextInput, + options?: ContextPackageOptions +): Promise { + const content = await readFile(resolveSafe(input.workspacePath, input.path), "utf8"); + return createContextPackage( + "file", + `File: ${input.path}`, + { + workspaceId: input.workspaceId, + path: input.path, + }, + content, + options + ); +} + +export async function buildDiffContextPackage( + input: DiffContextInput, + options?: ContextPackageOptions +): Promise { + const metadata = requireSessionMetadata(input.metadataRepo, input.sessionId); + const diff = await getSessionReviewDiff({ + sessionId: input.sessionId, + workspacePath: input.workspacePath, + metadataRepo: input.metadataRepo, + path: input.path, + }); + + return createContextPackage( + "git_diff", + `Git Diff: ${input.path}`, + { + workspaceId: metadata.workspaceId, + sessionId: input.sessionId, + path: input.path, + }, + diff, + options + ); +} + +export async function buildProjectSummaryContextPackage( + input: ProjectSummaryContextInput, + options?: ContextPackageOptions +): Promise { + const summary = await inspectWorkspaceIntelligence({ + workspaceId: input.workspaceId, + rootPath: input.workspacePath, + }); + + return createContextPackage( + "project_summary", + "Project Summary", + { + workspaceId: input.workspaceId, + }, + buildProjectSummaryBody(summary), + options + ); +} + +export async function buildSessionReviewContextPackage( + input: SessionScopedContextInput, + options?: ContextPackageOptions +): Promise { + const metadata = requireSessionMetadata(input.metadataRepo, input.sessionId); + const summary = await buildSessionReviewSummary({ + sessionId: input.sessionId, + workspacePath: input.workspacePath, + metadataRepo: input.metadataRepo, + }); + + const lines = [ + `Baseline: ${summary.baselineGitHead ?? "missing"}`, + "Changed files:", + ...(summary.changedFiles.length > 0 + ? summary.changedFiles.map((change) => `- ${change.status ?? "modified"}: ${change.path}`) + : ["- none"]), + "Verification runs:", + ...(summary.verificationRuns.length > 0 + ? summary.verificationRuns.map((run) => `- ${run.status}: ${run.command}`) + : ["- none"]), + ]; + + if (summary.warnings.length > 0) { + lines.push("Warnings:"); + for (const warning of summary.warnings) { + lines.push(`- ${warning.code}: ${warning.message}`); + } + } + + return createContextPackage( + "session_review", + `Session Review: ${summary.sessionId}`, + { + workspaceId: metadata.workspaceId, + sessionId: input.sessionId, + }, + lines.join("\n"), + options + ); +} diff --git a/packages/server/src/agent-instructions/generator.ts b/packages/server/src/agent-instructions/generator.ts new file mode 100644 index 00000000..fcd5e608 --- /dev/null +++ b/packages/server/src/agent-instructions/generator.ts @@ -0,0 +1,98 @@ +import type { WorkspaceIntelligenceSummary } from "@coder-studio/core"; + +const WORKING_RULES = [ + "Keep changes focused on the requested task.", + "Do not revert user changes unless explicitly asked.", + "Prefer the project's existing patterns.", + "Run the relevant verification command before reporting completion.", +] as const; + +const REVIEW_EXPECTATIONS = [ + "Summarize changed files.", + "Report verification commands and results.", + "Call out risks, skipped tests, and assumptions.", +] as const; + +const PROVIDER_NOTES = [ + "Claude Code: use the project rules above.", + "Codex: use the project rules above.", +] as const; + +export function buildAgentInstructionsMarkdown(summary: WorkspaceIntelligenceSummary): string { + const lines: string[] = ["# Agent Instructions", ""]; + + pushSection(lines, "Project Overview", buildProjectOverview(summary)); + pushSection(lines, "Development Commands", buildDevelopmentCommands(summary)); + pushSection(lines, "Working Rules", [...WORKING_RULES.map((rule) => `- ${rule}`)]); + pushSection( + lines, + "Review Expectations", + REVIEW_EXPECTATIONS.map((rule) => `- ${rule}`) + ); + pushSection( + lines, + "Provider Notes", + PROVIDER_NOTES.map((note) => `- ${note}`) + ); + + return lines.join("\n"); +} + +function buildProjectOverview(summary: WorkspaceIntelligenceSummary): string[] { + const lines: string[] = []; + + if (summary.git.isRepo) { + if (summary.git.branch) { + lines.push(`- Git branch: ${summary.git.branch}`); + } else { + lines.push("- Git repository: yes"); + } + } else { + lines.push("- Git repository: no"); + } + + if (summary.packageManager) { + lines.push(`- Package manager: ${summary.packageManager}`); + } + + if (summary.frameworks.length > 0) { + lines.push(`- Frameworks: ${summary.frameworks.join(", ")}`); + } + + if (summary.docs.length > 0) { + lines.push(`- Docs: ${summary.docs.map((doc) => doc.path).join(", ")}`); + } + + lines.push( + `- ${summary.agentInstructions.path}: ${summary.agentInstructions.exists ? "exists" : "missing"}` + ); + + return lines; +} + +function buildDevelopmentCommands(summary: WorkspaceIntelligenceSummary): string[] { + const lines: string[] = []; + const commandLabels: Record<"dev" | "test" | "build" | "lint", string> = { + dev: "Dev", + test: "Test", + build: "Build", + lint: "Lint", + }; + + for (const key of ["dev", "test", "build", "lint"] as const) { + const command = summary.recommendedCommands.find((item) => item.key === key)?.command; + if (!command) { + continue; + } + + lines.push(`- ${commandLabels[key]}: \`${command}\``); + } + + return lines; +} + +function pushSection(lines: string[], heading: string, body: string[]): void { + lines.push(`## ${heading}`, ""); + lines.push(...body); + lines.push(""); +} diff --git a/packages/server/src/agent-instructions/health.ts b/packages/server/src/agent-instructions/health.ts new file mode 100644 index 00000000..5189a532 --- /dev/null +++ b/packages/server/src/agent-instructions/health.ts @@ -0,0 +1,144 @@ +import type { AgentInstructionsHealth, AgentInstructionsHealthIssue } from "@coder-studio/core"; +import { AGENT_INSTRUCTIONS_RELATIVE_PATH } from "../workspace/workspace-state.js"; + +const REQUIRED_WORKING_RULES = [ + "Keep changes focused on the requested task.", + "Do not revert user changes unless explicitly asked.", + "Prefer the project's existing patterns.", + "Run the relevant verification command before reporting completion.", +] as const; + +const REQUIRED_REVIEW_EXPECTATIONS = [ + "Summarize changed files.", + "Report verification commands and results.", + "Call out risks, skipped tests, and assumptions.", +] as const; + +const PROVIDER_NOTE_MARKERS = ["Claude Code:", "Codex:"] as const; + +export function evaluateAgentInstructionsMarkdown(content: string): AgentInstructionsHealth { + if (!content.trim()) { + return { + path: AGENT_INSTRUCTIONS_RELATIVE_PATH, + exists: false, + status: "missing", + checks: { + projectOverview: false, + developmentCommands: false, + workingRules: false, + reviewExpectations: false, + safetyRules: false, + providerNotes: false, + }, + issues: [ + { + code: "missing_document", + message: `${AGENT_INSTRUCTIONS_RELATIVE_PATH} is missing`, + }, + ], + }; + } + + const sections = indexSections(content); + const projectOverview = sections.has("Project Overview"); + const developmentCommands = hasAnyBullet(sections.get("Development Commands")); + const workingRulesSection = sections.get("Working Rules"); + const reviewExpectationsSection = sections.get("Review Expectations"); + const providerNotesSection = sections.get("Provider Notes"); + const workingRules = hasAnyBullet(workingRulesSection); + const reviewExpectations = + hasAnyBullet(reviewExpectationsSection) && + REQUIRED_REVIEW_EXPECTATIONS.every((rule) => + reviewExpectationsSection?.some((line) => line.includes(rule)) + ); + const providerNotes = + hasAnyBullet(providerNotesSection) && + PROVIDER_NOTE_MARKERS.some((marker) => + providerNotesSection?.some((line) => line.includes(marker)) + ); + const safetyRules = REQUIRED_WORKING_RULES.every((rule) => + workingRulesSection?.some((line) => line.includes(rule)) + ); + + const issues: AgentInstructionsHealthIssue[] = []; + if (!projectOverview) { + issues.push({ + code: "missing_project_overview", + message: "Project Overview section is missing", + }); + } + if (!developmentCommands) { + issues.push({ + code: "missing_development_commands", + message: "Development Commands section is missing", + }); + } + if (!workingRules) { + issues.push({ + code: "missing_working_rules", + message: "Working Rules section is missing", + }); + } + if (!reviewExpectations) { + issues.push({ + code: "missing_review_expectations", + message: "Review Expectations section is missing", + }); + } + if (!safetyRules) { + issues.push({ + code: "missing_safety_rules", + message: "Working rules do not include the required safety rules", + }); + } + if (!providerNotes) { + issues.push({ + code: "missing_provider_notes", + message: "Provider Notes section is missing", + }); + } + + const status: AgentInstructionsHealth["status"] = issues.length === 0 ? "healthy" : "warning"; + + return { + path: AGENT_INSTRUCTIONS_RELATIVE_PATH, + exists: true, + status, + checks: { + projectOverview, + developmentCommands, + workingRules, + reviewExpectations, + safetyRules, + providerNotes, + }, + issues, + }; +} + +function indexSections(content: string): Map { + const sections = new Map(); + const lines = content.split(/\r?\n/); + let currentHeading: string | null = null; + + for (const line of lines) { + const heading = line.match(/^##\s+(.+?)\s*$/)?.[1]; + if (heading) { + currentHeading = heading; + if (!sections.has(heading)) { + sections.set(heading, []); + } + continue; + } + + if (currentHeading) { + sections.get(currentHeading)?.push(line); + } + } + + return sections; +} + +function hasAnyBullet(lines: string[] | undefined): boolean { + return Boolean(lines?.some((line) => line.trimStart().startsWith("- "))); +} diff --git a/packages/server/src/commands/agent-context.ts b/packages/server/src/commands/agent-context.ts new file mode 100644 index 00000000..840d1b30 --- /dev/null +++ b/packages/server/src/commands/agent-context.ts @@ -0,0 +1,132 @@ +import { z } from "zod"; +import { + buildDiffContextPackage, + buildFileContextPackage, + buildProjectSummaryContextPackage, + buildSessionReviewContextPackage, +} from "../agent-context/context-package.js"; +import { registerCommand } from "../ws/dispatch.js"; + +function requireWorkspace( + ctx: { + workspaceMgr: { + get(workspaceId: string): { id: string; path: string } | undefined; + }; + }, + workspaceId: string +): { id: string; path: string } { + const workspace = ctx.workspaceMgr.get(workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${workspaceId}` }; + } + + return workspace; +} + +function requireSessionMetadataRepo(ctx: { sessionMetadataRepo?: unknown }): asserts ctx is { + sessionMetadataRepo: NonNullable; +} { + if (!ctx.sessionMetadataRepo) { + throw { + code: "session_metadata_unavailable", + message: "Session metadata repository is not configured", + }; + } +} + +function getSessionWorkspace( + ctx: { + workspaceMgr: { + get(workspaceId: string): { id: string; path: string } | undefined; + }; + sessionMetadataRepo: { + get(sessionId: string): + | { + sessionId: string; + workspaceId: string; + } + | undefined; + }; + }, + sessionId: string +): { + metadata: { sessionId: string; workspaceId: string }; + workspace: { id: string; path: string }; +} { + const metadata = ctx.sessionMetadataRepo.get(sessionId); + if (!metadata) { + throw { + code: "session_metadata_not_found", + message: `Session metadata not found: ${sessionId}`, + }; + } + + return { + metadata, + workspace: requireWorkspace(ctx, metadata.workspaceId), + }; +} + +registerCommand( + "agentContext.fromFile", + z.object({ + workspaceId: z.string(), + path: z.string().trim().min(1), + }), + async (args, ctx) => { + const workspace = requireWorkspace(ctx, args.workspaceId); + return buildFileContextPackage({ + workspaceId: workspace.id, + workspacePath: workspace.path, + path: args.path, + }); + } +); + +registerCommand( + "agentContext.fromDiff", + z.object({ + sessionId: z.string(), + path: z.string().trim().min(1), + }), + async (args, ctx) => { + requireSessionMetadataRepo(ctx); + const { workspace } = getSessionWorkspace(ctx, args.sessionId); + return buildDiffContextPackage({ + sessionId: args.sessionId, + path: args.path, + workspacePath: workspace.path, + metadataRepo: ctx.sessionMetadataRepo, + }); + } +); + +registerCommand( + "agentContext.fromProjectSummary", + z.object({ + workspaceId: z.string(), + }), + async (args, ctx) => { + const workspace = requireWorkspace(ctx, args.workspaceId); + return buildProjectSummaryContextPackage({ + workspaceId: workspace.id, + workspacePath: workspace.path, + }); + } +); + +registerCommand( + "agentContext.fromSessionReview", + z.object({ + sessionId: z.string(), + }), + async (args, ctx) => { + requireSessionMetadataRepo(ctx); + const { workspace } = getSessionWorkspace(ctx, args.sessionId); + return buildSessionReviewContextPackage({ + sessionId: args.sessionId, + workspacePath: workspace.path, + metadataRepo: ctx.sessionMetadataRepo, + }); + } +); diff --git a/packages/server/src/commands/agent-instructions.ts b/packages/server/src/commands/agent-instructions.ts new file mode 100644 index 00000000..151a3758 --- /dev/null +++ b/packages/server/src/commands/agent-instructions.ts @@ -0,0 +1,141 @@ +/** + * Agent Instructions Commands + */ + +import type { AgentInstructionsDocument, AgentInstructionsHealth } from "@coder-studio/core"; +import { z } from "zod"; +import { buildAgentInstructionsMarkdown } from "../agent-instructions/generator.js"; +import { evaluateAgentInstructionsMarkdown } from "../agent-instructions/health.js"; +import { readFile, writeFile } from "../fs/file-io.js"; +import { inspectWorkspaceIntelligence } from "../workspace/intelligence.js"; +import { AGENT_INSTRUCTIONS_RELATIVE_PATH } from "../workspace/workspace-state.js"; +import { registerCommand } from "../ws/dispatch.js"; + +async function readAgentInstructionsDocument( + workspaceId: string, + rootPath: string +): Promise { + const path = AGENT_INSTRUCTIONS_RELATIVE_PATH; + + try { + const result = await readFile(workspaceId, rootPath, path); + if (result.kind !== "text") { + return { + path, + exists: true, + content: "", + }; + } + + return { + path, + exists: true, + content: result.content, + baseHash: result.baseHash, + }; + } catch { + return { + path, + exists: false, + content: "", + }; + } +} + +registerCommand( + "agentInstructions.read", + z.object({ + workspaceId: z.string(), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + return readAgentInstructionsDocument(workspace.id, workspace.path); + } +); + +registerCommand( + "agentInstructions.generate", + z.object({ + workspaceId: z.string(), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + const summary = await inspectWorkspaceIntelligence({ + workspaceId: workspace.id, + rootPath: workspace.path, + }); + + return { + path: AGENT_INSTRUCTIONS_RELATIVE_PATH, + exists: false, + content: buildAgentInstructionsMarkdown(summary), + } satisfies AgentInstructionsDocument; + } +); + +registerCommand( + "agentInstructions.write", + z.object({ + workspaceId: z.string(), + content: z.string(), + overwrite: z.boolean().optional(), + baseHash: z.string().optional(), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + const existing = await readAgentInstructionsDocument(workspace.id, workspace.path); + if (existing.exists && !args.overwrite) { + throw { + code: "agent_instructions_exists", + message: `${AGENT_INSTRUCTIONS_RELATIVE_PATH} already exists`, + }; + } + + const result = await writeFile( + workspace.path, + AGENT_INSTRUCTIONS_RELATIVE_PATH, + args.content, + args.baseHash + ); + ctx.eventBus.emit({ + type: "fs.dirty", + workspaceId: args.workspaceId, + reason: "file_content", + }); + + return { + path: AGENT_INSTRUCTIONS_RELATIVE_PATH, + exists: true, + content: args.content, + baseHash: result.newHash, + } satisfies AgentInstructionsDocument; + } +); + +registerCommand( + "agentInstructions.health", + z.object({ + workspaceId: z.string(), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + const document = await readAgentInstructionsDocument(workspace.id, workspace.path); + return evaluateAgentInstructionsMarkdown(document.content) satisfies AgentInstructionsHealth; + } +); diff --git a/packages/server/src/commands/custom-provider.ts b/packages/server/src/commands/custom-provider.ts new file mode 100644 index 00000000..6bb87735 --- /dev/null +++ b/packages/server/src/commands/custom-provider.ts @@ -0,0 +1,130 @@ +import type { CustomProviderConfig } from "@coder-studio/core"; +import { toProviderListItem } from "@coder-studio/providers"; +import { z } from "zod"; +import { + buildCustomProviderDefinition, + removeProviderDefinition, + upsertProviderDefinition, +} from "../provider-runtime/custom-provider.js"; +import { registerCommand } from "../ws/dispatch.js"; + +const CapabilitySchema = z.object({ + key: z.enum([ + "interactive_session", + "supervisor_eval", + "idle_detection", + "context_attach", + "review", + ]), + supported: z.boolean(), + label: z.string().min(1), +}); + +const BaseCustomProviderInputSchema = z.object({ + id: z + .string() + .trim() + .min(1) + .regex(/^[a-z0-9][a-z0-9-_]*$/), + displayName: z.string().trim().min(1), + command: z.string().trim().min(1), + args: z.array(z.string()), + env: z.record(z.string(), z.string()), + cwdMode: z.literal("workspace_root"), + sessionMode: z.literal("interactive"), + startupPrompt: z.string().optional(), + capabilities: z.array(CapabilitySchema).min(1), +}); + +function requireCustomProviderSupport(ctx: { + customProviderRepo?: unknown; + setProviderRegistry?: unknown; +}): asserts ctx is { + customProviderRepo: NonNullable; + setProviderRegistry: NonNullable; +} { + if (!ctx.customProviderRepo || !ctx.setProviderRegistry) { + throw { + code: "custom_provider_unavailable", + message: "Custom provider runtime is not configured", + }; + } +} + +function materializeConfig( + input: z.infer, + previous?: CustomProviderConfig +): CustomProviderConfig { + const now = Date.now(); + return { + ...input, + startupPrompt: input.startupPrompt?.trim() || undefined, + createdAt: previous?.createdAt ?? now, + updatedAt: now, + }; +} + +registerCommand("customProvider.list", z.object({}), async (_args, ctx) => { + requireCustomProviderSupport(ctx); + return ctx.customProviderRepo + .list() + .map((config) => toProviderListItem(buildCustomProviderDefinition(config))); +}); + +registerCommand("customProvider.create", BaseCustomProviderInputSchema, async (args, ctx) => { + requireCustomProviderSupport(ctx); + + if (ctx.providerRegistry.some((provider) => provider.id === args.id)) { + throw { + code: "custom_provider_exists", + message: `Provider already exists: ${args.id}`, + }; + } + + const config = materializeConfig(args); + const saved = ctx.customProviderRepo.set(config); + const definition = buildCustomProviderDefinition(saved); + ctx.setProviderRegistry(upsertProviderDefinition(ctx.providerRegistry, definition)); + + return toProviderListItem(definition); +}); + +registerCommand("customProvider.update", BaseCustomProviderInputSchema, async (args, ctx) => { + requireCustomProviderSupport(ctx); + + const existing = ctx.customProviderRepo.get(args.id); + if (!existing) { + throw { + code: "custom_provider_not_found", + message: `Custom provider not found: ${args.id}`, + }; + } + + const saved = ctx.customProviderRepo.set(materializeConfig(args, existing)); + const definition = buildCustomProviderDefinition(saved); + ctx.setProviderRegistry(upsertProviderDefinition(ctx.providerRegistry, definition)); + + return toProviderListItem(definition); +}); + +registerCommand( + "customProvider.delete", + z.object({ + id: z.string().trim().min(1), + }), + async (args, ctx) => { + requireCustomProviderSupport(ctx); + + const existing = ctx.customProviderRepo.get(args.id); + if (!existing) { + throw { + code: "custom_provider_not_found", + message: `Custom provider not found: ${args.id}`, + }; + } + + ctx.customProviderRepo.delete(args.id); + ctx.setProviderRegistry(removeProviderDefinition(ctx.providerRegistry, args.id)); + return { deleted: true, id: args.id }; + } +); diff --git a/packages/server/src/commands/index.ts b/packages/server/src/commands/index.ts index 416ea752..6f5c34c5 100644 --- a/packages/server/src/commands/index.ts +++ b/packages/server/src/commands/index.ts @@ -10,12 +10,17 @@ import "./activation.js"; import "./connection.js"; import "./recovery.js"; import "./session.js"; +import "./session-metadata.js"; +import "./session-review.js"; import "./terminal.js"; import "./file.js"; import "./git.js"; +import "./agent-instructions.js"; +import "./agent-context.js"; import "./settings.js"; import "./diagnostics.js"; import "./provider.js"; +import "./custom-provider.js"; import "./supervisor.js"; import "./worktree.js"; import "./fencing.js"; diff --git a/packages/server/src/commands/provider.ts b/packages/server/src/commands/provider.ts index f274f6ab..7bbaeef5 100644 --- a/packages/server/src/commands/provider.ts +++ b/packages/server/src/commands/provider.ts @@ -1,7 +1,12 @@ +import { toProviderListItem } from "@coder-studio/providers"; import { z } from "zod"; import { buildProviderRuntimeStatus } from "../provider-runtime/runtime-status.js"; import { registerCommand } from "../ws/dispatch.js"; +registerCommand("provider.list", z.object({}), async (_args, ctx) => { + return ctx.providerRegistry.map((provider) => toProviderListItem(provider)); +}); + registerCommand("provider.runtimeStatus", z.object({}), async (_args, ctx) => { return buildProviderRuntimeStatus(ctx.providerRegistry, ctx.providerRuntimeDeps); }); diff --git a/packages/server/src/commands/session-metadata.ts b/packages/server/src/commands/session-metadata.ts new file mode 100644 index 00000000..8e75d0d8 --- /dev/null +++ b/packages/server/src/commands/session-metadata.ts @@ -0,0 +1,55 @@ +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { registerCommand } from "../ws/dispatch.js"; + +function requireSessionMetadataRepo(ctx: { sessionMetadataRepo?: unknown }): asserts ctx is { + sessionMetadataRepo: NonNullable; +} { + if (!ctx.sessionMetadataRepo) { + throw { + code: "session_metadata_unavailable", + message: "Session metadata repository is not configured", + }; + } +} + +registerCommand( + "session.metadata.get", + z.object({ + sessionId: z.string(), + }), + async (args, ctx) => { + requireSessionMetadataRepo(ctx); + const metadata = ctx.sessionMetadataRepo.get(args.sessionId); + if (!metadata) { + throw { + code: "session_metadata_not_found", + message: `Session metadata not found: ${args.sessionId}`, + }; + } + + return metadata; + } +); + +registerCommand( + "session.verification.add", + z.object({ + sessionId: z.string(), + command: z.string().trim().min(1), + status: z.enum(["passed", "failed", "unknown"]), + exitCode: z.number().int().optional(), + summary: z.string().optional(), + }), + async (args, ctx) => { + requireSessionMetadataRepo(ctx); + return ctx.sessionMetadataRepo.addVerificationRun(args.sessionId, { + id: randomUUID(), + command: args.command, + status: args.status, + exitCode: args.exitCode, + summary: args.summary?.trim() || undefined, + createdAt: Date.now(), + }); + } +); diff --git a/packages/server/src/commands/session-review.ts b/packages/server/src/commands/session-review.ts new file mode 100644 index 00000000..da555d1d --- /dev/null +++ b/packages/server/src/commands/session-review.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; +import { buildSessionReviewSummary, getSessionReviewDiff } from "../session-review/review.js"; +import { registerCommand } from "../ws/dispatch.js"; + +function requireSessionMetadataRepo(ctx: { sessionMetadataRepo?: unknown }): asserts ctx is { + sessionMetadataRepo: NonNullable; +} { + if (!ctx.sessionMetadataRepo) { + throw { + code: "session_metadata_unavailable", + message: "Session metadata repository is not configured", + }; + } +} + +function requireWorkspace( + ctx: { + workspaceMgr: { + get(workspaceId: string): { id: string; path: string } | undefined; + }; + }, + workspaceId: string +): { id: string; path: string } { + const workspace = ctx.workspaceMgr.get(workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${workspaceId}` }; + } + + return workspace; +} + +registerCommand( + "sessionReview.summary", + z.object({ + sessionId: z.string(), + }), + async (args, ctx) => { + requireSessionMetadataRepo(ctx); + const metadata = ctx.sessionMetadataRepo.get(args.sessionId); + if (!metadata) { + throw { + code: "session_metadata_not_found", + message: `Session metadata not found: ${args.sessionId}`, + }; + } + + const workspace = requireWorkspace(ctx, metadata.workspaceId); + return buildSessionReviewSummary({ + sessionId: args.sessionId, + workspacePath: workspace.path, + metadataRepo: ctx.sessionMetadataRepo, + }); + } +); + +registerCommand( + "sessionReview.diff", + z.object({ + sessionId: z.string(), + path: z.string().trim().min(1), + }), + async (args, ctx) => { + requireSessionMetadataRepo(ctx); + const metadata = ctx.sessionMetadataRepo.get(args.sessionId); + if (!metadata) { + throw { + code: "session_metadata_not_found", + message: `Session metadata not found: ${args.sessionId}`, + }; + } + + const workspace = requireWorkspace(ctx, metadata.workspaceId); + return { + path: args.path, + diff: await getSessionReviewDiff({ + sessionId: args.sessionId, + workspacePath: workspace.path, + metadataRepo: ctx.sessionMetadataRepo, + path: args.path, + }), + }; + } +); diff --git a/packages/server/src/commands/session.ts b/packages/server/src/commands/session.ts index 0a917c41..65815a74 100644 --- a/packages/server/src/commands/session.ts +++ b/packages/server/src/commands/session.ts @@ -2,6 +2,8 @@ * Session Commands */ +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; import type { ProviderDefinition } from "@coder-studio/core"; import { z } from "zod"; import { buildProviderRuntimeStatus } from "../provider-runtime/runtime-status.js"; @@ -24,6 +26,25 @@ function getProviderFromRegistry( return registry.find((provider) => provider.id === providerId); } +async function tryReadGitHead(workspacePath: string): Promise { + try { + const gitEntryPath = join(workspacePath, ".git"); + const gitEntry = await readFile(gitEntryPath, "utf8").catch(() => null); + + let gitDir = gitEntryPath; + if (gitEntry && gitEntry.startsWith("gitdir:")) { + const relativeGitDir = gitEntry.slice("gitdir:".length).trim(); + gitDir = resolve(workspacePath, relativeGitDir); + } + + const head = await readFile(join(gitDir, "HEAD"), "utf8"); + const trimmed = head.trim(); + return trimmed.length > 0 && !trimmed.startsWith("ref:") ? trimmed : undefined; + } catch { + return undefined; + } +} + // session.list registerCommand( "session.list", @@ -73,7 +94,7 @@ registerCommand( }; } - return ctx.sessionMgr.create({ + const session = await ctx.sessionMgr.create({ workspaceId: args.workspaceId, workspacePath: workspace.path, providerId: args.providerId, @@ -81,6 +102,18 @@ registerCommand( draft: args.draft, themeBackground: args.themeBackground, }); + + ctx.sessionMetadataRepo?.upsert({ + sessionId: session.id, + workspaceId: args.workspaceId, + providerId: args.providerId, + objective: args.draft?.trim() || undefined, + baselineGitHead: await tryReadGitHead(workspace.path), + baselineCapturedAt: Date.now(), + verificationRuns: [], + }); + + return session; } ); @@ -112,6 +145,7 @@ registerCommand( } ctx.sessionMgr.delete(args.sessionId); + ctx.sessionMetadataRepo?.delete(args.sessionId); } ); @@ -181,5 +215,6 @@ registerCommand( ctx.workspaceMgr.updateUiState(session.workspaceId, nextUiState); ctx.sessionMgr.delete(args.sessionId); + ctx.sessionMetadataRepo?.delete(args.sessionId); } ); diff --git a/packages/server/src/commands/workspace.ts b/packages/server/src/commands/workspace.ts index 20b28070..6beffc45 100644 --- a/packages/server/src/commands/workspace.ts +++ b/packages/server/src/commands/workspace.ts @@ -6,6 +6,7 @@ import { readdir, realpath } from "node:fs/promises"; import { homedir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; import { z } from "zod"; +import { inspectWorkspaceIntelligence } from "../workspace/intelligence.js"; import { registerCommand } from "../ws/dispatch.js"; function resolveBrowsePath(path: string | undefined): string { @@ -87,6 +88,24 @@ registerCommand( } ); +registerCommand( + "workspace.intelligence", + z.object({ + workspaceId: z.string(), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + return inspectWorkspaceIntelligence({ + workspaceId: workspace.id, + rootPath: workspace.path, + }); + } +); + // workspace.close registerCommand( "workspace.close", diff --git a/packages/server/src/provider-runtime/custom-provider.ts b/packages/server/src/provider-runtime/custom-provider.ts new file mode 100644 index 00000000..ddafe73a --- /dev/null +++ b/packages/server/src/provider-runtime/custom-provider.ts @@ -0,0 +1,73 @@ +import type { + CustomProviderConfig, + ProviderCapabilityDescriptor, + ProviderDefinition, +} from "@coder-studio/core"; +import { z } from "zod"; + +const CUSTOM_PROVIDER_CONFIG_SCHEMA = z.object({}).passthrough(); + +function deriveProviderCapability( + capabilities: ProviderCapabilityDescriptor[] +): ProviderDefinition["capability"] { + const interactive = capabilities.find((capability) => capability.key === "interactive_session"); + if (!interactive?.supported) { + return "unsupported"; + } + + const allSupported = + capabilities.length > 0 && capabilities.every((capability) => capability.supported); + return allSupported ? "full" : "limited"; +} + +export function buildCustomProviderDefinition(config: CustomProviderConfig): ProviderDefinition { + const command = config.command.trim(); + const requiredCommand = command.split(/\s+/)[0] ?? command; + + return { + id: config.id, + displayName: config.displayName, + badge: "Custom", + kind: "custom", + capability: deriveProviderCapability(config.capabilities), + capabilities: config.capabilities.map((capability) => ({ ...capability })), + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: { + provider: "", + prerequisites: {}, + }, + strategies: {}, + }, + buildCommand(_providerConfig, ctx) { + return { + argv: [command, ...config.args], + env: { + ...config.env, + CODER_STUDIO_SESSION_ID: ctx.sessionId, + }, + cwd: ctx.workspacePath, + }; + }, + configSchema: CUSTOM_PROVIDER_CONFIG_SCHEMA, + defaultConfig: {}, + requiredCommands: requiredCommand ? [requiredCommand] : [], + }; +} + +export function upsertProviderDefinition( + registry: ProviderDefinition[], + provider: ProviderDefinition +): ProviderDefinition[] { + const next = registry.filter((item) => item.id !== provider.id); + next.push(provider); + return next; +} + +export function removeProviderDefinition( + registry: ProviderDefinition[], + providerId: string +): ProviderDefinition[] { + return registry.filter((item) => item.id !== providerId); +} diff --git a/packages/server/src/provider-runtime/install-manager.ts b/packages/server/src/provider-runtime/install-manager.ts index 0919eeb7..825c6edf 100644 --- a/packages/server/src/provider-runtime/install-manager.ts +++ b/packages/server/src/provider-runtime/install-manager.ts @@ -31,9 +31,27 @@ export class ProviderInstallManager { constructor(providers: ProviderDefinition[], deps: InstallManagerDeps = {}) { this.deps = deps; + this.setProviders(providers); + } + + setProviders(providers: ProviderDefinition[]): void { + const nextIds = new Set(providers.map((provider) => provider.id)); + this.providers.clear(); for (const provider of providers) { this.providers.set(provider.id, provider); } + + for (const providerId of this.activeJobIdsByProviderId.keys()) { + if (!nextIds.has(providerId)) { + this.activeJobIdsByProviderId.delete(providerId); + } + } + + for (const providerId of this.inFlightStartsByProviderId.keys()) { + if (!nextIds.has(providerId)) { + this.inFlightStartsByProviderId.delete(providerId); + } + } } async start(providerId: string): Promise { diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 640191a3..cbf3044a 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -31,6 +31,7 @@ import { LspToolManager } from "./lsp-tools/manager.js"; import { FileManifestStore } from "./lsp-tools/manifest-store.js"; import { resolveLspToolRoot } from "./lsp-tools/tool-root.js"; import { runCommandAsString } from "./provider-runtime/command-runner.js"; +import { buildCustomProviderDefinition } from "./provider-runtime/custom-provider.js"; import { createE2EProviderMockOverrides } from "./provider-runtime/e2e-provider-mock.js"; import { ProviderInstallManager } from "./provider-runtime/install-manager.js"; import type { RuntimeStatusDeps } from "./provider-runtime/runtime-status.js"; @@ -38,7 +39,9 @@ import { SessionManager } from "./session/manager.js"; import { AppearanceAssetRepo } from "./storage/repositories/appearance-asset-repo.js"; import { AuthLoginBlockRepo } from "./storage/repositories/auth-login-block-repo.js"; import { AuthSessionRepo } from "./storage/repositories/auth-session-repo.js"; +import { CustomProviderRepo } from "./storage/repositories/custom-provider-repo.js"; import { ProviderConfigRepo } from "./storage/repositories/provider-config-repo.js"; +import { SessionMetadataRepo } from "./storage/repositories/session-metadata-repo.js"; import { SessionRepo } from "./storage/repositories/session-repo.js"; import { SettingsRepo } from "./storage/repositories/settings-repo.js"; import { SupervisorRepo } from "./storage/repositories/supervisor-repo.js"; @@ -149,15 +152,25 @@ export async function createServer( const providerConfigRepo = new ProviderConfigRepo({ filePath: join(stateRoot, "state", "provider-configs.json"), }); + const customProviderRepo = new CustomProviderRepo({ + filePath: join(stateRoot, "state", "custom-providers.json"), + }); const workspaceRepo = new WorkspaceRepo({ filePath: join(stateRoot, "state", "workspaces.json"), }); + const sessionMetadataRepo = new SessionMetadataRepo({ + workspaceRepo, + }); + let activeProviderRegistry = [ + ...providerRegistry, + ...customProviderRepo.list().map((config) => buildCustomProviderDefinition(config)), + ]; const sessionMgr = new SessionManager({ terminalMgr, eventBus, db: sessionRepo, broadcaster: wsHub, - providerRegistry, + providerRegistry: activeProviderRegistry, providerConfigRepo, }); @@ -170,14 +183,16 @@ export async function createServer( broadcaster: wsHub, autoFetch, teardown: async (workspaceId) => { + const persistedSessions = sessionRepo.findByWorkspaceId(workspaceId); await lspMgr?.disposeWorkspace(workspaceId); await supervisorMgr?.deleteForWorkspace(workspaceId); await sessionMgr.stopForWorkspace(workspaceId); await terminalMgr.closeForWorkspace(workspaceId); sessionMgr.deleteEndedForWorkspace(workspaceId); - for (const session of sessionRepo.findByWorkspaceId(workspaceId)) { + for (const session of persistedSessions) { sessionRepo.delete(session.id); + sessionMetadataRepo.delete(session.id); } for (const terminal of terminalRepo.listByWorkspace(workspaceId)) { @@ -253,7 +268,7 @@ export async function createServer( terminalMgr, workspaceMgr, sessionMgr, - providerRegistry, + providerRegistry: activeProviderRegistry, providerConfigRepo, settingsRepo, supervisorRepo, @@ -269,7 +284,7 @@ export async function createServer( commandExists: providerMockOverrides.commandExists, } : {}; - const providerInstallMgr = new ProviderInstallManager(providerRegistry, { + const providerInstallMgr = new ProviderInstallManager(activeProviderRegistry, { ...providerRuntimeDeps, runCommand: providerMockOverrides?.runCommand ?? runCommandAsString, }); @@ -300,7 +315,7 @@ export async function createServer( broadcaster: wsHub, settingsRepo, providerConfigRepo, - providerRegistry, + providerRegistry: activeProviderRegistry, fencingMgr, supervisorMgr, autoFetch, @@ -312,6 +327,15 @@ export async function createServer( lspToolMgr, lspToolInstallMgr, updateService, + customProviderRepo, + sessionMetadataRepo, + setProviderRegistry: (providers) => { + activeProviderRegistry = providers; + commandContext.providerRegistry = providers; + providerInstallMgr.setProviders(providers); + sessionMgr.setProviderRegistry(providers); + supervisorMgr?.setProviderRegistry(providers); + }, }; wsHub.setCommandContext(commandContext); diff --git a/packages/server/src/session-review/review.ts b/packages/server/src/session-review/review.ts new file mode 100644 index 00000000..3ff06c35 --- /dev/null +++ b/packages/server/src/session-review/review.ts @@ -0,0 +1,240 @@ +import type { + AgentSessionMetadata, + GitChangeStatus, + GitFileChange, + SessionReviewSummary, +} from "@coder-studio/core"; +import { GitError, runGit } from "../git/cli.js"; +import { getFileDiff } from "../git/diff.js"; +import { SessionMetadataRepo } from "../storage/repositories/session-metadata-repo.js"; +import { WORKSPACE_STATE_DIR } from "../workspace/workspace-state.js"; + +interface SessionReviewInput { + sessionId: string; + workspacePath: string; + metadataRepo: SessionMetadataRepo; +} + +interface SessionReviewDiffInput extends SessionReviewInput { + path: string; +} + +type ParsedGitFileChange = + | { + path: string; + status: Exclude; + } + | { + oldPath: string; + path: string; + status: "renamed"; + }; + +const MISSING_BASELINE_WARNING = { + code: "missing_baseline" as const, + message: "Session baseline is missing.", +}; + +const NOT_GIT_REPO_WARNING = { + code: "not_git_repo" as const, + message: "Workspace is not a Git repository.", +}; + +function isWorkspaceStatePath(path: string): boolean { + return path === WORKSPACE_STATE_DIR || path.startsWith(`${WORKSPACE_STATE_DIR}/`); +} + +function requireSessionMetadata( + metadataRepo: SessionMetadataRepo, + sessionId: string +): AgentSessionMetadata { + const metadata = metadataRepo.get(sessionId); + if (!metadata) { + throw { + code: "session_metadata_not_found", + message: `Session metadata not found: ${sessionId}`, + }; + } + + return metadata; +} + +async function isGitRepository(workspacePath: string): Promise { + try { + await runGit(workspacePath, ["rev-parse", "--git-dir"]); + return true; + } catch (error) { + if (error instanceof GitError) { + return false; + } + + throw error; + } +} + +function compareGitChanges(a: GitFileChange, b: GitFileChange): number { + const pathCompare = a.path.localeCompare(b.path); + if (pathCompare !== 0) { + return pathCompare; + } + + return (a.oldPath ?? "").localeCompare(b.oldPath ?? ""); +} + +function mapGitStatus(code: string): Exclude { + switch (code[0]) { + case "A": + return "added"; + case "D": + return "deleted"; + case "M": + default: + return "modified"; + } +} + +async function listTrackedChangesSinceBaseline( + workspacePath: string, + baselineGitHead: string +): Promise { + const { stdout } = await runGit(workspacePath, [ + "diff", + "--name-status", + "--find-renames", + baselineGitHead, + "--", + ]); + + const changes: Array = stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const parts = line.split("\t"); + const statusCode = parts[0] ?? "M"; + + if (statusCode.startsWith("R")) { + const oldPath = parts[1]; + const path = parts[2]; + if (!oldPath || !path) { + return null; + } + + return { + oldPath, + path, + status: "renamed" as const, + }; + } + + const path = parts[1]; + if (!path) { + return null; + } + + return { + path, + status: mapGitStatus(statusCode), + }; + }); + + return changes + .filter((change): change is ParsedGitFileChange => change !== null) + .filter((change) => !isWorkspaceStatePath(change.path)) + .sort(compareGitChanges); +} + +async function listUntrackedChanges(workspacePath: string): Promise { + const { stdout } = await runGit(workspacePath, ["ls-files", "--others", "--exclude-standard"]); + return stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((path) => ({ + path, + status: "untracked" as const, + })) + .filter((change) => !isWorkspaceStatePath(change.path)) + .sort(compareGitChanges); +} + +async function isUntrackedPath(workspacePath: string, filePath: string): Promise { + const { stdout } = await runGit(workspacePath, [ + "ls-files", + "--others", + "--exclude-standard", + "--", + filePath, + ]); + + return stdout.trim().length > 0; +} + +export async function buildSessionReviewSummary( + input: SessionReviewInput +): Promise { + const metadata = requireSessionMetadata(input.metadataRepo, input.sessionId); + + if (!metadata.baselineGitHead) { + return { + sessionId: metadata.sessionId, + workspaceId: metadata.workspaceId, + baselineGitHead: metadata.baselineGitHead, + changedFiles: [], + verificationRuns: metadata.verificationRuns, + warnings: [MISSING_BASELINE_WARNING], + }; + } + + if (!(await isGitRepository(input.workspacePath))) { + return { + sessionId: metadata.sessionId, + workspaceId: metadata.workspaceId, + baselineGitHead: metadata.baselineGitHead, + changedFiles: [], + verificationRuns: metadata.verificationRuns, + warnings: [NOT_GIT_REPO_WARNING], + }; + } + + const trackedChanges = await listTrackedChangesSinceBaseline( + input.workspacePath, + metadata.baselineGitHead + ); + const trackedPaths = new Set(trackedChanges.map((change) => change.path)); + const untrackedChanges = (await listUntrackedChanges(input.workspacePath)).filter( + (change) => !trackedPaths.has(change.path) + ); + + return { + sessionId: metadata.sessionId, + workspaceId: metadata.workspaceId, + baselineGitHead: metadata.baselineGitHead, + changedFiles: [...trackedChanges, ...untrackedChanges], + verificationRuns: metadata.verificationRuns, + warnings: [], + }; +} + +export async function getSessionReviewDiff(input: SessionReviewDiffInput): Promise { + const metadata = requireSessionMetadata(input.metadataRepo, input.sessionId); + if (!metadata.baselineGitHead) { + return ""; + } + + if (!(await isGitRepository(input.workspacePath))) { + return ""; + } + + if (await isUntrackedPath(input.workspacePath, input.path)) { + return (await getFileDiff(input.workspacePath, input.path)).diff; + } + + const { stdout } = await runGit(input.workspacePath, [ + "diff", + metadata.baselineGitHead, + "--", + input.path, + ]); + return stdout; +} diff --git a/packages/server/src/session/manager.ts b/packages/server/src/session/manager.ts index d2209f0f..d6af1a88 100644 --- a/packages/server/src/session/manager.ts +++ b/packages/server/src/session/manager.ts @@ -231,6 +231,10 @@ export class SessionManager { }); } + setProviderRegistry(providerRegistry: ProviderDefinition[]): void { + this.deps.providerRegistry = providerRegistry; + } + /** * Create a new session with provider */ diff --git a/packages/server/src/storage/index.ts b/packages/server/src/storage/index.ts index 495f9a88..027fb398 100644 --- a/packages/server/src/storage/index.ts +++ b/packages/server/src/storage/index.ts @@ -7,7 +7,15 @@ export { AuthSessionRepo, type AuthSessionRepoOptions, } from "./repositories/auth-session-repo.js"; +export { + CustomProviderRepo, + type CustomProviderRepoOptions, +} from "./repositories/custom-provider-repo.js"; export { ProviderConfigRepo } from "./repositories/provider-config-repo.js"; +export { + SessionMetadataRepo, + type SessionMetadataRepoOptions, +} from "./repositories/session-metadata-repo.js"; export { type NewSession, rowToSession, diff --git a/packages/server/src/storage/repositories/custom-provider-repo.ts b/packages/server/src/storage/repositories/custom-provider-repo.ts new file mode 100644 index 00000000..e7c98a89 --- /dev/null +++ b/packages/server/src/storage/repositories/custom-provider-repo.ts @@ -0,0 +1,106 @@ +import type { CustomProviderConfig } from "@coder-studio/core"; +import { readJsonFile, writeJsonFileAtomic } from "./json-file-store.js"; + +interface CustomProviderFileRecord { + version: 1; + providers: Record; +} + +export interface CustomProviderRepoOptions { + filePath: string; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeConfig(config: CustomProviderConfig): CustomProviderConfig { + return { + ...config, + args: [...config.args], + env: { ...config.env }, + capabilities: config.capabilities.map((capability) => ({ ...capability })), + }; +} + +function normalizeFileConfigs(value: unknown): Record { + if (isRecord(value) && value.version === 1 && isRecord(value.providers)) { + return Object.fromEntries( + Object.entries(value.providers).map(([id, config]) => [ + id, + normalizeConfig(config as CustomProviderConfig), + ]) + ); + } + + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([id, config]) => [ + id, + normalizeConfig(config as CustomProviderConfig), + ]) + ); + } + + return {}; +} + +export class CustomProviderRepo { + private readonly filePath: string; + + constructor(input: CustomProviderRepoOptions) { + this.filePath = input.filePath; + } + + list(): CustomProviderConfig[] { + return Object.values(this.loadFileConfigs()).sort( + (left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id) + ); + } + + get(id: string): CustomProviderConfig | undefined { + return this.loadFileConfigs()[id]; + } + + set(config: CustomProviderConfig): CustomProviderConfig { + const existing = this.get(config.id); + const createdAt = existing?.createdAt ?? config.createdAt; + const normalized = normalizeConfig({ + ...config, + createdAt, + }); + + const next = this.loadFileConfigs(); + next[normalized.id] = normalized; + this.saveFileConfigs(next); + return next[normalized.id]!; + } + + delete(id: string): void { + const next = this.loadFileConfigs(); + if (!Object.prototype.hasOwnProperty.call(next, id)) { + return; + } + delete next[id]; + this.saveFileConfigs(next); + } + + private loadFileConfigs(): Record { + const parsed = readJsonFile>( + this.filePath + ); + if (parsed !== undefined) { + return normalizeFileConfigs(parsed); + } + + return {}; + } + + private saveFileConfigs(configs: Record): void { + const payload: CustomProviderFileRecord = { + version: 1, + providers: configs, + }; + writeJsonFileAtomic(this.filePath, payload); + } +} diff --git a/packages/server/src/storage/repositories/session-metadata-repo.ts b/packages/server/src/storage/repositories/session-metadata-repo.ts new file mode 100644 index 00000000..8bc426a1 --- /dev/null +++ b/packages/server/src/storage/repositories/session-metadata-repo.ts @@ -0,0 +1,176 @@ +import type { AgentSessionMetadata, AgentSessionVerificationRun } from "@coder-studio/core"; +import { + resolveWorkspaceStateFilePath, + SESSION_METADATA_FILE_NAME, +} from "../../workspace/workspace-state.js"; +import { readJsonFile, writeJsonFileAtomic } from "./json-file-store.js"; + +interface SessionMetadataFileRecord { + version: 1; + metadata: Record; +} + +interface SessionMetadataWorkspace { + id: string; + path: string; +} + +interface SessionMetadataWorkspaceRepo { + list(): SessionMetadataWorkspace[]; + findById(id: string): SessionMetadataWorkspace | undefined; +} + +export interface SessionMetadataRepoOptions { + workspaceRepo: SessionMetadataWorkspaceRepo; +} + +interface SessionMetadataLocation { + workspace: SessionMetadataWorkspace; + fileMetadata: Record; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeRun(run: AgentSessionVerificationRun): AgentSessionVerificationRun { + return { + ...run, + }; +} + +function normalizeMetadata(metadata: AgentSessionMetadata): AgentSessionMetadata { + return { + sessionId: metadata.sessionId, + workspaceId: metadata.workspaceId, + providerId: metadata.providerId, + objective: metadata.objective ?? undefined, + baselineGitHead: metadata.baselineGitHead ?? undefined, + baselineCapturedAt: metadata.baselineCapturedAt ?? undefined, + verificationRuns: metadata.verificationRuns.map(normalizeRun), + }; +} + +function normalizeFileMetadata(value: unknown): Record { + if (isRecord(value) && value.version === 1 && isRecord(value.metadata)) { + return Object.fromEntries( + Object.entries(value.metadata).map(([sessionId, metadata]) => [ + sessionId, + normalizeMetadata(metadata as AgentSessionMetadata), + ]) + ); + } + + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([sessionId, metadata]) => [ + sessionId, + normalizeMetadata(metadata as AgentSessionMetadata), + ]) + ); + } + + return {}; +} + +export class SessionMetadataRepo { + private readonly workspaceRepo: SessionMetadataWorkspaceRepo; + + constructor(input: SessionMetadataRepoOptions) { + this.workspaceRepo = input.workspaceRepo; + } + + upsert(metadata: AgentSessionMetadata): AgentSessionMetadata { + const normalized = normalizeMetadata(metadata); + const workspace = this.workspaceRepo.findById(normalized.workspaceId); + if (!workspace) { + throw new Error(`Workspace not found for session metadata: ${normalized.workspaceId}`); + } + + const existing = this.findSessionLocation(normalized.sessionId); + if (existing && existing.workspace.id !== workspace.id) { + delete existing.fileMetadata[normalized.sessionId]; + this.saveWorkspaceFileMetadata(existing.workspace.path, existing.fileMetadata); + } + + const next = + existing && existing.workspace.id === workspace.id + ? existing.fileMetadata + : this.loadWorkspaceFileMetadata(workspace.path); + next[normalized.sessionId] = normalized; + this.saveWorkspaceFileMetadata(workspace.path, next); + return next[normalized.sessionId]!; + } + + get(sessionId: string): AgentSessionMetadata | undefined { + return this.findSessionLocation(sessionId)?.fileMetadata[sessionId]; + } + + addVerificationRun(sessionId: string, run: AgentSessionVerificationRun): AgentSessionMetadata { + const existing = this.findSessionLocation(sessionId); + if (!existing) { + throw new Error(`Session metadata not found: ${sessionId}`); + } + + existing.fileMetadata[sessionId] = normalizeMetadata({ + ...existing.fileMetadata[sessionId]!, + verificationRuns: [...existing.fileMetadata[sessionId]!.verificationRuns, normalizeRun(run)], + }); + this.saveWorkspaceFileMetadata(existing.workspace.path, existing.fileMetadata); + return existing.fileMetadata[sessionId]!; + } + + delete(sessionId: string): void { + this.deleteFromAnyWorkspace(sessionId); + } + + private findSessionLocation(sessionId: string): SessionMetadataLocation | undefined { + for (const workspace of this.workspaceRepo.list()) { + const fileMetadata = this.loadWorkspaceFileMetadata(workspace.path); + if (Object.prototype.hasOwnProperty.call(fileMetadata, sessionId)) { + return { + workspace, + fileMetadata, + }; + } + } + + return undefined; + } + + deleteFromAnyWorkspace(sessionId: string): boolean { + const existing = this.findSessionLocation(sessionId); + if (!existing) { + return false; + } + + delete existing.fileMetadata[sessionId]; + this.saveWorkspaceFileMetadata(existing.workspace.path, existing.fileMetadata); + return true; + } + + private loadWorkspaceFileMetadata(workspacePath: string): Record { + const parsed = readJsonFile>( + resolveWorkspaceStateFilePath(workspacePath, SESSION_METADATA_FILE_NAME) + ); + if (parsed !== undefined) { + return normalizeFileMetadata(parsed); + } + + return {}; + } + + private saveWorkspaceFileMetadata( + workspacePath: string, + metadata: Record + ): void { + const payload: SessionMetadataFileRecord = { + version: 1, + metadata, + }; + writeJsonFileAtomic( + resolveWorkspaceStateFilePath(workspacePath, SESSION_METADATA_FILE_NAME), + payload + ); + } +} diff --git a/packages/server/src/supervisor/manager.ts b/packages/server/src/supervisor/manager.ts index 617c721c..5fb37f43 100644 --- a/packages/server/src/supervisor/manager.ts +++ b/packages/server/src/supervisor/manager.ts @@ -255,6 +255,10 @@ export class SupervisorManager { }); } + setProviderRegistry(providerRegistry: ProviderDefinition[]): void { + this.deps.providerRegistry = providerRegistry; + } + start(): void { this.lifecycleUnsubscribe?.(); this.lifecycleUnsubscribe = this.deps.eventBus.on( diff --git a/packages/server/src/workspace/intelligence.ts b/packages/server/src/workspace/intelligence.ts new file mode 100644 index 00000000..bd4334ff --- /dev/null +++ b/packages/server/src/workspace/intelligence.ts @@ -0,0 +1,270 @@ +import { access, lstat, readFile, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import type { WorkspaceIntelligenceSummary } from "@coder-studio/core"; +import { AGENT_INSTRUCTIONS_RELATIVE_PATH } from "./workspace-state.js"; + +type PackageManager = NonNullable; +type PackageScripts = WorkspaceIntelligenceSummary["scripts"]; + +interface PackageJsonManifest { + dependencies?: Record; + devDependencies?: Record; + scripts?: Record; +} + +interface WorkspaceIntelligenceInput { + workspaceId: string; + rootPath: string; +} + +const frameworkOrder = ["Next.js", "React", "Vite", "Node", "Monorepo"] as const; +const packageManagerCandidates: Array<{ file: string; manager: PackageManager }> = [ + { file: "pnpm-lock.yaml", manager: "pnpm" }, + { file: "yarn.lock", manager: "yarn" }, + { file: "bun.lockb", manager: "bun" }, + { file: "package-lock.json", manager: "npm" }, +]; +const packageScriptKeys: Array = ["dev", "test", "build", "lint"]; + +export async function inspectWorkspaceIntelligence( + input: WorkspaceIntelligenceInput +): Promise { + const [packageManager, manifest, git, docsExistence, agentsExists, frameworks] = + await Promise.all([ + detectPackageManager(input.rootPath), + readPackageJson(input.rootPath), + detectGitState(input.rootPath), + detectDocs(input.rootPath), + pathExists(join(input.rootPath, AGENT_INSTRUCTIONS_RELATIVE_PATH)), + detectFrameworks(input.rootPath), + ]); + + const scripts = extractScripts(manifest); + + return { + workspaceId: input.workspaceId, + rootPath: input.rootPath, + git, + packageManager, + frameworks, + scripts, + recommendedCommands: buildRecommendedCommands(packageManager, scripts), + docs: docsExistence, + agentInstructions: { + exists: agentsExists, + path: AGENT_INSTRUCTIONS_RELATIVE_PATH, + }, + }; +} + +async function detectPackageManager(rootPath: string): Promise { + for (const candidate of packageManagerCandidates) { + if (await pathExists(join(rootPath, candidate.file))) { + return candidate.manager; + } + } + + if (await pathExists(join(rootPath, "package.json"))) { + return "npm"; + } + + return undefined; +} + +async function readPackageJson(rootPath: string): Promise { + const packageJsonPath = join(rootPath, "package.json"); + if (!(await pathExists(packageJsonPath))) { + return null; + } + + try { + const raw = await readFile(packageJsonPath, "utf8"); + const parsed = JSON.parse(raw) as PackageJsonManifest; + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function extractScripts(manifest: PackageJsonManifest | null): PackageScripts { + const scripts = manifest?.scripts ?? {}; + + return { + dev: normalizeScript(scripts.dev), + test: normalizeScript(scripts.test), + build: normalizeScript(scripts.build), + lint: normalizeScript(scripts.lint), + }; +} + +function normalizeScript(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function buildRecommendedCommands( + packageManager: PackageManager | undefined, + scripts: PackageScripts +): WorkspaceIntelligenceSummary["recommendedCommands"] { + if (!packageManager) { + return []; + } + + return packageScriptKeys.flatMap((key) => { + if (!scripts[key]) { + return []; + } + + return [ + { + key, + command: buildPackageCommand(packageManager, key), + source: "package_json" as const, + }, + ]; + }); +} + +function buildPackageCommand( + packageManager: PackageManager, + scriptName: keyof PackageScripts +): string { + switch (packageManager) { + case "pnpm": + return `pnpm ${scriptName}`; + case "yarn": + return `yarn ${scriptName}`; + case "bun": + return `bun run ${scriptName}`; + case "npm": + default: + return `npm run ${scriptName}`; + } +} + +async function detectFrameworks(rootPath: string): Promise { + const manifest = await readPackageJson(rootPath); + const deps = { + ...(manifest?.dependencies ?? {}), + ...(manifest?.devDependencies ?? {}), + }; + const frameworks = new Set(); + + if ("next" in deps) { + frameworks.add("Next.js"); + } + if ("react" in deps) { + frameworks.add("React"); + } + if ("vite" in deps || (await hasAnyPath(rootPath, viteConfigFiles))) { + frameworks.add("Vite"); + } + if (manifest) { + frameworks.add("Node"); + } + if (await hasAnyPath(rootPath, ["pnpm-workspace.yaml", "turbo.json", "nx.json"])) { + frameworks.add("Monorepo"); + } + + return frameworkOrder.filter((framework) => frameworks.has(framework)); +} + +const viteConfigFiles = [ + "vite.config.ts", + "vite.config.js", + "vite.config.mjs", + "vite.config.cjs", +] as const; + +async function detectGitState(rootPath: string): Promise { + const gitPath = join(rootPath, ".git"); + let stats; + try { + stats = await lstat(gitPath); + } catch { + return { isRepo: false }; + } + + const gitDir = stats.isDirectory() + ? gitPath + : stats.isFile() + ? await resolveGitDirFromFile(rootPath, gitPath) + : null; + + if (!gitDir) { + return { isRepo: false }; + } + + const branch = await readGitBranch(gitDir); + return branch ? { isRepo: true, branch } : { isRepo: true }; +} + +async function resolveGitDirFromFile( + rootPath: string, + gitFilePath: string +): Promise { + try { + const raw = await readFile(gitFilePath, "utf8"); + const match = raw.match(/^gitdir:\s*(.+)\s*$/m); + if (!match) { + return null; + } + + const gitDirPath = match[1]; + if (!gitDirPath) { + return null; + } + return resolve(rootPath, gitDirPath); + } catch { + return null; + } +} + +async function readGitBranch(gitDir: string): Promise { + try { + const head = await readFile(join(gitDir, "HEAD"), "utf8"); + const refMatch = head.match(/^ref:\s*refs\/heads\/(.+)\s*$/); + return refMatch?.[1]; + } catch { + return undefined; + } +} + +async function detectDocs(rootPath: string): Promise { + const docs: WorkspaceIntelligenceSummary["docs"] = []; + + if (await pathExists(join(rootPath, "README.md"))) { + docs.push({ path: "README.md", kind: "readme" }); + } + + if (await pathExists(join(rootPath, "docs"))) { + try { + const docsStats = await stat(join(rootPath, "docs")); + if (docsStats.isDirectory()) { + docs.push({ path: "docs", kind: "docs" }); + } + } catch { + // Ignore a disappearing docs entry and keep the rest of the summary. + } + } + + return docs; +} + +async function hasAnyPath(rootPath: string, relativePaths: readonly string[]): Promise { + for (const relativePath of relativePaths) { + if (await pathExists(join(rootPath, relativePath))) { + return true; + } + } + + return false; +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} diff --git a/packages/server/src/workspace/workspace-state.ts b/packages/server/src/workspace/workspace-state.ts new file mode 100644 index 00000000..f0871680 --- /dev/null +++ b/packages/server/src/workspace/workspace-state.ts @@ -0,0 +1,11 @@ +import { join } from "node:path"; + +export const WORKSPACE_STATE_DIR = ".coder-studio" as const; +export const AGENT_INSTRUCTIONS_RELATIVE_PATH = `${WORKSPACE_STATE_DIR}/AGENTS.md` as const; +export const SESSION_METADATA_FILE_NAME = "session-metadata.json" as const; +export const SESSION_METADATA_RELATIVE_PATH = + `${WORKSPACE_STATE_DIR}/${SESSION_METADATA_FILE_NAME}` as const; + +export function resolveWorkspaceStateFilePath(workspacePath: string, fileName: string): string { + return join(workspacePath, WORKSPACE_STATE_DIR, fileName); +} diff --git a/packages/server/src/ws/dispatch.ts b/packages/server/src/ws/dispatch.ts index a2ab85d5..8377980b 100644 --- a/packages/server/src/ws/dispatch.ts +++ b/packages/server/src/ws/dispatch.ts @@ -15,7 +15,9 @@ import type { LspToolManager } from "../lsp-tools/manager.js"; import type { ProviderInstallManager } from "../provider-runtime/install-manager.js"; import type { RuntimeStatusDeps } from "../provider-runtime/runtime-status.js"; import type { SessionManager } from "../session/manager.js"; +import type { CustomProviderRepo } from "../storage/repositories/custom-provider-repo.js"; import type { ProviderConfigRepo } from "../storage/repositories/provider-config-repo.js"; +import type { SessionMetadataRepo } from "../storage/repositories/session-metadata-repo.js"; import type { SettingsRepo } from "../storage/repositories/settings-repo.js"; import type { SupervisorManager } from "../supervisor/manager.js"; import type { TerminalManager } from "../terminal/manager.js"; @@ -48,6 +50,9 @@ export interface CommandContext { lspToolMgr?: LspToolManager; lspToolInstallMgr?: LspToolInstallManager; updateService?: UpdateService; + customProviderRepo?: CustomProviderRepo; + sessionMetadataRepo?: SessionMetadataRepo; + setProviderRegistry?: (providers: ProviderDefinition[]) => void; } /** diff --git a/packages/web/src/features/agent-providers/actions/use-agent-providers.test.tsx b/packages/web/src/features/agent-providers/actions/use-agent-providers.test.tsx new file mode 100644 index 00000000..57211c8d --- /dev/null +++ b/packages/web/src/features/agent-providers/actions/use-agent-providers.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom + +import { renderHook, waitFor } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { wsClientAtom } from "../../../atoms/connection"; +import { useAgentProviders } from "./use-agent-providers"; + +function wrapperFor(store: ReturnType) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useAgentProviders", () => { + it("loads provider.list through websocket dispatch", async () => { + const sendCommand = vi.fn().mockResolvedValue([ + { + id: "claude", + displayName: "Claude Code", + badge: "Claude", + kind: "built_in", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], + requiredCommands: ["claude"], + }, + { + id: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], + requiredCommands: ["codex"], + }, + ]); + + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + const { result } = renderHook(() => useAgentProviders(), { + wrapper: wrapperFor(store), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(sendCommand).toHaveBeenCalledWith("provider.list", {}, undefined); + expect(result.current.error).toBeNull(); + expect(result.current.providers).toEqual([ + expect.objectContaining({ + id: "claude", + kind: "built_in", + }), + expect.objectContaining({ + id: "codex", + kind: "built_in", + }), + ]); + }); +}); diff --git a/packages/web/src/features/agent-providers/actions/use-agent-providers.ts b/packages/web/src/features/agent-providers/actions/use-agent-providers.ts new file mode 100644 index 00000000..a4a24af6 --- /dev/null +++ b/packages/web/src/features/agent-providers/actions/use-agent-providers.ts @@ -0,0 +1,46 @@ +import type { ProviderListItem } from "@coder-studio/core"; +import { useAtomValue } from "jotai"; +import { useEffect, useState } from "react"; +import { dispatchCommandAtom } from "../../../atoms/connection"; + +interface UseAgentProvidersResult { + providers: ProviderListItem[]; + isLoading: boolean; + error: string | null; + refresh: () => Promise; +} + +export function useAgentProviders(): UseAgentProvidersResult { + const dispatch = useAtomValue(dispatchCommandAtom); + const [providers, setProviders] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = async () => { + setIsLoading(true); + setError(null); + + const result = await dispatch("provider.list", {}); + if (!result.ok || !result.data) { + setProviders([]); + setError(result.error?.message ?? "Failed to load providers"); + setIsLoading(false); + return; + } + + setProviders(result.data); + setError(null); + setIsLoading(false); + }; + + useEffect(() => { + void refresh(); + }, [dispatch]); + + return { + providers, + isLoading, + error, + refresh, + }; +} diff --git a/packages/web/src/features/terminal-panel/__tests__/recovery-coordinator.test.ts b/packages/web/src/features/terminal-panel/__tests__/recovery-coordinator.test.ts index e3ee34a6..066d9351 100644 --- a/packages/web/src/features/terminal-panel/__tests__/recovery-coordinator.test.ts +++ b/packages/web/src/features/terminal-panel/__tests__/recovery-coordinator.test.ts @@ -481,4 +481,42 @@ describe("RecoveryCoordinator", () => { expect(setUiMode).toHaveBeenCalledWith("error", { reason: "too_old_no_snapshot" }); }); + + it("surfaces reconcile command failures as reconcile_failed details instead of a generic recovery failure", async () => { + const setUiMode = vi.fn(); + const sendCommand = vi.fn().mockResolvedValueOnce({ + ok: false, + error: { + code: "unknown_op", + message: "Unknown operation: recovery.reconcile", + }, + }); + + const coordinator = createRecoveryCoordinator({ + wsClient: { + getStatus: vi.fn(() => "connected"), + probeConnection: vi.fn().mockResolvedValue({ ok: true }), + onStatus: vi.fn(() => () => {}), + subscribe: vi.fn(() => () => {}), + } as never, + sendCommand, + applyReplay: vi.fn(), + applySnapshot: vi.fn(), + }); + + coordinator.registerTerminal({ + terminalId: "term-1", + workspaceId: "ws-1", + getRenderedSeq: () => 20, + setUiMode, + }); + + await coordinator.notifyReason("initial_mount", "term-1"); + + expect(setUiMode).toHaveBeenCalledWith("error", { + reason: "reconcile_failed", + operation: "recovery.reconcile", + errorCode: "unknown_op", + }); + }); }); diff --git a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx index c1dd9f45..93046855 100644 --- a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +++ b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx @@ -13,7 +13,11 @@ import { appearancePersonalizationAtom, localeAtom, themeAtom } from "../../../a import { wsClientAtom } from "../../../atoms/connection"; import { JotaiProvider } from "../../../test-utils/jotai-provider"; import { getThemeById } from "../../../theme"; -import type { TerminalReplayPayload, TerminalSnapshotPayload } from "../../../ws/client"; +import { + CommandResultError, + type TerminalReplayPayload, + type TerminalSnapshotPayload, +} from "../../../ws/client"; import { toastsAtom } from "../../notifications/atoms"; import { terminalMetaAtomFamily, terminalOutputAtomFamily } from "../atoms"; import type { HydrationRequestHandle, HydrationTier } from "../hydration-coordinator"; @@ -1388,6 +1392,144 @@ describe("XtermHost", () => { global.cancelAnimationFrame = originalCancelAnimationFrame; }); + it("suppresses the retryable recovery notice while the websocket is disconnected", async () => { + // Regression guard: the global connection banner is already shown when the + // socket is down, so xterm-host must not stack a "terminal history not + // recovered" notice on top of it. Coverage is split across two layers: + // 1. failHistoricalRecovery short-circuits to a quiet loading state + // when getStatus() != "connected". + // 2. The UI gate (wsHealthy) hides the notice even if the state were + // somehow set. + const store = createStore(); + const sendCommand = vi.fn().mockImplementation((op: string) => { + if (op === "terminal.snapshot") { + return Promise.resolve({ status: "unsupported" }); + } + + if (op === "terminal.replay") { + return Promise.reject(new Error("Command timeout: terminal.replay")); + } + + return Promise.resolve({ ok: true, data: { status: "ok" } }); + }); + const subscribe = vi.fn(() => vi.fn()); + const rafCallbacks: FrameRequestCallback[] = []; + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + + mockTerminal.cols = 132; + mockTerminal.rows = 36; + + global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }) as typeof requestAnimationFrame; + global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame; + + store.set(localeAtom, "zh"); + store.set(wsClientAtom, { + sendCommand, + subscribe, + getStatus: vi.fn(() => "disconnected"), + onStatus: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + await act(async () => { + const callback = rafCallbacks.shift(); + callback?.(16); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Even after the replay command rejects with a non-network-flavoured error, + // no recovery failure notice should appear while the socket itself is down. + expect(screen.queryByText("终端历史暂未恢复")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "重试恢复" })).not.toBeInTheDocument(); + expect(screen.queryByText("终端恢复检查未完成")).not.toBeInTheDocument(); + // The blocking loading overlay is also gated on a healthy socket. + expect(document.querySelector(".xterm-replay-overlay")).toBeFalsy(); + + global.requestAnimationFrame = originalRequestAnimationFrame; + global.cancelAnimationFrame = originalCancelAnimationFrame; + }); + + it("shows a recovery-check notice instead of retryable recovery when replay is unavailable as a command", async () => { + const store = createStore(); + const sendCommand = vi.fn().mockImplementation((op: string) => { + if (op === "terminal.snapshot") { + return Promise.resolve({ status: "unsupported" }); + } + + if (op === "terminal.replay") { + return Promise.reject( + new CommandResultError({ + code: "unknown_op", + message: "Unknown operation: terminal.replay", + }) + ); + } + + return Promise.resolve({ ok: true, data: { status: "ok" } }); + }); + const subscribe = vi.fn(() => vi.fn()); + const rafCallbacks: FrameRequestCallback[] = []; + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + + mockTerminal.cols = 132; + mockTerminal.rows = 36; + + global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }) as typeof requestAnimationFrame; + global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame; + + store.set(localeAtom, "zh"); + store.set(wsClientAtom, { + sendCommand, + subscribe, + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + await act(async () => { + const callback = rafCallbacks.shift(); + callback?.(16); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByText("终端恢复检查未完成")).toBeInTheDocument(); + }); + expect( + screen.getByText( + "这次没有完成恢复决策,当前终端仍可继续使用,但较早历史是否补齐暂时无法确认。请稍后重新检查。" + ) + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "重新检查" })).toBeInTheDocument(); + expect(screen.queryByText("终端历史暂未恢复")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "重试恢复" })).not.toBeInTheDocument(); + + global.requestAnimationFrame = originalRequestAnimationFrame; + global.cancelAnimationFrame = originalCancelAnimationFrame; + }); + it("retries local recovery when the retry action is clicked", async () => { const store = createStore(); const sendCommand = vi.fn().mockImplementation((op: string) => { @@ -2044,6 +2186,76 @@ describe("XtermHost", () => { }); }); + it("shows a recovery-check notice when the coordinator cannot run recovery.reconcile", async () => { + const probeConnection = vi.fn().mockResolvedValue({ ok: true }); + const sendCommand = vi.fn(async (op: string) => { + if (op === "recovery.reconcile") { + throw new Error("Unknown operation: recovery.reconcile"); + } + + if (op === "terminal.resize") { + return { status: "ok" }; + } + + throw new Error(`Unexpected op ${op}`); + }); + + const store = createStore(); + store.set(localeAtom, "zh"); + store.set(wsClientAtom, { + sendCommand, + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + probeConnection, + onStatus: vi.fn(() => () => {}), + } as never); + + setGlobalRecoveryCoordinator( + createRecoveryCoordinator({ + wsClient: { + getStatus: vi.fn(() => "connected"), + probeConnection, + onStatus: vi.fn(() => () => {}), + subscribe: vi.fn(() => () => {}), + } as never, + sendCommand: async (op, args, options) => { + try { + const data = await sendCommand(op, args, options); + return { ok: true, data }; + } catch (error) { + return { + ok: false, + error: { + code: "unknown_op", + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }, + applyReplay: vi.fn(), + applySnapshot: vi.fn(), + }) + ); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("终端恢复检查未完成")).toBeInTheDocument(); + }); + expect( + screen.getByText( + "这次没有完成恢复决策,当前终端仍可继续使用,但较早历史是否补齐暂时无法确认。请稍后重新检查。" + ) + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "重新检查" })).toBeInTheDocument(); + expect(screen.queryByText("终端历史暂未恢复")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "重试恢复" })).not.toBeInTheDocument(); + }); + it("shows a degraded overlay when replay returns unknown so unavailable terminals do not stay loading", async () => { const store = createStore(); const sendCommand = vi.fn().mockImplementation((op: string) => { diff --git a/packages/web/src/features/terminal-panel/recovery-coordinator.ts b/packages/web/src/features/terminal-panel/recovery-coordinator.ts index 686245c7..cdb2026d 100644 --- a/packages/web/src/features/terminal-panel/recovery-coordinator.ts +++ b/packages/web/src/features/terminal-panel/recovery-coordinator.ts @@ -13,6 +13,8 @@ import type { TerminalSnapshotPayload, } from "../../ws/client"; import { + isRecoveryControlPlaneError, + type RecoveryOperation, type RecoveryUiMode, type RecoveryUiModeDetail, TERMINAL_REPLAY_TIMEOUT_MS, @@ -203,6 +205,18 @@ export function createRecoveryCoordinator(deps: RecoveryCoordinatorDeps): Recove queueRecovery(terminalId, { reason: "socket_reconnected", skipProbe: true }); }; + const surfaceRecoveryCheckFailure = ( + terminal: RegisteredTerminal, + operation: RecoveryOperation, + errorCode?: string + ) => { + terminal.setUiMode("error", { + reason: "reconcile_failed", + operation, + errorCode, + } satisfies RecoveryUiModeDetail); + }; + const resolveIdleWaiters = (state: TerminalRecoveryState) => { const resolvers = state.idleResolvers; state.idleResolvers = []; @@ -256,7 +270,8 @@ export function createRecoveryCoordinator(deps: RecoveryCoordinatorDeps): Recove const requestSnapshot = async ( terminalId: string, terminal: RegisteredTerminal, - closed?: RecoveryClosedTerminalState + closed?: RecoveryClosedTerminalState, + options?: { controlPlaneFailureMeansRecoveryFailure?: boolean } ) => { if (disposed) { return; @@ -274,11 +289,31 @@ export function createRecoveryCoordinator(deps: RecoveryCoordinatorDeps): Recove } if (!snapshotResult.ok || !snapshotResult.data || snapshotResult.data.status !== "ok") { - if (shouldRetryAfterReconnect(snapshotResult.ok ? undefined : snapshotResult.error)) { + if (!snapshotResult.ok && shouldRetryAfterReconnect(snapshotResult.error)) { scheduleReconnectRecovery(terminalId); return; } + if (!snapshotResult.ok && isRecoveryControlPlaneError(snapshotResult.error)) { + if (options?.controlPlaneFailureMeansRecoveryFailure) { + terminal.setUiMode("error"); + return; + } + + surfaceRecoveryCheckFailure(terminal, "terminal.snapshot", snapshotResult.error?.code); + return; + } + + if (!snapshotResult.data) { + if (options?.controlPlaneFailureMeansRecoveryFailure) { + terminal.setUiMode("error"); + return; + } + + surfaceRecoveryCheckFailure(terminal, "terminal.snapshot"); + return; + } + terminal.setUiMode("error"); return; } @@ -350,12 +385,19 @@ export function createRecoveryCoordinator(deps: RecoveryCoordinatorDeps): Recove return; } - await requestSnapshot(decision.terminalId, terminal, decision.closed); + if (isRecoveryControlPlaneError(replayResult.error)) { + surfaceRecoveryCheckFailure(terminal, "terminal.replay", replayResult.error?.code); + return; + } + + await requestSnapshot(decision.terminalId, terminal, decision.closed, { + controlPlaneFailureMeansRecoveryFailure: true, + }); return; } if (!replayResult.data) { - terminal.setUiMode("error"); + surfaceRecoveryCheckFailure(terminal, "terminal.replay"); return; } @@ -364,8 +406,13 @@ export function createRecoveryCoordinator(deps: RecoveryCoordinatorDeps): Recove return; } + if (replayResult.data.status === "unknown") { + terminal.setUiMode("error", { reason: "unknown_terminal" }); + return; + } + if (replayResult.data.status !== "ok") { - terminal.setUiMode("error"); + surfaceRecoveryCheckFailure(terminal, "terminal.replay"); return; } @@ -416,7 +463,11 @@ export function createRecoveryCoordinator(deps: RecoveryCoordinatorDeps): Recove } for (const entry of entries) { - entry.setUiMode("error"); + surfaceRecoveryCheckFailure( + entry, + "recovery.reconcile", + result.ok ? undefined : result.error?.code + ); } return; } @@ -478,7 +529,7 @@ export function createRecoveryCoordinator(deps: RecoveryCoordinatorDeps): Recove return; } - terminal.setUiMode("error"); + surfaceRecoveryCheckFailure(terminal, "recovery.reconcile"); } }; diff --git a/packages/web/src/features/terminal-panel/replay-state.ts b/packages/web/src/features/terminal-panel/replay-state.ts index 19ccd7e9..1d463284 100644 --- a/packages/web/src/features/terminal-panel/replay-state.ts +++ b/packages/web/src/features/terminal-panel/replay-state.ts @@ -1,5 +1,11 @@ export const TERMINAL_REPLAY_TIMEOUT_MS = 120_000; +export type RecoveryOperation = + | "connection.probe" + | "recovery.reconcile" + | "terminal.replay" + | "terminal.snapshot"; + export type RecoveryUiMode = | "silent" | "closed" @@ -9,7 +15,9 @@ export type RecoveryUiMode = | "error"; export interface RecoveryUiModeDetail { - reason?: "too_old_no_snapshot" | "unknown_terminal"; + reason?: "too_old_no_snapshot" | "unknown_terminal" | "reconcile_failed"; + operation?: RecoveryOperation; + errorCode?: string; } export type TerminalReplayUiState = @@ -17,7 +25,7 @@ export type TerminalReplayUiState = | { kind: "ready" } | { kind: "closed" } | { kind: "unavailable" } - | { kind: "truncated" } + | { kind: "recovery_check_failed" } | { kind: "retryable_failure"; reason: "timeout" | "failed" } | { kind: "failed"; reason: "timeout" | "failed" } | { kind: "unrecoverable_history"; reason: "too_old_no_snapshot" }; @@ -39,3 +47,53 @@ export function classifyReplayFailure(error: unknown): "timeout" | "failed" { return "failed"; } + +function getErrorCode(error: unknown): string | null { + if ( + typeof error === "object" && + error !== null && + "code" in error && + typeof (error as { code?: unknown }).code === "string" + ) { + return String((error as { code: string }).code); + } + + return null; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return String((error as { message: string }).message); + } + + return ""; +} + +export function isRecoveryControlPlaneError(error: unknown): boolean { + const code = getErrorCode(error); + if ( + code === "no_client" || + code === "unknown_op" || + code === "activation_required" || + code === "validation_error" || + code === "internal_error" + ) { + return true; + } + + const message = getErrorMessage(error); + return ( + message.startsWith("Unknown operation:") || + message.includes("active session") || + message.includes("WebSocket client not initialized") + ); +} diff --git a/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx b/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx index 78860ef7..6b0a27e5 100644 --- a/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx +++ b/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx @@ -57,7 +57,9 @@ import { getTerminalFontSizeForViewport, terminalPreferencesAtom } from "../../p import { getGlobalRecoveryCoordinator } from "../../recovery-singleton"; import { classifyReplayFailure, + isRecoveryControlPlaneError, type RecoveryUiMode, + type RecoveryUiModeDetail, TERMINAL_REPLAY_TIMEOUT_MS, type TerminalReplayUiState, } from "../../replay-state"; @@ -620,6 +622,9 @@ export function XtermHost({ ((coveredSeq: number, closed?: RecoveryClosedTerminalState) => Promise) | null >(null); const failHistoricalRecoveryRef = useRef<((error: unknown) => Promise) | null>(null); + const showRecoveryCheckFailedRef = useRef< + ((detail?: RecoveryUiModeDetail) => Promise) | null + >(null); const showUnrecoverableHistoryRef = useRef<(() => Promise) | null>(null); const showUnavailableTerminalRef = useRef<(() => Promise) | null>(null); const retryHistoricalRecoveryRef = useRef<(() => void) | null>(null); @@ -1487,6 +1492,17 @@ export function XtermHost({ return; } + if (detail?.reason === "reconcile_failed") { + if (showRecoveryCheckFailedRef.current) { + void showRecoveryCheckFailedRef.current(detail); + return; + } + + recoveryReplayAnchorSeqRef.current = null; + setReplayUiState({ kind: "recovery_check_failed" }); + return; + } + if (failHistoricalRecoveryRef.current) { void failHistoricalRecoveryRef.current(new Error("terminal recovery failed")); return; @@ -1930,11 +1946,31 @@ export function XtermHost({ completeHistoricalRecoveryRef.current = completeHistoricalRecovery; const failHistoricalRecovery = async (error: unknown) => { - console.error("Failed to recover terminal output:", error); if (!mountedRef.current || !terminalRef.current) { return; } + // If the WebSocket is not currently connected, this failure is almost + // certainly a symptom of the transport outage rather than a real recovery + // problem. The global connection banner already surfaces the outage, and + // the coordinator's pendingSocketReconcile (or the legacy reconnect + // trigger when no coordinator is installed) will reschedule recovery + // once the socket comes back. Keep the UI in a quiet loading state so we + // don't stack a "recovery failed" notice on top of the connection banner. + const status = getConnectionStatus(); + if (status !== "connected") { + traceTerminal(terminalId, "recovery.fail.deferred-ws-unhealthy", { + status, + error: error instanceof Error ? error.message : String(error), + }); + activeRecoveryUiModeRef.current = "non_blocking_recovering"; + setReplayUiState({ kind: "loading" }); + deferRecoveryUntilReconnect(); + releaseHydration(); + return; + } + + console.error("Failed to recover terminal output:", error); activeRecoveryUiModeRef.current = "error"; const reason = classifyReplayFailure(error); setReplayUiState( @@ -1960,6 +1996,19 @@ export function XtermHost({ }; showUnrecoverableHistoryRef.current = showUnrecoverableHistory; + const showRecoveryCheckFailed = async (_detail?: RecoveryUiModeDetail) => { + if (!mountedRef.current || !terminalRef.current) { + return; + } + + activeRecoveryUiModeRef.current = "error"; + recoveryReplayAnchorSeqRef.current = null; + setReplayUiState({ kind: "recovery_check_failed" }); + releaseHydration(); + await flushHistoricalRecovery(); + }; + showRecoveryCheckFailedRef.current = showRecoveryCheckFailed; + const showUnavailableTerminal = async () => { if (!mountedRef.current || !terminalRef.current) { return; @@ -2060,11 +2109,23 @@ export function XtermHost({ return; } - void failHistoricalRecovery( - result.ok - ? new Error(`terminal.snapshot returned status ${result.data?.status ?? "unknown"}`) - : result.error - ); + if (!result.ok && isRecoveryControlPlaneError(result.error)) { + void showRecoveryCheckFailedRef.current?.({ + reason: "reconcile_failed", + operation: "terminal.snapshot", + }); + return; + } + + if (result.ok) { + void showRecoveryCheckFailedRef.current?.({ + reason: "reconcile_failed", + operation: "terminal.snapshot", + }); + return; + } + + void failHistoricalRecovery(result.error); }); }; @@ -2123,7 +2184,23 @@ export function XtermHost({ return; } - void failHistoricalRecovery(result.ok ? new Error("terminal.replay failed") : result.error); + if (!result.ok && isRecoveryControlPlaneError(result.error)) { + void showRecoveryCheckFailedRef.current?.({ + reason: "reconcile_failed", + operation: "terminal.replay", + }); + return; + } + + if (result.ok) { + void showRecoveryCheckFailedRef.current?.({ + reason: "reconcile_failed", + operation: "terminal.replay", + }); + return; + } + + void failHistoricalRecovery(result.error); }); }; @@ -2184,6 +2261,18 @@ export function XtermHost({ return; } + if ( + reason === "error" && + (isRecoveryControlPlaneError(error) || + (!result.ok && isRecoveryControlPlaneError(result.error))) + ) { + void showRecoveryCheckFailedRef.current?.({ + reason: "reconcile_failed", + operation: "terminal.snapshot", + }); + return; + } + void failHistoricalRecovery( result.ok ? new Error(`terminal.snapshot returned status ${result.data?.status ?? "unknown"}`) @@ -2373,6 +2462,7 @@ export function XtermHost({ applySnapshotPayloadRef.current = null; completeHistoricalRecoveryRef.current = null; failHistoricalRecoveryRef.current = null; + showRecoveryCheckFailedRef.current = null; showUnrecoverableHistoryRef.current = null; showUnavailableTerminalRef.current = null; retryHistoricalRecoveryRef.current = null; @@ -2732,16 +2822,24 @@ export function XtermHost({ const shouldBlockTerminal = replayUiState.kind === "loading" && activeRecoveryUiModeRef.current === "blocking_rebuild"; const canShowRecoverySurface = viewport === "mobile" || hydrationState.kind === "granted"; + // When WS is not connected, the global connection banner already explains the + // situation. Suppressing recovery overlays/notices here keeps us from stacking + // two competing messages on top of the terminal. + const wsHealthy = connectionStatus === "connected"; const showReplayOverlay = - ((replayUiState.kind === "loading" && shouldBlockTerminal && loadingOverlayVisible) || + canShowRecoverySurface && + ((wsHealthy && + replayUiState.kind === "loading" && + shouldBlockTerminal && + loadingOverlayVisible) || replayUiState.kind === "closed" || - replayUiState.kind === "unavailable") && - canShowRecoverySurface; + replayUiState.kind === "unavailable"); const showInlineRecoveryNotice = - replayUiState.kind === "retryable_failure" || - replayUiState.kind === "failed" || - replayUiState.kind === "unrecoverable_history" || - replayUiState.kind === "truncated"; + wsHealthy && + (replayUiState.kind === "recovery_check_failed" || + replayUiState.kind === "retryable_failure" || + replayUiState.kind === "failed" || + replayUiState.kind === "unrecoverable_history"); let replayTitle = ""; let replayBody = ""; @@ -2775,6 +2873,20 @@ export function XtermHost({ }) : t("terminal.replay.unknown_body"); replayTitle = t("terminal.replay.unknown_title"); + } else if (replayUiState.kind === "recovery_check_failed") { + noticeTitle = t("terminal.replay.reconcile_failed_title"); + noticeBody = t("terminal.replay.reconcile_failed_body"); + noticeAction = ( + + ); } else if (replayUiState.kind === "retryable_failure") { noticeTitle = t("terminal.replay.retryable_title"); noticeBody = t("terminal.replay.retryable_body"); @@ -2795,9 +2907,6 @@ export function XtermHost({ } else if (replayUiState.kind === "unrecoverable_history") { noticeTitle = t("terminal.replay.unrecoverable_title"); noticeBody = t("terminal.replay.unrecoverable_body"); - } else if (replayUiState.kind === "truncated") { - noticeTitle = t("terminal.replay.truncated_title"); - noticeBody = t("terminal.replay.truncated_body"); } return ( diff --git a/packages/web/src/features/welcome/index.test.tsx b/packages/web/src/features/welcome/index.test.tsx index ce113961..62508528 100644 --- a/packages/web/src/features/welcome/index.test.tsx +++ b/packages/web/src/features/welcome/index.test.tsx @@ -80,9 +80,10 @@ describe("WelcomePage", () => { expect(document.querySelector(".welcome-container--mobile")).toBeTruthy(); expect(document.querySelector(".welcome-card--mobile")).toBeTruthy(); expect(document.querySelector(".welcome-card.welcome-card--mobile")).toBeTruthy(); + expect(document.querySelector(".welcome-flow")).toBeTruthy(); }); - it("renders translated English copy when locale is set to en", () => { + it("renders task-oriented English activation copy with a step-first workflow", () => { const store = createStore(); store.set(localeAtom, "en"); @@ -94,37 +95,130 @@ describe("WelcomePage", () => { ); - expect(screen.getByText("DEPLOY ONCE, CODE EVERYWHERE")).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Welcome to Coder Studio" })).toBeInTheDocument(); + expect(screen.getByText("LOCAL AI CODING WORKSPACE")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { + name: "Open a workspace. Start an AI coding session.", + }) + ).toBeInTheDocument(); + expect(screen.getByText(/Choose a local project folder to get started\./)).toBeInTheDocument(); + expect(screen.getByText("How it works")).toBeInTheDocument(); + expect(screen.getByText("Step 1")).toBeInTheDocument(); + expect(screen.getByText("Open your project folder")).toBeInTheDocument(); + expect( + screen.getByText("Choose a local directory to become your active workspace.") + ).toBeInTheDocument(); + expect(screen.getByText("Step 2")).toBeInTheDocument(); + expect(screen.getByText("Start Claude or Codex")).toBeInTheDocument(); + expect( + screen.getByText("Inside the workspace, launch an AI session for that project.") + ).toBeInTheDocument(); + expect(screen.getByText("Need provider setup first?")).toBeInTheDocument(); + expect(screen.getByText("Why use it here")).toBeInTheDocument(); + expect(screen.getByText("Review code and Git side by side")).toBeInTheDocument(); + expect( + screen.getByText( + "Inspect files and changes next to the agent instead of switching between tools." + ) + ).toBeInTheDocument(); + expect(screen.getByText("Run commands in the same workspace")).toBeInTheDocument(); + expect( + screen.getByText( + "Use integrated terminals alongside your AI session when you need manual control." + ) + ).toBeInTheDocument(); expect(document.querySelector(".welcome-card__hero")).toBeTruthy(); - expect(document.querySelector(".welcome-card__actions")).toBeTruthy(); + expect(document.querySelector(".welcome-flow")).toBeTruthy(); + expect(document.querySelector(".welcome-flow__steps")).toBeTruthy(); + expect(document.querySelector(".welcome-flow__support")).toBeTruthy(); + expect(document.querySelector(".welcome-step-card")).toBeTruthy(); expect(document.querySelector(".welcome-card__features")).toBeTruthy(); - expect(document.querySelector(".welcome-actions-group")).toBeTruthy(); + expect(document.querySelector(".welcome-support-list")).toBeTruthy(); const openWorkspaceButton = screen.getByRole("button", { name: "Open Workspace" }); const settingsButton = screen.getByRole("button", { name: "Settings" }); - const featureCards = Array.from(document.querySelectorAll(".welcome-feature")); - - expect(featureCards).toHaveLength(3); + const stepCards = Array.from(document.querySelectorAll(".welcome-step-card")); + const supportItems = Array.from(document.querySelectorAll(".welcome-feature")); + const flowSupport = document.querySelector(".welcome-flow__support"); + const settingsCard = settingsButton.closest(".welcome-step-card"); + + expect(stepCards).toHaveLength(2); + expect(supportItems).toHaveLength(2); + expect(flowSupport?.contains(settingsButton)).toBe(true); + expect(settingsCard).toBeNull(); expect( openWorkspaceButton.querySelector('[data-icon-semantic="nav.newWorkspace"]') ).toBeTruthy(); expect(settingsButton.querySelector('[data-icon-semantic="nav.settings"]')).toBeTruthy(); + }); + + it("renders translated Chinese activation copy in the step-first layout when locale is set to zh", () => { + const store = createStore(); + store.set(localeAtom, "zh"); + + render( + + + + + + ); + + expect(screen.getByText("本地 AI 编码工作台")).toBeInTheDocument(); expect( - featureCards.some((card) => - card.querySelector('[data-icon-semantic="state.welcome.lightning"]') - ) + screen.getByRole("heading", { name: "先打开工作区,再启动 AI 编码会话" }) + ).toBeInTheDocument(); + expect(screen.getByText(/先选择一个本地项目目录。/)).toBeInTheDocument(); + expect(screen.getByText("使用步骤")).toBeInTheDocument(); + expect(screen.getByText("第 1 步")).toBeInTheDocument(); + expect(screen.getByText("打开你的项目目录")).toBeInTheDocument(); + expect(screen.getByText("先选择一个本地目录,作为当前工作区。")).toBeInTheDocument(); + expect(screen.getByText("第 2 步")).toBeInTheDocument(); + expect(screen.getByText("启动 Claude 或 Codex")).toBeInTheDocument(); + expect(screen.getByText("进入工作区后,再为当前项目启动一个 AI 会话。")).toBeInTheDocument(); + expect(screen.getByText("如果要先配置 Provider,可以先去设置。")).toBeInTheDocument(); + expect(screen.getByText("为什么在这里使用")).toBeInTheDocument(); + expect(screen.getByText("并排查看代码和 Git 变更")).toBeInTheDocument(); + expect( + screen.getByText("在 Agent 旁边直接查看文件和改动,不用在多个工具之间来回切换。") + ).toBeInTheDocument(); + expect(screen.getByText("在同一工作区运行命令")).toBeInTheDocument(); + expect( + screen.getByText("需要手动操作时,可以直接在集成终端里配合 AI 会话执行命令。") + ).toBeInTheDocument(); + }); + + it("renders the step and support icons", () => { + const store = createStore(); + store.set(localeAtom, "en"); + + render( + + + + + + ); + + const stepCards = Array.from(document.querySelectorAll(".welcome-step-card")); + const supportItems = Array.from(document.querySelectorAll(".welcome-feature")); + + expect( + stepCards.some((card) => card.querySelector('[data-icon-semantic="nav.newWorkspace"]')) + ).toBe(true); + expect( + stepCards.some((card) => card.querySelector('[data-icon-semantic="state.welcome.lightning"]')) ).toBe(true); expect( - featureCards.some((card) => card.querySelector('[data-icon-semantic="state.welcome.git"]')) + supportItems.some((card) => card.querySelector('[data-icon-semantic="state.welcome.git"]')) ).toBe(true); expect( - featureCards.some((card) => + supportItems.some((card) => card.querySelector('[data-icon-semantic="state.welcome.terminal"]') ) ).toBe(true); }); - it("renders the flat welcome shell with hero, actions, and features sections", () => { + it("renders the step-first welcome shell with hero, workflow, and secondary summary", () => { const store = createStore(); store.set(localeAtom, "en"); @@ -137,9 +231,11 @@ describe("WelcomePage", () => { ); expect(container.querySelector(".welcome-card__hero")).toBeTruthy(); - expect(container.querySelector(".welcome-card__actions")).toBeTruthy(); + expect(container.querySelector(".welcome-flow")).toBeTruthy(); + expect(container.querySelector(".welcome-flow__steps")).toBeTruthy(); + expect(container.querySelector(".welcome-flow__support")).toBeTruthy(); expect(container.querySelector(".welcome-card__features")).toBeTruthy(); - expect(container.querySelector(".welcome-actions-group")).toBeTruthy(); + expect(container.querySelector(".welcome-support-list")).toBeTruthy(); expect(container.querySelector(".welcome-card__panel")).toBeFalsy(); }); }); diff --git a/packages/web/src/features/welcome/index.tsx b/packages/web/src/features/welcome/index.tsx index 1e253895..9d1b9ad1 100644 --- a/packages/web/src/features/welcome/index.tsx +++ b/packages/web/src/features/welcome/index.tsx @@ -2,7 +2,7 @@ * Welcome Page Feature * * Landing page shown when no workspace is open. - * Displays product info, "Open Workspace" button, and feature highlights. + * Displays product info, a two-step workflow, and compact supporting context. */ import type { FC } from "react"; @@ -24,10 +24,10 @@ interface FeatureItem { * Welcome Page * * PRD §7.4: - * - Centered card with product info - * - "Open Workspace" button (primary action) - * - "Open Settings" link - * - Three feature highlights at bottom + * - Clear first-run workspace activation flow + * - "Open Workspace" button as the primary action + * - "Settings" as secondary setup help + * - Compact support context below the core steps */ export const WelcomePage: FC = () => { const t = useTranslation(); @@ -35,11 +35,6 @@ export const WelcomePage: FC = () => { const [workspaceLaunchOpen, setWorkspaceLaunchOpen] = useState(false); const isMobile = useViewport() === "mobile"; const features: FeatureItem[] = [ - { - iconSemantic: "state.welcome.lightning", - title: t("welcome.features.agent_first.title"), - description: t("welcome.features.agent_first.description"), - }, { iconSemantic: "state.welcome.git", title: t("welcome.features.git_tools.title"), @@ -62,43 +57,86 @@ export const WelcomePage: FC = () => { return ( <> -
-
-
-
{t("welcome.kicker")}
-

{t("welcome.title")}

-

{t("welcome.description")}

-
- -
-
- - - +
+
+
+
+
{t("welcome.kicker")}
+

{t("welcome.title")}

+

{t("welcome.description")}

+ +
+
+
+ {t("welcome.workflow_title")} +
+
+ +
+
+
+
{t("welcome.step_1_label")}
+
+ +
+
+ +
{t("welcome.step_1_title")}
+

{t("welcome.step_1_detail")}

+ + +
+ +
+
+
{t("welcome.step_2_label")}
+
+ +
+
+ +
{t("welcome.step_2_title")}
+

{t("welcome.step_2_detail")}

+
+
+ +
+

{t("welcome.settings_hint")}

+ + +
+
-
- {features.map((feature) => ( -
- -
-
{feature.title}
-
{feature.description}
+
+
{t("welcome.support_title")}
+
+ {features.map((feature) => ( +
+ +
+
{feature.title}
+
{feature.description}
+
-
- ))} + ))} +
diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 98565247..247e1749 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -343,6 +343,9 @@ "loading_body": "This terminal is unavailable while history is being restored. Please wait until recovery finishes before continuing. Larger histories may take longer to restore.", "failed_title": "Terminal history was not restored yet", "failed_body": "The terminal is still usable, but older output still could not be filled back in. Try again later or after a refresh; if the server still retains the history, it may still come back.", + "reconcile_failed_title": "Terminal recovery check did not complete", + "reconcile_failed_body": "Recovery inspection did not finish this time. The terminal is still usable, but it is temporarily unclear whether older history was filled back in. Try checking again shortly.", + "recheck_action": "Check again", "retryable_title": "Terminal history was not restored yet", "retryable_body": "The terminal is still usable, but older output was not filled back in this time. You can retry recovery; if the server still retains the history, it may still come back later or after a refresh.", "retry_action": "Retry recovery", @@ -353,9 +356,7 @@ "unknown_body_with_provider": "This terminal session is no longer present on the server. Reopen a {provider} session to continue?", "closed_title": "This session has ended", "closed_body": "Reopen a new session to continue?", - "closed_body_with_provider": "Reopen a {provider} session to continue?", - "truncated_title": "Terminal history is partial", - "truncated_body": "Older output has already fallen out of the replay buffer, but new output will continue to stream." + "closed_body_with_provider": "Reopen a {provider} session to continue?" } }, "file": { @@ -987,21 +988,26 @@ "conflict": "Resource conflict" }, "welcome": { - "kicker": "DEPLOY ONCE, CODE EVERYWHERE", - "title": "Welcome to Coder Studio", - "description": "Deploy your coding workspace once, then keep working from wherever you are. Same workspace, same context, across all your devices.", + "kicker": "LOCAL AI CODING WORKSPACE", + "title": "Open a workspace. Start an AI coding session.", + "description": "Choose a local project folder to get started. Once the workspace is open, you can launch Claude Code or Codex right where you review files, Git changes, and terminal output.", + "workflow_title": "How it works", + "step_1_label": "Step 1", + "step_1_title": "Open your project folder", + "step_1_detail": "Choose a local directory to become your active workspace.", + "step_2_label": "Step 2", + "step_2_title": "Start Claude or Codex", + "step_2_detail": "Inside the workspace, launch an AI session for that project.", + "settings_hint": "Need provider setup first?", + "support_title": "Why use it here", "features": { - "agent_first": { - "title": "Agent-first AI coding", - "description": "Launch AI sessions that write, test, and deploy code." - }, "git_tools": { - "title": "Built-in Git tools", - "description": "Stage, commit, and manage branches without leaving the IDE." + "title": "Review code and Git side by side", + "description": "Inspect files and changes next to the agent instead of switching between tools." }, "terminals": { - "title": "Integrated terminals", - "description": "Run commands and scripts alongside your AI sessions." + "title": "Run commands in the same workspace", + "description": "Use integrated terminals alongside your AI session when you need manual control." } } }, diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json index ba830611..5788e700 100644 --- a/packages/web/src/locales/zh.json +++ b/packages/web/src/locales/zh.json @@ -343,6 +343,9 @@ "loading_body": "恢复期间暂时无法使用当前终端;请耐心等待,历史内容恢复完成后再继续。内容较多时可能需要更久。", "failed_title": "终端历史暂未恢复", "failed_body": "当前终端可以继续使用,但较早输出这次仍未补齐。请稍后或刷新页面后再看;如果服务端仍保留历史,后续仍可能找回。", + "reconcile_failed_title": "终端恢复检查未完成", + "reconcile_failed_body": "这次没有完成恢复决策,当前终端仍可继续使用,但较早历史是否补齐暂时无法确认。请稍后重新检查。", + "recheck_action": "重新检查", "retryable_title": "终端历史暂未恢复", "retryable_body": "当前终端可以继续使用,但较早输出这次没有补齐。你可以重试恢复;如果服务端仍保留历史,稍后或刷新页面后仍可能找回。", "retry_action": "重试恢复", @@ -353,9 +356,7 @@ "unknown_body_with_provider": "这个终端会话已经不在服务端。是否重新打开一个 {provider} 会话继续?", "closed_title": "当前会话已结束", "closed_body": "是否重新打开一个新会话继续。", - "closed_body_with_provider": "是否重新打开一个 {provider} 会话继续。", - "truncated_title": "历史内容不完整", - "truncated_body": "较早的终端输出已被回放缓冲区淘汰,后续输出仍会继续显示。" + "closed_body_with_provider": "是否重新打开一个 {provider} 会话继续。" } }, "file": { @@ -987,21 +988,26 @@ "conflict": "资源冲突" }, "welcome": { - "kicker": "一次部署,随处编码", - "title": "欢迎使用 Coder Studio", - "description": "只需部署一次编码工作区,即可在任何地方继续工作。同一个工作区,同一个上下文,跨所有设备。", + "kicker": "本地 AI 编码工作台", + "title": "先打开工作区,再启动 AI 编码会话", + "description": "先选择一个本地项目目录。打开工作区后,你就可以在同一个界面里启动 Claude Code 或 Codex,同时查看文件、Git 变更和终端输出。", + "workflow_title": "使用步骤", + "step_1_label": "第 1 步", + "step_1_title": "打开你的项目目录", + "step_1_detail": "先选择一个本地目录,作为当前工作区。", + "step_2_label": "第 2 步", + "step_2_title": "启动 Claude 或 Codex", + "step_2_detail": "进入工作区后,再为当前项目启动一个 AI 会话。", + "settings_hint": "如果要先配置 Provider,可以先去设置。", + "support_title": "为什么在这里使用", "features": { - "agent_first": { - "title": "以 Agent 为核心的 AI 编码", - "description": "启动能够编写、测试和部署代码的 AI 会话。" - }, "git_tools": { - "title": "内置 Git 工具", - "description": "无需离开 IDE,就能暂存、提交并管理分支。" + "title": "并排查看代码和 Git 变更", + "description": "在 Agent 旁边直接查看文件和改动,不用在多个工具之间来回切换。" }, "terminals": { - "title": "集成终端", - "description": "在 AI 会话旁边直接运行命令和脚本。" + "title": "在同一工作区运行命令", + "description": "需要手动操作时,可以直接在集成终端里配合 AI 会话执行命令。" } } }, diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index 409b1ef9..b1172ea7 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -2629,9 +2629,15 @@ body.is-dragging-pane { padding: var(--sp-10); } +.welcome-container--landing { + height: 100%; + min-height: 0; + overflow-y: auto; +} + .welcome-card { - width: min(100%, 920px); - max-width: 920px; + width: min(100%, 1040px); + max-width: 1040px; display: flex; flex-direction: column; align-items: stretch; @@ -2644,6 +2650,17 @@ body.is-dragging-pane { text-align: left; } +.welcome-card--landing { + gap: var(--sp-5); +} + +.welcome-layout { + display: grid; + grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.1fr); + gap: var(--sp-7); + align-items: start; +} + .welcome-card__hero, .welcome-card__actions, .welcome-card__panel, @@ -2657,6 +2674,11 @@ body.is-dragging-pane { gap: var(--sp-3); } +.welcome-card--landing .welcome-card__hero { + gap: var(--sp-4); + padding-right: 0; +} + .welcome-card__actions, .welcome-card__panel, .welcome-card__features { @@ -2666,11 +2688,119 @@ body.is-dragging-pane { .welcome-actions-group { display: flex; - align-items: center; + align-items: flex-start; gap: var(--sp-3); flex-wrap: wrap; } +.welcome-step-hint, +.welcome-step-detail, +.welcome-settings-hint { + width: 100%; + margin: 0; +} + +.welcome-step-hint { + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-ter); +} + +.welcome-flow { + display: flex; + flex-direction: column; + gap: var(--sp-4); + min-width: 0; +} + +.welcome-flow__header { + display: flex; + align-items: center; + justify-content: flex-start; +} + +.welcome-flow__steps { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sp-3); +} + +.welcome-flow__support { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + border: 1px solid var(--component-mix-border-default-84pct-transparent); + border-radius: var(--radius-lg); + background: var(--component-mix-surface-page-96pct-surface-panel-4pct); +} + +.welcome-step-card { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-4); + border: 1px solid var(--component-mix-border-default-84pct-transparent); + border-radius: var(--radius-lg); + background: var(--component-mix-surface-panel-92pct-surface-page); +} + +.welcome-step-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); +} + +.welcome-step-card__label { + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.welcome-step-card__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-md); + color: var(--text-secondary); + background: var(--component-mix-surface-page-94pct-surface-panel-6pct); +} + +.welcome-step-card__title { + font-size: var(--type-heading-5-size); + line-height: var(--type-heading-5-line-height); + font-weight: var(--type-heading-5-weight); + color: var(--text-primary); + min-height: calc(var(--type-heading-5-line-height) * 2); +} + +.welcome-step-detail { + max-width: 34ch; + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); + color: var(--text-secondary); + flex: 1; +} + +.welcome-settings-hint { + max-width: 32ch; + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); + color: var(--text-tertiary); +} + .welcome-kicker { font-size: var(--type-body-6-size); line-height: var(--type-body-6-line-height); @@ -2681,19 +2811,20 @@ body.is-dragging-pane { } .welcome-title { - font-size: var(--type-heading-1-size); - line-height: var(--type-heading-1-line-height); - font-weight: var(--type-heading-1-weight); + font-size: var(--type-heading-3-size); + line-height: var(--type-heading-3-line-height); + font-weight: var(--type-heading-3-weight); margin: 0; color: var(--text); + max-width: 12ch; } .welcome-body { - font-size: var(--type-body-3-size); - line-height: var(--type-body-3-line-height); - font-weight: var(--type-body-3-weight); + font-size: var(--type-body-4-size); + line-height: var(--type-body-4-line-height); + font-weight: var(--type-body-4-weight); color: var(--text-secondary); - max-width: 440px; + max-width: 38ch; margin: 0; } @@ -2967,16 +3098,37 @@ body.is-dragging-pane { width: 100%; } +.welcome-support { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.welcome-support__title { + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.welcome-support-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sp-2); +} + .welcome-feature { display: flex; align-items: flex-start; gap: var(--sp-3); - padding: var(--sp-4); + padding: var(--sp-3); border-radius: var(--radius-lg); background: transparent; min-height: 0; justify-content: flex-start; - flex-direction: column; + flex-direction: row; border: 1px solid var(--component-mix-border-default-84pct-transparent); box-shadow: none; text-align: left; @@ -2995,9 +3147,9 @@ body.is-dragging-pane { } .welcome-feature-title { - font-size: var(--type-body-3-size); - line-height: var(--type-body-3-line-height); - font-weight: var(--type-body-3-weight); + font-size: var(--type-body-4-size); + line-height: var(--type-body-4-line-height); + font-weight: var(--type-body-4-weight); color: var(--text-primary); } @@ -11877,6 +12029,7 @@ body.is-dragging-pane .session-action-btn-drag { align-items: stretch; justify-content: flex-start; min-height: 100dvh; + overflow-y: auto; padding: calc(env(safe-area-inset-top, 0px) + var(--sp-4)) calc(env(safe-area-inset-right, 0px) + var(--sp-4)) calc(env(safe-area-inset-bottom, 0px) + var(--sp-5)) @@ -11886,12 +12039,22 @@ body.is-dragging-pane .session-action-btn-drag { .welcome-card--mobile { width: 100%; max-width: none; - padding: var(--sp-6) var(--sp-4); - gap: var(--sp-5); + padding: var(--sp-4); + gap: var(--sp-3); border-radius: var(--radius-lg); box-shadow: none; } + .welcome-card--mobile.welcome-card--landing { + min-height: auto; + } + + .welcome-card--mobile .welcome-layout { + display: flex; + flex-direction: column; + gap: var(--sp-3); + } + .welcome-card--mobile .welcome-card__actions { align-items: stretch; } @@ -11902,31 +12065,58 @@ body.is-dragging-pane .session-action-btn-drag { } .welcome-card--mobile .welcome-title { - font-size: var(--type-heading-1-size); - line-height: var(--type-heading-1-line-height); - font-weight: var(--type-heading-1-weight); + font-size: var(--type-heading-3-size); + line-height: var(--type-heading-3-line-height); + font-weight: var(--type-heading-3-weight); letter-spacing: 0; + max-width: 10ch; } .welcome-card--mobile .welcome-body { max-width: none; - font-size: var(--type-body-3-size); - line-height: var(--type-body-3-line-height); - font-weight: var(--type-body-3-weight); + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); + } + + .welcome-card--mobile .welcome-step-detail, + .welcome-card--mobile .welcome-settings-hint { + max-width: none; + } + + .welcome-card--mobile .welcome-flow { + gap: var(--sp-3); + } + + .welcome-card--mobile .welcome-flow__steps { + grid-template-columns: 1fr; + gap: var(--sp-2); + } + + .welcome-card--mobile .welcome-flow__support { + flex-direction: column; + align-items: stretch; + padding: var(--sp-3); + } + + .welcome-card--mobile .welcome-step-card { + padding: var(--sp-3); + gap: var(--sp-2); } .welcome-card--mobile.diagnostics-card { width: 100%; } - .welcome-card--mobile .welcome-features { + .welcome-card--mobile .welcome-support-list { grid-template-columns: 1fr; + gap: var(--sp-2); } .welcome-card--mobile .welcome-feature { align-items: flex-start; min-height: 0; - padding: var(--sp-4); + padding: var(--sp-3); border-radius: var(--radius-lg); text-align: left; } @@ -11938,12 +12128,14 @@ body.is-dragging-pane .session-action-btn-drag { .welcome-card--mobile .welcome-btn { width: 100%; justify-content: center; - padding-inline: var(--sp-5); + min-width: 0; + padding-inline: var(--sp-4); border-radius: var(--radius-md); } .welcome-card--mobile .welcome-link { width: 100%; + justify-content: center; } .auth-screen--mobile { @@ -14420,7 +14612,18 @@ body.is-dragging-pane .session-action-btn-drag { .session-card.session-card--active { background: var(--workspace-session-active-surface); - box-shadow: inset 0 0 0 1px var(--component-mix-border-focus-84pct-transparent); + box-shadow: none; + position: relative; + isolation: isolate; +} + +.workspace-main-stage .session-card.session-card--active::after { + content: ""; + position: absolute; + inset: 0; + z-index: var(--z-inline); + border: 1px solid var(--component-mix-border-focus-84pct-transparent); + pointer-events: none; } .session-header, @@ -14597,9 +14800,14 @@ body.is-dragging-pane .session-action-btn-drag { .mobile-shell__agent-stage .session-card.session-card--active { background: var(--workspace-session-mobile-active-surface); + box-shadow: none; backdrop-filter: var(--material-backdrop-filter); } +.mobile-shell__agent-stage > .session-card.session-card--active > .panel-header { + border-top: 1px solid var(--component-mix-border-focus-76pct-transparent); +} + @media (max-width: 899px), (pointer: coarse) { .mobile-shell { --mobile-safe-top: env(safe-area-inset-top, 0px); diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts index 44bf9bcb..efbbb1d0 100644 --- a/packages/web/src/styles/components.theme.test.ts +++ b/packages/web/src/styles/components.theme.test.ts @@ -1028,6 +1028,9 @@ describe("components.css theme-sensitive surfaces", () => { const sessionTerminal = getLastRuleBlock(".session-terminal"); const sessionCard = getLastRuleBlock(".session-card"); const activeSessionCard = getLastRuleBlock(".session-card.session-card--active"); + const activeSessionCardOverlay = getLastRuleBlock( + ".workspace-main-stage .session-card.session-card--active::after" + ); const activeSessionHeader = getRuleBlocksFrom( stylesheet, ".session-card.session-card--active > .panel-header" @@ -1147,9 +1150,16 @@ describe("components.css theme-sensitive surfaces", () => { expect(sessionCard).toContain("backdrop-filter: var(--material-backdrop-filter)"); expect(activeSessionCard).toContain("background: var(--workspace-session-active-surface)"); expect(activeSessionCard).not.toContain("background: var(--bg-active)"); - expect(activeSessionCard).toContain("box-shadow: inset 0 0 0 1px"); - expect(activeSessionCard).toContain("var(--component-mix-border-focus-84pct-transparent)"); + expect(activeSessionCard).toContain("box-shadow: none"); + expect(activeSessionCardOverlay).toContain('content: ""'); + expect(activeSessionCardOverlay).toContain("position: absolute"); + expect(activeSessionCardOverlay).toContain("inset: 0"); + expect(activeSessionCardOverlay).toContain( + "border: 1px solid var(--component-mix-border-focus-84pct-transparent)" + ); + expect(activeSessionCardOverlay).toContain("pointer-events: none"); expect(activeSessionHeader).toContain("background: var(--workspace-session-header-surface)"); + expect(activeSessionHeader).not.toContain("border-top:"); expect(activeSessionHeader).toContain("backdrop-filter: var(--material-backdrop-filter)"); expect(activeSessionHeader).not.toContain("var(--bg-active) 88%"); expect(activeSessionTitle).toContain("color: var(--text-primary)"); @@ -1264,6 +1274,33 @@ describe("components.css theme-sensitive surfaces", () => { expect(authCard).not.toContain("rgba(13, 20, 26, 0.94)"); }); + it("styles the welcome page as a step-first workflow with compact support content", () => { + const welcomeLayout = getLastRuleBlock(".welcome-layout"); + const welcomeFlow = getLastRuleBlock(".welcome-flow"); + const welcomeSteps = getLastRuleBlock(".welcome-flow__steps"); + const welcomeFlowSupport = getLastRuleBlock(".welcome-flow__support"); + const stepCard = getLastRuleBlock(".welcome-step-card"); + const supportList = getLastRuleBlock(".welcome-support-list"); + const stepHint = getLastRuleBlock(".welcome-step-hint"); + const stepDetail = getLastRuleBlock(".welcome-step-detail"); + const settingsHint = getLastRuleBlock(".welcome-settings-hint"); + + expect(welcomeLayout).toContain("display: grid"); + expect(welcomeLayout).toContain("grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.1fr)"); + expect(welcomeFlow).toContain("flex-direction: column"); + expect(welcomeSteps).toContain("display: grid"); + expect(welcomeSteps).toContain("grid-template-columns: repeat(2, minmax(0, 1fr))"); + expect(welcomeFlowSupport).toContain("display: flex"); + expect(stepCard).toContain("border-radius: var(--radius-lg)"); + expect(stepCard).toContain("background: var(--component-mix-surface-panel-92pct-surface-page)"); + expect(supportList).toContain("grid-template-columns: repeat(2, minmax(0, 1fr))"); + expect(stepHint).toContain("text-transform: uppercase"); + expect(stepHint).toContain("color: var(--text-ter)"); + expect(stepDetail).toContain("max-width: 34ch"); + expect(stepDetail).toContain("color: var(--text-secondary)"); + expect(settingsHint).toContain("color: var(--text-tertiary)"); + }); + it("keeps quick actions sized to its label instead of icon-button width", () => { const quickActions = getLastRuleBlock(".topbar-quick-actions"); @@ -1511,6 +1548,13 @@ describe("components.css theme-sensitive surfaces", () => { const xtermReplayCard = getLastRuleBlock(".xterm-replay-overlay__card"); const sessionProgress = getLastRuleBlock(".session-progress"); const sessionHeader = getLastRuleBlock(".session-header"); + const activeSessionCard = getLastRuleBlock(".session-card.session-card--active"); + const activeSessionCardOverlay = getLastRuleBlock( + ".workspace-main-stage .session-card.session-card--active::after" + ); + const activeSessionHeader = getLastRuleBlock( + ".session-card.session-card--active > .panel-header" + ); const supervisorCard = getLastRuleBlock(".supervisor-card"); const sessionHeaderLeft = getLastRuleBlock(".session-header-left"); const sessionHeaderCopyBlocks = getRuleBlocksFrom(stylesheet, ".session-header-copy"); @@ -1536,6 +1580,11 @@ describe("components.css theme-sensitive surfaces", () => { expect(xtermReplayCard).toContain("border-radius: var(--terminal-local-overlay-radius)"); expect(sessionProgress).toContain("background: var(--state-info-bg)"); expect(sessionHeader).toContain("padding: var(--gap-tight) var(--inset-control-inline)"); + expect(activeSessionCard).toContain("box-shadow: none"); + expect(activeSessionCardOverlay).toContain( + "border: 1px solid var(--component-mix-border-focus-84pct-transparent)" + ); + expect(activeSessionHeader).not.toContain("border-top:"); expect(supervisorCard).toContain("background: var(--workspace-session-header-surface)"); expect(supervisorCard).toContain("backdrop-filter: var(--material-backdrop-filter)"); expect(sessionHeaderLeft).toContain("gap: var(--gap-default)"); @@ -2613,11 +2662,21 @@ describe("components.css theme-sensitive surfaces", () => { it("stacks mobile welcome and auth shells vertically so cards size to content", () => { const welcomeContainer = getLastRuleBlock(".welcome-container--mobile"); + const mobileWelcomeLayout = getLastRuleBlock(".welcome-card--mobile .welcome-layout"); + const mobileWelcomeSteps = getLastRuleBlock(".welcome-card--mobile .welcome-flow__steps"); + const mobileWelcomeSupport = getLastRuleBlock(".welcome-card--mobile .welcome-flow__support"); + const mobileSupportList = getLastRuleBlock(".welcome-card--mobile .welcome-support-list"); const authScreen = getLastRuleBlock(".auth-screen--mobile"); expect(welcomeContainer).toContain("flex-direction: column"); expect(welcomeContainer).toContain("align-items: stretch"); expect(welcomeContainer).toContain("justify-content: flex-start"); + expect(welcomeContainer).toContain("overflow-y: auto"); + expect(mobileWelcomeLayout).toContain("flex-direction: column"); + expect(mobileWelcomeSteps).toContain("grid-template-columns: 1fr"); + expect(mobileWelcomeSupport).toContain("flex-direction: column"); + expect(mobileWelcomeSupport).toContain("align-items: stretch"); + expect(mobileSupportList).toContain("grid-template-columns: 1fr"); expect(authScreen).toContain("padding:"); }); @@ -3196,8 +3255,14 @@ describe("components.css theme-sensitive surfaces", () => { ".mobile-shell--landscape-compact .mobile-shell__viewport" ); const sessionCard = getLastRuleBlock(".mobile-shell__agent-stage > .session-card"); + const activeSessionCard = getLastRuleBlock( + ".mobile-shell__agent-stage .session-card.session-card--active" + ); const progress = getLastRuleBlock(".mobile-shell__agent-stage .session-progress"); const header = getLastRuleBlock(".mobile-shell__agent-stage > .session-card > .panel-header"); + const activeHeader = getLastRuleBlock( + ".mobile-shell__agent-stage > .session-card.session-card--active > .panel-header" + ); const titleRow = getLastRuleBlock(".mobile-shell__agent-stage .session-title-row"); const headerRight = getLastRuleBlock(".mobile-shell__agent-stage .session-header-right"); const badges = getLastGroupedRuleBlock( @@ -3215,9 +3280,11 @@ describe("components.css theme-sensitive surfaces", () => { expect(content).toContain("gap: 4px"); expect(sessionCard).toContain("border-radius: 0"); expect(sessionCard).toContain("box-shadow: none"); + expect(activeSessionCard).toContain("box-shadow: none"); expect(progress).toContain("display: none"); expect(header).toContain("padding: 4px"); expect(header).toContain("border-bottom:"); + expect(activeHeader).toContain("border-top:"); expect(header).not.toContain("linear-gradient("); expect(titleRow).toContain("gap: 6px"); expect(headerRight).toContain("max-width: 100%");