diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 16454173..ec7cae0d 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -4057,6 +4057,7 @@ pub(crate) enum ViewPanel { Context, Work, Tickets, + Mcp, Agents, Templates, Skills, @@ -4072,6 +4073,7 @@ impl ViewPanel { "context" => Ok(Self::Context), "work" => Ok(Self::Work), "tickets" => Ok(Self::Tickets), + "mcp" => Ok(Self::Mcp), "agents" => Ok(Self::Agents), "templates" => Ok(Self::Templates), "skills" => Ok(Self::Skills), @@ -4091,6 +4093,7 @@ impl ViewPanel { Self::Context => "context", Self::Work => "work", Self::Tickets => "tickets", + Self::Mcp => "mcp", Self::Agents => "agents", Self::Templates => "templates", Self::Skills => "skills", @@ -4106,6 +4109,7 @@ impl ViewPanel { Self::Context => "Project context", Self::Work => "Work state", Self::Tickets => "Tickets", + Self::Mcp => "MCP", Self::Agents => "Agents", Self::Templates => "Templates", Self::Skills => "Skills", @@ -4411,6 +4415,18 @@ mod view_window_tests { assert_eq!(view_window_url(panel), "index.html?panel=tickets"); } + #[test] + fn mcp_view_window_contract_is_stable() { + let panel = ViewPanel::parse("mcp").unwrap(); + + assert_eq!(panel, ViewPanel::Mcp); + assert_eq!(panel.as_str(), "mcp"); + assert_eq!(panel.title(), "MCP"); + assert_eq!(view_window_label(panel), "view-mcp"); + assert_eq!(view_window_url(panel), "index.html?panel=mcp"); + assert_eq!(view_panel_from_window_label("view-mcp"), Some(panel)); + } + #[test] fn view_window_rejects_unknown_panel() { let err = ViewPanel::parse("terminal").unwrap_err(); diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 461ab6ee..af6998b3 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -1090,6 +1090,24 @@ mod tests { assert_eq!(url.as_deref(), Some("index.html?panel=agents")); } + #[test] + fn persisted_identity_accepts_mcp_view_label() { + let (kind, panel, project_id, plugin_layout, url) = + persisted_window_identity_from_label("view-mcp").unwrap(); + + assert_eq!(kind, PersistedWindowKind::View); + assert_eq!(panel.as_deref(), Some("mcp")); + assert!(project_id.is_none()); + assert!(plugin_layout.is_none()); + assert_eq!(url.as_deref(), Some("index.html?panel=mcp")); + assert_eq!( + persisted_view_identity_from_label("view-mcp") + .unwrap() + .as_str(), + "mcp" + ); + } + #[test] fn persisted_identity_accepts_plugin_layout_view_labels() { let plugin_id = domain::PluginId::new("dev.idea.android-plugin").unwrap(); diff --git a/frontend/src/app/ViewWindow.test.tsx b/frontend/src/app/ViewWindow.test.tsx index 95996a0d..175f0280 100644 --- a/frontend/src/app/ViewWindow.test.tsx +++ b/frontend/src/app/ViewWindow.test.tsx @@ -44,6 +44,13 @@ describe("parseViewWindowParams (#47)", () => { }); }); + it("accepts the detachable MCP panel", () => { + expect(parseViewWindowParams("?panel=mcp")).toEqual({ + kind: "panel", + panel: "mcp", + }); + }); + it("parses a plugin layout window query from the backend base64url contract", () => { expect( parseViewWindowParams( diff --git a/frontend/src/features/projects/ProjectsView.detach.test.tsx b/frontend/src/features/projects/ProjectsView.detach.test.tsx index 06b1e40e..4175b8ab 100644 --- a/frontend/src/features/projects/ProjectsView.detach.test.tsx +++ b/frontend/src/features/projects/ProjectsView.detach.test.tsx @@ -5,7 +5,7 @@ * main window), and the backend's close event re-toggles it back out of * "detached". The one-slot invariant holds throughout. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, screen, within, waitFor, fireEvent } from "@testing-library/react"; import { @@ -83,6 +83,41 @@ describe("ProjectsView detach to OS window (#23)", () => { expect(screen.queryByRole("dialog", { name: "Git" })).toBeNull(); }); + it("detaches the MCP panel through the same OS-window path", async () => { + const { windowGateway } = await renderWithProject(); + + pickPlacement("MCP", "Fenêtre détachée"); + + await waitFor(() => expect(windowGateway.open.has("mcp")).toBe(true)); + expect(screen.queryByRole("dialog", { name: "MCP" })).toBeNull(); + }); + + it("keeps the panel in place and surfaces a retry when detaching fails", async () => { + const { windowGateway } = await renderWithProject(); + const openViewWindow = vi + .spyOn(windowGateway, "openViewWindow") + .mockRejectedValueOnce(new Error("INVALID")) + .mockResolvedValue(undefined); + + pickPlacement("MCP", "Flottant"); + await screen.findByRole("dialog", { name: "MCP" }); + fireEvent.click( + screen.getByRole("button", { name: "Fenêtre détachée — MCP" }), + ); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain( + "Le panneau MCP ne peut pas être détaché pour le moment.", + ); + expect(screen.getByRole("dialog", { name: "MCP" })).toBeTruthy(); + + fireEvent.click(within(alert).getByRole("button", { name: "Réessayer" })); + await waitFor(() => expect(openViewWindow).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "MCP" })).toBeNull(), + ); + }); + it("re-toggles the placement out of detached when the OS window closes", async () => { const { windowGateway } = await renderWithProject(); diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 2a86b7b2..17e6308f 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -246,6 +246,7 @@ export function ProjectsView() { const [pendingConversationOpen, setPendingConversationOpen] = useState(null); const [taskToasts, setTaskToasts] = useState([]); + const [detachErrorPanel, setDetachErrorPanel] = useState(null); const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null; const activeViewerConversation = @@ -446,11 +447,17 @@ export function ProjectsView() { // shell. On failure the placement is left untouched (no ghost "detached" slot). function detachPanel(panel: PanelId) { if (!active) return; + setDetachErrorPanel(null); void windowGateway .openViewWindow(panel) - .then(() => setPlacement(panel, "detached")) + .then(() => { + setDetachErrorPanel(null); + setPlacement(panel, "detached"); + }) .catch(() => { - /* window failed to open; keep the current placement */ + // Keep the current placement (no ghost detached slot), but surface the + // failure so the action never appears to do nothing (#299). + setDetachErrorPanel(panel); }); } @@ -868,6 +875,20 @@ export function ProjectsView() { {vm.error}

)} + {detachErrorPanel && ( +
+ + Le panneau {PANEL_TITLE[detachErrorPanel]} ne peut pas être détaché + pour le moment. + + +
+ )} {/* ── Project tab bar (#26): tabs = open projects, "+" opens Projects ── */} { expect(isDetachablePanel("git")).toBe(true); expect(isDetachablePanel("tickets")).toBe(true); expect(isDetachablePanel("memory")).toBe(true); + expect(isDetachablePanel("mcp")).toBe(true); }); it("excludes projects (main-window-only chrome) and unknown panels", () => {