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

@ -1,22 +1,28 @@
/**
* `ViewWindow` — the panel-only entry (ticket #23).
* `ViewWindow` — the panel-only entry (ticket #23, reworked in #47).
*
* A detached OS window loads the same SPA with `?panel=<panel>&project=<id>`;
* A detached OS window loads the same SPA with `?panel=<panel>` (no project id);
* {@link main} routes to this component instead of the full {@link App} when a
* `panel` param is present. It re-instantiates the DI (same adapters/gateways
* as the main window — views are gateway/read-model-driven, so no heavy state
* is transferred) and renders **only** the requested view for the given
* project.
* `panel` param is present. It re-instantiates the DI (same adapters/gateways as
* the main window — views are gateway/read-model-driven, so no heavy state is
* transferred) and renders **only** the requested view for the project the main
* window currently has in focus.
*
* Pure presentation over the ports: it resolves the project through the
* {@link ProjectGateway} (for its name/root) and hands off to the shared
* {@link ViewPanelBody}. No `invoke()`, no cross-window messaging.
* Focus, not embedding (#47): the window never carries — nor reopens — a project
* id. It reads the current focus from the {@link FocusedProjectGateway} at mount
* and then tracks {@link FocusedProjectGateway.onFocusedProjectChanged}. While no
* project is focused it mounts **no** project-scoped panel and shows an "open a
* project" shell, so a window restored at restart never surfaces a stale
* project's data.
*
* Pure presentation over the ports: no `invoke()`, no cross-window messaging,
* and crucially no `openProject()` — opening a project is the main window's job.
*/
import { useEffect, useState } from "react";
import type { Project } from "@/domain";
import { Panel, Spinner } from "@/shared";
import type { FocusedProject } from "@/ports";
import { Panel } from "@/shared";
import { PANEL_TITLE, type PanelId } from "@/features/projects";
import { ViewPanelBody, type ViewPanelId } from "@/features/projects";
import { useGateways } from "./di";
@ -26,49 +32,59 @@ const DETACHABLE = new Set<string>(
(Object.keys(PANEL_TITLE) as PanelId[]).filter((p) => p !== "projects"),
);
/** Parsed `?panel=&project=` params, or `null` when absent/invalid. */
/** Parsed `?panel=` params, or `null` when absent/invalid. */
export interface ViewWindowParams {
panel: ViewPanelId;
projectId: string;
}
/** Reads and validates the panel-only params from a query string. */
/** Reads and validates the panel-only param from a query string (#47: no project). */
export function parseViewWindowParams(
search: string,
): ViewWindowParams | null {
const params = new URLSearchParams(search);
const panel = params.get("panel");
const projectId = params.get("project");
if (!panel || !projectId || !DETACHABLE.has(panel)) return null;
return { panel: panel as ViewPanelId, projectId };
if (!panel || !DETACHABLE.has(panel)) return null;
return { panel: panel as ViewPanelId };
}
export interface ViewWindowProps {
panel: ViewPanelId;
projectId: string;
}
export function ViewWindow({ panel, projectId }: ViewWindowProps) {
const { project } = useGateways();
const [resolved, setResolved] = useState<Project | null>(null);
const [error, setError] = useState<string | null>(null);
export function ViewWindow({ panel }: ViewWindowProps) {
const { focusedProject } = useGateways();
// The focused project this window follows. `undefined` = not yet resolved
// (initial read in flight); `null` = resolved, no project focused.
const [focus, setFocus] = useState<FocusedProject | null | undefined>(
undefined,
);
// Resolve the project to get its root (the agents panel needs it) and name
// (window title). Opening is idempotent on the backend registry.
// Read the current focus at mount, then track changes. Never opens a project:
// the window is a pure follower of the main window's focus (#47).
useEffect(() => {
let cancelled = false;
project
.openProject(projectId)
.then((p) => {
if (!cancelled) setResolved(p);
let unsubscribe: (() => void) | undefined;
focusedProject
.getFocusedProject()
.then((project) => {
if (!cancelled) setFocus(project);
})
.catch((e: unknown) => {
if (!cancelled) setError(describeError(e));
.catch(() => {
if (!cancelled) setFocus(null);
});
void focusedProject
.onFocusedProjectChanged((project) => {
if (!cancelled) setFocus(project);
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [project, projectId]);
}, [focusedProject]);
return (
<div className="flex h-full flex-col bg-canvas text-content">
@ -76,35 +92,28 @@ export function ViewWindow({ panel, projectId }: ViewWindowProps) {
<h1 className="text-sm font-semibold text-content">
{PANEL_TITLE[panel]}
</h1>
{resolved && (
<span className="truncate text-xs text-muted">· {resolved.name}</span>
{focus && (
<span className="truncate text-xs text-muted">· {focus.name}</span>
)}
</header>
<main className="min-h-0 flex-1 overflow-auto p-4">
{error ? (
<Panel className="border-danger/40">
<p className="text-sm text-danger">Error: {error}</p>
</Panel>
) : resolved ? (
{focus ? (
// Only mount a project-scoped panel when a project is actually focused
// — the panels require a real `projectRoot`, never a placeholder.
<ViewPanelBody
panel={panel}
projectId={resolved.id}
projectRoot={resolved.root}
projectId={focus.id}
projectRoot={focus.root}
/>
) : (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>Loading</span>
</div>
<Panel>
<p className="text-sm text-muted">
Ouvrez un projet dans la fenêtre principale pour afficher ce
panneau.
</p>
</Panel>
)}
</main>
</div>
);
}
function describeError(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as { message: unknown }).message);
}
return String(e);
}