diff --git a/crates/app-tauri/capabilities/default.json b/crates/app-tauri/capabilities/default.json index 679314a..3bd1ec1 100644 --- a/crates/app-tauri/capabilities/default.json +++ b/crates/app-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Default capability set for the IdeA main window.", - "windows": ["main"], + "description": "Default capability set for the IdeA main window and detached view windows.", + "windows": ["main", "view-*"], "permissions": ["core:default", "dialog:allow-open"] } diff --git a/crates/app-tauri/gen/schemas/capabilities.json b/crates/app-tauri/gen/schemas/capabilities.json index 7c2a13a..e3360ce 100644 --- a/crates/app-tauri/gen/schemas/capabilities.json +++ b/crates/app-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capability set for the IdeA main window.","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capability set for the IdeA main window and detached view windows.","local":true,"windows":["main","view-*"],"permissions":["core:default","dialog:allow-open"]}} \ No newline at end of file diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 0d2c4b7..0954d44 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -4,8 +4,9 @@ //! [`AppState`], map `Result` to `Result`. No business logic lives here. +use serde::Serialize; use tauri::ipc::Channel; -use tauri::State; +use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent}; use crate::dto::DismissEmbedderSuggestionRequestDto; use application::{ @@ -2167,6 +2168,277 @@ pub async fn git_graph( use crate::dto::{parse_tab_id, MoveTabResultDto}; use application::MoveTabToNewWindowInput; +/// Event emitted for detached view-window lifecycle changes. +pub const VIEW_WINDOW_LIFECYCLE_EVENT: &str = "view-window://lifecycle"; + +/// Response returned by `open_view_window`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenViewWindowResponseDto { + /// Stable Tauri window label for this `(panel, projectId)`. + pub label: String, + /// App URL loaded by the panel-only window. + pub url: String, + /// Whether the command reused and focused an existing window. + pub already_open: bool, +} + +/// Response returned by `close_view_window`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CloseViewWindowResponseDto { + /// Stable Tauri window label for this `(panel, projectId)`. + pub label: String, + /// `true` when a live window was found and close was requested. + pub closed: bool, +} + +/// Payload emitted on `view-window://lifecycle`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ViewWindowLifecycleEventDto { + /// Lifecycle kind: `"opened"`, `"focused"` or `"closed"`. + pub kind: &'static str, + /// Panel id rendered by the detached window. + pub panel: String, + /// Project id rendered by the detached window. + pub project_id: String, + /// Stable Tauri window label. + pub label: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ViewPanel { + Projects, + Context, + Work, + Tickets, + Agents, + Templates, + Skills, + Permissions, + Memory, + Git, +} + +impl ViewPanel { + fn parse(raw: &str) -> Result { + match raw { + "projects" => Ok(Self::Projects), + "context" => Ok(Self::Context), + "work" => Ok(Self::Work), + "tickets" => Ok(Self::Tickets), + "agents" => Ok(Self::Agents), + "templates" => Ok(Self::Templates), + "skills" => Ok(Self::Skills), + "permissions" => Ok(Self::Permissions), + "memory" => Ok(Self::Memory), + "git" => Ok(Self::Git), + _ => Err(ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid view panel: {raw}"), + }), + } + } + + const fn as_str(self) -> &'static str { + match self { + Self::Projects => "projects", + Self::Context => "context", + Self::Work => "work", + Self::Tickets => "tickets", + Self::Agents => "agents", + Self::Templates => "templates", + Self::Skills => "skills", + Self::Permissions => "permissions", + Self::Memory => "memory", + Self::Git => "git", + } + } + + const fn title(self) -> &'static str { + match self { + Self::Projects => "Projects", + Self::Context => "Project context", + Self::Work => "Work state", + Self::Tickets => "Tickets", + Self::Agents => "Agents", + Self::Templates => "Templates", + Self::Skills => "Skills", + Self::Permissions => "Permissions", + Self::Memory => "Memory", + Self::Git => "Git", + } + } +} + +fn view_window_label(panel: ViewPanel, project_id: domain::ProjectId) -> String { + format!("view-{}-{}", panel.as_str(), project_id.as_uuid().simple()) +} + +fn view_window_url(panel: ViewPanel, project_id: domain::ProjectId) -> String { + format!("index.html?panel={}&project={}", panel.as_str(), project_id) +} + +fn emit_view_window_lifecycle( + app: &AppHandle, + kind: &'static str, + panel: ViewPanel, + project_id: domain::ProjectId, + label: &str, +) { + let _ = app.emit( + VIEW_WINDOW_LIFECYCLE_EVENT, + ViewWindowLifecycleEventDto { + kind, + panel: panel.as_str().to_owned(), + project_id: project_id.to_string(), + label: label.to_owned(), + }, + ); +} + +/// `open_view_window` — open or focus a detached OS window for one project view. +/// +/// The window is a normal decorated, resizable system window. Its webview loads +/// `index.html?panel=&project=` so the frontend can boot a +/// panel-only shell with the regular gateways and read models. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel or malformed project +/// id, `NOT_FOUND`/`STORE` if the project cannot be loaded, `INTERNAL` if Tauri +/// fails to create/focus the window). +#[tauri::command] +pub async fn open_view_window( + app: AppHandle, + panel: String, + project_id: String, + state: State<'_, AppState>, +) -> Result { + let panel = ViewPanel::parse(&panel)?; + let project = resolve_project(&project_id, &state).await?; + let project_id = project.id; + let label = view_window_label(panel, project_id); + let url = view_window_url(panel, project_id); + + if let Some(window) = app.get_webview_window(&label) { + window.show().map_err(internal_window_error)?; + window.set_focus().map_err(internal_window_error)?; + emit_view_window_lifecycle(&app, "focused", panel, project_id, &label); + return Ok(OpenViewWindowResponseDto { + label, + url, + already_open: true, + }); + } + + let window = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.clone().into())) + .title(format!("IdeA - {}", panel.title())) + .inner_size(1120.0, 760.0) + .min_inner_size(720.0, 480.0) + .resizable(true) + .maximizable(true) + .minimizable(true) + .closable(true) + .decorations(true) + .build() + .map_err(internal_window_error)?; + + let event_app = app.clone(); + let event_label = label.clone(); + window.on_window_event(move |event| { + if let WindowEvent::CloseRequested { .. } = event { + emit_view_window_lifecycle(&event_app, "closed", panel, project_id, &event_label); + } + }); + emit_view_window_lifecycle(&app, "opened", panel, project_id, &label); + + Ok(OpenViewWindowResponseDto { + label, + url, + already_open: false, + }) +} + +/// `close_view_window` — request closing the detached OS window for one project view. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel or malformed project +/// id, `INTERNAL` if Tauri fails to close the window). +#[tauri::command] +pub async fn close_view_window( + app: AppHandle, + panel: String, + project_id: String, +) -> Result { + let panel = ViewPanel::parse(&panel)?; + let project_id = parse_project_id(&project_id)?; + let label = view_window_label(panel, project_id); + let Some(window) = app.get_webview_window(&label) else { + return Ok(CloseViewWindowResponseDto { + label, + closed: false, + }); + }; + + window.close().map_err(internal_window_error)?; + Ok(CloseViewWindowResponseDto { + label, + closed: true, + }) +} + +fn internal_window_error(error: impl std::fmt::Display) -> ErrorDto { + ErrorDto { + code: "INTERNAL".to_owned(), + message: error.to_string(), + } +} + +#[cfg(test)] +mod view_window_tests { + use super::*; + use domain::ProjectId; + use uuid::Uuid; + + #[test] + fn view_window_label_and_url_are_stable() { + let project_id = ProjectId::from_uuid(Uuid::from_u128(0x123)); + let panel = ViewPanel::Tickets; + + assert_eq!( + view_window_label(panel, project_id), + "view-tickets-00000000000000000000000000000123" + ); + assert_eq!( + view_window_url(panel, project_id), + "index.html?panel=tickets&project=00000000-0000-0000-0000-000000000123" + ); + } + + #[test] + fn view_window_rejects_unknown_panel() { + let err = ViewPanel::parse("terminal").unwrap_err(); + + assert_eq!(err.code, "INVALID"); + assert!(err.message.contains("invalid view panel: terminal")); + } + + #[test] + fn view_window_lifecycle_payload_is_camel_case() { + let payload = ViewWindowLifecycleEventDto { + kind: "closed", + panel: "tickets".to_owned(), + project_id: Uuid::from_u128(7).to_string(), + label: "view-tickets-7".to_owned(), + }; + + let json = serde_json::to_string(&payload).unwrap(); + assert!(json.contains("\"projectId\""), "json was {json}"); + assert!(!json.contains("project_id"), "no snake_case leak: {json}"); + } +} + /// `move_tab_to_new_window` — detach a tab into a brand-new OS window. /// /// Applies the workspace topology change (the tab is *moved*, not duplicated) diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index bd2ebe1..8b79a5e 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -233,6 +233,8 @@ pub fn run() { commands::read_memory_index, commands::recall_memory, commands::resolve_memory_links, + commands::open_view_window, + commands::close_view_window, commands::move_tab_to_new_window, commands::spawn_background_command, commands::cancel_background_task, diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 4637a5a..4fa7745 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -28,6 +28,7 @@ import { TauriPermissionGateway } from "./permission"; import { TauriWorkStateGateway } from "./workState"; import { TauriConversationGateway } from "./conversation"; import { TauriTicketGateway } from "./ticket"; +import { TauriWindowGateway } from "./window"; function notImplemented(what: string): never { const err: GatewayError = { @@ -63,6 +64,7 @@ export function createTauriGateways(): Gateways { workState: new TauriWorkStateGateway(), conversation: new TauriConversationGateway(), ticket: new TauriTicketGateway(), + window: new TauriWindowGateway(), }; } @@ -83,4 +85,5 @@ export { TauriWorkStateGateway, TauriConversationGateway, TauriTicketGateway, + TauriWindowGateway, }; diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index dc04083..3132b47 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -84,6 +84,8 @@ import type { TicketListQuery, CreateTicketInput, UpdateTicketInput, + ViewWindowClosed, + WindowGateway, WorkStateGateway, } from "@/ports"; import { normalizeProjectWorkState } from "../workStateNormalization"; @@ -1086,6 +1088,51 @@ class MockRemoteGateway implements RemoteGateway { async connect(): Promise {} } +/** + * 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). + */ +export class MockWindowGateway implements WindowGateway { + /** Currently-open detached windows, keyed `panel|projectId`. */ + 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, 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 onViewWindowClosed( + handler: (event: ViewWindowClosed) => void, + ): Promise { + this.listeners.add(handler); + return () => { + this.listeners.delete(handler); + }; + } + + /** 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 }); + } + + private emit(event: ViewWindowClosed): void { + for (const l of this.listeners) l(event); + } +} + /** * The pre-filled reference catalogue the mock serves — mirror of the backend's * **selectable** catalogue (§17.3/D7): only profiles drivable in structured mode @@ -2400,6 +2447,7 @@ export function createMockGateways(): Gateways { workState: new MockWorkStateGateway(), conversation: new MockConversationGateway(), ticket: new MockTicketGateway(systemGateway), + window: new MockWindowGateway(), }; } diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 4e9bf09..7566d56 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -30,6 +30,7 @@ describe("createMockGateways", () => { "template", "terminal", "ticket", + "window", "workState", ]); }); diff --git a/frontend/src/adapters/window.test.ts b/frontend/src/adapters/window.test.ts new file mode 100644 index 0000000..d0bb369 --- /dev/null +++ b/frontend/src/adapters/window.test.ts @@ -0,0 +1,71 @@ +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 { TauriWindowGateway } from "./window"; + +describe("TauriWindowGateway (#23)", () => { + beforeEach(() => { + invoke.mockReset().mockResolvedValue({}); + listen.mockReset(); + }); + + it("open/close relay camelCase payloads to the backend commands", async () => { + const gw = new TauriWindowGateway(); + await gw.openViewWindow("tickets", "proj-1"); + expect(invoke).toHaveBeenCalledWith("open_view_window", { + panel: "tickets", + projectId: "proj-1", + }); + await gw.closeViewWindow("git", "proj-2"); + expect(invoke).toHaveBeenCalledWith("close_view_window", { + panel: "git", + projectId: "proj-2", + }); + }); + + it("onViewWindowClosed listens on the lifecycle channel and forwards only closes", async () => { + // Capture the raw Tauri listener so we can drive lifecycle events at will. + 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 TauriWindowGateway(); + const handler = vi.fn(); + const off = await gw.onViewWindowClosed(handler); + + expect(listen).toHaveBeenCalledWith( + "view-window://lifecycle", + expect.any(Function), + ); + + // opened / focused are dropped… + raw?.({ + payload: { kind: "opened", panel: "git", projectId: "p", label: "view-git-p" }, + }); + raw?.({ + payload: { kind: "focused", panel: "git", projectId: "p", label: "view-git-p" }, + }); + expect(handler).not.toHaveBeenCalled(); + + // …only closed reaches the port handler, narrowed to { panel, projectId }. + raw?.({ + payload: { kind: "closed", panel: "git", projectId: "p", label: "view-git-p" }, + }); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ panel: "git", projectId: "p" }); + + off(); + expect(unlisten).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/adapters/window.ts b/frontend/src/adapters/window.ts new file mode 100644 index 0000000..b2abdbe --- /dev/null +++ b/frontend/src/adapters/window.ts @@ -0,0 +1,54 @@ +/** + * Tauri adapter for {@link WindowGateway} (ticket #23). Detaches a View into its + * own OS window by delegating to the backend's window-lifecycle commands. 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 below mirror the backend contract owned by + * DevBackend (build-verified). The backend exposes a single lifecycle channel + * `view-window://lifecycle`; this adapter narrows it to the port's close-only + * `onViewWindowClosed` by filtering on `kind === "closed"`. + */ + +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"; + +/** The single Tauri event carrying detached-window lifecycle transitions. */ +const VIEW_WINDOW_LIFECYCLE = "view-window://lifecycle"; + +/** Backend lifecycle payload (discriminated on `kind`). */ +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 closeViewWindow(panel: string, projectId: string): Promise { + await invoke("close_view_window", { panel, projectId }); + } + + 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 }`. + const unlisten = await listen( + VIEW_WINDOW_LIFECYCLE, + (e) => { + if (e.payload.kind !== "closed") return; + handler({ panel: e.payload.panel, projectId: e.payload.projectId }); + }, + ); + return unlisten; + } +} diff --git a/frontend/src/app/ViewWindow.test.tsx b/frontend/src/app/ViewWindow.test.tsx new file mode 100644 index 0000000..6c5579f --- /dev/null +++ b/frontend/src/app/ViewWindow.test.tsx @@ -0,0 +1,62 @@ +/** + * #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). + */ +import { describe, it, expect } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; + +import { DIProvider } from "@/app/di"; +import { + MockAgentGateway, + MockProjectGateway, + MockSystemGateway, + MockTicketGateway, +} from "@/adapters/mock"; +import type { Gateways } from "@/ports"; +import { ViewWindow, parseViewWindowParams } from "./ViewWindow"; + +describe("parseViewWindowParams (#23)", () => { + it("parses a valid panel + project", () => { + 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(); + expect(parseViewWindowParams("?project=p-1")).toBeNull(); + expect(parseViewWindowParams("?panel=projects&project=p-1")).toBeNull(); + expect(parseViewWindowParams("?panel=bogus&project=p-1")).toBeNull(); + expect(parseViewWindowParams("")).toBeNull(); + }); +}); + +describe("ViewWindow (#23)", () => { + it("resolves the project and renders only the requested view", async () => { + const system = new MockSystemGateway(); + const project = new MockProjectGateway(); + const created = await project.createProject("alpha", "/p/a"); + const gateways = { + system, + project, + agent: new MockAgentGateway(), + ticket: new MockTicketGateway(system), + } as unknown as Gateways; + + render( + + + , + ); + + // The window chrome shows the panel title and the resolved project name. + expect(screen.getByRole("heading", { name: "Tickets" })).toBeTruthy(); + 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(), + ); + }); +}); diff --git a/frontend/src/app/ViewWindow.tsx b/frontend/src/app/ViewWindow.tsx new file mode 100644 index 0000000..f5aaa08 --- /dev/null +++ b/frontend/src/app/ViewWindow.tsx @@ -0,0 +1,110 @@ +/** + * `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); +} diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index 2c0e465..0d16e25 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -4,6 +4,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App"; +import { ViewWindow, parseViewWindowParams } from "./ViewWindow"; import { DIProvider } from "./di"; import "@/shared/styles/theme.css"; @@ -12,10 +13,18 @@ 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. +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 new file mode 100644 index 0000000..92664db --- /dev/null +++ b/frontend/src/features/projects/ProjectsView.detach.test.tsx @@ -0,0 +1,105 @@ +/** + * #23 — detaching a View into its own OS window from `ProjectsView`. The + * "Window" menu (and the docked/floating header ⤢ control) call the + * {@link WindowGateway}; the slot becomes "detached" (nothing renders in the + * main window), and the backend's close event re-toggles it back out of + * "detached". The one-slot invariant holds throughout. + */ +import { describe, it, expect } from "vitest"; +import { render, screen, within, waitFor, fireEvent } from "@testing-library/react"; + +import { + MockAgentGateway, + MockGitGateway, + MockProfileGateway, + MockProjectGateway, + MockSystemGateway, + MockTemplateGateway, + MockWindowGateway, + MockWorkStateGateway, +} from "@/adapters/mock"; +import type { Gateways } from "@/ports"; +import { DIProvider } from "@/app/di"; +import { ProjectsView } from "./ProjectsView"; + +async function renderWithProject() { + const project = new MockProjectGateway(); + const created = await project.createProject("alpha", "/p/a"); + const agentGateway = new MockAgentGateway(); + const windowGateway = new MockWindowGateway(); + const gateways = { + system: new MockSystemGateway(), + project, + agent: agentGateway, + profile: new MockProfileGateway(), + template: new MockTemplateGateway(agentGateway), + git: new MockGitGateway(), + workState: new MockWorkStateGateway(), + window: windowGateway, + } as unknown as Gateways; + render( + + + , + ); + fireEvent.click(await screen.findByRole("button", { name: "Open" })); + await waitFor(() => + expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1), + ); + return { windowGateway, projectId: created.id }; +} + +function openMenuItem(menu: string, item: string) { + fireEvent.click(screen.getByRole("button", { name: menu })); + fireEvent.click(screen.getByRole("button", { name: item })); +} + +describe("ProjectsView detach to OS window (#23)", () => { + it("Window menu detaches a view: opens the OS window and marks the slot detached", async () => { + const { windowGateway, projectId } = await renderWithProject(); + + // A docked view is visible in the main window first… + openMenuItem("View", "Git"); + const dialog = await screen.findByRole("dialog", { name: "Git" }); + fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" })); + await screen.findByRole("complementary", { name: "left dock" }); + + // …then « Git → nouvelle fenêtre » pops it out. + openMenuItem("Window", "Git → nouvelle fenêtre"); + + await waitFor(() => + expect(windowGateway.open.has(`git|${projectId}`)).toBe(true), + ); + // The slot is detached ⇒ nothing renders for Git in the main window. + await waitFor(() => + expect(screen.queryByRole("complementary", { name: "left dock" })).toBeNull(), + ); + expect(screen.queryByRole("dialog", { name: "Git" })).toBeNull(); + }); + + it("re-toggles the placement out of detached when the OS window closes", async () => { + const { windowGateway, projectId } = await renderWithProject(); + + openMenuItem("Window", "Git → nouvelle fenêtre"); + await waitFor(() => + expect(windowGateway.open.has(`git|${projectId}`)).toBe(true), + ); + + // The « Window » item shows the active marker (●) while detached. + const openWindowMenu = () => + fireEvent.click(screen.getByRole("button", { name: "Window" })); + const gitWindowItem = () => + screen.getByRole("button", { name: "Git → nouvelle fenêtre" }); + + openWindowMenu(); + await waitFor(() => expect(gitWindowItem().textContent).toContain("●")); + openWindowMenu(); // close the dropdown + + // The backend reports the OS window closed ⇒ the slot leaves "detached". + windowGateway.simulateOsClose("git", projectId); + openWindowMenu(); + await waitFor(() => + expect(gitWindowItem().textContent).not.toContain("●"), + ); + }); +}); diff --git a/frontend/src/features/projects/ProjectsView.docking.test.tsx b/frontend/src/features/projects/ProjectsView.docking.test.tsx index b53f6be..839f1b1 100644 --- a/frontend/src/features/projects/ProjectsView.docking.test.tsx +++ b/frontend/src/features/projects/ProjectsView.docking.test.tsx @@ -14,6 +14,7 @@ import { MockProjectGateway, MockSystemGateway, MockTemplateGateway, + MockWindowGateway, MockWorkStateGateway, } from "@/adapters/mock"; import type { Gateways } from "@/ports"; @@ -32,6 +33,7 @@ async function renderWithProject() { template: new MockTemplateGateway(agentGateway), git: new MockGitGateway(), workState: new MockWorkStateGateway(), + window: new MockWindowGateway(), } as unknown as Gateways; render( diff --git a/frontend/src/features/projects/ProjectsView.ls7.test.tsx b/frontend/src/features/projects/ProjectsView.ls7.test.tsx index a57dcca..e2ac38c 100644 --- a/frontend/src/features/projects/ProjectsView.ls7.test.tsx +++ b/frontend/src/features/projects/ProjectsView.ls7.test.tsx @@ -16,6 +16,7 @@ import { MockSystemGateway, MockTemplateGateway, MockTerminalGateway, + MockWindowGateway, } from "@/adapters/mock"; import type { ConversationGateway, @@ -102,6 +103,7 @@ function renderView(project: MockProjectGateway) { terminal: new MockTerminalGateway(), workState: fixedWorkState(), conversation: fixedConversation(), + window: new MockWindowGateway(), } as unknown as Gateways; return render( diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index e274730..487faa7 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -31,17 +31,9 @@ import { useEffect, useState, type ReactNode } from "react"; import type { DomainEvent, LayoutInfo } from "@/domain"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; -import { AgentsPanel } from "@/features/agents"; -import { TemplatesPanel } from "@/features/templates"; -import { SkillsPanel } from "@/features/skills"; -import { MemoryPanel } from "@/features/memory"; -import { EmbedderSettings } from "@/features/embedder"; -import { PermissionsPanel } from "@/features/permissions"; -import { ProjectWorkStatePanel } from "@/features/workstate"; -import { TicketsView } from "@/features/tickets"; import { ConversationViewer } from "@/features/conversations"; import { ProfilesSettings } from "@/features/first-run"; -import { GitPanel, GitGraphView } from "@/features/git"; +import { GitGraphView } from "@/features/git"; import { Button, DockRegion, @@ -55,11 +47,12 @@ import { type MenuBarMenu, } from "@/shared"; import { useGateways } from "@/app/di"; -import { ProjectContextPanel } from "./ProjectContextPanel"; import { useProjects } from "./useProjects"; +import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody"; import { PANEL_TITLE, floatingPanels, + isDetached, isDockedTo, panelsDockedTo, placementOf, @@ -104,7 +97,7 @@ function isTerminalBackgroundTaskEvent( export function ProjectsView() { const vm = useProjects(); - const { system } = useGateways(); + const { system, window: windowGateway } = useGateways(); const [name, setName] = useState(""); const [root, setRoot] = useState(""); // Placement of every open view (#22): each panel is "closed" (absent), @@ -237,6 +230,48 @@ 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). + function detachPanel(panel: PanelId) { + if (!active) return; + const projectId = active.id; + void windowGateway + .openViewWindow(panel, projectId) + .then(() => setPlacement(panel, "detached")) + .catch(() => { + /* window failed to open; keep the current placement */ + }); + } + + // When a detached window closes (OS close or programmatic), re-toggle its + // placement out of "detached" so it isn't stuck as an invisible ghost slot. + useEffect(() => { + let unsubscribe: (() => void) | undefined; + let cancelled = false; + void windowGateway + .onViewWindowClosed(({ panel }) => { + setPlacements((prev) => + prev[panel as PanelId] === "detached" + ? (() => { + const next = { ...prev }; + delete next[panel as PanelId]; + return next; + })() + : prev, + ); + }) + .then((u) => { + if (cancelled) u(); + else unsubscribe = u; + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, [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) { @@ -315,6 +350,19 @@ export function ProjectsView() { onSelect: () => toggleFloating(it.id), })), }, + { + // #23: pop a view out into its own OS window. Disabled without an active + // project (the detached window is project-scoped); active ⇒ detached. + id: "window", + label: "Window", + items: viewItems.map((it) => ({ + id: it.id, + label: `${PANEL_TITLE[it.id]} → nouvelle fenêtre`, + active: isDetached(placementOf(placements, it.id)), + disabled: !active, + onSelect: () => detachPanel(it.id), + })), + }, { id: "settings", label: "Settings", @@ -407,7 +455,9 @@ export function ProjectsView() { ); - // Body of the currently-open panel window (null ⇒ no window). + // Body of a panel wherever it is placed (docked/floating). Delegates to the + // shared {@link ViewPanelBody} (also used by the detached ViewWindow) so a + // view is identical across placements. "projects" is main-window-only chrome. function renderPanel(panel: PanelId): ReactNode { if (panel === "projects") return projectsManager; if (!active) { @@ -415,36 +465,14 @@ export function ProjectsView() {

Open a project to use this panel.

); } - switch (panel) { - case "context": - return ; - case "work": - return ( - - ); - case "tickets": - return ; - case "agents": - return ; - case "templates": - return ; - case "skills": - return ; - case "permissions": - return ; - case "memory": - return ( -
- - -
- ); - case "git": - return ; - } + return ( + + ); } // Projects manager renders inline in the welcome area only when it is not @@ -492,6 +520,15 @@ export function ProjectsView() { > ⧉ +