merge(views): le panneau MCP se détache en fenêtre OS — #299 (QA verte reconfirmée par Git: rustup run stable cargo fmt --all -- --check, git diff --check, cargo test -p app-tauri view_window_tests 9/9 dont mcp_view_window_contract_is_stable, persisted_identity_accepts_mcp_view_label 1/1, npm test 22/22 sur ViewWindow/ProjectsView.detach/viewPlacement, npm run build tsc+vite OK; branche fix/299-mcp-panel-detach-window commit e4622cd; cause racine: enum Rust ViewPanel sans variante Mcp donc open_view_window rejetait INVALID silencieusement; correctif borné 6 fichiers: variante ViewPanel::Mcp + contrats label view-mcp/url/title/identité persistée, alerte explicite avec Réessayer sur échec de détachement dans ProjectsView; restants: rebuild AppImage + validation live du détachement MCP depuis l'app installée; fichiers tickets/296/*, tickets/299/ et scripts/ hors périmètre; aucun push)
This commit is contained in:
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -246,6 +246,7 @@ export function ProjectsView() {
|
||||
const [pendingConversationOpen, setPendingConversationOpen] =
|
||||
useState<PendingConversationOpen | null>(null);
|
||||
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
|
||||
const [detachErrorPanel, setDetachErrorPanel] = useState<PanelId | null>(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}
|
||||
</p>
|
||||
)}
|
||||
{detachErrorPanel && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mx-4 mt-2 flex shrink-0 items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
<span>
|
||||
Le panneau {PANEL_TITLE[detachErrorPanel]} ne peut pas être détaché
|
||||
pour le moment.
|
||||
</span>
|
||||
<Button size="sm" onClick={() => detachPanel(detachErrorPanel)}>
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Project tab bar (#26): tabs = open projects, "+" opens Projects ── */}
|
||||
<ProjectTabs
|
||||
|
||||
@ -24,6 +24,7 @@ describe("isDetachablePanel (#50)", () => {
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user