Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions RECAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 36 additions & 7 deletions crates/paddleboard_ai_dock/src/ai_dock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub struct AiDock {
tab: AiDockTab,
catalog: Arc<Catalog>,
mcp_view: Option<Entity<agent_ui::McpServersView>>,
expanded: bool,
}

impl AiDock {
Expand All @@ -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>) {
self.expanded = !self.expanded;
cx.notify();
}

fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
Expand Down Expand Up @@ -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()
Expand All @@ -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()
}
Expand Down Expand Up @@ -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| {
Expand Down
162 changes: 150 additions & 12 deletions crates/paddleboard_ai_dock/src/ai_dock/mcp_tab.rs
Original file line number Diff line number Diff line change
@@ -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<AiDock>,
window: &mut Window,
cx: &mut Context<AiDock>,
) -> 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<String> = 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(
Expand All @@ -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<String>,
cx: &mut Context<AiDock>,
) -> 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<AiDock>,
) -> 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<AiDock>) {
let id: Arc<str> = entry.id.as_str().into();
let command_path = entry.command.clone();
let args = entry.args.clone();
let fs = <dyn 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();
}
Binary file removed paddleboard-5.png
Binary file not shown.
Loading