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:
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user