Merge feature/ticket50-open-windows-menu-state into develop

Ticket #50 : commande backend `list_open_view_windows` + consommateur
frontend qui réconcilie l'état du menu Panneaux avec les fenêtres
détachées réellement ouvertes. Validé QA vert (frontend
typecheck+build+677 tests ; backend cargo check/build + 7 tests
view_window).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 18:15:09 +02:00
9 changed files with 386 additions and 1 deletions

View File

@ -0,0 +1,98 @@
/**
* #50 — at mount, `ProjectsView` reconciles its `placements` with the detached
* view windows already open at the OS level (a panel-only window restored by the
* OS before the main window mounted). Without this, the Panneaux menu would show
* such a window as « Fermé ». This exercises the wiring end-to-end through the
* {@link WindowGateway}; the reconciliation invariants themselves are unit-tested
* in `viewPlacement.test.ts`.
*/
import { describe, it, expect } from "vitest";
import {
render,
screen,
within,
waitFor,
fireEvent,
} from "@testing-library/react";
import {
MockAgentGateway,
MockGitGateway,
MockProfileGateway,
MockFocusedProjectGateway,
MockProjectGateway,
MockSystemGateway,
MockTemplateGateway,
MockWindowGateway,
MockWorkStateGateway,
} from "@/adapters/mock";
import type { Gateways, WindowGateway } from "@/ports";
import { DIProvider } from "@/app/di";
import { ProjectsView } from "./ProjectsView";
async function renderWith(windowGateway: WindowGateway) {
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(),
window: windowGateway,
focusedProject: new MockFocusedProjectGateway(),
} 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),
);
}
/** Opens « Panneaux → Git » and returns the « Fenêtre détachée » submenu leaf. */
function openGitDetachedLeaf() {
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
fireEvent.click(screen.getByRole("button", { name: "Git" }));
return screen.getByRole("button", { name: "Fenêtre détachée" });
}
describe("ProjectsView startup reconciliation (#50)", () => {
it("marks an already-open detached window as « détaché » at mount", async () => {
const windowGateway = new MockWindowGateway();
// A panel-only Git window is already open at the OS level before mount.
windowGateway.open.add("git");
await renderWith(windowGateway);
// The « Fenêtre détachée » leaf reads ACTIVE (●) — not « Fermé ».
await waitFor(() =>
expect(openGitDetachedLeaf().textContent).toContain("●"),
);
});
it("does not reconcile when enumeration fails (no ghost detached slot)", async () => {
const windowGateway = new MockWindowGateway();
windowGateway.listOpenViewWindows = () =>
Promise.reject(new Error("boom"));
await renderWith(windowGateway);
// Git stays « Fermé »: the detached leaf is not active.
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
fireEvent.click(screen.getByRole("button", { name: "Git" }));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Fenêtre détachée" }).textContent,
).not.toContain("●"),
);
});
});

View File

