diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 46f21a5..f972b69 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -90,6 +90,7 @@ import type { UpdateTicketInput, UiPreferencesGateway, ViewWindowClosed, + ViewWindowSnapshot, WindowGateway, FocusedProject, FocusedProjectGateway, @@ -1118,6 +1119,16 @@ export class MockWindowGateway implements WindowGateway { } } + async listOpenViewWindows(): Promise { + // Mirror the backend enumeration: every currently-open detached window is + // reported visible, with a stable label derived from its panel id. + return [...this.open].map((panel) => ({ + panel, + label: panel, + visible: true, + })); + } + async onViewWindowClosed( handler: (event: ViewWindowClosed) => void, ): Promise { diff --git a/frontend/src/adapters/window.ts b/frontend/src/adapters/window.ts index fd7f33b..fcada39 100644 --- a/frontend/src/adapters/window.ts +++ b/frontend/src/adapters/window.ts @@ -14,7 +14,11 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import type { Unsubscribe } from "@/domain"; -import type { ViewWindowClosed, WindowGateway } from "@/ports"; +import type { + ViewWindowClosed, + ViewWindowSnapshot, + WindowGateway, +} from "@/ports"; /** The single Tauri event carrying detached-window lifecycle transitions. */ const VIEW_WINDOW_LIFECYCLE = "view-window://lifecycle"; @@ -41,6 +45,12 @@ export class TauriWindowGateway implements WindowGateway { await invoke("close_view_window", { panel }); } + async listOpenViewWindows(): Promise { + // Backend serialises the snapshots in camelCase (panel/label/visible), so the + // wire shape matches the port type directly. + return invoke("list_open_view_windows"); + } + async onViewWindowClosed( handler: (event: ViewWindowClosed) => void, ): Promise { diff --git a/frontend/src/features/projects/ProjectsView.reconcile.test.tsx b/frontend/src/features/projects/ProjectsView.reconcile.test.tsx new file mode 100644 index 0000000..6fcc844 --- /dev/null +++ b/frontend/src/features/projects/ProjectsView.reconcile.test.tsx @@ -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( + + + , + ); + 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("●"), + ); + }); +}); diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 3b498b7..ec6c660 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -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) { diff --git a/frontend/src/features/projects/viewPlacement.test.ts b/frontend/src/features/projects/viewPlacement.test.ts new file mode 100644 index 0000000..70f12b7 --- /dev/null +++ b/frontend/src/features/projects/viewPlacement.test.ts @@ -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); + }); +}); diff --git a/frontend/src/features/projects/viewPlacement.ts b/frontend/src/features/projects/viewPlacement.ts index eeab569..e469ce2 100644 --- a/frontend/src/features/projects/viewPlacement.ts +++ b/frontend/src/features/projects/viewPlacement.ts @@ -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; +} diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 9cb8eee..b1b6137 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -947,6 +947,21 @@ export interface ViewWindowClosed { panel: string; } +/** + * A snapshot of one detached view window currently open at the OS level + * (ticket #50). Enumerated at startup so the main window can reconcile its + * `placements` state with windows that were already open before it mounted (a + * panel-only window restored by the OS shows as "detached", not "closed"). + * + * `panel` is a `PanelId`-shaped string (kept as `string` at the port seam); + * `visible` distinguishes a truly-open window from a tracked-but-hidden one. + */ +export interface ViewWindowSnapshot { + panel: string; + label: string; + visible: boolean; +} + /** * Detaching a View into its own OS window (ticket #23, reworked in #47). * @@ -968,6 +983,14 @@ export interface WindowGateway { openViewWindow(panel: string): Promise; /** Closes the detached window for `panel` if one is open. */ closeViewWindow(panel: string): Promise; + /** + * Lists the detached view windows currently open at the OS level (ticket #50). + * Called once at main-window mount to reconcile `placements` with windows that + * survived a restart / were opened before mount, so a panel-only window shows + * as "detached" rather than "closed" in the Panneaux menu. Best-effort: callers + * treat a rejection as "no reconciliation". + */ + listOpenViewWindows(): Promise; /** * Subscribes to detached-window close events (OS close or programmatic). The * handler receives the `{ panel }` that closed. Returns an unsubscribe.