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:
2026-07-07 16:53:43 +02:00
parent 2323a71f01
commit 69e785ec7e
21 changed files with 962 additions and 55 deletions

View File

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

View File

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