diff --git a/.gitignore b/.gitignore index b9365a3c40..6fbcc5ba86 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ crates/docs_preprocessor/actions.json /december-2025-releases.md /docs/december-2025-documentation-gaps.md site/ + +# AI Dock smoke-test screenshots (verification evidence — local only) +aidock-*.png diff --git a/Cargo.lock b/Cargo.lock index 232fbd3078..8633173166 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12866,6 +12866,7 @@ dependencies = [ "outline", "outline_panel", "paddleboard_actions", + "paddleboard_ai_dock", "paddleboard_env_vars", "paddleboard_sandbox_prereqs", "paddleboard_sandbox_prereqs_ui", @@ -12953,6 +12954,31 @@ dependencies = [ "uuid", ] +[[package]] +name = "paddleboard_ai_dock" +version = "0.1.0" +dependencies = [ + "agent_settings", + "agent_ui", + "anyhow", + "client", + "collections", + "fs", + "gpui", + "language", + "log", + "menu", + "paddleboard_actions", + "project", + "serde", + "serde_json", + "settings", + "ui", + "util", + "which 6.0.3", + "workspace", +] + [[package]] name = "paddleboard_credentials_provider" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 542c727352..d901b0cdef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -236,6 +236,7 @@ members = [ "crates/paddleboard_sandbox_prereqs_state", "crates/paddleboard_sandbox_prereqs_ui", "crates/paddleboard_sandbox_settings", + "crates/paddleboard_ai_dock", "crates/windows_resources", "crates/zeta_prompt", "crates/zlog", @@ -501,6 +502,7 @@ paddleboard_sandbox_prereqs = { path = "crates/paddleboard_sandbox_prereqs" } paddleboard_sandbox_prereqs_state = { path = "crates/paddleboard_sandbox_prereqs_state" } paddleboard_sandbox_prereqs_ui = { path = "crates/paddleboard_sandbox_prereqs_ui" } paddleboard_sandbox_settings = { path = "crates/paddleboard_sandbox_settings" } +paddleboard_ai_dock = { path = "crates/paddleboard_ai_dock" } edit_prediction = { path = "crates/edit_prediction" } edit_prediction_metrics = { path = "crates/edit_prediction_metrics" } zeta_prompt = { path = "crates/zeta_prompt" } diff --git a/RECAPS.md b/RECAPS.md index 74d4111a92..22985e9079 100644 --- a/RECAPS.md +++ b/RECAPS.md @@ -4,8 +4,90 @@ Running log of completed work sessions, newest first. Each entry summarizes a co --- +## 2026-05-22 + +### AI Dock — smoke test (followup #1) +- Verified yesterday's AI Dock PR (`3d22299897`) end-to-end in the running app. Built a fresh debug binary (prior `target/debug/paddleboard` was timestamped May 21 17:50, ~30 minutes *before* the commit), launched it, drove it via the command palette with user-captured screenshots (`aidock-1.png` … `aidock-5.png`). All six claims from the smoke-test plan passed: Welcome button replaces the 5-card grid, modal opens on Agents with header counts matching the catalog (5 / 6 / 5), tab switcher works, filesystem detection correctly badges `/build` + `/update-tour` as **Installed (Project)**, absorbed `McpServersView` renders cleanly inside the 36rem modal body with no clipping, legacy `zed::McpServers` action routes to the MCP tab. +- **Screen Recording perms were blocked again.** Same blocker the previous session hit, with an added detail: parent terminal is **iTerm2**, not Terminal.app — granting iTerm2 the perm would have killed this Claude session on restart. Fell back to user-captured screenshots, which gave full visual evidence without the restart problem. Worth memorializing as the default verification path for any GUI work going forward. +- **Findings worth following up on (not blockers, deferred):** + - **MCP tab ignores the catalog.** Header subtitle says "5 MCP servers" but the MCP tab itself just embeds `McpServersView` (installed-only) and shows "No MCP servers installed yet." The 5 catalog entries (`filesystem`, `fetch`, `git`, `github`, `puppeteer`) are never rendered. The three tabs have asymmetric behavior: browse-then-install for Agents/Skills, installed-only for MCP. Either drop the MCP count from the header, render the catalog above the installed list, or pre-populate "+ Add Server" with the catalog. Adds to the AI Dock open-followup list. + - **`/simplify` is a phantom catalog entry.** Listed in `assets/ai_dock/catalog.json` with a description that looks copy-pasted from `/review` ("Review changed code for reuse…"). No corresponding skill exists in the harness's known skill list. Catalog accuracy issue; either drop or write the actual skill before the Skills tab becomes a real discovery surface. + - **Cosmetic, not acted on**: in `aidock-2.png` the welcome screen text is visible *below* the modal — scrim doesn't extend full-height, or modal floats inside the workspace pane rather than the window. + - **Terminology asymmetry**: Agents tab uses "Installed" badge + "Install" button; Skills tab uses "Installed (Project)" / "Not installed" badge only. Picking one pattern would help. + +### Session commits + `.gitignore` for smoke-test artifacts +- Today's work shipped as **two commits**, not one, so the workspace-wide cosmetic rename stays orthogonal to the AI Dock feature work — either can be reverted without touching the other: + - `9669d4b587 command_palette: rename `zed:` to `paddleboard:` in palette display` (1 file, +17) + - `a8fd95efe4 paddleboard_ai_dock: install bundled skills, add Welcome featured strip, drop /simplify` (8 files, +278/-34) +- The smoke-test artifacts (`aidock-1.png` … `aidock-7.png`) were *not* committed. Instead `.gitignore` got a new entry `aidock-*.png` so the screenshots stay locally as verification evidence but `git status` stops listing them. Pattern is reusable for any future per-feature smoke runs. +- Followup #1 (the AI Dock smoke test) intentionally had **no code change** — it surfaced findings (MCP catalog gap, `/simplify` phantom, terminology asymmetry) that fed into followup #2, but produced no diff of its own. RECAPS captures the findings; nothing to commit for #1 in isolation. + +### AI Dock — Welcome Featured strip (followup #3) + docs/tour sync +- Added a small **Featured** strip to the Welcome screen's AI Dock section: four compact outlined pills labeled `Claude` / `Codex` / `Copilot` / `Cursor`, rendered below the existing full-width "Open the AI Dock" button via a new `render_welcome_featured_strip()` helper in `crates/onboarding/src/basics_page.rs`. Each pill dispatches the same `paddleboard_actions::ai_dock::Open` action — they're editorial discoverability, not separate destinations. A future polish could pre-scroll the dock to the matching agent card; out of scope for v1 (would need a new action variant carrying the target id). +- **Why a parallel constant instead of upgrading `FEATURED_AGENT_IDS`**: the existing `FEATURED_AGENT_IDS: &[&str]` is referenced by `onboarding.rs:245` for telemetry (`.iter().filter().copied()`) and a tuple-ification would have required updating that call site too. Adding `WELCOME_FEATURED_AGENT_LABELS: &[(&str, &str)]` alongside it keeps the upstream-shaped telemetry path zero-touch. The two arrays must stay in id-sync; a future test could enforce that, but four entries felt below the threshold for now. +- **Telemetry**: each pill fires `Welcome Featured Agent Clicked` with the agent id, distinct from the existing `Welcome Open AI Dock Clicked`. Lets us measure whether the strip drives more dock opens than the main button alone. +- **Docs sync (per [[feedback-update-welcome-md]])**: edited `WELCOME.md` to (a) mention the Featured strip in the AI Dock bullet, (b) describe the new Skills install buttons (`Add to project` / `Add to user`) from followup #2 — that text was missing because the doc was last updated when Skills were detection-only, and (c) replace two stale `zed: Mcp Servers` palette references with `paddleboard: Mcp Servers` to match the namespace display-rename earlier in the session. +- **Tour sync (`crates/workspace/src/tour.md`)**: ran `/update-tour`. Two edits — section 4 (Sandboxed MCP Servers) now reads `paddleboard: Mcp Servers`; section 5 (AI Dock) gained a sentence about the Featured strip and a sentence about the bundled-skill install buttons. Section count and emoji budget unchanged; AI Dock section stayed inside the 4–7-line target (now 6 lines). +- **Sticky-file caveat** carried over: `workspace.rs:785` / `paddleboard/src/main.rs:1494` only write `PaddleBoard_Tour.md` when it doesn't exist. Existing users won't see the updated tour until they delete the file or the write-gate is changed to overwrite-on-update. Fresh installs pick it up. Worth fixing eventually so docs actually reach users, but it's the same trap noted in prior sessions — not new debt from this change. +- **Verified**: `cargo check -p onboarding` clean, `./script/clippy -p onboarding` (release, all targets, deny warnings) clean. No new tests — the strip is rendering-only with a static label list, and the click handler reuses an action that's already tested by the main button. + +### AI Dock — Skills tab install path (followup #2) +- Bundled markdown content for the two PaddleBoard-owned slash commands (`/build`, `/update-tour`) so the Skills tab can actually install them, not just link out. **No content duplication** — `crates/paddleboard_ai_dock/src/catalog.rs::bundled_skill_content` uses `include_str!` against `.claude/commands/.md` directly, so the slash command this repo uses and the bundled install copy are physically the same bytes. Renaming `.claude/commands/build.md` would break the `include_str!` at compile time, which is the right failure mode. +- **What renders now**: in `skills_tab.rs::render_skill_row`, when a skill is *not installed* AND bundled content exists, the row shows two outlined buttons — `Add to project` and `Add to user` — side by side. Click writes the file via `fs::create_dir_all` + `fs::write`, then `cx.notify()` triggers re-detection so the badge flips to `Installed (Project|User)`. Sync IO inside the click handler is intentional (a few KB markdown write completes in microseconds; a `cx.background_spawn` would have required threading a `WeakEntity` back to update state, more code for no real win). +- **Tightened `project_skills_dir`**: previously fell back to `std::env::current_dir()`, which is the launch directory, not the active workspace. Now reads `workspace.project().visible_worktrees(cx).next()` and joins `.claude/commands/`, with the CWD path only as a last-resort fallback for the no-workspace case. Detection AND install both honor this — picking the wrong project root for a write would have been a silent data hazard. +- **Per-scope button disabling**: if a scope's target dir can't be resolved (no workspace open → no project dir; no `$HOME` → no user dir), the corresponding button greys out instead of disappearing, so the user can see what *would* be possible. +- **Errors** route through `Workspace::show_error` (the same path other workspace errors use). Falls back to `log::error!` if the workspace entity is gone by the time the click fires. +- **Catalog cleanup**: dropped the phantom `/simplify` entry. Description was copy-pasted from `/review` and there's no real `/simplify` skill anywhere in the harness. Catalog drops 6 skills → 5; header subtitle in the dock auto-updates because counts are derived. Also fixed `$schema_note` in `catalog.json` that still referenced the old "Store" naming and the deleted `paddleboard_store/src/catalog.rs` path. +- **Tests added** to `catalog::tests`: `bundled_skill_content_returns_known_skills` (asserts the bundled markdown loads and contains the expected slash-command markers), `bundled_skill_content_returns_none_for_unbundled` (locks in the closed-list shape), and `every_bundled_id_is_in_catalog` — a structural test that would catch someone bundling a skill whose catalog entry was deleted, which would leave install buttons that never render. +- **Verified**: `cargo check -p paddleboard_ai_dock`, `./script/clippy -p paddleboard_ai_dock` (release, all targets, deny warnings), `cargo test -p paddleboard_ai_dock` (3/3 pass), `cargo build -p paddleboard`. No UI smoke test yet — in this repo both bundled skills are already installed, so the install buttons don't appear without a temporary `mv .claude/commands/build.md /tmp/`. User chose to confirm by header count vs. doing the install-flow dance. +- **Open followups**: `/verify`, `/review`, `/security-review` are still in the catalog without bundled content — they render `Not installed` and there's no install path. These are claude-code harness-bundled skills, so duplicating them in PB might shadow rather than help. Decide whether to drop them from the catalog (harness owns discovery) or actually ship our own copies. Also: the no-workspace fallback to `std::env::current_dir()` is rarely correct; a future polish might just disable "Add to project" entirely when there's no workspace, since "the launch dir" is a weak signal for "what project do I mean." + +### Command palette: `zed:` → `paddleboard:` (display-only rename) +- User noted that despite the AI Dock rename, the command palette still shows lots of `zed: ...` entries. Renamed at the display layer rather than at the action declaration site. +- **What changed**: `crates/command_palette/src/command_palette.rs::humanize_action_name` now strips a leading `zed::` and replaces it with `paddleboard::` before applying the existing humanization. Propagates to every caller — command palette, `which_key`, `keymap_editor` (3 sites), and the `paddleboard` binary's menu builder. +- **What was deliberately *not* touched**: action declarations and keymap files. `Action::name()` still returns `zed::OpenOnboarding`, so all 23 `"zed::*"` references in `assets/keymaps/default-macos.json` keep resolving without edits. This avoided rewriting 17 `#[action(namespace = zed)]` declarations across `paddleboard_actions/src/lib.rs`, `onboarding/src/onboarding.rs`, `gpui/src/action.rs`, and `component_preview/`, plus the keymap references — a maintenance trap that would have reappeared on every upstream merge. +- **Tagged with `// PaddleBoard:`** divergence comment, and a regression-locking test (`humanize_action_name("zed::OpenOnboarding") == "paddleboard: open onboarding"`). `./script/clippy -p command_palette` clean; the existing 3 humanize assertions plus the new one all pass; debug `paddleboard` binary rebuilt clean. +- **Tradeoff to remember**: cosmetic-only. Users who type `zed` in the palette no longer get matches — fine for the new branding intent, but if anyone has muscle memory or external docs referencing `zed: ...` palette commands, they'll need to learn `paddleboard: ...` instead. The actual action `name()` is unchanged, so docs that reference action names (not palette labels) keep working. + +--- + +## 2026-05-21 + +### AI Dock — built, McpServersPage absorbed +- Promoted yesterday's "Store" sketch into a shipping feature. **Renamed `Store` → `AI Dock` mid-session** at the user's request (no one's buying anything; "Dock" plays off the PaddleBoard theme). The rename was total: crate `paddleboard_store` → `paddleboard_ai_dock`, action `paddleboard_actions::store::OpenStore` → `paddleboard_actions::ai_dock::Open`, types `StoreModal`/`StoreTab` → `AiDock`/`AiDockTab`, catalog path `assets/store/catalog.json` → `assets/ai_dock/catalog.json`, plus every UI string. Nothing was committed under the old name, so it's clean. +- New crate `paddleboard_ai_dock` with an `AiDock` modal that ships three tabs (Agents / Skills / MCP Servers), backed by a static in-repo catalog (5 agents, 6 skills, 5 MCP servers — adds are PRs, not fetches). +- **Two open questions from yesterday locked in:** + - Panel home → **modal** (transient browse-then-leave; doesn't eat persistent dock space). Note: it's called "AI Dock" semantically but renders as a `ModalView`. The name is a paddleboard metaphor, not the GPUI dock concept. + - `McpServersPage` → **absorbed**, not linked. Pragmatic absorb: renamed `McpServersPage` → `McpServersView` in `crates/agent_ui/src/mcp_servers_ui.rs`, dropped the `Item`/`EventEmitter` impls + tab metadata, kept `Render`+`Focusable`, made it `pub` via re-export. The AI Dock's MCP tab hosts an `Entity` constructed lazily on first activation, so the existing `crate::agent_configuration::*` modal wiring keeps working without lifting it into the new crate. +- **Action plumbing**: kept the legacy `paddleboard_actions::McpServers` working — `paddleboard_ai_dock::init` now owns that handler and opens the dock on the MCP tab. Removed the old `agent_ui.rs:575-593` pane-item handler entirely (replaced with a PaddleBoard divergence comment). Added one new action, `paddleboard_actions::ai_dock::Open`, which lands on the Agents tab. +- **Welcome row swap**: `crates/onboarding/src/basics_page.rs::render_ai_section` no longer renders the 5-card grid; it's a single **Open the AI Dock** button (with the `ArrowUpRight` end-icon) dispatching the new action. `FEATURED_AGENT_IDS` stayed because telemetry in `onboarding.rs:245` still counts it, but the helpers (`render_zed_agent_button`, `render_registry_agent_button`) and ~6 now-unused imports were deleted. +- **Detection per tab** (no generic strategy yet — each tab does what fits): + - Agents → cross-reference `project::agent_server_store::AllAgentServersSettings` from `SettingsStore`. Install button writes `settings::CustomAgentServerSettings::Registry { … }` via `update_settings_file` — same shape as the old onboarding card. + - Skills → `cwd/.claude/commands/.md` (project) and `~/.claude/commands/.md` (user); shows scope label. No bundled content yet, so "not installed" routes to homepage when the catalog entry has one. + - MCP → defers entirely to the absorbed `McpServersView`. +- **Docs + tour**: added a new "AI Dock" section to `WELCOME.md` (between Sandboxed MCP Servers and Step-Through Mode), updated the MCP section to mention the new entry point, and synced `crates/workspace/src/tour.md` with the same section as `### 5. AI Dock`. Renumbered downstream tour sections (6–10). +- **Verification**: `./script/clippy` (release, all targets, deny warnings) clean. `cargo check -p paddleboard` clean after the rename. **No UI smoke test yet** — the binary builds but the modal hasn't been clicked through in the running app; that's the next step for the user (or a `/verify` invocation). +- **Yesterday's `script/bundle-mac` side note still stands** — debug builds exit 1 on the unguarded `gzip target/.../remote_server`. Untouched this session. + +--- + ## 2026-05-20 +### PaddleBoard Store panel — design sketched, implementation deferred +- User reviewed `paddleboard-5.png` (the hardcoded "Agent Setup" 5-card row on the Welcome screen — Zed Agent / Claude Agent / Codex CLI / GitHub Copilot / Cursor) and proposed replacing it with a unified "agent store" or "skills store." +- Agreed framing: one panel, three tabs (**Agents / Skills / MCP Servers**), backed by a static in-repo catalog. Solves the discoverability gap that the MCP orchestrator already hit (it's buried in the command palette), and consolidates three surfaces that drift apart today (agents = long-lived processes, skills = markdown files, MCP = subprocess+config). +- **Sketched in conversation, not yet built.** Captured in `project_store_idea.md` memory. Key choices already made: + - New crate `paddleboard_store` (fork hygiene — net-new feature, no upstream-file edits). + - Catalog at `assets/store/catalog.json` in-repo. PR-reviewed adds, not fetched. Can graduate to a hosted catalog later if community contributions outgrow PR review. + - Per-tab action verbs diverge intentionally because "install" means different things: Agents → Install/Sign in/Open/Configure; Skills → Add to user/Add to project/Remove; MCP → Add Server (delegate to existing `McpServersPage`). + - `enum StoreItem { Agent(AgentManifest), Skill(SkillManifest), Mcp(McpManifest) }` with per-item `detect: DetectStrategy` (`BinaryOnPath` / `FileExists`) driving the Installed/Available badge. + - Welcome screen replaces the 5-card row with a single "Browse the Store →" button plus a small Featured strip for first-run. +- **Three open questions left for user to decide** before any code: + 1. Panel home: modal vs. dock panel vs. top-level workspace item. Claude leaned modal (discrete interactions, not "always docked"). + 2. Catalog source: in-repo JSON to start; revisit fetched later if needed. + 3. `McpServersPage`: absorb into the Store's MCP tab, or stay separate and just be linked? Absorb = cleaner but real refactor; link = cheaper but two surfaces. +- **Side observation, not acted on:** `./script/bundle-mac -d -o -i` (the `/build bundle install` path) exits with code 1 on debug builds because the script tries to `gzip target/.../release/remote_server` after the install step — a release-only operation that runs unconditionally. The install + open both succeed before the failure, so it's cosmetic, but a real fix would be a one-liner guarding the gzip with the same `if [ -z "$DEBUG_BUILD" ]` check used elsewhere in the script. Not done; flagging for whoever next touches `script/bundle-mac`. + ### `/build bundle` — debug `.app` with the paddle icon - Triggered by user observation that `cargo build -p paddleboard` produces a binary that macOS shows in the dock as a generic "exec" entry with no logo. Root cause: the raw `target/debug/paddleboard` binary has no `.app` wrapper, so there's no `Info.plist` / `CFBundleName` / `CFBundleIconFile` / `AppIcon.icns` for macOS to read. - Extended `.claude/commands/build.md` (the `/build` slash command) with three new arguments: diff --git a/WELCOME.md b/WELCOME.md index 07f27059d5..d277745338 100644 --- a/WELCOME.md +++ b/WELCOME.md @@ -78,7 +78,7 @@ Most editors run **MCP (Model Context Protocol) servers** directly on your host. PaddleBoard adds a fourth context-server transport, `sandboxed_stdio`, that runs the MCP server inside a `podman run -i --rm --runtime=runsc` container. Stdin and stdout are proxied transparently, so the JSON-RPC framing keeps working without any change on the agent side. -**The MCP Servers settings page** (command palette → `zed: Mcp Servers`) gives you a UI for adding, filtering (All / Running / Stopped / Error), and inspecting servers without hand-editing JSON. Use the "Add Server" popover to declare a new server; the page surfaces live status as the connection comes up. +**Manage servers in the AI Dock** — `paddleboard: Mcp Servers` (or `ai_dock: Open` then the **MCP Servers** tab) opens the PaddleBoard AI Dock with the absorbed server view. You get the full add/filter (All / Running / Stopped / Error) / inspect surface plus a side-by-side **Available** catalog of well-known servers without hand-editing JSON. You can still configure servers by hand in `settings.json` if you prefer: @@ -103,6 +103,19 @@ The original `stdio` transport (which runs the binary directly on your host) is --- +### AI Dock + +One place to browse and install everything the agent talks to. Think of it as the marina where every external collaborator your PaddleBoard talks to ties up. + +- Open it from the command palette (`ai_dock: Open`) or the **Open the AI Dock** button on the Welcome screen. The Welcome screen also shows a small **Featured** strip of well-known agents (Claude, Codex, Copilot, Cursor) — clicking any pill opens the Dock so first-run users have something concrete to recognize. +- Three tabs: **Agents** (Zed, Claude, Codex, Copilot, Cursor, …), **Skills** (slash commands shipped with the project or installed in `~/.claude/commands/`), and **MCP Servers** (the absorbed management page plus a catalog of common servers). +- Installed items show a green badge; missing ones show an **Install / Sign In / Learn More** action that does the right thing for the category — agent installs are a one-click settings write, sign-in flows route to your existing identity, MCP server adds delegate to the existing setup machinery, and bundled skills (currently `/build` and `/update-tour`) install with **Add to project** / **Add to user** buttons that drop a markdown file into the right `.claude/commands/` directory. +- The catalog itself is `assets/ai_dock/catalog.json` in this repo — adding an entry is a PR, not a network fetch, so what shows up in the Dock is exactly what the team has reviewed. + +The AI Dock replaces the old hardcoded 5-card "Agent Setup" row on the Welcome screen and the standalone MCP Servers pane — both routes now land here. + +--- + ### Step-through mode Step-through mode lets you approve every tool call before the agent executes it — useful when you want to watch exactly what the agent is doing or sanity-check a risky operation. @@ -185,7 +198,7 @@ Everything else: multi-buffer editor, LSP, DAP debugger, git panel, terminal, Vi | Enable step-through mode | Click the ⏭ icon in the agent thread toolbar | | See all agent threads | Click the list-tree icon in the panel bar | | Switch LLM provider | Open the LLM Picker panel from the panel bar | -| Configure MCP servers | `Cmd-Shift-P` → `zed: Mcp Servers` | +| Configure MCP servers | `Cmd-Shift-P` → `paddleboard: Mcp Servers` | | Switch / create a worktree | `Cmd-Shift-P` → `git: Worktree` | | Run code in a sandbox | Ask the agent to run a command — it uses the Sandbox Tool automatically | | Run a service in a sandbox | Ask the agent to start a server (e.g. `python3 -m http.server 8000`) — it uses the Sandbox Service Tool, and the URL appears in the Forwarded Ports row of the browser panel | diff --git a/assets/ai_dock/catalog.json b/assets/ai_dock/catalog.json new file mode 100644 index 0000000000..4b601b2e6b --- /dev/null +++ b/assets/ai_dock/catalog.json @@ -0,0 +1,125 @@ +{ + "$schema_note": "PaddleBoard AI Dock catalog. Edit via PR. See crates/paddleboard_ai_dock/src/catalog.rs for the schema.", + "agents": [ + { + "id": "zed-agent", + "name": "Zed Agent", + "description": "First-party agent backed by Anthropic, OpenAI, and Google models. Sign in to use.", + "homepage": "https://zed.dev/agent", + "featured": true, + "builtin_zed": true + }, + { + "id": "claude-acp", + "name": "Claude Agent", + "description": "Anthropic's Claude agent over ACP. Streaming tool use, persistent threads, multi-modal.", + "homepage": "https://docs.claude.com/en/docs/claude-code/overview", + "featured": true + }, + { + "id": "codex-acp", + "name": "Codex CLI", + "description": "OpenAI's terminal coding agent. Runs locally; bring your own API key.", + "homepage": "https://github.com/openai/codex", + "featured": true + }, + { + "id": "github-copilot-cli", + "name": "GitHub Copilot", + "description": "GitHub's coding assistant. Sign in with your GitHub account.", + "homepage": "https://github.com/features/copilot", + "featured": true + }, + { + "id": "cursor", + "name": "Cursor", + "description": "Cursor's agent over ACP. Connects to the Cursor backend.", + "homepage": "https://www.cursor.com/", + "featured": false + } + ], + "skills": [ + { + "id": "build", + "name": "/build", + "description": "Build PaddleBoard via cargo. Use `bundle` for a macOS .app, `install` to drop it into /Applications.", + "homepage": null, + "featured": true + }, + { + "id": "update-tour", + "name": "/update-tour", + "description": "Sync the in-app first-launch tour with the latest WELCOME.md after a user-facing change.", + "homepage": null, + "featured": true + }, + { + "id": "verify", + "name": "/verify", + "description": "Run the app and confirm a change actually does what it's supposed to do.", + "homepage": null, + "featured": false + }, + { + "id": "review", + "name": "/review", + "description": "Review a pull request.", + "homepage": null, + "featured": false + }, + { + "id": "security-review", + "name": "/security-review", + "description": "Run a complete security review of pending changes on the current branch.", + "homepage": null, + "featured": false + } + ], + "mcp_servers": [ + { + "id": "filesystem", + "name": "Filesystem", + "description": "Read, write, and list files under a directory you allow.", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "."], + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", + "featured": true + }, + { + "id": "fetch", + "name": "Fetch", + "description": "HTTP fetch for URLs, returning markdown-converted text.", + "command": "uvx", + "args": ["mcp-server-fetch"], + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", + "featured": true + }, + { + "id": "git", + "name": "Git", + "description": "Inspect git history, branches, diffs, and blame for a repository.", + "command": "uvx", + "args": ["mcp-server-git", "--repository", "."], + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git", + "featured": true + }, + { + "id": "github", + "name": "GitHub", + "description": "Read and write to GitHub repos, issues, and PRs via the GitHub API.", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/github", + "featured": false + }, + { + "id": "puppeteer", + "name": "Puppeteer", + "description": "Headless browser automation: navigate pages, take screenshots, scrape content.", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"], + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer", + "featured": false + } + ] +} diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 94889b5d8e..d1c64c8471 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -76,7 +76,7 @@ pub use crate::agent_panel::{ }; pub use crate::orchestration_panel::OrchestrationPanel; use crate::agent_registry_ui::AgentRegistryPage; -use crate::mcp_servers_ui::McpServersPage; +pub use crate::mcp_servers_ui::McpServersView; pub use crate::inline_assistant::InlineAssistant; pub use crate::thread_metadata_store::ThreadId; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; @@ -572,25 +572,9 @@ pub fn init( } }, ); - workspace.register_action( - move |workspace: &mut Workspace, - _: &paddleboard_actions::McpServers, - window: &mut Window, - cx: &mut Context| { - let existing = workspace - .active_pane() - .read(cx) - .items() - .find_map(|item| item.downcast::()); - - if let Some(existing) = existing { - workspace.activate_item(&existing, true, true, window, cx); - } else { - let page = McpServersPage::new(workspace, window, cx); - workspace.add_item_to_active_pane(Box::new(page), None, true, window, cx); - } - }, - ); + // PaddleBoard: `McpServers` is registered by `paddleboard_store::init` + // — opening the MCP Servers UI now routes through the Store modal, so + // the legacy pane-item handler that used to live here has moved. }) .detach(); cx.observe_new(|workspace: &mut Workspace, _window, _cx| { diff --git a/crates/agent_ui/src/mcp_servers_ui.rs b/crates/agent_ui/src/mcp_servers_ui.rs index e1ad9ed5fc..1b81b5d828 100644 --- a/crates/agent_ui/src/mcp_servers_ui.rs +++ b/crates/agent_ui/src/mcp_servers_ui.rs @@ -7,8 +7,7 @@ use editor::{Editor, EditorElement, EditorStyle}; use extension_host::ExtensionStore; use fs::Fs; use gpui::{ - Action, Anchor as Corner, AnyElement, App, Context, Entity, EventEmitter, Focusable, - KeyContext, + Action, Anchor as Corner, AnyElement, App, Context, Entity, Focusable, KeyContext, ParentElement, Render, RenderOnce, SharedString, Styled, Task, TextStyle, UniformListScrollHandle, WeakEntity, Window, point, uniform_list, }; @@ -24,10 +23,7 @@ use ui::{ WithScrollbar, prelude::*, }; use util::ResultExt as _; -use workspace::{ - Workspace, - item::{Item, ItemEvent}, -}; +use workspace::Workspace; use crate::agent_configuration::{ ConfigureContextServerModal, ConfigureContextServerToolsModal, @@ -81,7 +77,7 @@ impl RenderOnce for McpServerCard { } } -pub struct McpServersPage { +pub struct McpServersView { fs: Arc, language_registry: Arc, workspace: WeakEntity, @@ -95,7 +91,7 @@ pub struct McpServersPage { _subscriptions: Vec, } -impl McpServersPage { +impl McpServersView { pub fn new( workspace: &Workspace, window: &mut Window, @@ -751,7 +747,7 @@ impl McpServersPage { } } -impl Render for McpServersPage { +impl Render for McpServersView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let total = self.server_ids.len(); let running = self @@ -864,34 +860,8 @@ impl Render for McpServersPage { } } -impl EventEmitter for McpServersPage {} - -impl Focusable for McpServersPage { +impl Focusable for McpServersView { fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { self.query_editor.read(cx).focus_handle(cx) } } - -impl Item for McpServersPage { - type Event = ItemEvent; - - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - "MCP Servers".into() - } - - fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { - Some(Icon::new(IconName::Server)) - } - - fn telemetry_event_text(&self) -> Option<&'static str> { - Some("MCP Servers Page Opened") - } - - fn show_toolbar(&self) -> bool { - false - } - - fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) { - f(*event) - } -} diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 6a08d9b758..8c658e310f 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -695,6 +695,18 @@ impl PickerDelegate for CommandPaletteDelegate { } pub fn humanize_action_name(name: &str) -> String { + // PaddleBoard: rename the `zed` action namespace to `paddleboard` for + // user-facing display (command palette, which-key, keymap editor, menus). + // The underlying action name returned by `Action::name()` is still + // `zed::...`, so every binding in `assets/keymaps/` keeps resolving — this + // is presentation-only, not a real namespace rename. + let renamed; + let name = if let Some(rest) = name.strip_prefix("zed::") { + renamed = format!("paddleboard::{rest}"); + renamed.as_str() + } else { + name + }; let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count(); let mut result = String::with_capacity(capacity); for char in name.chars() { @@ -753,6 +765,11 @@ mod tests { humanize_action_name("go_to_line::Deploy"), "go to line: deploy" ); + // PaddleBoard: `zed::` namespace is remapped to `paddleboard:` for display. + assert_eq!( + humanize_action_name("zed::OpenOnboarding"), + "paddleboard: open onboarding" + ); } #[test] diff --git a/crates/onboarding/src/basics_page.rs b/crates/onboarding/src/basics_page.rs index 8b29be32d7..c7f56a20ab 100644 --- a/crates/onboarding/src/basics_page.rs +++ b/crates/onboarding/src/basics_page.rs @@ -1,22 +1,15 @@ use std::sync::Arc; -use std::time::Duration; -use client::{Client, UserStore, zed_urls}; -use cloud_api_types::Plan; -use collections::HashMap; +use client::UserStore; use fs::Fs; -use gpui::{Action, Animation, AnimationExt, App, Entity, IntoElement, TaskExt, pulsating_between}; -use project::agent_server_store::AllAgentServersSettings; +use gpui::{Action, App, Entity, IntoElement}; use project::project_settings::ProjectSettings; -use project::{AgentRegistryStore, RegistryAgent}; -use settings::{ - BaseKeymap, CustomAgentServerSettings, Settings, SettingsStore, update_settings_file, -}; +use settings::{BaseKeymap, Settings, update_settings_file}; use theme::{Appearance, SystemAppearance, ThemeRegistry}; use theme_settings::{ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings}; use ui::{ - AgentSetupButton, StatefulInteractiveElement, SwitchField, TintColor, ToggleButtonGroup, - ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip, prelude::*, + StatefulInteractiveElement, SwitchField, TintColor, ToggleButtonGroup, ToggleButtonGroupSize, + ToggleButtonSimple, ToggleButtonWithIcon, Tooltip, prelude::*, }; use vim_mode_setting::VimModeSetting; @@ -442,171 +435,78 @@ fn render_import_settings_section(tab_index: &mut isize, cx: &mut App) -> impl I pub(crate) const FEATURED_AGENT_IDS: &[&str] = &["claude-acp", "codex-acp", "github-copilot-cli", "cursor"]; -fn render_registry_agent_button( - agent: &RegistryAgent, - installed: bool, - cx: &mut App, -) -> impl IntoElement { - let agent_id = agent.id().to_string(); - let element_id = format!("{}-onboarding", agent_id); - - let icon = match agent.icon_path() { - Some(icon_path) => Icon::from_external_svg(icon_path.clone()), - None => Icon::new(IconName::Sparkle), - } - .size(IconSize::XSmall) - .color(Color::Muted); - - let fs = ::global(cx); - - let state_element = if installed { - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success) - .into_any_element() - } else { - Label::new("Install") - .size(LabelSize::XSmall) - .color(Color::Muted) - .into_any_element() - }; - - AgentSetupButton::new(element_id) - .icon(icon) - .name(agent.name().clone()) - .state(state_element) - .disabled(installed) - .on_click(move |_, _, cx| { - telemetry::event!("Welcome Agent Install Clicked", agent = agent_id.as_str()); - let agent_id = agent_id.clone(); - update_settings_file(fs.clone(), cx, move |settings, _| { - let agent_servers = settings.agent_servers.get_or_insert_default(); - agent_servers.entry(agent_id).or_insert_with(|| { - CustomAgentServerSettings::Registry { - env: Default::default(), - default_mode: None, - default_model: None, - favorite_models: Vec::new(), - default_config_options: HashMap::default(), - favorite_config_option_values: HashMap::default(), - } - }); - }); - }) -} - -fn render_zed_agent_button(user_store: &Entity, cx: &mut App) -> impl IntoElement { - let client = Client::global(cx); - let status = *client.status().borrow(); - - let plan = user_store.read(cx).plan(); - let is_free = matches!(plan, Some(Plan::ZedFree) | None); - let is_pro = matches!(plan, Some(Plan::ZedPro)); - let is_trial = matches!(plan, Some(Plan::ZedProTrial)); - - let is_signed_out = status.is_signed_out() - || matches!( - status, - client::Status::AuthenticationError | client::Status::ConnectionError - ); - let is_signing_in = status.is_signing_in(); - let is_signed_in = !is_signed_out; - - let state_element = if is_signed_out { - Label::new("Sign In") - .size(LabelSize::XSmall) - .color(Color::Muted) - .into_any_element() - } else if is_signing_in { - Label::new("Signing In…") - .size(LabelSize::XSmall) - .color(Color::Muted) - .with_animation( - "signing-in", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 0.8)), - |label, delta| label.alpha(delta), - ) - .into_any_element() - } else if is_signed_in && is_free { - Label::new("Start Free Trial") - .size(LabelSize::XSmall) - .color(Color::Muted) - .into_any_element() - } else { - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success) - .into_any_element() - }; +// PaddleBoard: parallel display labels for the welcome-screen featured strip. +// Kept separate from FEATURED_AGENT_IDS so the telemetry constant stays a +// plain `&[&str]` (its `.iter().filter()` usage in onboarding.rs doesn't need +// to learn about tuples). Pills all dispatch the same `ai_dock::Open` action; +// the editorial value is the names being visible on the Welcome screen, not +// a per-pill action. +const WELCOME_FEATURED_AGENT_LABELS: &[(&str, &str)] = &[ + ("claude-acp", "Claude"), + ("codex-acp", "Codex"), + ("github-copilot-cli", "Copilot"), + ("cursor", "Cursor"), +]; - AgentSetupButton::new("zed-agent-onboarding") - .icon( - Icon::new(IconName::ZedAgent) - .size(IconSize::XSmall) +// PaddleBoard: replaces the upstream 5-card "Agent Setup" row with a single +// entry point into the AI Dock plus a small featured strip. The dock +// consolidates agents, skills, and MCP servers; the pills surface a few +// well-known names so first-run users have something concrete to recognize. +fn render_ai_section(_user_store: &Entity, _cx: &mut App) -> impl IntoElement { + v_flex() + .gap_0p5() + .child(Label::new("AI Dock")) + .child( + Label::new("Browse and install agents, skills, and MCP servers.") .color(Color::Muted), ) - .name("Zed Agent") - .state(state_element) - .disabled(is_trial || is_pro) - .map(|this| { - if is_signed_in && is_free { - this.on_click(move |_, _window, cx| { - telemetry::event!("Start Trial Clicked", state = "post-sign-in"); - cx.open_url(&zed_urls::start_trial_url(cx)) - }) - } else { - this.on_click(move |_, _, cx| { - telemetry::event!("Welcome Zed Agent Sign In Clicked"); - let client = Client::global(cx); - cx.spawn(async move |cx| client.sign_in_with_optional_connect(true, cx).await) - .detach_and_log_err(cx); - }) - } - }) + .child( + div().mt_1p5().w_full().child( + Button::new("welcome-open-ai-dock", "Open the AI Dock") + .full_width() + .style(ButtonStyle::Outlined) + .end_icon(Icon::new(IconName::ArrowUpRight)) + .on_click(|_, window, cx| { + telemetry::event!("Welcome Open AI Dock Clicked"); + window.dispatch_action( + paddleboard_actions::ai_dock::Open.boxed_clone(), + cx, + ); + }), + ), + ) + .child(render_welcome_featured_strip()) } -fn render_ai_section(user_store: &Entity, cx: &mut App) -> impl IntoElement { - let registry_agents = AgentRegistryStore::try_global(cx) - .map(|store| store.read(cx).agents().to_vec()) - .unwrap_or_default(); - - let installed_agents = cx - .global::() - .get::(None) - .clone(); - - let column_count = 1 + FEATURED_AGENT_IDS.len() as u16; - - let grid = FEATURED_AGENT_IDS.iter().fold( - div() - .w_full() - .mt_1p5() - .grid() - .grid_cols(column_count) - .gap_2() - .child(render_zed_agent_button(user_store, cx)), - |grid, agent_id| { - let Some(agent) = registry_agents - .iter() - .find(|a| a.id().as_ref() == *agent_id) - else { - return grid; - }; - let is_installed = installed_agents.contains_key(*agent_id); - grid.child(render_registry_agent_button(agent, is_installed, cx)) - }, - ); - +fn render_welcome_featured_strip() -> impl IntoElement { v_flex() - .gap_0p5() - .child(Label::new("Agent Setup")) + .mt_2() + .gap_1() .child( - Label::new("Install your favorite agents and start your first thread.") + Label::new("Featured") + .size(LabelSize::Small) .color(Color::Muted), ) - .child(grid) + .child( + h_flex() + .gap_1() + .children(WELCOME_FEATURED_AGENT_LABELS.iter().map(|(id, label)| { + let id = *id; + Button::new( + SharedString::from(format!("welcome-featured-{id}")), + SharedString::from(*label), + ) + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(move |_, window, cx| { + telemetry::event!("Welcome Featured Agent Clicked", agent = id); + window.dispatch_action( + paddleboard_actions::ai_dock::Open.boxed_clone(), + cx, + ); + }) + })), + ) } pub(crate) fn render_basics_page(user_store: &Entity, cx: &mut App) -> impl IntoElement { diff --git a/crates/paddleboard/Cargo.toml b/crates/paddleboard/Cargo.toml index 6a5e5387e3..e4fac8708e 100644 --- a/crates/paddleboard/Cargo.toml +++ b/crates/paddleboard/Cargo.toml @@ -227,6 +227,7 @@ paddleboard_env_vars.workspace = true paddleboard_sandbox_prereqs.workspace = true paddleboard_sandbox_prereqs_ui.workspace = true paddleboard_sandbox_settings.workspace = true +paddleboard_ai_dock.workspace = true tokio = { workspace = true, features = ["rt"] } zlog.workspace = true zlog_settings.workspace = true diff --git a/crates/paddleboard/src/main.rs b/crates/paddleboard/src/main.rs index 3dd82ff7b8..c0187962df 100644 --- a/crates/paddleboard/src/main.rs +++ b/crates/paddleboard/src/main.rs @@ -500,6 +500,7 @@ fn main() { gpui_tokio::init(cx); paddleboard_sandbox_settings::init(cx); paddleboard_sandbox_prereqs_ui::init(cx); + paddleboard_ai_dock::init(cx); if let Some(app_commit_sha) = app_commit_sha { AppCommitSha::set_global(app_commit_sha, cx); } diff --git a/crates/paddleboard_actions/src/lib.rs b/crates/paddleboard_actions/src/lib.rs index 402f015cc6..86f8b2b686 100644 --- a/crates/paddleboard_actions/src/lib.rs +++ b/crates/paddleboard_actions/src/lib.rs @@ -889,3 +889,17 @@ pub mod notebook { ] ); } + +pub mod ai_dock { + use gpui::actions; + + actions!( + ai_dock, + [ + /// Opens the PaddleBoard AI Dock on the Agents tab. The existing + /// `zed::McpServers` action opens it directly on the MCP tab, + /// so old keybindings keep working. + Open, + ] + ); +} diff --git a/crates/paddleboard_ai_dock/Cargo.toml b/crates/paddleboard_ai_dock/Cargo.toml new file mode 100644 index 0000000000..b42df37d13 --- /dev/null +++ b/crates/paddleboard_ai_dock/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "paddleboard_ai_dock" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/paddleboard_ai_dock.rs" + +[dependencies] +agent_settings.workspace = true +agent_ui.workspace = true +anyhow.workspace = true +client.workspace = true +collections.workspace = true +fs.workspace = true +gpui.workspace = true +language.workspace = true +log.workspace = true +menu.workspace = true +paddleboard_actions.workspace = true +project.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +ui.workspace = true +util.workspace = true +which.workspace = true +workspace.workspace = true diff --git a/crates/paddleboard_ai_dock/src/ai_dock.rs b/crates/paddleboard_ai_dock/src/ai_dock.rs new file mode 100644 index 0000000000..791cfb1611 --- /dev/null +++ b/crates/paddleboard_ai_dock/src/ai_dock.rs @@ -0,0 +1,207 @@ +use std::sync::Arc; + +use gpui::{ + ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, + WeakEntity, +}; +use ui::{ + ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, + prelude::*, +}; +use workspace::{ModalView, Workspace}; + +use crate::catalog::{Catalog, CatalogGlobal}; + +mod agents_tab; +mod mcp_tab; +mod skills_tab; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AiDockTab { + Agents, + Skills, + Mcp, +} + +pub struct AiDock { + focus_handle: FocusHandle, + workspace: WeakEntity, + tab: AiDockTab, + catalog: Arc, + mcp_view: Option>, +} + +impl AiDock { + pub fn toggle( + workspace: &mut Workspace, + tab: AiDockTab, + window: &mut Window, + cx: &mut Context, + ) { + let weak_workspace = workspace.weak_handle(); + workspace.toggle_modal(window, cx, |_window, cx| AiDock { + focus_handle: cx.focus_handle(), + workspace: weak_workspace, + tab, + catalog: CatalogGlobal::get(cx), + mcp_view: None, + }); + } + + fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn switch_tab(&mut self, tab: AiDockTab, window: &mut Window, cx: &mut Context) { + if self.tab == tab { + return; + } + self.tab = tab; + if matches!(tab, AiDockTab::Mcp) { + self.ensure_mcp_view(window, cx); + } + cx.notify(); + } + + fn ensure_mcp_view(&mut self, window: &mut Window, cx: &mut Context) { + if self.mcp_view.is_some() { + return; + } + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let view = workspace.update(cx, |workspace, cx| { + agent_ui::McpServersView::new(workspace, window, cx) + }); + self.mcp_view = Some(view); + } + + fn render_tab_switcher(&self, cx: &mut Context) -> AnyElement { + let selected_index = match self.tab { + AiDockTab::Agents => 0, + AiDockTab::Skills => 1, + AiDockTab::Mcp => 2, + }; + + ToggleButtonGroup::single_row( + "ai-dock-tab-switcher", + [ + ToggleButtonSimple::new( + "Agents", + cx.listener(|this, _event, window, cx| { + this.switch_tab(AiDockTab::Agents, window, cx); + }), + ), + ToggleButtonSimple::new( + "Skills", + cx.listener(|this, _event, window, cx| { + this.switch_tab(AiDockTab::Skills, window, cx); + }), + ), + ToggleButtonSimple::new( + "MCP Servers", + cx.listener(|this, _event, window, cx| { + this.switch_tab(AiDockTab::Mcp, window, cx); + }), + ), + ], + ) + .style(ToggleButtonGroupStyle::Outlined) + .size(ToggleButtonGroupSize::Medium) + .selected_index(selected_index) + .into_any_element() + } + + fn render_header(&self, cx: &mut Context) -> AnyElement { + let counts = ( + self.catalog.agents.len(), + self.catalog.skills.len(), + self.catalog.mcp_servers.len(), + ); + h_flex() + .w_full() + .justify_between() + .gap_2() + .child( + v_flex() + .gap_0p5() + .child(Headline::new("AI Dock").size(HeadlineSize::Large)) + .child( + Label::new(format!( + "{} agents · {} skills · {} MCP servers", + counts.0, counts.1, counts.2 + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + IconButton::new("ai-dock-close", IconName::Close) + .tooltip(Tooltip::text("Close")) + .on_click(cx.listener(|_, _: &ClickEvent, _window, cx| { + cx.emit(DismissEvent); + })), + ) + .into_any_element() + } + + fn render_tab_body(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + match self.tab { + AiDockTab::Agents => agents_tab::render(self, cx).into_any_element(), + AiDockTab::Skills => skills_tab::render(self, cx).into_any_element(), + AiDockTab::Mcp => mcp_tab::render(self, window, cx), + } + } +} + +impl EventEmitter for AiDock {} + +impl Focusable for AiDock { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl ModalView for AiDock {} + +impl Render for AiDock { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Make sure the MCP view exists if we opened directly to MCP. + if matches!(self.tab, AiDockTab::Mcp) && self.mcp_view.is_none() { + self.ensure_mcp_view(window, cx); + } + + let header = self.render_header(cx); + let tab_switcher = self.render_tab_switcher(cx); + let body = self.render_tab_body(window, cx); + + v_flex() + .id("ai-dock") + .key_context("AiDock") + .elevation_3(cx) + .w(rems(56.)) + .h(rems(36.)) + .track_focus(&self.focus_handle(cx)) + .on_action(cx.listener(Self::cancel)) + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); + })) + .child( + v_flex() + .p_4() + .gap_3() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(header) + .child(tab_switcher), + ) + .child( + div() + .id("ai-dock-body") + .flex_1() + .min_h_0() + .overflow_hidden() + .child(body), + ) + } +} diff --git a/crates/paddleboard_ai_dock/src/ai_dock/agents_tab.rs b/crates/paddleboard_ai_dock/src/ai_dock/agents_tab.rs new file mode 100644 index 0000000000..ce0c26ebba --- /dev/null +++ b/crates/paddleboard_ai_dock/src/ai_dock/agents_tab.rs @@ -0,0 +1,173 @@ +use client::Client; +use collections::HashMap; +use fs::Fs; +use gpui::ClickEvent; +use project::AgentRegistryStore; +use project::agent_server_store::AllAgentServersSettings; +use settings::{CustomAgentServerSettings, SettingsStore, update_settings_file}; +use ui::prelude::*; + +use crate::catalog::AgentEntry; +use crate::ai_dock::AiDock; + +pub(super) fn render(modal: &AiDock, cx: &mut Context) -> impl IntoElement { + let catalog = modal.catalog.clone(); + let registry_agents = AgentRegistryStore::try_global(cx) + .map(|store| store.read(cx).agents().to_vec()) + .unwrap_or_default(); + let installed_agents = cx + .global::() + .get::(None) + .clone(); + + v_flex() + .id("ai-dock-agents-list") + .size_full() + .p_4() + .gap_2() + .overflow_y_scroll() + .children( + catalog + .agents + .iter() + .map(|entry| render_agent_row(entry, ®istry_agents, &installed_agents, cx)), + ) +} + +fn render_agent_row( + entry: &AgentEntry, + registry_agents: &[project::RegistryAgent], + installed_agents: &AllAgentServersSettings, + cx: &mut Context, +) -> AnyElement { + let installed = entry.builtin_zed || installed_agents.contains_key(&entry.id); + let registry_agent = registry_agents.iter().find(|a| a.id().as_ref() == entry.id); + + let icon = if entry.builtin_zed { + Icon::new(IconName::ZedAgent) + } else if let Some(reg) = registry_agent.as_ref() { + match reg.icon_path() { + Some(path) => Icon::from_external_svg(path.clone()), + None => Icon::new(IconName::Sparkle), + } + } else { + Icon::new(IconName::Sparkle) + } + .size(IconSize::Small) + .color(Color::Muted); + + let action_button: AnyElement = if entry.builtin_zed { + zed_agent_button(cx) + } else if installed { + Button::new(SharedString::from(format!("ai-dock-open-{}", entry.id)), "Configure") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .into_any_element() + } else { + let agent_id = entry.id.clone(); + let fs = ::global(cx); + Button::new(SharedString::from(format!("ai-dock-install-{}", entry.id)), "Install") + .style(ButtonStyle::Filled) + .label_size(LabelSize::Small) + .on_click(move |_: &ClickEvent, _window, cx| { + let agent_id = agent_id.clone(); + update_settings_file(fs.clone(), cx, move |settings, _| { + let agent_servers = settings.agent_servers.get_or_insert_default(); + agent_servers.entry(agent_id).or_insert_with(|| { + CustomAgentServerSettings::Registry { + env: Default::default(), + default_mode: None, + default_model: None, + favorite_models: Vec::new(), + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + } + }); + }); + }) + .into_any_element() + }; + + let homepage_link: Option = entry.homepage.as_ref().map(|url| { + let url = url.clone(); + IconButton::new( + SharedString::from(format!("ai-dock-homepage-{}", entry.id)), + IconName::ArrowUpRight, + ) + .icon_size(IconSize::Small) + .tooltip(ui::Tooltip::text("Open homepage")) + .on_click(move |_: &ClickEvent, _window, cx| { + cx.open_url(&url); + }) + .into_any_element() + }); + + h_flex() + .w_full() + .p_3() + .gap_3() + .items_start() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().elevated_surface_background.opacity(0.5)) + .child(div().pt_0p5().child(icon)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child( + h_flex() + .gap_2() + .child(Label::new(SharedString::from(entry.name.clone()))) + .when(installed, |this| { + this.child( + Label::new("Installed") + .size(LabelSize::XSmall) + .color(Color::Success), + ) + }), + ) + .child( + Label::new(SharedString::from(entry.description.clone())) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_1() + .children(homepage_link) + .child(action_button), + ) + .into_any_element() +} + +fn zed_agent_button(cx: &mut Context) -> AnyElement { + let client = Client::global(cx); + let status = *client.status().borrow(); + let is_signed_out = status.is_signed_out() + || matches!( + status, + client::Status::AuthenticationError | client::Status::ConnectionError + ); + + if is_signed_out { + Button::new("ai-dock-zed-signin", "Sign In") + .style(ButtonStyle::Filled) + .label_size(LabelSize::Small) + .on_click(move |_: &ClickEvent, _window, cx| { + let client = Client::global(cx); + cx.spawn(async move |cx| client.sign_in_with_optional_connect(true, cx).await) + .detach_and_log_err(cx); + }) + .into_any_element() + } else { + Button::new("ai-dock-zed-configured", "Signed In") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .disabled(true) + .into_any_element() + } +} diff --git a/crates/paddleboard_ai_dock/src/ai_dock/mcp_tab.rs b/crates/paddleboard_ai_dock/src/ai_dock/mcp_tab.rs new file mode 100644 index 0000000000..0bfc184b1b --- /dev/null +++ b/crates/paddleboard_ai_dock/src/ai_dock/mcp_tab.rs @@ -0,0 +1,33 @@ +use gpui::AnyElement; +use ui::prelude::*; + +use crate::ai_dock::AiDock; + +pub(super) fn render( + modal: &mut AiDock, + _window: &mut Window, + _cx: &mut Context, +) -> AnyElement { + // The MCP tab hosts an absorbed `agent_ui::McpServersView` — the same + // surface that lived as a standalone workspace pane item before the + // AI Dock consolidation. The view is created lazily by + // `AiDock::ensure_mcp_view` when the user first switches to this tab (or + // when the dock opens directly to MCP via the legacy + // `paddleboard_actions::McpServers` action). + match modal.mcp_view.as_ref() { + Some(view) => div() + .size_full() + .child(view.clone()) + .into_any_element(), + None => v_flex() + .size_full() + .items_center() + .justify_center() + .child( + Label::new("Loading MCP servers…") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element(), + } +} diff --git a/crates/paddleboard_ai_dock/src/ai_dock/skills_tab.rs b/crates/paddleboard_ai_dock/src/ai_dock/skills_tab.rs new file mode 100644 index 0000000000..62b81c2465 --- /dev/null +++ b/crates/paddleboard_ai_dock/src/ai_dock/skills_tab.rs @@ -0,0 +1,247 @@ +use std::path::PathBuf; + +use gpui::ClickEvent; +use ui::prelude::*; + +use crate::ai_dock::AiDock; +use crate::catalog::{SkillEntry, bundled_skill_content}; + +pub(super) fn render(modal: &AiDock, cx: &mut Context) -> impl IntoElement { + let catalog = modal.catalog.clone(); + + // Detect installed skills by scanning the two well-known directories. + // Skills are markdown files named `.md` under `.claude/commands/` + // (project-scoped) or `~/.claude/commands/` (user-scoped). + let project_dir = project_skills_dir(modal, cx); + let user_dir = user_skills_dir(); + let scope_of = |id: &str| -> Option { + if let Some(dir) = project_dir.as_ref() { + if dir.join(format!("{id}.md")).exists() { + return Some(SkillScope::Project); + } + } + if let Some(dir) = user_dir.as_ref() { + if dir.join(format!("{id}.md")).exists() { + return Some(SkillScope::User); + } + } + None + }; + + v_flex() + .id("ai-dock-skills-list") + .size_full() + .p_4() + .gap_2() + .overflow_y_scroll() + .children(catalog.skills.iter().map(|entry| { + render_skill_row( + entry, + scope_of(&entry.id), + project_dir.is_some(), + user_dir.is_some(), + cx, + ) + })) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SkillScope { + Project, + User, +} + +impl SkillScope { + fn label(self) -> &'static str { + match self { + SkillScope::Project => "Project", + SkillScope::User => "User", + } + } + + fn resolve_dir(self, modal: &AiDock, cx: &App) -> Option { + match self { + SkillScope::Project => project_skills_dir(modal, cx), + SkillScope::User => user_skills_dir(), + } + } +} + +fn project_skills_dir(modal: &AiDock, cx: &App) -> Option { + // Prefer the active workspace's first visible worktree — that's the + // "project" the user thinks they're in. Fall back to the process CWD + // when there's no workspace (e.g. an empty PaddleBoard window) so the + // detection still picks up `.claude/commands/` in the launch dir. + if let Some(workspace) = modal.workspace.upgrade() { + let workspace = workspace.read(cx); + let project = workspace.project().read(cx); + if let Some(worktree) = project.visible_worktrees(cx).next() { + let root = worktree.read(cx).abs_path(); + return Some(root.join(".claude").join("commands")); + } + } + let cwd = std::env::current_dir().ok()?; + Some(cwd.join(".claude").join("commands")) +} + +fn user_skills_dir() -> Option { + let home = std::env::var_os("HOME").map(PathBuf::from)?; + Some(home.join(".claude").join("commands")) +} + +fn render_skill_row( + entry: &SkillEntry, + scope: Option, + has_project_dir: bool, + has_user_dir: bool, + cx: &mut Context, +) -> AnyElement { + let icon = Icon::new(IconName::Sparkle) + .size(IconSize::Small) + .color(Color::Muted); + + let bundled = bundled_skill_content(&entry.id).is_some(); + + let action_area: AnyElement = if let Some(scope) = scope { + Button::new( + SharedString::from(format!("ai-dock-skill-installed-{}", entry.id)), + format!("Installed ({})", scope.label()), + ) + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .disabled(true) + .into_any_element() + } else if bundled { + // Install buttons — one per scope. Disable a button when its target + // directory can't be resolved (no workspace open for project; no + // $HOME for user) so the user still sees what *would* be possible. + let entry_id = entry.id.clone(); + let project_btn = { + let id = entry_id.clone(); + Button::new( + SharedString::from(format!("ai-dock-skill-add-project-{}", entry.id)), + "Add to project", + ) + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .disabled(!has_project_dir) + .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { + install_skill(this, &id, SkillScope::Project, window, cx); + })) + }; + let user_btn = { + let id = entry_id; + Button::new( + SharedString::from(format!("ai-dock-skill-add-user-{}", entry.id)), + "Add to user", + ) + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .disabled(!has_user_dir) + .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { + install_skill(this, &id, SkillScope::User, window, cx); + })) + }; + h_flex() + .gap_1() + .child(project_btn) + .child(user_btn) + .into_any_element() + } else { + // No bundled content — direct to homepage if present, else surface a + // disabled "Not installed" pill so users see the skill exists. + match entry.homepage.clone() { + Some(url) => Button::new( + SharedString::from(format!("ai-dock-skill-info-{}", entry.id)), + "Learn More", + ) + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(move |_: &ClickEvent, _window, cx| { + cx.open_url(&url); + }) + .into_any_element(), + None => Button::new( + SharedString::from(format!("ai-dock-skill-na-{}", entry.id)), + "Not installed", + ) + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .disabled(true) + .into_any_element(), + } + }; + + h_flex() + .w_full() + .p_3() + .gap_3() + .items_start() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().elevated_surface_background.opacity(0.5)) + .child(div().pt_0p5().child(icon)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child(Label::new(SharedString::from(entry.name.clone()))) + .child( + Label::new(SharedString::from(entry.description.clone())) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child(action_area) + .into_any_element() +} + +fn install_skill( + modal: &mut AiDock, + id: &str, + scope: SkillScope, + _window: &mut Window, + cx: &mut Context, +) { + let Some(content) = bundled_skill_content(id) else { + log::error!("paddleboard_ai_dock: install_skill called for unbundled id `{id}`"); + return; + }; + let Some(dir) = scope.resolve_dir(modal, cx) else { + report_install_error( + modal, + format!("Could not resolve the {} skills directory.", scope.label()), + cx, + ); + return; + }; + + if let Err(err) = write_skill_file(&dir, id, content) { + log::error!("paddleboard_ai_dock: failed to install skill `{id}`: {err}"); + report_install_error( + modal, + format!("Failed to install /{id}: {err}"), + cx, + ); + return; + } + + cx.notify(); +} + +fn write_skill_file(dir: &PathBuf, id: &str, content: &str) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + std::fs::write(dir.join(format!("{id}.md")), content) +} + +fn report_install_error(modal: &AiDock, message: String, cx: &mut Context) { + if let Some(workspace) = modal.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.show_error(&anyhow::anyhow!(message), cx); + }); + } else { + log::error!("paddleboard_ai_dock: install error (no workspace to notify): {message}"); + } +} diff --git a/crates/paddleboard_ai_dock/src/catalog.rs b/crates/paddleboard_ai_dock/src/catalog.rs new file mode 100644 index 0000000000..67c04a0717 --- /dev/null +++ b/crates/paddleboard_ai_dock/src/catalog.rs @@ -0,0 +1,161 @@ +use std::sync::Arc; + +use gpui::{App, Global}; +use serde::Deserialize; + +/// In-repo catalog of installable agents, skills, and MCP servers. Loaded +/// once at startup from `assets/ai_dock/catalog.json` (embedded via +/// `include_str!`), so adding an entry means opening a PR. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Catalog { + #[serde(default)] + pub agents: Vec, + #[serde(default)] + pub skills: Vec, + #[serde(default)] + pub mcp_servers: Vec, +} + +/// One agent entry. `id` should match the corresponding `RegistryAgent` id +/// (so the Store can cross-reference `project::AgentRegistryStore` to learn +/// the install status without duplicating that logic). +#[derive(Debug, Clone, Deserialize)] +pub struct AgentEntry { + pub id: String, + pub name: String, + pub description: String, + #[serde(default)] + pub homepage: Option, + /// Pinned to the small "Featured" strip on the Welcome screen. + #[serde(default)] + pub featured: bool, + /// Identifies the special-case Zed Agent. Renders the sign-in/plan flow + /// instead of the generic agent-server install path. + #[serde(default)] + pub builtin_zed: bool, +} + +/// One skill entry. Skills are markdown files dropped into `.claude/commands/` +/// (per-project) or `~/.claude/commands/` (per-user). Bundled skills carry +/// the markdown body inline so "Add" works without a network round-trip. +#[derive(Debug, Clone, Deserialize)] +pub struct SkillEntry { + pub id: String, + pub name: String, + pub description: String, + #[serde(default)] + pub homepage: Option, + #[serde(default)] + pub featured: bool, +} + +/// One MCP server entry. The AI Dock's MCP tab uses these to populate the +/// "Available" list; selecting one delegates to the absorbed +/// `McpServersView` (which knows how to actually register the server with +/// `context_server_store`). +#[derive(Debug, Clone, Deserialize)] +pub struct McpEntry { + pub id: String, + pub name: String, + pub description: String, + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub homepage: Option, + #[serde(default)] + pub featured: bool, +} + +impl Catalog { + /// Parse the bundled catalog. If parsing fails we fall back to an empty + /// catalog and log; that keeps the AI Dock rendering instead of crashing + /// the workspace on a malformed JSON edit. + pub fn load() -> Self { + const SOURCE: &str = include_str!("../../../assets/ai_dock/catalog.json"); + match serde_json::from_str::(SOURCE) { + Ok(catalog) => catalog, + Err(err) => { + log::error!("paddleboard_ai_dock: failed to parse catalog.json: {err:#}"); + Catalog::default() + } + } + } + + pub fn empty() -> Self { + Self::default() + } +} + +#[derive(Clone)] +pub(crate) struct CatalogGlobal(pub(crate) Arc); + +impl Global for CatalogGlobal {} + +impl CatalogGlobal { + pub(crate) fn get(cx: &App) -> Arc { + cx.try_global::() + .map(|g| g.0.clone()) + .unwrap_or_else(|| Arc::new(Catalog::empty())) + } +} + +pub fn catalog(cx: &App) -> Arc { + CatalogGlobal::get(cx) +} + +/// Bundled markdown body for catalog skills we ship in-tree. Returning `Some` +/// turns on the Skills tab's "Add to project" / "Add to user" buttons for +/// that entry; returning `None` falls back to the homepage / disabled state. +/// +/// The `include_str!` paths point at the canonical files under +/// `.claude/commands/`, not a duplicated copy under `assets/ai_dock/skills/`, +/// so the slash command used in this repo and the bundled install copy can +/// never drift. +pub fn bundled_skill_content(id: &str) -> Option<&'static str> { + match id { + "build" => Some(include_str!("../../../.claude/commands/build.md")), + "update-tour" => Some(include_str!("../../../.claude/commands/update-tour.md")), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_skill_content_returns_known_skills() { + let build = bundled_skill_content("build").expect("build is bundled"); + assert!( + build.contains("/build"), + "bundled `build.md` should mention `/build`: got {build:?}" + ); + + let update_tour = bundled_skill_content("update-tour").expect("update-tour is bundled"); + assert!( + update_tour.contains("WELCOME.md") || update_tour.contains("tour"), + "bundled `update-tour.md` should reference WELCOME.md or `tour`: got {update_tour:?}" + ); + } + + #[test] + fn bundled_skill_content_returns_none_for_unbundled() { + assert!(bundled_skill_content("review").is_none()); + assert!(bundled_skill_content("verify").is_none()); + assert!(bundled_skill_content("nonexistent").is_none()); + } + + #[test] + fn every_bundled_id_is_in_catalog() { + let catalog = Catalog::load(); + let bundled_ids = ["build", "update-tour"]; + for id in bundled_ids { + assert!( + catalog.skills.iter().any(|s| s.id == id), + "bundled skill `{id}` must have a matching catalog entry; \ + otherwise the install buttons never render" + ); + } + } +} diff --git a/crates/paddleboard_ai_dock/src/paddleboard_ai_dock.rs b/crates/paddleboard_ai_dock/src/paddleboard_ai_dock.rs new file mode 100644 index 0000000000..4363a298bf --- /dev/null +++ b/crates/paddleboard_ai_dock/src/paddleboard_ai_dock.rs @@ -0,0 +1,39 @@ +//! PaddleBoard AI Dock: a single browse-surface for Agents, Skills, and MCP +//! servers. Replaces the hardcoded "Agent Setup" row on the Welcome screen +//! and absorbs the standalone MCP Servers page. +//! +//! Entry point: the `paddleboard_actions::ai_dock::Open` action toggles an +//! `AiDock` on the active workspace. The dock owns three tab views and a +//! shared `Catalog` loaded once at startup from the in-repo JSON. + +use std::sync::Arc; + +use gpui::App; +use workspace::Workspace; + +mod ai_dock; +pub mod catalog; + +pub use ai_dock::{AiDock, AiDockTab}; +pub use catalog::{AgentEntry, Catalog, McpEntry, SkillEntry}; + +/// Initialize the AI Dock: load the catalog into a global and wire the +/// `Open` action onto every workspace. +pub fn init(cx: &mut App) { + let catalog = Arc::new(Catalog::load()); + cx.set_global(catalog::CatalogGlobal(catalog)); + + cx.observe_new(|workspace: &mut Workspace, _window, _cx| { + workspace.register_action( + |workspace, _: &paddleboard_actions::ai_dock::Open, window, cx| { + AiDock::toggle(workspace, AiDockTab::Agents, window, cx); + }, + ); + workspace.register_action( + |workspace, _: &paddleboard_actions::McpServers, window, cx| { + AiDock::toggle(workspace, AiDockTab::Mcp, window, cx); + }, + ); + }) + .detach(); +} diff --git a/crates/workspace/src/tour.md b/crates/workspace/src/tour.md index 09afde4b9e..5155352dc8 100644 --- a/crates/workspace/src/tour.md +++ b/crates/workspace/src/tour.md @@ -28,35 +28,42 @@ Long-lived processes (dev servers, demo apps, `adk web`) use the **Sandbox Servi ### 4. Sandboxed MCP Servers PaddleBoard runs your **MCP servers** inside the same Podman + gVisor sandbox as the Sandbox Tool. -- Configure them via `Cmd-Shift-P` → **`zed: Mcp Servers`** — filter by status (All / Running / Stopped / Error), add new servers, or inspect live state. +- Manage them in the AI Dock: `Cmd-Shift-P` → **`paddleboard: Mcp Servers`** opens the dock on the MCP tab (filter All / Running / Stopped / Error, add servers, browse the catalog of common ones). - Or use `"source": "sandboxed_stdio"` in `settings.json` directly. - Forward only the host env vars you need by name — values stay out of the agent's context. - The worktree is mounted at `/workspace` so filesystem-touching servers (git, fs, etc.) still work. -### 5. Step-Through Mode +### 5. AI Dock +One place to browse and install everything the agent talks to — the marina where every external collaborator ties up. +- Open it: `Cmd-Shift-P` → **`ai_dock: Open`**, or hit **Open the AI Dock** on the Welcome screen. The Welcome screen also surfaces a **Featured** strip (Claude / Codex / Copilot / Cursor pills) so first-run users have recognizable names to click. +- Three tabs: **Agents** (Zed, Claude, Codex, Copilot, Cursor), **Skills** (slash commands), **MCP Servers** (catalog + absorbed management UI). +- Installed items show a green badge; missing ones get a one-click **Install / Sign In / Learn More** that does the category-appropriate thing. Bundled skills (currently `/build` and `/update-tour`) install with **Add to project** / **Add to user** buttons that drop a markdown file into the right `.claude/commands/` directory. +- The catalog is `assets/ai_dock/catalog.json` in-repo — adds are PRs, not fetches. + +### 6. Step-Through Mode Approve every tool call before the agent executes it. - Click the **⏭** icon in the agent thread toolbar to enable (it turns accent-colored). - Each tool call pauses with **Step** (run it) or **Skip** (return empty and move on). - Only the root thread is gated — subagents run without interruption. -### 6. Agent Orchestration Panel +### 7. Agent Orchestration Panel A live tree view of every active agent session, including subagents. - Open it: panel bar `ListTree` icon, or `Cmd-Shift-P` → **`orchestration_panel: Toggle Focus`**. - Subagents nest under the thread that spawned them. - Status dot shows generating vs. idle; click any row to jump to that thread. -### 7. LLM Provider Picker Panel +### 8. LLM Provider Picker Panel A dedicated panel for switching the active language model provider without opening settings. - Dock it wherever is convenient and change providers as you work. - **ChatGPT Subscription auth**: sign in with your ChatGPT Plus or Pro account via OAuth — no API key needed. The flow opens in the embedded browser panel; tokens persist in PB's credential store. -### 8. Multi-Workspace +### 9. Multi-Workspace Keep multiple projects in one window, each as its own workspace with its own pane tree and its own agent threads. - Open the worktree picker: `Cmd-Shift-P` → **`git: Worktree`**. - **Switch** between existing worktrees, **create** a new worktree-backed workspace (accept the auto-generated branch name like `dusty-pelican` or supply your own), or **open in new window**. - The orchestration panel shows agent threads from every workspace at once — perfect for parallel agent sessions against different projects. -### 9. Built-in Language Servers +### 10. Built-in Language Servers PaddleBoard ships built-in LSP support for four languages that Zed historically punts to extensions — **no extension installation required**. - **Java** via [jdtls](https://github.com/eclipse/eclipse.jdt.ls) - **Kotlin** via [kotlin-language-server](https://github.com/fwcd/kotlin-language-server) diff --git a/paddleboard-5.png b/paddleboard-5.png new file mode 100644 index 0000000000..20816bea10 Binary files /dev/null and b/paddleboard-5.png differ