feat(ui): fenêtres View système séparées — WebviewWindow Tauri + port WindowGateway (#23)
Détache les vues dans de vraies fenêtres système Tauri (multi-écran, fullscreen). Backend : commandes de gestion de WebviewWindow, capabilities et composition root. Frontend : nouveau port WindowGateway et son adaptateur window, entrée panel-only ViewWindow/ViewPanelBody, détachement câblé dans ProjectsView. 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:
@ -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"]
|
||||
}
|
||||
|
||||
@ -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"]}}
|
||||
@ -4,8 +4,9 @@
|
||||
//! [`AppState`], map `Result<Output, AppError>` to `Result<ResponseDto,
|
||||
//! ErrorDto>`. 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<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.
|
||||
///
|
||||
/// Applies the workspace topology change (the tab is *moved*, not duplicated)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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<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
|
||||
* **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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ describe("createMockGateways", () => {
|
||||
"template",
|
||||
"terminal",
|
||||
"ticket",
|
||||
"window",
|
||||
"workState",
|
||||
]);
|
||||
});
|
||||
|
||||
71
frontend/src/adapters/window.test.ts
Normal file
71
frontend/src/adapters/window.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
54
frontend/src/adapters/window.ts
Normal file
54
frontend/src/adapters/window.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
62
frontend/src/app/ViewWindow.test.tsx
Normal file
62
frontend/src/app/ViewWindow.test.tsx
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
110
frontend/src/app/ViewWindow.tsx
Normal file
110
frontend/src/app/ViewWindow.tsx
Normal 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);
|
||||
}
|
||||
@ -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(
|
||||
<React.StrictMode>
|
||||
<DIProvider>
|
||||
<App />
|
||||
{viewParams ? (
|
||||
<ViewWindow panel={viewParams.panel} projectId={viewParams.projectId} />
|
||||
) : (
|
||||
<App />
|
||||
)}
|
||||
</DIProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
105
frontend/src/features/projects/ProjectsView.detach.test.tsx
Normal file
105
frontend/src/features/projects/ProjectsView.detach.test.tsx
Normal 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("●"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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(
|
||||
<DIProvider gateways={gateways}>
|
||||
|
||||
@ -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(
|
||||
<DIProvider gateways={gateways}>
|
||||
|
||||
@ -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() {
|
||||
</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 {
|
||||
if (panel === "projects") return projectsManager;
|
||||
if (!active) {
|
||||
@ -415,36 +465,14 @@ export function ProjectsView() {
|
||||
<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 (
|
||||
<ProjectWorkStatePanel
|
||||
projectId={active.id}
|
||||
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} />;
|
||||
}
|
||||
return (
|
||||
<ViewPanelBody
|
||||
panel={panel as ViewPanelId}
|
||||
projectId={active.id}
|
||||
projectRoot={active.root}
|
||||
onOpenConversation={openConversation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Projects manager renders inline in the welcome area only when it is not
|
||||
@ -492,6 +520,15 @@ export function ProjectsView() {
|
||||
>
|
||||
⧉
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`open ${title} in new window`}
|
||||
disabled={!active || isDetached(placement)}
|
||||
onClick={() => detachPanel(panel)}
|
||||
>
|
||||
⤢
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
75
frontend/src/features/projects/ViewPanelBody.tsx
Normal file
75
frontend/src/features/projects/ViewPanelBody.tsx
Normal 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} />;
|
||||
}
|
||||
}
|
||||
@ -3,3 +3,7 @@
|
||||
export { ProjectsView } from "./ProjectsView";
|
||||
export { useProjects } 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";
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
fireEvent,
|
||||
} 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 { DIProvider } from "@/app/di";
|
||||
import { ProjectsView } from "./ProjectsView";
|
||||
@ -34,6 +34,7 @@ function renderView(
|
||||
template: new MockTemplateGateway(agentGateway),
|
||||
git: new MockGitGateway(),
|
||||
workState: new MockWorkStateGateway(),
|
||||
window: new MockWindowGateway(),
|
||||
} as unknown as Gateways;
|
||||
return {
|
||||
project,
|
||||
|
||||
@ -7,12 +7,13 @@
|
||||
* - `"closed"` — not shown anywhere,
|
||||
* - `"floating"` — shown in a modal {@link FloatingWindow} (the historical
|
||||
* 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
|
||||
* window) as a further mutually-exclusive slot without touching the invariant.
|
||||
* Absent from the map ≡ `"closed"`, so the default (nothing open) is the empty
|
||||
* map.
|
||||
* One view = exactly one slot (the invariant): a view is closed, floating,
|
||||
* docked **or** detached, never two at once. Absent from the map ≡ `"closed"`,
|
||||
* so the default (nothing open) is the empty map.
|
||||
*/
|
||||
|
||||
import type { DockSide } from "@/shared";
|
||||
@ -32,9 +33,13 @@ export type PanelId =
|
||||
|
||||
/**
|
||||
* 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"`). */
|
||||
export type ViewPlacements = Partial<Record<PanelId, ViewPlacement>>;
|
||||
@ -85,3 +90,8 @@ export function floatingPanels(placements: ViewPlacements): PanelId[] {
|
||||
(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";
|
||||
}
|
||||
|
||||
@ -881,6 +881,44 @@ export interface TicketGateway {
|
||||
): 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 composition (real vs mock) is chosen in `app/`.
|
||||
@ -903,4 +941,5 @@ export interface Gateways {
|
||||
workState: WorkStateGateway;
|
||||
conversation: ConversationGateway;
|
||||
ticket: TicketGateway;
|
||||
window: WindowGateway;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user