fix(view-windows): réconcilier l'état des fenêtres détachées au boot (#50)
Branche le consommateur frontend sur la commande backend `list_open_view_windows` : le port window expose la liste des fenêtres de panneaux détachées réellement ouvertes côté OS, l'adaptateur Tauri (et son double mock) l'implémente, et `ProjectsView` réconcilie le placement des vues au démarrage à partir de ce snapshot au lieu de supposer un état. Couvre `viewPlacement` et la réconciliation par des tests dédiés. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -90,6 +90,7 @@ import type {
|
|||||||
UpdateTicketInput,
|
UpdateTicketInput,
|
||||||
UiPreferencesGateway,
|
UiPreferencesGateway,
|
||||||
ViewWindowClosed,
|
ViewWindowClosed,
|
||||||
|
ViewWindowSnapshot,
|
||||||
WindowGateway,
|
WindowGateway,
|
||||||
FocusedProject,
|
FocusedProject,
|
||||||
FocusedProjectGateway,
|
FocusedProjectGateway,
|
||||||
@ -1118,6 +1119,16 @@ export class MockWindowGateway implements WindowGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listOpenViewWindows(): Promise<ViewWindowSnapshot[]> {
|
||||||
|
// 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(
|
async onViewWindowClosed(
|
||||||
handler: (event: ViewWindowClosed) => void,
|
handler: (event: ViewWindowClosed) => void,
|
||||||
): Promise<Unsubscribe> {
|
): Promise<Unsubscribe> {
|
||||||
|
|||||||
@ -14,7 +14,11 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
import type { Unsubscribe } from "@/domain";
|
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. */
|
/** The single Tauri event carrying detached-window lifecycle transitions. */
|
||||||
const VIEW_WINDOW_LIFECYCLE = "view-window://lifecycle";
|
const VIEW_WINDOW_LIFECYCLE = "view-window://lifecycle";
|
||||||
@ -41,6 +45,12 @@ export class TauriWindowGateway implements WindowGateway {
|
|||||||
await invoke("close_view_window", { panel });
|
await invoke("close_view_window", { panel });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listOpenViewWindows(): Promise<ViewWindowSnapshot[]> {
|
||||||
|
// Backend serialises the snapshots in camelCase (panel/label/visible), so the
|
||||||
|
// wire shape matches the port type directly.
|
||||||
|
return invoke<ViewWindowSnapshot[]>("list_open_view_windows");
|
||||||
|
}
|
||||||
|
|
||||||
async onViewWindowClosed(
|
async onViewWindowClosed(
|
||||||
handler: (event: ViewWindowClosed) => void,
|
handler: (event: ViewWindowClosed) => void,
|
||||||
): Promise<Unsubscribe> {
|
): Promise<Unsubscribe> {
|
||||||
|
|||||||
@ -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("●"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -61,6 +61,7 @@ import {
|
|||||||
isDockedTo,
|
isDockedTo,
|
||||||
panelsDockedTo,
|
panelsDockedTo,
|
||||||
placementOf,
|
placementOf,
|
||||||
|
reconcileDetachedWindows,
|
||||||
type PanelId,
|
type PanelId,
|
||||||
type ViewPlacement,
|
type ViewPlacement,
|
||||||
type ViewPlacements,
|
type ViewPlacements,
|
||||||
@ -281,6 +282,31 @@ export function ProjectsView() {
|
|||||||
};
|
};
|
||||||
}, [windowGateway]);
|
}, [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
|
// 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.
|
// windows so they don't obscure the viewer (LS7). Docked views stay beside it.
|
||||||
function openConversation(conversationId: string) {
|
function openConversation(conversationId: string) {
|
||||||
|
|||||||
89
frontend/src/features/projects/viewPlacement.test.ts
Normal file
89
frontend/src/features/projects/viewPlacement.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { DockSide } from "@/shared";
|
import type { DockSide } from "@/shared";
|
||||||
|
import type { ViewWindowSnapshot } from "@/ports";
|
||||||
|
|
||||||
/** A panel reachable from the menu bar. */
|
/** A panel reachable from the menu bar. */
|
||||||
export type PanelId =
|
export type PanelId =
|
||||||
@ -95,3 +96,44 @@ export function floatingPanels(placements: ViewPlacements): PanelId[] {
|
|||||||
export function isDetached(placement: ViewPlacement): boolean {
|
export function isDetached(placement: ViewPlacement): boolean {
|
||||||
return placement === "detached";
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -947,6 +947,21 @@ export interface ViewWindowClosed {
|
|||||||
panel: string;
|
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).
|
* Detaching a View into its own OS window (ticket #23, reworked in #47).
|
||||||
*
|
*
|
||||||
@ -968,6 +983,14 @@ export interface WindowGateway {
|
|||||||
openViewWindow(panel: string): Promise<void>;
|
openViewWindow(panel: string): Promise<void>;
|
||||||
/** Closes the detached window for `panel` if one is open. */
|
/** Closes the detached window for `panel` if one is open. */
|
||||||
closeViewWindow(panel: string): Promise<void>;
|
closeViewWindow(panel: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* 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<ViewWindowSnapshot[]>;
|
||||||
/**
|
/**
|
||||||
* Subscribes to detached-window close events (OS close or programmatic). The
|
* Subscribes to detached-window close events (OS close or programmatic). The
|
||||||
* handler receives the `{ panel }` that closed. Returns an unsubscribe.
|
* handler receives the `{ panel }` that closed. Returns an unsubscribe.
|
||||||
|
|||||||
Reference in New Issue
Block a user