/** * `ViewWindow` — the panel-only entry (ticket #23). * * A detached OS window loads the same SPA with `?panel=&project=`; * {@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. * * 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. */ import { useEffect, useState } from "react"; import type { Project } from "@/domain"; import { Panel, Spinner } from "@/shared"; import { PANEL_TITLE, type PanelId } from "@/features/projects"; import { ViewPanelBody, type ViewPanelId } from "@/features/projects"; import { useGateways } from "./di"; /** Panels that may be shown in a detached window (everything but "projects"). */ const DETACHABLE = new Set( (Object.keys(PANEL_TITLE) as PanelId[]).filter((p) => p !== "projects"), ); /** Parsed `?panel=&project=` params, or `null` when absent/invalid. */ export interface ViewWindowParams { panel: ViewPanelId; projectId: string; } /** Reads and validates the panel-only params from a query string. */ 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 }; } export interface ViewWindowProps { panel: ViewPanelId; projectId: string; } export function ViewWindow({ panel, projectId }: ViewWindowProps) { const { project } = useGateways(); const [resolved, setResolved] = useState(null); const [error, setError] = useState(null); // Resolve the project to get its root (the agents panel needs it) and name // (window title). Opening is idempotent on the backend registry. useEffect(() => { let cancelled = false; project .openProject(projectId) .then((p) => { if (!cancelled) setResolved(p); }) .catch((e: unknown) => { if (!cancelled) setError(describeError(e)); }); return () => { cancelled = true; }; }, [project, projectId]); return (

{PANEL_TITLE[panel]}

{resolved && ( · {resolved.name} )}
{error ? (

Error: {error}

) : resolved ? ( ) : (
Loading…
)}
); } function describeError(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as { message: unknown }).message); } return String(e); }