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

@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const invoke = vi.fn();
const listen = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invoke(...args),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: (...args: unknown[]) => listen(...args),
}));
import { TauriFocusedProjectGateway } from "./focusedProject";
describe("TauriFocusedProjectGateway (#47)", () => {
beforeEach(() => {
invoke.mockReset().mockResolvedValue(null);
listen.mockReset();
});
it("set/get relay to the backend focused-project commands", async () => {
const gw = new TauriFocusedProjectGateway();
await gw.setFocusedProject({ id: "p1", name: "alpha", root: "/p/a" });
expect(invoke).toHaveBeenCalledWith("set_focused_project", {
project: { id: "p1", name: "alpha", root: "/p/a" },
});
await gw.setFocusedProject(null);
expect(invoke).toHaveBeenCalledWith("set_focused_project", {
project: null,
});
invoke.mockResolvedValueOnce({ id: "p2", name: "beta", root: "/p/b" });
await expect(gw.getFocusedProject()).resolves.toEqual({
id: "p2",
name: "beta",
root: "/p/b",
});
expect(invoke).toHaveBeenCalledWith("get_focused_project");
// A `null`/undefined focus normalises to `null`.
invoke.mockResolvedValueOnce(null);
await expect(gw.getFocusedProject()).resolves.toBeNull();
});
it("onFocusedProjectChanged listens on the channel and forwards the project (or null)", async () => {
let raw: ((e: { payload: unknown }) => void) | undefined;
const unlisten = vi.fn();
listen.mockImplementation((_name: string, cb: typeof raw) => {
raw = cb;
return Promise.resolve(unlisten);
});
const gw = new TauriFocusedProjectGateway();
const handler = vi.fn();
const off = await gw.onFocusedProjectChanged(handler);
expect(listen).toHaveBeenCalledWith(
"focused-project://changed",
expect.any(Function),
);
raw?.({ payload: { project: { id: "p1", name: "alpha", root: "/p/a" } } });
expect(handler).toHaveBeenLastCalledWith({
id: "p1",
name: "alpha",
root: "/p/a",
});
raw?.({ payload: { project: null } });
expect(handler).toHaveBeenLastCalledWith(null);
off();
expect(unlisten).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,48 @@
/**
* Tauri adapter for {@link FocusedProjectGateway} (ticket #47). Bridges the
* main-window ⇄ detached-panel focused-project channel to the backend commands
* and event. Like the sibling adapters, this is the only layer allowed to touch
* `@tauri-apps/api`; components reach it exclusively through the port.
*
* Command/event names and payloads mirror the backend contract owned by
* DevBackend: `set_focused_project` / `get_focused_project` and the
* `focused-project://changed` event whose payload is `{ project: FocusedProject
* | null }`.
*/
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { Unsubscribe } from "@/domain";
import type { FocusedProject, FocusedProjectGateway } from "@/ports";
/** The Tauri event carrying focused-project transitions. */
const FOCUSED_PROJECT_CHANGED = "focused-project://changed";
/** Backend payload for `focused-project://changed`. */
interface FocusedProjectChanged {
project: FocusedProject | null;
}
export class TauriFocusedProjectGateway implements FocusedProjectGateway {
async setFocusedProject(project: FocusedProject | null): Promise<void> {
// The backend command takes `project: Option<FocusedProjectDto>`; `null`
// clears the focus (no project open).
await invoke("set_focused_project", { project });
}
async getFocusedProject(): Promise<FocusedProject | null> {
const project = await invoke<FocusedProject | null>("get_focused_project");
return project ?? null;
}
async onFocusedProjectChanged(
handler: (project: FocusedProject | null) => void,
): Promise<Unsubscribe> {
const unlisten = await listen<FocusedProjectChanged>(
FOCUSED_PROJECT_CHANGED,
(e) => handler(e.payload.project ?? null),
);
return unlisten;
}
}

View File

@ -30,6 +30,7 @@ import { TauriWorkStateGateway } from "./workState";
import { TauriConversationGateway } from "./conversation";
import { TauriTicketGateway } from "./ticket";
import { TauriWindowGateway } from "./window";
import { TauriFocusedProjectGateway } from "./focusedProject";
import { LocalStorageUiPreferencesGateway } from "./uiPreferences";
function notImplemented(what: string): never {
@ -68,6 +69,7 @@ export function createTauriGateways(): Gateways {
conversation: new TauriConversationGateway(),
ticket: new TauriTicketGateway(),
window: new TauriWindowGateway(),
focusedProject: new TauriFocusedProjectGateway(),
uiPreferences: new LocalStorageUiPreferencesGateway(),
};
}
@ -91,5 +93,6 @@ export {
TauriConversationGateway,
TauriTicketGateway,
TauriWindowGateway,
TauriFocusedProjectGateway,
LocalStorageUiPreferencesGateway,
};

View File

@ -91,6 +91,8 @@ import type {
UiPreferencesGateway,
ViewWindowClosed,
WindowGateway,
FocusedProject,
FocusedProjectGateway,
WorkStateGateway,
} from "@/ports";
import { normalizeProjectWorkState } from "../workStateNormalization";
@ -1094,27 +1096,25 @@ class MockRemoteGateway implements RemoteGateway {
}
/**
* In-memory {@link WindowGateway} for tests/dev (#23). Tracks which views are
* "detached" in a set keyed by `panel|projectId`, and lets tests simulate the
* OS closing a window via {@link simulateOsClose}. `closeViewWindow` also emits
* the close event, mirroring the backend (a programmatic close still notifies).
* In-memory {@link WindowGateway} for tests/dev (#23, panel-only in #47). Tracks
* which views are "detached" in a set keyed by `panel` alone (a panel-only
* window follows the focused project, so no project id is part of the key), and
* lets tests simulate the OS closing a window via {@link simulateOsClose}.
* `closeViewWindow` also emits the close event, mirroring the backend (a
* programmatic close still notifies).
*/
export class MockWindowGateway implements WindowGateway {
/** Currently-open detached windows, keyed `panel|projectId`. */
/** Currently-open detached windows, keyed by `panel`. */
readonly open = new Set<string>();
private readonly listeners = new Set<(e: ViewWindowClosed) => void>();
private key(panel: string, projectId: string): string {
return `${panel}|${projectId}`;
async openViewWindow(panel: string): Promise<void> {
this.open.add(panel);
}
async openViewWindow(panel: string, projectId: string): Promise<void> {
this.open.add(this.key(panel, projectId));
}
async closeViewWindow(panel: string, projectId: string): Promise<void> {
if (this.open.delete(this.key(panel, projectId))) {
this.emit({ panel, projectId });
async closeViewWindow(panel: string): Promise<void> {
if (this.open.delete(panel)) {
this.emit({ panel });
}
}
@ -1128,9 +1128,9 @@ export class MockWindowGateway implements WindowGateway {
}
/** Test hook: simulate the user closing the OS window (fires the event). */
simulateOsClose(panel: string, projectId: string): void {
this.open.delete(this.key(panel, projectId));
this.emit({ panel, projectId });
simulateOsClose(panel: string): void {
this.open.delete(panel);
this.emit({ panel });
}
private emit(event: ViewWindowClosed): void {
@ -1138,6 +1138,38 @@ export class MockWindowGateway implements WindowGateway {
}
}
/**
* In-memory {@link FocusedProjectGateway} for tests/dev (#47). Holds the current
* focus in memory and fans changes out to subscribers — the mock counterpart to
* the backend's shared focused-project state + `focused-project://changed`
* event. `setFocusedProject` always notifies (mirrors the backend emit), so a
* detached-window test can drive a panel window's focus transitions.
*/
export class MockFocusedProjectGateway implements FocusedProjectGateway {
private focused: FocusedProject | null = null;
private readonly listeners = new Set<
(project: FocusedProject | null) => void
>();
async setFocusedProject(project: FocusedProject | null): Promise<void> {
this.focused = project;
for (const l of this.listeners) l(project);
}
async getFocusedProject(): Promise<FocusedProject | null> {
return this.focused;
}
async onFocusedProjectChanged(
handler: (project: FocusedProject | null) => void,
): Promise<Unsubscribe> {
this.listeners.add(handler);
return () => {
this.listeners.delete(handler);
};
}
}
/**
* The pre-filled reference catalogue the mock serves — mirror of the backend's
* **selectable** catalogue (§17.3/D7): only profiles drivable in structured mode
@ -2596,6 +2628,7 @@ export function createMockGateways(): Gateways {
conversation: new MockConversationGateway(),
ticket: new MockTicketGateway(systemGateway),
window: new MockWindowGateway(),
focusedProject: new MockFocusedProjectGateway(),
uiPreferences: new MockUiPreferencesGateway(),
};
}

View File

@ -17,6 +17,7 @@ describe("createMockGateways", () => {
"agent",
"conversation",
"embedder",
"focusedProject",
"git",
"input",
"layout",

View File

@ -17,17 +17,15 @@ describe("TauriWindowGateway (#23)", () => {
listen.mockReset();
});
it("open/close relay camelCase payloads to the backend commands", async () => {
it("open/close relay panel-only payloads to the backend commands (#47)", async () => {
const gw = new TauriWindowGateway();
await gw.openViewWindow("tickets", "proj-1");
await gw.openViewWindow("tickets");
expect(invoke).toHaveBeenCalledWith("open_view_window", {
panel: "tickets",
projectId: "proj-1",
});
await gw.closeViewWindow("git", "proj-2");
await gw.closeViewWindow("git");
expect(invoke).toHaveBeenCalledWith("close_view_window", {
panel: "git",
projectId: "proj-2",
});
});
@ -51,19 +49,19 @@ describe("TauriWindowGateway (#23)", () => {
// opened / focused are dropped…
raw?.({
payload: { kind: "opened", panel: "git", projectId: "p", label: "view-git-p" },
payload: { kind: "opened", panel: "git", label: "view-git" },
});
raw?.({
payload: { kind: "focused", panel: "git", projectId: "p", label: "view-git-p" },
payload: { kind: "focused", panel: "git", label: "view-git" },
});
expect(handler).not.toHaveBeenCalled();
// …only closed reaches the port handler, narrowed to { panel, projectId }.
// …only closed reaches the port handler, narrowed to { panel } (#47).
raw?.({
payload: { kind: "closed", panel: "git", projectId: "p", label: "view-git-p" },
payload: { kind: "closed", panel: "git", label: "view-git" },
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({ panel: "git", projectId: "p" });
expect(handler).toHaveBeenCalledWith({ panel: "git" });
off();
expect(unlisten).toHaveBeenCalled();

View File

@ -19,34 +19,38 @@ import type { ViewWindowClosed, WindowGateway } from "@/ports";
/** The single Tauri event carrying detached-window lifecycle transitions. */
const VIEW_WINDOW_LIFECYCLE = "view-window://lifecycle";
/** Backend lifecycle payload (discriminated on `kind`). */
/**
* Backend lifecycle payload (discriminated on `kind`). Ticket #47 dropped the
* `projectId` field — a detached window is panel-only and follows the focused
* project, so lifecycle events carry only `panel` + `label`.
*/
interface ViewWindowLifecycle {
kind: "opened" | "focused" | "closed";
panel: string;
projectId: string;
label: string;
}
export class TauriWindowGateway implements WindowGateway {
async openViewWindow(panel: string, projectId: string): Promise<void> {
// camelCase args (Tauri maps them to the command's `panel` / `project_id`).
await invoke("open_view_window", { panel, projectId });
async openViewWindow(panel: string): Promise<void> {
// Panel-only window (#47): no project id — the window follows the focused
// project published on `focused-project://changed`.
await invoke("open_view_window", { panel });
}
async closeViewWindow(panel: string, projectId: string): Promise<void> {
await invoke("close_view_window", { panel, projectId });
async closeViewWindow(panel: string): Promise<void> {
await invoke("close_view_window", { panel });
}
async onViewWindowClosed(
handler: (event: ViewWindowClosed) => void,
): Promise<Unsubscribe> {
// One backend channel for opened/focused/closed; the port only surfaces the
// close, so drop the other transitions and forward `{ panel, projectId }`.
// close, so drop the other transitions and forward `{ panel }`.
const unlisten = await listen<ViewWindowLifecycle>(
VIEW_WINDOW_LIFECYCLE,
(e) => {
if (e.payload.kind !== "closed") return;
handler({ panel: e.payload.panel, projectId: e.payload.projectId });
handler({ panel: e.payload.panel });
},
);
return unlisten;

View File

@ -1,14 +1,17 @@
/**
* #23 — the panel-only entry. `parseViewWindowParams` validates the
* `?panel=&project=` query, and `ViewWindow` resolves the project through the
* gateway then renders only the requested view (here: the tickets view).
* #23/#47 — the panel-only entry. `parseViewWindowParams` validates the
* `?panel=` query (no project id anymore), and `ViewWindow` follows the main
* window's focused project: it shows an "open a project" shell while no project
* is focused and mounts the requested view once one is, without ever opening a
* project itself.
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, act } from "@testing-library/react";
import { DIProvider } from "@/app/di";
import {
MockAgentGateway,
MockFocusedProjectGateway,
MockProjectGateway,
MockSystemGateway,
MockTicketGateway,
@ -16,45 +19,124 @@ import {
import type { Gateways } from "@/ports";
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
describe("parseViewWindowParams (#23)", () => {
it("parses a valid panel + project", () => {
describe("parseViewWindowParams (#47)", () => {
it("parses a valid panel-only query (ignores any legacy project param)", () => {
expect(parseViewWindowParams("?panel=tickets")).toEqual({
panel: "tickets",
});
// A restored legacy URL that still carries `project` parses to panel only.
expect(parseViewWindowParams("?panel=tickets&project=p-1")).toEqual({
panel: "tickets",
projectId: "p-1",
});
});
it("rejects a missing project, missing panel, or the non-detachable projects panel", () => {
expect(parseViewWindowParams("?panel=tickets")).toBeNull();
it("rejects a missing panel or the non-detachable projects panel", () => {
expect(parseViewWindowParams("?project=p-1")).toBeNull();
expect(parseViewWindowParams("?panel=projects&project=p-1")).toBeNull();
expect(parseViewWindowParams("?panel=bogus&project=p-1")).toBeNull();
expect(parseViewWindowParams("?panel=projects")).toBeNull();
expect(parseViewWindowParams("?panel=bogus")).toBeNull();
expect(parseViewWindowParams("")).toBeNull();
});
});
describe("ViewWindow (#23)", () => {
it("resolves the project and renders only the requested view", async () => {
describe("ViewWindow (#47)", () => {
it("shows the 'open a project' shell when no project is focused", async () => {
const system = new MockSystemGateway();
const gateways = {
system,
project: new MockProjectGateway(),
agent: new MockAgentGateway(),
ticket: new MockTicketGateway(system),
focusedProject: new MockFocusedProjectGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<ViewWindow panel="tickets" />
</DIProvider>,
);
// The panel title chrome is present, but no project-scoped view mounts…
expect(screen.getByRole("heading", { name: "Tickets" })).toBeTruthy();
await waitFor(() =>
expect(screen.getByText(/Ouvrez un projet/i)).toBeTruthy(),
);
expect(screen.queryByLabelText("search tickets")).toBeNull();
});
it("mounts the view for the focused project and follows focus changes", async () => {
const system = new MockSystemGateway();
const project = new MockProjectGateway();
const created = await project.createProject("alpha", "/p/a");
const focusedProject = new MockFocusedProjectGateway();
const gateways = {
system,
project,
agent: new MockAgentGateway(),
ticket: new MockTicketGateway(system),
focusedProject,
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<ViewWindow panel="tickets" projectId={created.id} />
<ViewWindow panel="tickets" />
</DIProvider>,
);
// The window chrome shows the panel title and the resolved project name.
expect(screen.getByRole("heading", { name: "Tickets" })).toBeTruthy();
// No focus yet → shell.
await waitFor(() =>
expect(screen.getByText(/Ouvrez un projet/i)).toBeTruthy(),
);
// The main window publishes a focused project → the window follows it.
await act(async () => {
await focusedProject.setFocusedProject({
id: created.id,
name: "alpha",
root: "/p/a",
});
});
await waitFor(() => expect(screen.getByText("· alpha")).toBeTruthy());
// The tickets view itself mounted (its search box is present).
await waitFor(() =>
expect(screen.getByLabelText("search tickets")).toBeTruthy(),
);
// Focus cleared (project closed) → back to the shell, view unmounts.
await act(async () => {
await focusedProject.setFocusedProject(null);
});
await waitFor(() =>
expect(screen.getByText(/Ouvrez un projet/i)).toBeTruthy(),
);
expect(screen.queryByLabelText("search tickets")).toBeNull();
});
it("reads the current focus at mount (restored window with a live focus)", async () => {
const system = new MockSystemGateway();
const project = new MockProjectGateway();
const created = await project.createProject("beta", "/p/b");
const focusedProject = new MockFocusedProjectGateway();
// Focus already set before the window mounts (main window opened first).
await focusedProject.setFocusedProject({
id: created.id,
name: "beta",
root: "/p/b",
});
const gateways = {
system,
project,
agent: new MockAgentGateway(),
ticket: new MockTicketGateway(system),
focusedProject,
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<ViewWindow panel="tickets" />
</DIProvider>,
);
await waitFor(() => expect(screen.getByText("· beta")).toBeTruthy());
await waitFor(() =>
expect(screen.getByLabelText("search tickets")).toBeTruthy(),
);

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

View File

@ -13,15 +13,16 @@ if (!root) {
throw new Error("missing #root element");
}
// #23: a detached view window loads the SPA with `?panel=&project=`. When those
// params are present and valid, render only that view; otherwise the full app.
// #23/#47: a detached view window loads the SPA with `?panel=` (panel-only, no
// project id). When the param is present and valid, render only that view — it
// follows the main window's focused project; otherwise the full app.
const viewParams = parseViewWindowParams(window.location.search);
ReactDOM.createRoot(root).render(
<React.StrictMode>
<DIProvider>
{viewParams ? (
<ViewWindow panel={viewParams.panel} projectId={viewParams.projectId} />
<ViewWindow panel={viewParams.panel} />
) : (
<App />
)}

View File

@ -12,6 +12,7 @@ import {
MockAgentGateway,
MockGitGateway,
MockProfileGateway,
MockFocusedProjectGateway,
MockProjectGateway,
MockSystemGateway,
MockTemplateGateway,
@ -24,7 +25,7 @@ import { ProjectsView } from "./ProjectsView";
async function renderWithProject() {
const project = new MockProjectGateway();
const created = await project.createProject("alpha", "/p/a");
await project.createProject("alpha", "/p/a");
const agentGateway = new MockAgentGateway();
const windowGateway = new MockWindowGateway();
const gateways = {
@ -36,6 +37,7 @@ async function renderWithProject() {
git: new MockGitGateway(),
workState: new MockWorkStateGateway(),
window: windowGateway,
focusedProject: new MockFocusedProjectGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
@ -46,7 +48,7 @@ async function renderWithProject() {
await waitFor(() =>
expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1),
);
return { windowGateway, projectId: created.id };
return { windowGateway };
}
/**
@ -62,7 +64,7 @@ function pickPlacement(panelTitle: string, placementLabel: string) {
describe("ProjectsView detach to OS window (#23)", () => {
it("« Fenêtre détachée » detaches a view: opens the OS window and marks the slot detached", async () => {
const { windowGateway, projectId } = await renderWithProject();
const { windowGateway } = await renderWithProject();
// A docked view is visible in the main window first…
pickPlacement("Git", "Ancré à gauche");
@ -72,7 +74,7 @@ describe("ProjectsView detach to OS window (#23)", () => {
pickPlacement("Git", "Fenêtre détachée");
await waitFor(() =>
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
expect(windowGateway.open.has("git")).toBe(true),
);
// The slot is detached ⇒ nothing renders for Git in the main window.
await waitFor(() =>
@ -82,11 +84,11 @@ describe("ProjectsView detach to OS window (#23)", () => {
});
it("re-toggles the placement out of detached when the OS window closes", async () => {
const { windowGateway, projectId } = await renderWithProject();
const { windowGateway } = await renderWithProject();
pickPlacement("Git", "Fenêtre détachée");
await waitFor(() =>
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
expect(windowGateway.open.has("git")).toBe(true),
);
// The « Fenêtre détachée » submenu leaf shows the active marker (●) while
@ -103,7 +105,7 @@ describe("ProjectsView detach to OS window (#23)", () => {
fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); // close
// The backend reports the OS window closed ⇒ the slot leaves "detached".
windowGateway.simulateOsClose("git", projectId);
windowGateway.simulateOsClose("git");
openGitSubmenu();
await waitFor(() =>
expect(detachedLeaf().textContent).not.toContain("●"),

View File

@ -0,0 +1,78 @@
/**
* #47 — `ProjectsView` is the single writer of the focused-project channel: it
* publishes the active project (or `null` when none is active) so detached
* panel-only windows can follow it. This drives the open → publish → close →
* publish-null flow and asserts what reaches the {@link FocusedProjectGateway}.
*/
import { describe, it, expect } from "vitest";
import { render, screen, within, waitFor, fireEvent } from "@testing-library/react";
import {
MockAgentGateway,
MockFocusedProjectGateway,
MockGitGateway,
MockProfileGateway,
MockProjectGateway,
MockSystemGateway,
MockTemplateGateway,
MockWindowGateway,
MockWorkStateGateway,
} from "@/adapters/mock";
import type { FocusedProject, Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { ProjectsView } from "./ProjectsView";
async function renderView() {
const project = new MockProjectGateway();
const created = await project.createProject("alpha", "/p/a");
const agentGateway = new MockAgentGateway();
const focusedProject = new MockFocusedProjectGateway();
// Record every published focus so we can assert the sequence.
const published: (FocusedProject | null)[] = [];
await focusedProject.onFocusedProjectChanged((p) => published.push(p));
const gateways = {
system: new MockSystemGateway(),
project,
agent: agentGateway,
profile: new MockProfileGateway(),
template: new MockTemplateGateway(agentGateway),
git: new MockGitGateway(),
workState: new MockWorkStateGateway(),
window: new MockWindowGateway(),
focusedProject,
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<ProjectsView />
</DIProvider>,
);
return { focusedProject, published, projectId: created.id };
}
describe("ProjectsView focus publishing (#47)", () => {
it("publishes the active project on open and null when it is closed", async () => {
const { focusedProject, published, projectId } = await renderView();
// Mount with no active project publishes a `null` focus.
await waitFor(() => expect(published).toContainEqual(null));
// Open the project → publishes the { id, name, root } snapshot.
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
await waitFor(() =>
expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1),
);
await waitFor(() =>
expect(focusedProject.getFocusedProject()).resolves.toEqual({
id: projectId,
name: "alpha",
root: "/p/a",
}),
);
// Close the tab → back to no active project → publishes `null`.
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
await waitFor(() =>
expect(focusedProject.getFocusedProject()).resolves.toBeNull(),
);
});
});

View File

@ -102,7 +102,7 @@ function isTerminalBackgroundTaskEvent(
export function ProjectsView() {
const vm = useProjects();
const { system, window: windowGateway } = useGateways();
const { system, window: windowGateway, focusedProject } = useGateways();
const [name, setName] = useState("");
const [root, setRoot] = useState("");
// Placement of every open view (#22): each panel is "closed" (absent),
@ -141,6 +141,17 @@ export function ProjectsView() {
setViewerConversationId(null);
}, [active?.id]);
// Publish the focused project (#47) so detached panel-only windows follow the
// main window: they render this project, or an "open a project" shell when
// none is active. Publish on EVERY change of `active` — including
// switching-away to null — so a restored panel window never shows a stale
// project. Optional-chained: unit tests may omit this gateway.
useEffect(() => {
void focusedProject?.setFocusedProject(
active ? { id: active.id, name: active.name, root: active.root } : null,
);
}, [focusedProject, active?.id, active?.name, active?.root]);
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let cancelled = false;
@ -227,15 +238,16 @@ export function ProjectsView() {
});
}
// Detach a view into its own OS window (#23): ask the backend to open the
// window, then mark the slot "detached" so nothing renders for it in the main
// window. Requires an active project (the window is project-scoped). On
// failure the placement is left untouched (no ghost "detached" slot).
// Detach a view into its own OS window (#23, panel-only in #47): ask the
// backend to open the window, then mark the slot "detached" so nothing renders
// for it in the main window. The window is panel-only and follows the focused
// project (published above), so no project id is passed. Requires an active
// project only so a detached window opens onto something rather than the empty
// shell. On failure the placement is left untouched (no ghost "detached" slot).
function detachPanel(panel: PanelId) {
if (!active) return;
const projectId = active.id;
void windowGateway
.openViewWindow(panel, projectId)
.openViewWindow(panel)
.then(() => setPlacement(panel, "detached"))
.catch(() => {
/* window failed to open; keep the current placement */

View File

@ -937,40 +937,84 @@ export interface TicketGateway {
* Payload of the OS window-lifecycle event that fires when a detached view
* window closes (ticket #23). Lets the main window re-toggle the view's
* placement out of `"detached"`.
*
* Ticket #47: a detached window is now **panel-only** and follows the main
* window's focused project rather than embedding a project id, so the close
* event no longer carries a `projectId` — the placement is keyed by `panel`.
*/
export interface ViewWindowClosed {
/** The detached panel id (a `PanelId`, kept as `string` at the port seam). */
panel: string;
/** The project the detached window was showing. */
projectId: string;
}
/**
* Detaching a View into its own OS window (ticket #23).
* Detaching a View into its own OS window (ticket #23, reworked in #47).
*
* The backend owns the Tauri `WebviewWindow` lifecycle (create/focus/close,
* anti-duplicate registry). This port is the UI's only door to it — components
* never call `invoke()` directly. `panel` is a `PanelId`-shaped string; it is
* typed as `string` here so ports need not depend on the `features/` layer.
*
* A detached window is **panel-only**: it no longer carries a project id and
* instead follows the focused project published through
* {@link FocusedProjectGateway}. Windows are therefore keyed by `panel` alone.
*/
export interface WindowGateway {
/**
* Opens — or focuses, if already open — a separate OS window rendering only
* `panel` for `projectId` (the backend's `open_view_window` command).
* `panel` (the backend's `open_view_window` command). The window follows the
* main window's focused project; no project id is passed.
*/
openViewWindow(panel: string, projectId: string): Promise<void>;
/** Closes the detached window for `panel`/`projectId` if one is open. */
closeViewWindow(panel: string, projectId: string): Promise<void>;
openViewWindow(panel: string): Promise<void>;
/** Closes the detached window for `panel` if one is open. */
closeViewWindow(panel: string): Promise<void>;
/**
* Subscribes to detached-window close events (OS close or programmatic). The
* handler receives the `{ panel, projectId }` that closed. Returns an
* unsubscribe.
* handler receives the `{ panel }` that closed. Returns an unsubscribe.
*/
onViewWindowClosed(
handler: (event: ViewWindowClosed) => void,
): Promise<Unsubscribe>;
}
/**
* A focused-project snapshot (ticket #47). Mirrors the backend
* `FocusedProjectDto` — the minimal identity a panel-only window needs to mount
* a project-scoped view (id for the read-models, name for chrome, root as the
* agents panel's cwd base).
*/
export interface FocusedProject {
id: string;
name: string;
root: string;
}
/**
* The focused-project channel between the main window and detached panel-only
* windows (ticket #47).
*
* The main window is the single writer: it publishes the currently active
* project (or `null` when none is open) via {@link setFocusedProject}. Detached
* windows are readers: they read the current focus at mount
* ({@link getFocusedProject}) then track changes via
* {@link onFocusedProjectChanged}. This decouples a restored panel window from
* any stale embedded project id — with no focus it shows an "open a project"
* shell and mounts no project-scoped panel.
*/
export interface FocusedProjectGateway {
/** Publishes the focused project (or `null` when no project is active). */
setFocusedProject(project: FocusedProject | null): Promise<void>;
/** Reads the current focused project; `null` when none is focused. */
getFocusedProject(): Promise<FocusedProject | null>;
/**
* Subscribes to focused-project changes. The handler receives the new focus
* (or `null`). Returns an unsubscribe.
*/
onFocusedProjectChanged(
handler: (project: FocusedProject | null) => void,
): Promise<Unsubscribe>;
}
/**
* Thin UI-preferences port (ticket #29). Persists small, per-surface pieces of
* UI state (like the ticket filters) so they survive a restart / reopen. This is
@ -1018,5 +1062,6 @@ export interface Gateways {
conversation: ConversationGateway;
ticket: TicketGateway;
window: WindowGateway;
focusedProject: FocusedProjectGateway;
uiPreferences: UiPreferencesGateway;
}