diff --git a/frontend/src/adapters/focusedProject.test.ts b/frontend/src/adapters/focusedProject.test.ts new file mode 100644 index 0000000..e2a3154 --- /dev/null +++ b/frontend/src/adapters/focusedProject.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/adapters/focusedProject.ts b/frontend/src/adapters/focusedProject.ts new file mode 100644 index 0000000..8f51345 --- /dev/null +++ b/frontend/src/adapters/focusedProject.ts @@ -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 { + // The backend command takes `project: Option`; `null` + // clears the focus (no project open). + await invoke("set_focused_project", { project }); + } + + async getFocusedProject(): Promise { + const project = await invoke("get_focused_project"); + return project ?? null; + } + + async onFocusedProjectChanged( + handler: (project: FocusedProject | null) => void, + ): Promise { + const unlisten = await listen( + FOCUSED_PROJECT_CHANGED, + (e) => handler(e.payload.project ?? null), + ); + return unlisten; + } +} diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 421727d..cb06a3c 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -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, }; diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index e6b55c0..46f21a5 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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(); private readonly listeners = new Set<(e: ViewWindowClosed) => void>(); - private key(panel: string, projectId: string): string { - return `${panel}|${projectId}`; + async openViewWindow(panel: string): Promise { + this.open.add(panel); } - async openViewWindow(panel: string, projectId: string): Promise { - this.open.add(this.key(panel, projectId)); - } - - async closeViewWindow(panel: string, projectId: string): Promise { - if (this.open.delete(this.key(panel, projectId))) { - this.emit({ panel, projectId }); + async closeViewWindow(panel: string): Promise { + 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 { + this.focused = project; + for (const l of this.listeners) l(project); + } + + async getFocusedProject(): Promise { + return this.focused; + } + + async onFocusedProjectChanged( + handler: (project: FocusedProject | null) => void, + ): Promise { + 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(), }; } diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 348d7ba..64b8712 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -17,6 +17,7 @@ describe("createMockGateways", () => { "agent", "conversation", "embedder", + "focusedProject", "git", "input", "layout", diff --git a/frontend/src/adapters/window.test.ts b/frontend/src/adapters/window.test.ts index d0bb369..46d9a76 100644 --- a/frontend/src/adapters/window.test.ts +++ b/frontend/src/adapters/window.test.ts @@ -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(); diff --git a/frontend/src/adapters/window.ts b/frontend/src/adapters/window.ts index b2abdbe..fd7f33b 100644 --- a/frontend/src/adapters/window.ts +++ b/frontend/src/adapters/window.ts @@ -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 { - // camelCase args (Tauri maps them to the command's `panel` / `project_id`). - await invoke("open_view_window", { panel, projectId }); + async openViewWindow(panel: string): Promise { + // 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 { - await invoke("close_view_window", { panel, projectId }); + async closeViewWindow(panel: string): Promise { + await invoke("close_view_window", { panel }); } async onViewWindowClosed( handler: (event: ViewWindowClosed) => void, ): Promise { // 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( 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; diff --git a/frontend/src/app/ViewWindow.test.tsx b/frontend/src/app/ViewWindow.test.tsx index 6c5579f..41ab38e 100644 --- a/frontend/src/app/ViewWindow.test.tsx +++ b/frontend/src/app/ViewWindow.test.tsx @@ -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( + + + , + ); + + // 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( - + , ); - // 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( + + + , + ); + + await waitFor(() => expect(screen.getByText("· beta")).toBeTruthy()); await waitFor(() => expect(screen.getByLabelText("search tickets")).toBeTruthy(), ); diff --git a/frontend/src/app/ViewWindow.tsx b/frontend/src/app/ViewWindow.tsx index f5aaa08..4a96bee 100644 --- a/frontend/src/app/ViewWindow.tsx +++ b/frontend/src/app/ViewWindow.tsx @@ -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=&project=`; + * A detached OS window loads the same SPA with `?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( (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(null); - const [error, setError] = useState(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( + 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 (
@@ -76,35 +92,28 @@ export function ViewWindow({ panel, projectId }: ViewWindowProps) {

{PANEL_TITLE[panel]}

- {resolved && ( - · {resolved.name} + {focus && ( + · {focus.name} )}
- {error ? ( - -

Error: {error}

-
- ) : resolved ? ( + {focus ? ( + // Only mount a project-scoped panel when a project is actually focused + // — the panels require a real `projectRoot`, never a placeholder. ) : ( -
- - Loading… -
+ +

+ Ouvrez un projet dans la fenêtre principale pour afficher ce + panneau. +

+
)}
); } - -function describeError(e: unknown): string { - if (e && typeof e === "object" && "message" in e) { - return String((e as { message: unknown }).message); - } - return String(e); -} diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index 0d16e25..c6819d7 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -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( {viewParams ? ( - + ) : ( )} diff --git a/frontend/src/features/projects/ProjectsView.detach.test.tsx b/frontend/src/features/projects/ProjectsView.detach.test.tsx index ed5b71f..06b1e40 100644 --- a/frontend/src/features/projects/ProjectsView.detach.test.tsx +++ b/frontend/src/features/projects/ProjectsView.detach.test.tsx @@ -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( @@ -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("●"), diff --git a/frontend/src/features/projects/ProjectsView.focus.test.tsx b/frontend/src/features/projects/ProjectsView.focus.test.tsx new file mode 100644 index 0000000..acd47a9 --- /dev/null +++ b/frontend/src/features/projects/ProjectsView.focus.test.tsx @@ -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( + + + , + ); + 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(), + ); + }); +}); diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index f37fc25..3b498b7 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -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 */ diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index e2fa381..9cb8eee 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -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; - /** Closes the detached window for `panel`/`projectId` if one is open. */ - closeViewWindow(panel: string, projectId: string): Promise; + openViewWindow(panel: string): Promise; + /** Closes the detached window for `panel` if one is open. */ + closeViewWindow(panel: string): Promise; /** * 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; } +/** + * 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; + /** Reads the current focused project; `null` when none is focused. */ + getFocusedProject(): Promise; + /** + * Subscribes to focused-project changes. The handler receives the new focus + * (or `null`). Returns an unsubscribe. + */ + onFocusedProjectChanged( + handler: (project: FocusedProject | null) => void, + ): Promise; +} + /** * 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; }