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,105 @@
/**
* #23 — detaching a View into its own OS window from `ProjectsView`. The
* "Window" menu (and the docked/floating header ⤢ control) call the
* {@link WindowGateway}; the slot becomes "detached" (nothing renders in the
* 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 { render, screen, within, waitFor, fireEvent } from "@testing-library/react";
import {
MockAgentGateway,
MockGitGateway,
MockProfileGateway,
MockProjectGateway,
MockSystemGateway,
MockTemplateGateway,
MockWindowGateway,
MockWorkStateGateway,
} from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { ProjectsView } from "./ProjectsView";
async function renderWithProject() {
const project = new MockProjectGateway();
const created = await project.createProject("alpha", "/p/a");
const agentGateway = new MockAgentGateway();
const windowGateway = new MockWindowGateway();
const gateways = {
system: new MockSystemGateway(),
project,
agent: agentGateway,
profile: new MockProfileGateway(),
template: new MockTemplateGateway(agentGateway),
git: new MockGitGateway(),
workState: new MockWorkStateGateway(),
window: windowGateway,
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<ProjectsView />
</DIProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
await waitFor(() =>
expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1),
);
return { windowGateway, projectId: created.id };
}
function openMenuItem(menu: string, item: string) {
fireEvent.click(screen.getByRole("button", { name: menu }));
fireEvent.click(screen.getByRole("button", { name: item }));
}
describe("ProjectsView detach to OS window (#23)", () => {
it("Window menu detaches a view: opens the OS window and marks the slot detached", async () => {
const { windowGateway, projectId } = await renderWithProject();
// A docked view is visible in the main window first…
openMenuItem("View", "Git");
const dialog = await screen.findByRole("dialog", { name: "Git" });
fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" }));
await screen.findByRole("complementary", { name: "left dock" });
// …then « Git → nouvelle fenêtre » pops it out.
openMenuItem("Window", "Git → nouvelle fenêtre");
await waitFor(() =>
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
);
// The slot is detached ⇒ nothing renders for Git in the main window.
await waitFor(() =>
expect(screen.queryByRole("complementary", { name: "left dock" })).toBeNull(),
);
expect(screen.queryByRole("dialog", { name: "Git" })).toBeNull();
});
it("re-toggles the placement out of detached when the OS window closes", async () => {
const { windowGateway, projectId } = await renderWithProject();
openMenuItem("Window", "Git → nouvelle fenêtre");
await waitFor(() =>
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
);
// The « Window » item shows the active marker (●) while detached.
const openWindowMenu = () =>
fireEvent.click(screen.getByRole("button", { name: "Window" }));
const gitWindowItem = () =>
screen.getByRole("button", { name: "Git → nouvelle fenêtre" });
openWindowMenu();
await waitFor(() => expect(gitWindowItem().textContent).toContain("●"));
openWindowMenu(); // close the dropdown
// The backend reports the OS window closed ⇒ the slot leaves "detached".
windowGateway.simulateOsClose("git", projectId);
openWindowMenu();
await waitFor(() =>
expect(gitWindowItem().textContent).not.toContain("●"),
);
});
});

View File

@ -14,6 +14,7 @@ import {
MockProjectGateway,
MockSystemGateway,
MockTemplateGateway,
MockWindowGateway,
MockWorkStateGateway,
} from "@/adapters/mock";
import type { Gateways } from "@/ports";
@ -32,6 +33,7 @@ async function renderWithProject() {
template: new MockTemplateGateway(agentGateway),
git: new MockGitGateway(),
workState: new MockWorkStateGateway(),
window: new MockWindowGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>

View File

@ -16,6 +16,7 @@ import {
MockSystemGateway,
MockTemplateGateway,
MockTerminalGateway,
MockWindowGateway,
} from "@/adapters/mock";
import type {
ConversationGateway,
@ -102,6 +103,7 @@ function renderView(project: MockProjectGateway) {
terminal: new MockTerminalGateway(),
workState: fixedWorkState(),
conversation: fixedConversation(),
window: new MockWindowGateway(),
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>

View File

@ -31,17 +31,9 @@ import { useEffect, useState, type ReactNode } from "react";
import type { DomainEvent, LayoutInfo } from "@/domain";
import { LayoutGrid, LayoutTabs } from "@/features/layout";
import { AgentsPanel } from "@/features/agents";
import { TemplatesPanel } from "@/features/templates";
import { SkillsPanel } from "@/features/skills";
import { MemoryPanel } from "@/features/memory";
import { EmbedderSettings } from "@/features/embedder";
import { PermissionsPanel } from "@/features/permissions";
import { ProjectWorkStatePanel } from "@/features/workstate";
import { TicketsView } from "@/features/tickets";
import { ConversationViewer } from "@/features/conversations";
import { ProfilesSettings } from "@/features/first-run";
import { GitPanel, GitGraphView } from "@/features/git";
import { GitGraphView } from "@/features/git";
import {
Button,
DockRegion,
@ -55,11 +47,12 @@ import {
type MenuBarMenu,
} from "@/shared";
import { useGateways } from "@/app/di";
import { ProjectContextPanel } from "./ProjectContextPanel";
import { useProjects } from "./useProjects";
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
import {
PANEL_TITLE,
floatingPanels,
isDetached,
isDockedTo,
panelsDockedTo,
placementOf,
@ -104,7 +97,7 @@ function isTerminalBackgroundTaskEvent(
export function ProjectsView() {
const vm = useProjects();
const { system } = useGateways();
const { system, window: windowGateway } = useGateways();
const [name, setName] = useState("");
const [root, setRoot] = useState("");
// Placement of every open view (#22): each panel is "closed" (absent),
@ -237,6 +230,48 @@ export function ProjectsView() {
});
}
// Detach a view into its own OS window (#23): ask the backend to open the
// window, then mark the slot "detached" so nothing renders for it in the main
// window. Requires an active project (the window is project-scoped). On
// failure the placement is left untouched (no ghost "detached" slot).
function detachPanel(panel: PanelId) {
if (!active) return;
const projectId = active.id;
void windowGateway
.openViewWindow(panel, projectId)
.then(() => setPlacement(panel, "detached"))
.catch(() => {
/* window failed to open; keep the current placement */
});
}
// When a detached window closes (OS close or programmatic), re-toggle its
// placement out of "detached" so it isn't stuck as an invisible ghost slot.
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void windowGateway
.onViewWindowClosed(({ panel }) => {
setPlacements((prev) =>
prev[panel as PanelId] === "detached"
? (() => {
const next = { ...prev };
delete next[panel as PanelId];
return next;
})()
: prev,
);
})
.then((u) => {
if (cancelled) u();
else unsubscribe = u;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [windowGateway]);
// Opening a conversation viewer takes over the main area — dismiss floating
// windows so they don't obscure the viewer (LS7). Docked views stay beside it.
function openConversation(conversationId: string) {
@ -315,6 +350,19 @@ export function ProjectsView() {
onSelect: () => toggleFloating(it.id),
})),
},
{
// #23: pop a view out into its own OS window. Disabled without an active
// project (the detached window is project-scoped); active ⇒ detached.
id: "window",
label: "Window",
items: viewItems.map((it) => ({
id: it.id,
label: `${PANEL_TITLE[it.id]} → nouvelle fenêtre`,
active: isDetached(placementOf(placements, it.id)),
disabled: !active,
onSelect: () => detachPanel(it.id),
})),
},
{
id: "settings",
label: "Settings",
@ -407,7 +455,9 @@ export function ProjectsView() {
</div>
);
// Body of the currently-open panel window (null ⇒ no window).
// Body of a panel wherever it is placed (docked/floating). Delegates to the
// shared {@link ViewPanelBody} (also used by the detached ViewWindow) so a
// view is identical across placements. "projects" is main-window-only chrome.
function renderPanel(panel: PanelId): ReactNode {
if (panel === "projects") return projectsManager;
if (!active) {
@ -415,36 +465,14 @@ export function ProjectsView() {
<p className="text-sm text-muted">Open a project to use this panel.</p>
);
}
switch (panel) {
case "context":
return <ProjectContextPanel projectId={active.id} />;
case "work":
return (
<ProjectWorkStatePanel
projectId={active.id}
onOpenConversation={openConversation}
/>
);
case "tickets":
return <TicketsView projectId={active.id} />;
case "agents":
return <AgentsPanel projectId={active.id} projectRoot={active.root} />;
case "templates":
return <TemplatesPanel projectId={active.id} />;
case "skills":
return <SkillsPanel projectId={active.id} />;
case "permissions":
return <PermissionsPanel projectId={active.id} />;
case "memory":
return (
<div className="flex flex-col gap-4">
<MemoryPanel projectId={active.id} />
<EmbedderSettings />
</div>
);
case "git":
return <GitPanel projectId={active.id} />;
}
return (
<ViewPanelBody
panel={panel as ViewPanelId}
projectId={active.id}
projectRoot={active.root}
onOpenConversation={openConversation}
/>
);
}
// Projects manager renders inline in the welcome area only when it is not
@ -492,6 +520,15 @@ export function ProjectsView() {
>
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`open ${title} in new window`}
disabled={!active || isDetached(placement)}
onClick={() => detachPanel(panel)}
>
</Button>
<Button
size="sm"
variant="ghost"

View File

@ -0,0 +1,75 @@
/**
* `ViewPanelBody` — renders the body of a single View panel from its id and a
* project context, reusing the existing feature panels as-is.
*
* Shared (ticket #23) between the in-window placements of {@link ProjectsView}
* (docked/floating) and the detached {@link ViewWindow} (its own OS window), so
* a view looks and behaves the same wherever it is placed. The `"projects"`
* panel is intentionally **not** handled here — the projects manager is
* main-window chrome, never a detachable view.
*/
import type { ReactNode } from "react";
import { AgentsPanel } from "@/features/agents";
import { TemplatesPanel } from "@/features/templates";
import { SkillsPanel } from "@/features/skills";
import { MemoryPanel } from "@/features/memory";
import { EmbedderSettings } from "@/features/embedder";
import { PermissionsPanel } from "@/features/permissions";
import { ProjectWorkStatePanel } from "@/features/workstate";
import { TicketsView } from "@/features/tickets";
import { GitPanel } from "@/features/git";
import { ProjectContextPanel } from "./ProjectContextPanel";
import type { PanelId } from "./viewPlacement";
/** Panels that render a project-scoped view (everything except "projects"). */
export type ViewPanelId = Exclude<PanelId, "projects">;
export interface ViewPanelBodyProps {
panel: ViewPanelId;
projectId: string;
/** Project root — required by the agents panel (its cwd base). */
projectRoot: string;
/** Optional: open a conversation viewer (only wired inside the main window). */
onOpenConversation?: (conversationId: string) => void;
}
/** Renders the requested view panel for the given project. */
export function ViewPanelBody({
panel,
projectId,
projectRoot,
onOpenConversation,
}: ViewPanelBodyProps): ReactNode {
switch (panel) {
case "context":
return <ProjectContextPanel projectId={projectId} />;
case "work":
return (
<ProjectWorkStatePanel
projectId={projectId}
onOpenConversation={onOpenConversation}
/>
);
case "tickets":
return <TicketsView projectId={projectId} />;
case "agents":
return <AgentsPanel projectId={projectId} projectRoot={projectRoot} />;
case "templates":
return <TemplatesPanel projectId={projectId} />;
case "skills":
return <SkillsPanel projectId={projectId} />;
case "permissions":
return <PermissionsPanel projectId={projectId} />;
case "memory":
return (
<div className="flex flex-col gap-4">
<MemoryPanel projectId={projectId} />
<EmbedderSettings />
</div>
);
case "git":
return <GitPanel projectId={projectId} />;
}
}

View File

@ -3,3 +3,7 @@
export { ProjectsView } from "./ProjectsView";
export { useProjects } from "./useProjects";
export type { ProjectsViewModel } from "./useProjects";
export { ViewPanelBody } from "./ViewPanelBody";
export type { ViewPanelId, ViewPanelBodyProps } from "./ViewPanelBody";
export { PANEL_TITLE, placementOf, isDetached } from "./viewPlacement";
export type { PanelId, ViewPlacement, ViewPlacements } from "./viewPlacement";

View File

@ -12,7 +12,7 @@ import {
fireEvent,
} from "@testing-library/react";
import { MockAgentGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway, MockWorkStateGateway } from "@/adapters/mock";
import { MockAgentGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway, MockWindowGateway, MockWorkStateGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { ProjectsView } from "./ProjectsView";
@ -34,6 +34,7 @@ function renderView(
template: new MockTemplateGateway(agentGateway),
git: new MockGitGateway(),
workState: new MockWorkStateGateway(),
window: new MockWindowGateway(),
} as unknown as Gateways;
return {
project,

View File

@ -7,12 +7,13 @@
* - `"closed"` — not shown anywhere,
* - `"floating"` — shown in a modal {@link FloatingWindow} (the historical
* behaviour),
* - `{ dock }` — anchored in-flow in the left/right {@link DockRegion}.
* - `{ dock }` — anchored in-flow in the left/right {@link DockRegion},
* - `"detached"` — popped out into its own OS window (ticket #23); nothing is
* rendered for it in the main window while detached.
*
* The union is **deliberately extensible**: ticket #23 adds `"detached"` (an OS
* window) as a further mutually-exclusive slot without touching the invariant.
* Absent from the map ≡ `"closed"`, so the default (nothing open) is the empty
* map.
* One view = exactly one slot (the invariant): a view is closed, floating,
* docked **or** detached, never two at once. Absent from the map ≡ `"closed"`,
* so the default (nothing open) is the empty map.
*/
import type { DockSide } from "@/shared";
@ -32,9 +33,13 @@ export type PanelId =
/**
* Where a view currently lives. Exactly one slot per view (the invariant).
* `{ dock }` carries the side; #23 will extend this union with `"detached"`.
* `{ dock }` carries the side; `"detached"` means it lives in its own OS window.
*/
export type ViewPlacement = "closed" | "floating" | { dock: DockSide };
export type ViewPlacement =
| "closed"
| "floating"
| { dock: DockSide }
| "detached";
/** The placement of every open view (absent key ≡ `"closed"`). */
export type ViewPlacements = Partial<Record<PanelId, ViewPlacement>>;
@ -85,3 +90,8 @@ export function floatingPanels(placements: ViewPlacements): PanelId[] {
(panel) => placementOf(placements, panel) === "floating",
);
}
/** True when the view is popped out into its own OS window (#23). */
export function isDetached(placement: ViewPlacement): boolean {
return placement === "detached";
}