From e4622cd75364d4fccd63158800a4ca1187730466 Mon Sep 17 00:00:00 2001
From: Blomios
Date: Wed, 9 Sep 2026 21:41:41 +0200
Subject: [PATCH] =?UTF-8?q?fix(app-tauri,frontend):=20le=20panneau=20MCP?=
=?UTF-8?q?=20se=20d=C3=A9tache=20en=20fen=C3=AAtre=20OS=20=E2=80=94=20#29?=
=?UTF-8?q?9=20(cause:=20enum=20Rust=20ViewPanel=20sans=20variante=20Mcp,?=
=?UTF-8?q?=20open=5Fview=5Fwindow("mcp")=20rejetait=20INVALID=20et=20deta?=
=?UTF-8?q?chPanel=20ne=20touchait=20le=20placement=20qu'en=20cas=20de=20s?=
=?UTF-8?q?ucc=C3=A8s=20donc=20aucun=20effet=20visible=20ni=20message;=20f?=
=?UTF-8?q?ix:=20variante=20ViewPanel::Mcp=20avec=20parse/as=5Fstr/title?=
=?UTF-8?q?=20"MCP"=20+=20contrat=20stable=20label=20view-mcp=20/=20url=20?=
=?UTF-8?q?index.html=3Fpanel=3Dmcp=20/=20decodage=20view=5Fpanel=5Ffrom?=
=?UTF-8?q?=5Fwindow=5Flabel,=20identite=20persistee=20des=20fenetres=20vi?=
=?UTF-8?q?ew-mcp=20acceptee=20a=20la=20restauration=20#50,=20et=20Project?=
=?UTF-8?q?sView=20surface=20desormais=20un=20echec=20de=20detachement=20v?=
=?UTF-8?q?ia=20alerte=20role=3Dalert=20avec=20bouton=20Reessayer=20au=20l?=
=?UTF-8?q?ieu=20de=20l'echec=20silencieux=20=E2=80=94=20placement=20conse?=
=?UTF-8?q?rve=20tant=20que=20la=20fenetre=20OS=20ne=20s'ouvre=20pas;=20QA?=
=?UTF-8?q?=20verte=20reconfirmee=20par=20Git:=20rustup=20run=20stable=20c?=
=?UTF-8?q?argo=20fmt=20--all=20--=20--check,=20git=20diff=20--check,=20ca?=
=?UTF-8?q?rgo=20test=20-p=20app-tauri=20view=5Fwindow=5Ftests=209/9=20don?=
=?UTF-8?q?t=20mcp=5Fview=5Fwindow=5Fcontract=5Fis=5Fstable,=20persisted?=
=?UTF-8?q?=5Fidentity=5Faccepts=5Fmcp=5Fview=5Flabel=201/1,=20npm=20test?=
=?UTF-8?q?=2022/22=20sur=20ViewWindow/ProjectsView.detach/viewPlacement,?=
=?UTF-8?q?=20npm=20run=20build=20tsc+vite=20OK;=20fichiers=20tickets/296/?=
=?UTF-8?q?*,=20tickets/299/,=20scripts/=20et=20sync=20counter/index=20hor?=
=?UTF-8?q?s=20perimetre;=20aucun=20push)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
crates/app-tauri/src/commands.rs | 16 ++++++++
crates/app-tauri/src/lib.rs | 18 +++++++++
frontend/src/app/ViewWindow.test.tsx | 7 ++++
.../projects/ProjectsView.detach.test.tsx | 37 ++++++++++++++++++-
.../src/features/projects/ProjectsView.tsx | 25 ++++++++++++-
.../features/projects/viewPlacement.test.ts | 1 +
6 files changed, 101 insertions(+), 3 deletions(-)
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", () => {