feat(ui): fenêtres View système séparées — WebviewWindow Tauri + port WindowGateway (#23)

Détache les vues dans de vraies fenêtres système Tauri (multi-écran,
fullscreen). Backend : commandes de gestion de WebviewWindow, capabilities
et composition root. Frontend : nouveau port WindowGateway et son adaptateur
window, entrée panel-only ViewWindow/ViewPanelBody, détachement câblé dans
ProjectsView. QA vert : app-tauri 63, frontend typecheck + vitest 546/546.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 16:53:43 +02:00
parent 2323a71f01
commit 69e785ec7e
21 changed files with 962 additions and 55 deletions

View File

@ -0,0 +1,62 @@
/**
* #23 — the panel-only entry. `parseViewWindowParams` validates the
* `?panel=&project=` query, and `ViewWindow` resolves the project through the
* gateway then renders only the requested view (here: the tickets view).
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { DIProvider } from "@/app/di";
import {
MockAgentGateway,
MockProjectGateway,
MockSystemGateway,
MockTicketGateway,
} from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
describe("parseViewWindowParams (#23)", () => {
it("parses a valid panel + project", () => {
expect(parseViewWindowParams("?panel=tickets&project=p-1")).toEqual({
panel: "tickets",
projectId: "p-1",
});
});
it("rejects a missing project, missing panel, or the non-detachable projects panel", () => {
expect(parseViewWindowParams("?panel=tickets")).toBeNull();
expect(parseViewWindowParams("?project=p-1")).toBeNull();
expect(parseViewWindowParams("?panel=projects&project=p-1")).toBeNull();
expect(parseViewWindowParams("?panel=bogus&project=p-1")).toBeNull();
expect(parseViewWindowParams("")).toBeNull();
});
});
describe("ViewWindow (#23)", () => {
it("resolves the project and renders only the requested view", async () => {
const system = new MockSystemGateway();
const project = new MockProjectGateway();
const created = await project.createProject("alpha", "/p/a");
const gateways = {
system,
project,
agent: new MockAgentGateway(),
ticket: new MockTicketGateway(system),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<ViewWindow panel="tickets" projectId={created.id} />
</DIProvider>,
);
// The window chrome shows the panel title and the resolved project name.
expect(screen.getByRole("heading", { name: "Tickets" })).toBeTruthy();
await waitFor(() => expect(screen.getByText("· alpha")).toBeTruthy());
// The tickets view itself mounted (its search box is present).
await waitFor(() =>
expect(screen.getByLabelText("search tickets")).toBeTruthy(),
);
});
});

View File

@ -0,0 +1,110 @@
/**
* `ViewWindow` — the panel-only entry (ticket #23).
*
* A detached OS window loads the same SPA with `?panel=<panel>&project=<id>`;
* {@link main} routes to this component instead of the full {@link App} when a
* `panel` param is present. It re-instantiates the DI (same adapters/gateways
* as the main window — views are gateway/read-model-driven, so no heavy state
* is transferred) and renders **only** the requested view for the given
* project.
*
* Pure presentation over the ports: it resolves the project through the
* {@link ProjectGateway} (for its name/root) and hands off to the shared
* {@link ViewPanelBody}. No `invoke()`, no cross-window messaging.
*/
import { useEffect, useState } from "react";
import type { Project } from "@/domain";
import { Panel, Spinner } from "@/shared";
import { PANEL_TITLE, type PanelId } from "@/features/projects";
import { ViewPanelBody, type ViewPanelId } from "@/features/projects";
import { useGateways } from "./di";
/** Panels that may be shown in a detached window (everything but "projects"). */
const DETACHABLE = new Set<string>(
(Object.keys(PANEL_TITLE) as PanelId[]).filter((p) => p !== "projects"),
);
/** Parsed `?panel=&project=` params, or `null` when absent/invalid. */
export interface ViewWindowParams {
panel: ViewPanelId;
projectId: string;
}
/** Reads and validates the panel-only params from a query string. */
export function parseViewWindowParams(
search: string,
): ViewWindowParams | null {
const params = new URLSearchParams(search);
const panel = params.get("panel");
const projectId = params.get("project");
if (!panel || !projectId || !DETACHABLE.has(panel)) return null;
return { panel: panel as ViewPanelId, projectId };
}
export interface ViewWindowProps {
panel: ViewPanelId;
projectId: string;
}
export function ViewWindow({ panel, projectId }: ViewWindowProps) {
const { project } = useGateways();
const [resolved, setResolved] = useState<Project | null>(null);
const [error, setError] = useState<string | null>(null);
// Resolve the project to get its root (the agents panel needs it) and name
// (window title). Opening is idempotent on the backend registry.
useEffect(() => {
let cancelled = false;
project
.openProject(projectId)
.then((p) => {
if (!cancelled) setResolved(p);
})
.catch((e: unknown) => {
if (!cancelled) setError(describeError(e));
});
return () => {
cancelled = true;
};
}, [project, projectId]);
return (
<div className="flex h-full flex-col bg-canvas text-content">
<header className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<h1 className="text-sm font-semibold text-content">
{PANEL_TITLE[panel]}
</h1>
{resolved && (
<span className="truncate text-xs text-muted">· {resolved.name}</span>
)}
</header>
<main className="min-h-0 flex-1 overflow-auto p-4">
{error ? (
<Panel className="border-danger/40">
<p className="text-sm text-danger">Error: {error}</p>
</Panel>
) : resolved ? (
<ViewPanelBody
panel={panel}
projectId={resolved.id}
projectRoot={resolved.root}
/>
) : (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>Loading</span>
</div>
)}
</main>
</div>
);
}
function describeError(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as { message: unknown }).message);
}
return String(e);
}

View File

@ -4,6 +4,7 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
import { DIProvider } from "./di";
import "@/shared/styles/theme.css";
@ -12,10 +13,18 @@ if (!root) {
throw new Error("missing #root element");
}
// #23: a detached view window loads the SPA with `?panel=&project=`. When those
// params are present and valid, render only that view; otherwise the full app.
const viewParams = parseViewWindowParams(window.location.search);
ReactDOM.createRoot(root).render(
<React.StrictMode>
<DIProvider>
<App />
{viewParams ? (
<ViewWindow panel={viewParams.panel} projectId={viewParams.projectId} />
) : (
<App />
)}
</DIProvider>
</React.StrictMode>,
);