diff --git a/RECAPS.md b/RECAPS.md index 22985e9079..4d07c38129 100644 --- a/RECAPS.md +++ b/RECAPS.md @@ -15,6 +15,28 @@ Running log of completed work sessions, newest first. Each entry summarizes a co - **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. +### AI Dock — expandable modal +- Added a maximize/minimize toggle to the AI Dock modal header so users can grow the dock when there's more content to scan (catalog rows, install lists) than the default size comfortably fits. `crates/paddleboard_ai_dock/src/ai_dock.rs` gained an `expanded: bool` field on `AiDock` (defaults to `false`), a `toggle_expanded` method, and a new `IconButton` in `render_header` placed left of the close button. +- **Two-state, not free-resize.** Normal: `56rem × 36rem` (~896×576 px, unchanged from before). Expanded: `80rem × 54rem` (~1280×864 px) — about 2.1× the area. Chose preset sizes over drag-to-resize because GPUI doesn't have a built-in resizable-modal primitive and rolling one would be a much bigger lift than the user's "minor change" framing implied. +- **Icon pair**: `IconName::Maximize` when collapsed, `IconName::Minimize` when expanded; tooltips "Expand" / "Collapse" match. Reusing the same `IconButton` slot (just swapping icon + handler payload) keeps the header layout stable across toggles — no shifting. +- **State is per-modal-instance**, not persisted. Closing and reopening the dock starts collapsed. That's intentional for now — persisting modal layout state across reopens is the kind of small-but-controversial UX call worth deferring until someone explicitly wants it. +- **Intentionally preserved**: the modal's body layout (`flex_1().min_h_0().overflow_hidden()` wrapping the per-tab content) handles the size change without code changes. Each tab's render path (`agents_tab`, `skills_tab`, `mcp_tab`) is size-agnostic — rows are full-width and the embedded `McpServersView` has its own internal scroll — so growing the modal just gives them more room to breathe, no per-tab adjustments needed. +- **Verified**: `cargo check -p paddleboard_ai_dock` clean, `./script/clippy -p paddleboard_ai_dock` (release, all targets, deny warnings) clean. Build of `paddleboard` in flight; UI verification pending. +- **Open follow-ups**: persist expanded-state across reopens (settings or a workspace-scoped global), maybe support drag-to-resize once GPUI grows a resizable-modal primitive. + +### AI Dock — MCP catalog gap fix +- Followup to the smoke-test finding from earlier in this session and called out in PR #38: the MCP tab subtitle said "5 MCP servers" but the tab itself only embedded `McpServersView` (installed-only) and never rendered the 5 catalog entries (`filesystem`, `fetch`, `git`, `github`, `puppeteer`). First-run users would click MCP, see "No MCP servers installed yet", and have no way to find what's available without going elsewhere. +- **What landed**: rewrote `crates/paddleboard_ai_dock/src/ai_dock/mcp_tab.rs` to render a compact **Available** section at the top of the MCP tab (above the absorbed `McpServersView`). Each catalog entry renders as a small row with icon, name, description (truncated), and an **Install / Installed** button. The "Install" button writes a `ContextServerSettingsContent::Stdio` entry into `settings.project.context_servers` keyed by the catalog id, using the catalog's `command` + `args`. +- **Install detection** reads `ProjectSettings::get_global(cx).context_servers.keys()` each render and cross-references catalog ids. Click handler also calls `cx.notify()` after `update_settings_file` so the row flips to "Installed" without waiting for the settings store's async cascade — the absorbed view below independently picks up the new server via its existing `context_server_store` subscription. +- **Default variant choice**: defaulted to `Stdio` (unsandboxed) instead of `SandboxedStdio` because `SandboxedStdio` requires a container `image` field and the catalog doesn't currently specify one. Users who want sandboxing can flip the variant after install, or we can extend `McpEntry` with an optional `image` field in a followup. Trade-off written up below. +- **Intentionally preserved**: the absorbed `McpServersView` is *untouched* — no edits to `crates/agent_ui/src/mcp_servers_ui.rs`. The catalog section sits above it in a `v_flex().size_full()` with the view as `flex_1().min_h_0()` below. Fork hygiene wins: every keymap binding, search filter, "+ Add Server" popover, status indicator etc. that the absorbed view ships keeps working without divergence. +- **Layout**: the `Available` section is fixed-height (~5 rows × tight padding), the McpServersView gets the remaining ~280px of the 36rem modal body. Tight but workable for v1; if more catalog entries get added, a future polish might toggle the catalog section collapsed-by-default or move it to a sub-tab. +- **Verified**: `cargo check -p paddleboard_ai_dock` clean, `./script/clippy -p paddleboard_ai_dock` (release, all targets, deny warnings) clean. Build of `paddleboard` in flight. UI smoke test pending — verifying the catalog rows render, the install button writes the right settings entry, and the absorbed view picks up the new server. +- **Open follow-ups**: + - Extend `McpEntry` with an optional `image` field so SandboxedStdio can be the install default for servers that have a known container image (e.g. `ghcr.io/github/github-mcp-server:latest` for `github`). Aligns with PB's sandboxing emphasis. + - The catalog is currently project-scoped (writes to `settings.project.context_servers`). User-scoped install via top-level `context_servers` (or per the [[feedback-install-wizard-ux]] memory, maybe both with a scope picker like Skills) is worth considering. + - Possible UX polish: collapse the Available section once all catalog entries are installed, since it'd be pure visual clutter at that point. + ### 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) diff --git a/crates/paddleboard_ai_dock/src/ai_dock.rs b/crates/paddleboard_ai_dock/src/ai_dock.rs index 791cfb1611..e5b86e5340 100644 --- a/crates/paddleboard_ai_dock/src/ai_dock.rs +++ b/crates/paddleboard_ai_dock/src/ai_dock.rs @@ -29,6 +29,7 @@ pub struct AiDock { tab: AiDockTab, catalog: Arc, mcp_view: Option>, + expanded: bool, } impl AiDock { @@ -45,9 +46,15 @@ impl AiDock { tab, catalog: CatalogGlobal::get(cx), mcp_view: None, + expanded: false, }); } + fn toggle_expanded(&mut self, _window: &mut Window, cx: &mut Context) { + self.expanded = !self.expanded; + cx.notify(); + } + fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context) { cx.emit(DismissEvent); } @@ -118,6 +125,11 @@ impl AiDock { self.catalog.skills.len(), self.catalog.mcp_servers.len(), ); + let (expand_icon, expand_tooltip) = if self.expanded { + (IconName::Minimize, "Collapse") + } else { + (IconName::Maximize, "Expand") + }; h_flex() .w_full() .justify_between() @@ -136,11 +148,22 @@ impl AiDock { ), ) .child( - IconButton::new("ai-dock-close", IconName::Close) - .tooltip(Tooltip::text("Close")) - .on_click(cx.listener(|_, _: &ClickEvent, _window, cx| { - cx.emit(DismissEvent); - })), + h_flex() + .gap_1() + .child( + IconButton::new("ai-dock-expand", expand_icon) + .tooltip(Tooltip::text(expand_tooltip)) + .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { + this.toggle_expanded(window, cx); + })), + ) + .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() } @@ -175,12 +198,18 @@ impl Render for AiDock { let tab_switcher = self.render_tab_switcher(cx); let body = self.render_tab_body(window, cx); + let (width, height) = if self.expanded { + (rems(80.), rems(54.)) + } else { + (rems(56.), rems(36.)) + }; + v_flex() .id("ai-dock") .key_context("AiDock") .elevation_3(cx) - .w(rems(56.)) - .h(rems(36.)) + .w(width) + .h(height) .track_focus(&self.focus_handle(cx)) .on_action(cx.listener(Self::cancel)) .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { diff --git a/crates/paddleboard_ai_dock/src/ai_dock/mcp_tab.rs b/crates/paddleboard_ai_dock/src/ai_dock/mcp_tab.rs index 0bfc184b1b..8e327d8282 100644 --- a/crates/paddleboard_ai_dock/src/ai_dock/mcp_tab.rs +++ b/crates/paddleboard_ai_dock/src/ai_dock/mcp_tab.rs @@ -1,26 +1,42 @@ -use gpui::AnyElement; +use std::sync::Arc; + +use collections::HashSet; +use fs::Fs; +use gpui::{AnyElement, ClickEvent}; +use project::project_settings::ProjectSettings; +use settings::{ + ContextServerCommand, ContextServerSettingsContent, Settings as _, update_settings_file, +}; use ui::prelude::*; use crate::ai_dock::AiDock; +use crate::catalog::McpEntry; pub(super) fn render( modal: &mut AiDock, - _window: &mut Window, - _cx: &mut Context, + 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() { + // Make sure the absorbed McpServersView exists — we host it below the + // catalog section, so it has to be ready before we lay things out. + modal.ensure_mcp_view(window, cx); + + let catalog = modal.catalog.clone(); + let installed_ids: HashSet = ProjectSettings::get_global(cx) + .context_servers + .keys() + .map(|k| k.to_string()) + .collect(); + + let catalog_section = render_catalog_section(&catalog.mcp_servers, &installed_ids, cx); + let installed_view: AnyElement = match modal.mcp_view.as_ref() { Some(view) => div() - .size_full() + .flex_1() + .min_h_0() .child(view.clone()) .into_any_element(), None => v_flex() - .size_full() + .flex_1() .items_center() .justify_center() .child( @@ -29,5 +45,127 @@ pub(super) fn render( .size(LabelSize::Small), ) .into_any_element(), + }; + + v_flex() + .size_full() + .child(catalog_section) + .child(installed_view) + .into_any_element() +} + +fn render_catalog_section( + entries: &[McpEntry], + installed_ids: &HashSet, + cx: &mut Context, +) -> AnyElement { + if entries.is_empty() { + return div().into_any_element(); } + + v_flex() + .p_3() + .gap_1p5() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new("Available") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + v_flex().gap_1().children( + entries + .iter() + .map(|entry| render_catalog_row(entry, installed_ids.contains(&entry.id), cx)), + ), + ) + .into_any_element() +} + +fn render_catalog_row( + entry: &McpEntry, + is_installed: bool, + cx: &mut Context, +) -> AnyElement { + let action: AnyElement = if is_installed { + Button::new( + SharedString::from(format!("ai-dock-mcp-installed-{}", entry.id)), + "Installed", + ) + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .disabled(true) + .into_any_element() + } else { + let entry_for_click = entry.clone(); + Button::new( + SharedString::from(format!("ai-dock-mcp-install-{}", entry.id)), + "Install", + ) + .style(ButtonStyle::Filled) + .label_size(LabelSize::Small) + .on_click(cx.listener(move |_, _: &ClickEvent, _window, cx| { + install_mcp_server(&entry_for_click, cx); + })) + .into_any_element() + }; + + h_flex() + .w_full() + .py_1() + .px_2() + .gap_2p5() + .items_center() + .rounded_md() + .child( + Icon::new(IconName::Server) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child(Label::new(SharedString::from(entry.name.clone())).size(LabelSize::Small)) + .child( + Label::new(SharedString::from(entry.description.clone())) + .size(LabelSize::XSmall) + .color(Color::Muted) + .truncate(), + ), + ) + .child(action) + .into_any_element() +} + +fn install_mcp_server(entry: &McpEntry, cx: &mut Context) { + let id: Arc = entry.id.as_str().into(); + let command_path = entry.command.clone(); + let args = entry.args.clone(); + let fs = ::global(cx); + + update_settings_file(fs.clone(), cx, move |settings, _| { + settings + .project + .context_servers + .entry(id) + .or_insert_with(|| ContextServerSettingsContent::Stdio { + enabled: true, + remote: false, + command: ContextServerCommand { + path: command_path.into(), + args, + env: None, + timeout: None, + }, + }); + }); + + // Settings writes propagate asynchronously to `context_server_store`, + // which the absorbed McpServersView observes. Notifying here re-reads + // the installed-ids set so the catalog row flips to "Installed" right + // away without waiting for the next external state change. + cx.notify(); } diff --git a/paddleboard-5.png b/paddleboard-5.png deleted file mode 100644 index 20816bea10..0000000000 Binary files a/paddleboard-5.png and /dev/null differ