fix(app-tauri,frontend): le panneau MCP se détache en fenêtre OS — #299 (cause: enum Rust ViewPanel sans variante Mcp, open_view_window("mcp") rejetait INVALID et detachPanel ne touchait le placement qu'en cas de succès donc aucun effet visible ni message; fix: variante ViewPanel::Mcp avec parse/as_str/title "MCP" + contrat stable label view-mcp / url index.html?panel=mcp / decodage view_panel_from_window_label, identite persistee des fenetres view-mcp acceptee a la restauration #50, et ProjectsView surface desormais un echec de detachement via alerte role=alert avec bouton Reessayer au lieu de l'echec silencieux — placement conserve tant que la fenetre OS ne s'ouvre pas; QA verte reconfirmee 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; fichiers tickets/296/*, tickets/299/, scripts/ et sync counter/index hors perimetre; aucun push)

This commit is contained in:
2026-09-09 21:41:41 +02:00
parent 602abfea58
commit e4622cd753
6 changed files with 101 additions and 3 deletions

View File

@ -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();

View File

@ -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

View File

@ -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", () => {