feat(ui): anchor de views — primitive de docking DockRegion + modèle ViewPlacement (#22)

Introduit la primitive de docking DockRegion et le modèle ViewPlacement
pour ancrer les vues dans le chrome, câblés dans ProjectsView. Tests
unitaires DockRegion et test d'intégration docking de ProjectsView.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 11:45:46 +02:00
parent 2f8467e2dd
commit 419e9b8498
6 changed files with 592 additions and 50 deletions

View File

@ -0,0 +1,122 @@
/**
* #22 — view anchoring/docking in `ProjectsView`. A view opened from the menu
* starts floating (modal window); its header controls move it into the in-flow
* left/right {@link DockRegion} and back, and the one-slot-per-view invariant
* holds (docking a floating view removes the modal window).
*/
import { describe, it, expect } from "vitest";
import { render, screen, within, waitFor, fireEvent } from "@testing-library/react";
import {
MockAgentGateway,
MockGitGateway,
MockProfileGateway,
MockProjectGateway,
MockSystemGateway,
MockTemplateGateway,
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();
await project.createProject("alpha", "/p/a");
const agentGateway = new MockAgentGateway();
const gateways = {
system: new MockSystemGateway(),
project,
agent: agentGateway,
profile: new MockProfileGateway(),
template: new MockTemplateGateway(agentGateway),
git: new MockGitGateway(),
workState: new MockWorkStateGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<ProjectsView />
</DIProvider>,
);
// Open the seeded project so panels have an active project to render for.
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
await waitFor(() =>
expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1),
);
}
/** Opens a menu-bar dropdown item. */
function openMenuItem(menu: string, item: string) {
fireEvent.click(screen.getByRole("button", { name: menu }));
fireEvent.click(screen.getByRole("button", { name: item }));
}
describe("ProjectsView view docking (#22)", () => {
it("opens a view floating, then docks it left as an in-flow column", async () => {
await renderWithProject();
// View → Git opens a modal floating window (historical behaviour).
openMenuItem("View", "Git");
const dialog = await screen.findByRole("dialog", { name: "Git" });
expect(dialog).toBeTruthy();
// Dock it to the left via the header control.
fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" }));
// The modal window is gone; the Git view now lives in the left dock column.
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Git" })).toBeNull(),
);
const leftDock = screen.getByRole("complementary", { name: "left dock" });
expect(within(leftDock).getByLabelText("Git panel")).toBeTruthy();
// The main surface is still present beside the dock (in-flow, not overlay).
expect(screen.getByRole("main")).toBeTruthy();
});
it("keeps one slot per view: docking right moves it off the left dock", async () => {
await renderWithProject();
openMenuItem("View", "Git");
const dialog = await screen.findByRole("dialog", { name: "Git" });
fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" }));
const leftDock = await screen.findByRole("complementary", { name: "left dock" });
const leftHeader = within(leftDock).getByLabelText("Git panel");
// Move it to the right dock from the docked header control.
fireEvent.click(
within(leftHeader).getByRole("button", { name: "dock Git right" }),
);
await waitFor(() =>
expect(screen.queryByRole("complementary", { name: "left dock" })).toBeNull(),
);
const rightDock = screen.getByRole("complementary", { name: "right dock" });
expect(within(rightDock).getByLabelText("Git panel")).toBeTruthy();
});
it("docks two different views side by side (left + right)", async () => {
await renderWithProject();
// Dock Git left.
openMenuItem("View", "Git");
fireEvent.click(
within(await screen.findByRole("dialog", { name: "Git" })).getByRole(
"button",
{ name: "dock Git left" },
),
);
// Dock Agents right.
openMenuItem("View", "Agents");
fireEvent.click(
within(await screen.findByRole("dialog", { name: "Agents" })).getByRole(
"button",
{ name: "dock Agents right" },
),
);
const leftDock = await screen.findByRole("complementary", { name: "left dock" });
const rightDock = await screen.findByRole("complementary", { name: "right dock" });
expect(within(leftDock).getByLabelText("Git panel")).toBeTruthy();
expect(within(rightDock).getByLabelText("Agents panel")).toBeTruthy();
});
});