fix(windows): fenêtres de panneau détachées suivent le projet en focus côté UI (#47)

Partie frontend. Ajoute un adaptateur focusedProject et un port dédié : la
ViewWindow détachée n'est plus liée à un project_id figé, elle s'abonne à
l'event focused-project émis par la fenêtre principale et affiche le panneau
du projet courant. ProjectsView propage le focus ; le détachement crée une
fenêtre panel-only. Couvert par les tests window/ViewWindow/focusedProject/
ProjectsView.focus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 12:37:45 +02:00
parent 6387fac34f
commit 221cc8be78
14 changed files with 522 additions and 130 deletions

View File

@ -12,6 +12,7 @@ import {
MockAgentGateway,
MockGitGateway,
MockProfileGateway,
MockFocusedProjectGateway,
MockProjectGateway,
MockSystemGateway,
MockTemplateGateway,
@ -24,7 +25,7 @@ import { ProjectsView } from "./ProjectsView";
async function renderWithProject() {
const project = new MockProjectGateway();
const created = await project.createProject("alpha", "/p/a");
await project.createProject("alpha", "/p/a");
const agentGateway = new MockAgentGateway();
const windowGateway = new MockWindowGateway();
const gateways = {
@ -36,6 +37,7 @@ async function renderWithProject() {
git: new MockGitGateway(),
workState: new MockWorkStateGateway(),
window: windowGateway,
focusedProject: new MockFocusedProjectGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
@ -46,7 +48,7 @@ async function renderWithProject() {
await waitFor(() =>
expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1),
);
return { windowGateway, projectId: created.id };
return { windowGateway };
}
/**
@ -62,7 +64,7 @@ function pickPlacement(panelTitle: string, placementLabel: string) {
describe("ProjectsView detach to OS window (#23)", () => {
it("« Fenêtre détachée » detaches a view: opens the OS window and marks the slot detached", async () => {
const { windowGateway, projectId } = await renderWithProject();
const { windowGateway } = await renderWithProject();
// A docked view is visible in the main window first…
pickPlacement("Git", "Ancré à gauche");
@ -72,7 +74,7 @@ describe("ProjectsView detach to OS window (#23)", () => {
pickPlacement("Git", "Fenêtre détachée");
await waitFor(() =>
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
expect(windowGateway.open.has("git")).toBe(true),
);
// The slot is detached ⇒ nothing renders for Git in the main window.
await waitFor(() =>
@ -82,11 +84,11 @@ describe("ProjectsView detach to OS window (#23)", () => {
});
it("re-toggles the placement out of detached when the OS window closes", async () => {
const { windowGateway, projectId } = await renderWithProject();
const { windowGateway } = await renderWithProject();
pickPlacement("Git", "Fenêtre détachée");
await waitFor(() =>
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
expect(windowGateway.open.has("git")).toBe(true),
);
// The « Fenêtre détachée » submenu leaf shows the active marker (●) while
@ -103,7 +105,7 @@ describe("ProjectsView detach to OS window (#23)", () => {
fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); // close
// The backend reports the OS window closed ⇒ the slot leaves "detached".
windowGateway.simulateOsClose("git", projectId);
windowGateway.simulateOsClose("git");
openGitSubmenu();
await waitFor(() =>
expect(detachedLeaf().textContent).not.toContain("●"),

View File

@ -0,0 +1,78 @@
/**
* #47 — `ProjectsView` is the single writer of the focused-project channel: it
* publishes the active project (or `null` when none is active) so detached
* panel-only windows can follow it. This drives the open → publish → close →
* publish-null flow and asserts what reaches the {@link FocusedProjectGateway}.
*/
import { describe, it, expect } from "vitest";
import { render, screen, within, waitFor, fireEvent } from "@testing-library/react";
import {
MockAgentGateway,
MockFocusedProjectGateway,
MockGitGateway,
MockProfileGateway,
MockProjectGateway,
MockSystemGateway,
MockTemplateGateway,
MockWindowGateway,
MockWorkStateGateway,
} from "@/adapters/mock";
import type { FocusedProject, Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { ProjectsView } from "./ProjectsView";
async function renderView() {
const project = new MockProjectGateway();
const created = await project.createProject("alpha", "/p/a");
const agentGateway = new MockAgentGateway();
const focusedProject = new MockFocusedProjectGateway();
// Record every published focus so we can assert the sequence.
const published: (FocusedProject | null)[] = [];
await focusedProject.onFocusedProjectChanged((p) => published.push(p));
const gateways = {
system: new MockSystemGateway(),
project,
agent: agentGateway,
profile: new MockProfileGateway(),
template: new MockTemplateGateway(agentGateway),
git: new MockGitGateway(),
workState: new MockWorkStateGateway(),
window: new MockWindowGateway(),
focusedProject,
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<ProjectsView />
</DIProvider>,
);
return { focusedProject, published, projectId: created.id };
}
describe("ProjectsView focus publishing (#47)", () => {
it("publishes the active project on open and null when it is closed", async () => {
const { focusedProject, published, projectId } = await renderView();
// Mount with no active project publishes a `null` focus.
await waitFor(() => expect(published).toContainEqual(null));
// Open the project → publishes the { id, name, root } snapshot.
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
await waitFor(() =>
expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1),
);
await waitFor(() =>
expect(focusedProject.getFocusedProject()).resolves.toEqual({
id: projectId,
name: "alpha",
root: "/p/a",
}),
);
// Close the tab → back to no active project → publishes `null`.
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
await waitFor(() =>
expect(focusedProject.getFocusedProject()).resolves.toBeNull(),
);
});
});

View File

@ -102,7 +102,7 @@ function isTerminalBackgroundTaskEvent(
export function ProjectsView() {
const vm = useProjects();
const { system, window: windowGateway } = useGateways();
const { system, window: windowGateway, focusedProject } = useGateways();
const [name, setName] = useState("");
const [root, setRoot] = useState("");
// Placement of every open view (#22): each panel is "closed" (absent),
@ -141,6 +141,17 @@ export function ProjectsView() {
setViewerConversationId(null);
}, [active?.id]);
// Publish the focused project (#47) so detached panel-only windows follow the
// main window: they render this project, or an "open a project" shell when
// none is active. Publish on EVERY change of `active` — including
// switching-away to null — so a restored panel window never shows a stale
// project. Optional-chained: unit tests may omit this gateway.
useEffect(() => {
void focusedProject?.setFocusedProject(
active ? { id: active.id, name: active.name, root: active.root } : null,
);
}, [focusedProject, active?.id, active?.name, active?.root]);
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let cancelled = false;
@ -227,15 +238,16 @@ 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).
// Detach a view into its own OS window (#23, panel-only in #47): ask the
// backend to open the window, then mark the slot "detached" so nothing renders
// for it in the main window. The window is panel-only and follows the focused
// project (published above), so no project id is passed. Requires an active
// project only so a detached window opens onto something rather than the empty
// shell. 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)
.openViewWindow(panel)
.then(() => setPlacement(panel, "detached"))
.catch(() => {
/* window failed to open; keep the current placement */