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

@ -2336,6 +2336,18 @@ pub struct CloseViewWindowResponseDto {
pub closed: bool,
}
/// Snapshot returned by `list_open_view_windows`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ViewWindowSnapshot {
/// Panel id rendered by the detached window.
pub panel: String,
/// Stable Tauri window label.
pub label: String,
/// Whether Tauri currently reports the OS window as visible.
pub visible: bool,
}
/// Payload emitted on `view-window://lifecycle`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@ -2429,6 +2441,19 @@ pub(crate) fn view_window_url(panel: ViewPanel) -> String {
format!("index.html?panel={}", panel.as_str())
}
fn view_panel_from_window_label(label: &str) -> Option<ViewPanel> {
let rest = label.strip_prefix("view-")?;
if let Ok(panel) = ViewPanel::parse(rest) {
return Some(panel);
}
let (panel, project_id) = rest.rsplit_once('-')?;
if project_id.len() != 32 || uuid::Uuid::parse_str(project_id).is_err() {
return None;
}
ViewPanel::parse(panel).ok()
}
pub(crate) fn emit_view_window_lifecycle(
app: &AppHandle,
kind: &'static str,
@ -2468,6 +2493,30 @@ pub async fn get_focused_project(
Ok(state.get_focused_project())
}
/// `list_open_view_windows` — return the detached panel windows currently
/// registered by Tauri.
#[tauri::command]
pub fn list_open_view_windows(app: AppHandle) -> Vec<ViewWindowSnapshot> {
let mut windows = app
.webview_windows()
.into_iter()
.filter_map(|(label, window)| {
let panel = view_panel_from_window_label(&label)?;
Some(ViewWindowSnapshot {
panel: panel.as_str().to_owned(),
label,
visible: window.is_visible().unwrap_or(true),
})
})
.collect::<Vec<_>>();
windows.sort_by(|left, right| {
left.panel
.cmp(&right.panel)
.then(left.label.cmp(&right.label))
});
windows
}
/// `open_view_window` — open or focus a detached OS window for one panel.
///
/// The window is a normal decorated, resizable system window. Its webview loads
@ -2578,6 +2627,42 @@ mod view_window_tests {
assert!(err.message.contains("invalid view panel: terminal"));
}
#[test]
fn view_window_label_parser_accepts_stable_and_legacy_labels() {
assert_eq!(
view_panel_from_window_label("view-tickets")
.unwrap()
.as_str(),
"tickets"
);
assert_eq!(
view_panel_from_window_label("view-tickets-0000000000000000000000000000002a")
.unwrap()
.as_str(),
"tickets"
);
assert!(view_panel_from_window_label("main").is_none());
assert!(view_panel_from_window_label("view-unknown").is_none());
assert!(view_panel_from_window_label("view-tickets-not-a-project").is_none());
}
#[test]
fn view_window_snapshot_payload_is_camel_case() {
let payload = ViewWindowSnapshot {
panel: "tickets".to_owned(),
label: "view-tickets".to_owned(),
visible: true,
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains("\"panel\":\"tickets\""), "json was {json}");
assert!(
json.contains("\"label\":\"view-tickets\""),
"json was {json}"
);
assert!(json.contains("\"visible\":true"), "json was {json}");
}
#[test]
fn view_window_lifecycle_payload_is_camel_case() {
let payload = ViewWindowLifecycleEventDto {

View File

@ -264,6 +264,7 @@ pub fn run() {
commands::resolve_memory_links,
commands::set_focused_project,
commands::get_focused_project,
commands::list_open_view_windows,
commands::open_view_window,
commands::close_view_window,
commands::move_tab_to_new_window,

View File

@ -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<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(
handler: (event: ViewWindowClosed) => void,
): Promise<Unsubscribe> {

View File

@ -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<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(
handler: (event: ViewWindowClosed) => void,
): Promise<Unsubscribe> {

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;
}

View File

@ -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<void>;
/** Closes the detached window for `panel` if one is open. */
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
* handler receives the `{ panel }` that closed. Returns an unsubscribe.