@ -61,6 +61,7 @@ import {
isDockedTo,
panelsDockedTo,
placementOf,
reconcileDetachedWindows,
type PanelId,
type ViewPlacement,
type ViewPlacements,
@ -281,6 +282,31 @@ export function ProjectsView() {
};
}, [windowGateway]);
// On mount, reconcile `placements` with detached windows already open at the
// OS level (#50): a panel-only window restored by the OS before the main
// window mounted is unknown to `placements`, so the Panneaux menu would wrongly
// show it "Fermé". Enumerate them once and mark each as "detached" — but only
// best-effort and without clobbering more specific state: skip non-visible
// snapshots, skip `projects`/unknown panels, and never overwrite a placement
// the user (or a UI restore) already set to something other than "closed". A
// failed enumeration leaves `placements` untouched (no ghost slots). Independent
// of `active`: a panel-only window can be open with no active project.
useEffect(() => {
let cancelled = false;
void windowGateway
.listOpenViewWindows()
.then((snapshots) => {
if (cancelled) return;
setPlacements((prev) => reconcileDetachedWindows(prev, snapshots));
})
.catch(() => {
/* best-effort only: no reconciliation on failure */
});
return () => {
cancelled = true;
};
}, [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) {

View File

@ -0,0 +1,89 @@
/**
* #50 — startup reconciliation of `placements` with the detached view windows
* already open at the OS level. Unit-tests the pure `isDetachablePanel` guard
* and `reconcileDetachedWindows`, covering every Architect invariant:
* only-visible, known-detachable-only (projects/unknown excluded), and the
* no-clobber rule (never overwrite a non-closed placement).
*/
import { describe, it, expect } from "vitest";
import type { ViewWindowSnapshot } from "@/ports";
import {
isDetachablePanel,
reconcileDetachedWindows,
type ViewPlacements,
} from "./viewPlacement";
const snap = (
panel: string,
visible = true,
): ViewWindowSnapshot => ({ panel, label: panel, visible });
describe("isDetachablePanel (#50)", () => {
it("accepts every known panel except projects", () => {
expect(isDetachablePanel("git")).toBe(true);
expect(isDetachablePanel("tickets")).toBe(true);
expect(isDetachablePanel("memory")).toBe(true);
});
it("excludes projects (main-window-only chrome) and unknown panels", () => {
expect(isDetachablePanel("projects")).toBe(false);
expect(isDetachablePanel("bogus")).toBe(false);
expect(isDetachablePanel("")).toBe(false);
});
});
describe("reconcileDetachedWindows (#50)", () => {
it("marks a visible detachable snapshot as detached from an empty state", () => {
const next = reconcileDetachedWindows({}, [snap("git")]);
expect(next).toEqual({ git: "detached" });
});
it("reconciles several panels at once", () => {
const next = reconcileDetachedWindows({}, [snap("git"), snap("tickets")]);
expect(next).toEqual({ git: "detached", tickets: "detached" });
});
it("ignores non-visible snapshots", () => {
const prev: ViewPlacements = {};
const next = reconcileDetachedWindows(prev, [snap("git", false)]);
expect(next).toBe(prev); // referentially unchanged
});
it("excludes the projects panel", () => {
const prev: ViewPlacements = {};
const next = reconcileDetachedWindows(prev, [snap("projects")]);
expect(next).toBe(prev);
});
it("ignores unknown panel ids", () => {
const prev: ViewPlacements = {};
const next = reconcileDetachedWindows(prev, [snap("bogus")]);
expect(next).toBe(prev);
});
it("never clobbers a non-closed placement (floating/dock/detached preserved)", () => {
const prev: ViewPlacements = {
git: "floating",
tickets: { dock: "left" },
memory: "detached",
};
const next = reconcileDetachedWindows(prev, [
snap("git"),
snap("tickets"),
snap("memory"),
]);
expect(next).toBe(prev); // nothing changed → same reference
});
it("reconciles only the closed panels, leaving others intact", () => {
const prev: ViewPlacements = { git: "floating" };
const next = reconcileDetachedWindows(prev, [snap("git"), snap("agents")]);
expect(next).toEqual({ git: "floating", agents: "detached" });
});
it("returns the same reference when there is nothing to reconcile", () => {
const prev: ViewPlacements = {};
expect(reconcileDetachedWindows(prev, [])).toBe(prev);
});
});

View File

@ -17,6 +17,7 @@
*/
import type { DockSide } from "@/shared";
import type { ViewWindowSnapshot } from "@/ports";
/** A panel reachable from the menu bar. */
export type PanelId =
@ -95,3 +96,44 @@ export function floatingPanels(placements: ViewPlacements): PanelId[] {
export function isDetached(placement: ViewPlacement): boolean {
return placement === "detached";
}
/**
* True when `panel` is a known, detachable {@link PanelId} — every panel except
* `projects`, which is main-window-only chrome (its submenu never offers the
* detached-window action). Used by the startup reconciliation (#50) to accept
* only detachable panels from a `ViewWindowSnapshot`: `projects` is excluded and
* any unknown panel string is ignored.
*/
export function isDetachablePanel(panel: string): boolean {
return panel !== "projects" && panel in PANEL_TITLE;
}
/**
* Reconcile `prev` placements with the detached windows already open at the OS
* level (#50), returning the merged placements — or `prev` unchanged when there
* is nothing to reconcile (referential-stability so a React setter is a no-op).
*
* Strict, best-effort merge honouring the Architect invariants:
* - only `visible === true` snapshots are considered;
* - only known detachable panels — `projects` and unknown panel strings are
* dropped ({@link isDetachablePanel});
* - a panel is set to `"detached"` **only** when its current placement is
* `"closed"`/absent — an existing `floating`/dock/`detached` is never
* clobbered (no loss of user state or of a more specific UI restore).
*/
export function reconcileDetachedWindows(
prev: ViewPlacements,
snapshots: ViewWindowSnapshot[],
): ViewPlacements {
let changed = false;
const next: ViewPlacements = { ...prev };
for (const item of snapshots) {
if (!item.visible) continue;
if (!isDetachablePanel(item.panel)) continue;
const panel = item.panel as PanelId;
if (placementOf(prev, panel) !== "closed") continue;
next[panel] = "detached";
changed = true;
}
return changed ? next : prev;
}