Merge feature/ticket23-system-view-windows into develop (#23)

Fenêtres View système séparées (WebviewWindow Tauri, multi-écran,
fullscreen) — backend commandes/capabilities + composition root,
frontend port WindowGateway + entrée panel-only. Clôt le sprint
UI rework. QA vert : app-tauri 63, frontend typecheck + vitest 546/546.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 16:53:52 +02:00
21 changed files with 962 additions and 55 deletions

View File

@ -1,7 +1,7 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Default capability set for the IdeA main window.", "description": "Default capability set for the IdeA main window and detached view windows.",
"windows": ["main"], "windows": ["main", "view-*"],
"permissions": ["core:default", "dialog:allow-open"] "permissions": ["core:default", "dialog:allow-open"]
} }

View File

@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capability set for the IdeA main window.","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}} {"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"]}}

View File

@ -4,8 +4,9 @@
//! [`AppState`], map `Result<Output, AppError>` to `Result<ResponseDto, //! [`AppState`], map `Result<Output, AppError>` to `Result<ResponseDto,
//! ErrorDto>`. No business logic lives here. //! ErrorDto>`. No business logic lives here.
use serde::Serialize;
use tauri::ipc::Channel; use tauri::ipc::Channel;
use tauri::State; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent};
use crate::dto::DismissEmbedderSuggestionRequestDto; use crate::dto::DismissEmbedderSuggestionRequestDto;
use application::{ use application::{
@ -2167,6 +2168,277 @@ pub async fn git_graph(
use crate::dto::{parse_tab_id, MoveTabResultDto}; use crate::dto::{parse_tab_id, MoveTabResultDto};
use application::MoveTabToNewWindowInput; 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<Self, ErrorDto> {
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=<panel>&project=<projectId>` 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<OpenViewWindowResponseDto, ErrorDto> {
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<CloseViewWindowResponseDto, ErrorDto> {
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. /// `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) /// Applies the workspace topology change (the tab is *moved*, not duplicated)

View File

@ -233,6 +233,8 @@ pub fn run() {
commands::read_memory_index, commands::read_memory_index,
commands::recall_memory, commands::recall_memory,
commands::resolve_memory_links, commands::resolve_memory_links,
commands::open_view_window,
commands::close_view_window,
commands::move_tab_to_new_window, commands::move_tab_to_new_window,
commands::spawn_background_command, commands::spawn_background_command,
commands::cancel_background_task, commands::cancel_background_task,

View File

@ -28,6 +28,7 @@ import { TauriPermissionGateway } from "./permission";
import { TauriWorkStateGateway } from "./workState"; import { TauriWorkStateGateway } from "./workState";
import { TauriConversationGateway } from "./conversation"; import { TauriConversationGateway } from "./conversation";
import { TauriTicketGateway } from "./ticket"; import { TauriTicketGateway } from "./ticket";
import { TauriWindowGateway } from "./window";
function notImplemented(what: string): never { function notImplemented(what: string): never {
const err: GatewayError = { const err: GatewayError = {
@ -63,6 +64,7 @@ export function createTauriGateways(): Gateways {
workState: new TauriWorkStateGateway(), workState: new TauriWorkStateGateway(),
conversation: new TauriConversationGateway(), conversation: new TauriConversationGateway(),
ticket: new TauriTicketGateway(), ticket: new TauriTicketGateway(),
window: new TauriWindowGateway(),
}; };
} }
@ -83,4 +85,5 @@ export {
TauriWorkStateGateway, TauriWorkStateGateway,
TauriConversationGateway, TauriConversationGateway,
TauriTicketGateway, TauriTicketGateway,
TauriWindowGateway,
}; };

View File

@ -84,6 +84,8 @@ import type {
TicketListQuery, TicketListQuery,
CreateTicketInput, CreateTicketInput,
UpdateTicketInput, UpdateTicketInput,
ViewWindowClosed,
WindowGateway,
WorkStateGateway, WorkStateGateway,
} from "@/ports"; } from "@/ports";
import { normalizeProjectWorkState } from "../workStateNormalization"; import { normalizeProjectWorkState } from "../workStateNormalization";
@ -1086,6 +1088,51 @@ class MockRemoteGateway implements RemoteGateway {
async connect(): Promise<void> {} async connect(): Promise<void> {}
} }
/**
* 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<string>();
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<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 onViewWindowClosed(
handler: (event: ViewWindowClosed) => void,
): Promise<Unsubscribe> {
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 * The pre-filled reference catalogue the mock serves — mirror of the backend's
* **selectable** catalogue (§17.3/D7): only profiles drivable in structured mode * **selectable** catalogue (§17.3/D7): only profiles drivable in structured mode
@ -2400,6 +2447,7 @@ export function createMockGateways(): Gateways {
workState: new MockWorkStateGateway(), workState: new MockWorkStateGateway(),
conversation: new MockConversationGateway(), conversation: new MockConversationGateway(),
ticket: new MockTicketGateway(systemGateway), ticket: new MockTicketGateway(systemGateway),
window: new MockWindowGateway(),
}; };
} }

View File

@ -30,6 +30,7 @@ describe("createMockGateways", () => {
"template", "template",
"terminal", "terminal",
"ticket", "ticket",
"window",
"workState", "workState",
]); ]);
}); });

View File

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

View File

@ -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<void> {
// 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<void> {
await invoke("close_view_window", { panel, projectId });
}
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 }`.
const unlisten = await listen<ViewWindowLifecycle>(
VIEW_WINDOW_LIFECYCLE,
(e) => {
if (e.payload.kind !== "closed") return;
handler({ panel: e.payload.panel, projectId: e.payload.projectId });
},
);
return unlisten;
}
}

View File

@ -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(
<DIProvider gateways={gateways}>
<ViewWindow panel="tickets" projectId={created.id} />
</DIProvider>,
);
// 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(),
);
});
});

View File

@ -0,0 +1,110 @@
/**
* `ViewWindow` — the panel-only entry (ticket #23).
*
* A detached OS window loads the same SPA with `?panel=<panel>&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.
*
* 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<string>(
(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<Project | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="flex h-full flex-col bg-canvas text-content">
<header className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<h1 className="text-sm font-semibold text-content">
{PANEL_TITLE[panel]}
</h1>
{resolved && (
<span className="truncate text-xs text-muted">· {resolved.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 ? (
<ViewPanelBody
panel={panel}
projectId={resolved.id}
projectRoot={resolved.root}
/>
) : (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>Loading</span>
</div>
)}
</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

@ -4,6 +4,7 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
import { DIProvider } from "./di"; import { DIProvider } from "./di";
import "@/shared/styles/theme.css"; import "@/shared/styles/theme.css";
@ -12,10 +13,18 @@ if (!root) {
throw new Error("missing #root element"); 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( ReactDOM.createRoot(root).render(
<React.StrictMode> <React.StrictMode>
<DIProvider> <DIProvider>
{viewParams ? (
<ViewWindow panel={viewParams.panel} projectId={viewParams.projectId} />
) : (
<App /> <App />
)}
</DIProvider> </DIProvider>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@ -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(
<DIProvider gateways={gateways}>
<ProjectsView />
</DIProvider>,
);
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("●"),
);
});
});

View File

@ -14,6 +14,7 @@ import {
MockProjectGateway, MockProjectGateway,
MockSystemGateway, MockSystemGateway,
MockTemplateGateway, MockTemplateGateway,
MockWindowGateway,
MockWorkStateGateway, MockWorkStateGateway,
} from "@/adapters/mock"; } from "@/adapters/mock";
import type { Gateways } from "@/ports"; import type { Gateways } from "@/ports";
@ -32,6 +33,7 @@ async function renderWithProject() {
template: new MockTemplateGateway(agentGateway), template: new MockTemplateGateway(agentGateway),
git: new MockGitGateway(), git: new MockGitGateway(),
workState: new MockWorkStateGateway(), workState: new MockWorkStateGateway(),
window: new MockWindowGateway(),
} as unknown as Gateways; } as unknown as Gateways;
render( render(
<DIProvider gateways={gateways}> <DIProvider gateways={gateways}>

View File

@ -16,6 +16,7 @@ import {
MockSystemGateway, MockSystemGateway,
MockTemplateGateway, MockTemplateGateway,
MockTerminalGateway, MockTerminalGateway,
MockWindowGateway,
} from "@/adapters/mock"; } from "@/adapters/mock";
import type { import type {
ConversationGateway, ConversationGateway,
@ -102,6 +103,7 @@ function renderView(project: MockProjectGateway) {
terminal: new MockTerminalGateway(), terminal: new MockTerminalGateway(),
workState: fixedWorkState(), workState: fixedWorkState(),
conversation: fixedConversation(), conversation: fixedConversation(),
window: new MockWindowGateway(),
} as unknown as Gateways; } as unknown as Gateways;
return render( return render(
<DIProvider gateways={gateways}> <DIProvider gateways={gateways}>

View File

@ -31,17 +31,9 @@ import { useEffect, useState, type ReactNode } from "react";
import type { DomainEvent, LayoutInfo } from "@/domain"; import type { DomainEvent, LayoutInfo } from "@/domain";
import { LayoutGrid, LayoutTabs } from "@/features/layout"; 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 { ConversationViewer } from "@/features/conversations";
import { ProfilesSettings } from "@/features/first-run"; import { ProfilesSettings } from "@/features/first-run";
import { GitPanel, GitGraphView } from "@/features/git"; import { GitGraphView } from "@/features/git";
import { import {
Button, Button,
DockRegion, DockRegion,
@ -55,11 +47,12 @@ import {
type MenuBarMenu, type MenuBarMenu,
} from "@/shared"; } from "@/shared";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import { ProjectContextPanel } from "./ProjectContextPanel";
import { useProjects } from "./useProjects"; import { useProjects } from "./useProjects";
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
import { import {
PANEL_TITLE, PANEL_TITLE,
floatingPanels, floatingPanels,
isDetached,
isDockedTo, isDockedTo,
panelsDockedTo, panelsDockedTo,
placementOf, placementOf,
@ -104,7 +97,7 @@ function isTerminalBackgroundTaskEvent(
export function ProjectsView() { export function ProjectsView() {
const vm = useProjects(); const vm = useProjects();
const { system } = useGateways(); const { system, window: windowGateway } = useGateways();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [root, setRoot] = useState(""); const [root, setRoot] = useState("");
// Placement of every open view (#22): each panel is "closed" (absent), // 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 // 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. // windows so they don't obscure the viewer (LS7). Docked views stay beside it.
function openConversation(conversationId: string) { function openConversation(conversationId: string) {
@ -315,6 +350,19 @@ export function ProjectsView() {
onSelect: () => toggleFloating(it.id), 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", id: "settings",
label: "Settings", label: "Settings",
@ -407,7 +455,9 @@ export function ProjectsView() {
</div> </div>
); );
// 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 { function renderPanel(panel: PanelId): ReactNode {
if (panel === "projects") return projectsManager; if (panel === "projects") return projectsManager;
if (!active) { if (!active) {
@ -415,36 +465,14 @@ export function ProjectsView() {
<p className="text-sm text-muted">Open a project to use this panel.</p> <p className="text-sm text-muted">Open a project to use this panel.</p>
); );
} }
switch (panel) {
case "context":
return <ProjectContextPanel projectId={active.id} />;
case "work":
return ( return (
<ProjectWorkStatePanel <ViewPanelBody
panel={panel as ViewPanelId}
projectId={active.id} projectId={active.id}
projectRoot={active.root}
onOpenConversation={openConversation} onOpenConversation={openConversation}
/> />
); );
case "tickets":
return <TicketsView projectId={active.id} />;
case "agents":
return <AgentsPanel projectId={active.id} projectRoot={active.root} />;
case "templates":
return <TemplatesPanel projectId={active.id} />;
case "skills":
return <SkillsPanel projectId={active.id} />;
case "permissions":
return <PermissionsPanel projectId={active.id} />;
case "memory":
return (
<div className="flex flex-col gap-4">
<MemoryPanel projectId={active.id} />
<EmbedderSettings />
</div>
);
case "git":
return <GitPanel projectId={active.id} />;
}
} }
// Projects manager renders inline in the welcome area only when it is not // Projects manager renders inline in the welcome area only when it is not
@ -492,6 +520,15 @@ export function ProjectsView() {
> >
</Button> </Button>
<Button
size="sm"
variant="ghost"
aria-label={`open ${title} in new window`}
disabled={!active || isDetached(placement)}
onClick={() => detachPanel(panel)}
>
</Button>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"

View File

@ -0,0 +1,75 @@
/**
* `ViewPanelBody` — renders the body of a single View panel from its id and a
* project context, reusing the existing feature panels as-is.
*
* Shared (ticket #23) between the in-window placements of {@link ProjectsView}
* (docked/floating) and the detached {@link ViewWindow} (its own OS window), so
* a view looks and behaves the same wherever it is placed. The `"projects"`
* panel is intentionally **not** handled here — the projects manager is
* main-window chrome, never a detachable view.
*/
import type { ReactNode } from "react";
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 { GitPanel } from "@/features/git";
import { ProjectContextPanel } from "./ProjectContextPanel";
import type { PanelId } from "./viewPlacement";
/** Panels that render a project-scoped view (everything except "projects"). */
export type ViewPanelId = Exclude<PanelId, "projects">;
export interface ViewPanelBodyProps {
panel: ViewPanelId;
projectId: string;
/** Project root — required by the agents panel (its cwd base). */
projectRoot: string;
/** Optional: open a conversation viewer (only wired inside the main window). */
onOpenConversation?: (conversationId: string) => void;
}
/** Renders the requested view panel for the given project. */
export function ViewPanelBody({
panel,
projectId,
projectRoot,
onOpenConversation,
}: ViewPanelBodyProps): ReactNode {
switch (panel) {
case "context":
return <ProjectContextPanel projectId={projectId} />;
case "work":
return (
<ProjectWorkStatePanel
projectId={projectId}
onOpenConversation={onOpenConversation}
/>
);
case "tickets":
return <TicketsView projectId={projectId} />;
case "agents":
return <AgentsPanel projectId={projectId} projectRoot={projectRoot} />;
case "templates":
return <TemplatesPanel projectId={projectId} />;
case "skills":
return <SkillsPanel projectId={projectId} />;
case "permissions":
return <PermissionsPanel projectId={projectId} />;
case "memory":
return (
<div className="flex flex-col gap-4">
<MemoryPanel projectId={projectId} />
<EmbedderSettings />
</div>
);
case "git":
return <GitPanel projectId={projectId} />;
}
}

View File

@ -3,3 +3,7 @@
export { ProjectsView } from "./ProjectsView"; export { ProjectsView } from "./ProjectsView";
export { useProjects } from "./useProjects"; export { useProjects } from "./useProjects";
export type { ProjectsViewModel } from "./useProjects"; export type { ProjectsViewModel } from "./useProjects";
export { ViewPanelBody } from "./ViewPanelBody";
export type { ViewPanelId, ViewPanelBodyProps } from "./ViewPanelBody";
export { PANEL_TITLE, placementOf, isDetached } from "./viewPlacement";
export type { PanelId, ViewPlacement, ViewPlacements } from "./viewPlacement";

View File

@ -12,7 +12,7 @@ import {
fireEvent, fireEvent,
} from "@testing-library/react"; } from "@testing-library/react";
import { MockAgentGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway, MockWorkStateGateway } from "@/adapters/mock"; import { MockAgentGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway, MockWindowGateway, MockWorkStateGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports"; import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import { ProjectsView } from "./ProjectsView"; import { ProjectsView } from "./ProjectsView";
@ -34,6 +34,7 @@ function renderView(
template: new MockTemplateGateway(agentGateway), template: new MockTemplateGateway(agentGateway),
git: new MockGitGateway(), git: new MockGitGateway(),
workState: new MockWorkStateGateway(), workState: new MockWorkStateGateway(),
window: new MockWindowGateway(),
} as unknown as Gateways; } as unknown as Gateways;
return { return {
project, project,

View File

@ -7,12 +7,13 @@
* - `"closed"` — not shown anywhere, * - `"closed"` — not shown anywhere,
* - `"floating"` — shown in a modal {@link FloatingWindow} (the historical * - `"floating"` — shown in a modal {@link FloatingWindow} (the historical
* behaviour), * behaviour),
* - `{ dock }` — anchored in-flow in the left/right {@link DockRegion}. * - `{ dock }` — anchored in-flow in the left/right {@link DockRegion},
* - `"detached"` — popped out into its own OS window (ticket #23); nothing is
* rendered for it in the main window while detached.
* *
* The union is **deliberately extensible**: ticket #23 adds `"detached"` (an OS * One view = exactly one slot (the invariant): a view is closed, floating,
* window) as a further mutually-exclusive slot without touching the invariant. * docked **or** detached, never two at once. Absent from the map ≡ `"closed"`,
* Absent from the map ≡ `"closed"`, so the default (nothing open) is the empty * so the default (nothing open) is the empty map.
* map.
*/ */
import type { DockSide } from "@/shared"; import type { DockSide } from "@/shared";
@ -32,9 +33,13 @@ export type PanelId =
/** /**
* Where a view currently lives. Exactly one slot per view (the invariant). * Where a view currently lives. Exactly one slot per view (the invariant).
* `{ dock }` carries the side; #23 will extend this union with `"detached"`. * `{ dock }` carries the side; `"detached"` means it lives in its own OS window.
*/ */
export type ViewPlacement = "closed" | "floating" | { dock: DockSide }; export type ViewPlacement =
| "closed"
| "floating"
| { dock: DockSide }
| "detached";
/** The placement of every open view (absent key ≡ `"closed"`). */ /** The placement of every open view (absent key ≡ `"closed"`). */
export type ViewPlacements = Partial<Record<PanelId, ViewPlacement>>; export type ViewPlacements = Partial<Record<PanelId, ViewPlacement>>;
@ -85,3 +90,8 @@ export function floatingPanels(placements: ViewPlacements): PanelId[] {
(panel) => placementOf(placements, panel) === "floating", (panel) => placementOf(placements, panel) === "floating",
); );
} }
/** True when the view is popped out into its own OS window (#23). */
export function isDetached(placement: ViewPlacement): boolean {
return placement === "detached";
}

View File

@ -881,6 +881,44 @@ export interface TicketGateway {
): Promise<void>; ): Promise<void>;
} }
/**
* 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"`.
*/
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).
*
* 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.
*/
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).
*/
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>;
/**
* Subscribes to detached-window close events (OS close or programmatic). The
* handler receives the `{ panel, projectId }` that closed. Returns an
* unsubscribe.
*/
onViewWindowClosed(
handler: (event: ViewWindowClosed) => void,
): Promise<Unsubscribe>;
}
/** /**
* The full set of gateways the app depends on, injected via the DI provider. * The full set of gateways the app depends on, injected via the DI provider.
* The composition (real vs mock) is chosen in `app/`. * The composition (real vs mock) is chosen in `app/`.
@ -903,4 +941,5 @@ export interface Gateways {
workState: WorkStateGateway; workState: WorkStateGateway;
conversation: ConversationGateway; conversation: ConversationGateway;
ticket: TicketGateway; ticket: TicketGateway;
window: WindowGateway;
} }