/** * `ProjectsView` — the top-level project surface (L2 / L11). * * IDE layout (full remaining height), reworked in ticket #16: * * ┌───────────────────────────────────────────────────────┐ * │ PROJECT TABS [ alpha × ][ beta × ] [ + ] │ * ├───────────────────────────────────────────────────────┤ * │ MENU BAR Panneaux │ Settings │ * ├───────────────────────────────────────────────────────┤ * │ MAIN — LayoutGrid (fills the FULL width) │ * │ — or the projects manager / welcome when no project │ * └───────────────────────────────────────────────────────┘ * * The former left sidebar is gone: every panel (context, work, tickets, agents, * templates, skills, permissions, memory, git, projects) is reached from the * single **Panneaux** menu (#26), whose per-panel submenu picks the placement * directly — closed / floating / docked left/right / detached OS window — and * shows it in a chrome-level {@link FloatingWindow} or {@link DockRegion}. * Project creation/switching is the **Projects** panel, opened by the tab-bar * "+" (or `Panneaux → Projects`) and, when no project is open, shown inline in * the welcome area (so it is always reachable). * * Pure presentation: all behaviour comes from {@link useProjects}. Styling via * `@/shared`; no inline styles beyond z-index tokens, no `invoke()`. * * **Test-contract** — the create-project form + known-projects list are in the * DOM whenever no project is active (welcome area) or when the Projects window is * open; the project tab bar (`role="tablist"`) is always present. */ import { useEffect, useState, type ReactNode } from "react"; import type { DomainEvent, LayoutInfo } from "@/domain"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; import { ConversationViewer } from "@/features/conversations"; import { ProfilesSettings } from "@/features/first-run"; import { GitGraphView } from "@/features/git"; import { Button, DockRegion, FloatingWindow, IconButton, Input, MenuBar, Panel, cn, zIndex, type FloatingWindowSize, type MenuBarItem, type MenuBarMenu, } from "@/shared"; import { useGateways } from "@/app/di"; import { useProjects } from "./useProjects"; import { ProjectTabs } from "./ProjectTabs"; import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody"; import { PANEL_TITLE, floatingPanels, isDetached, isDockedTo, panelsDockedTo, placementOf, type PanelId, type ViewPlacement, type ViewPlacements, } from "./viewPlacement"; /** Window width preset per panel (content-heavy panels get more room). */ const PANEL_SIZE: Record = { projects: "md", context: "lg", work: "lg", tickets: "lg", agents: "lg", templates: "lg", skills: "lg", permissions: "md", memory: "lg", git: "lg", }; interface BackgroundTaskToast { id: string; projectId: string; agentId: string; taskId: string; state: string; } function isTerminalBackgroundTaskEvent( event: DomainEvent, ): event is Extract { return ( event.type === "backgroundTaskChanged" && (event.state === "completed" || event.state === "failed" || event.state === "cancelled" || event.state === "delivered") ); } export function ProjectsView() { const vm = useProjects(); const { system, window: windowGateway } = useGateways(); const [name, setName] = useState(""); const [root, setRoot] = useState(""); // Placement of every open view (#22): each panel is "closed" (absent), // "floating" (modal window), or docked left/right. One view = exactly one // slot. Ephemeral local state in V1 — restore-at-restart is a deferred // backend follow-up. const [placements, setPlacements] = useState({}); // Width (px) of each dock column, driven by the DockRegion resize handle. const [leftDockWidth, setLeftDockWidth] = useState(340); const [rightDockWidth, setRightDockWidth] = useState(340); // Top-level view switch (#16): when true, the main area shows the AI Profiles // settings instead of the project surface. The single menu bar stays visible // so the user can toggle back from Settings → AI Profiles. const [showSettings, setShowSettings] = useState(false); // The active layout (id + kind), reported by LayoutTabs — the single source of // truth. `kind` decides whether the main area is the terminal grid or the git // graph view. const [activeLayout, setActiveLayout] = useState(null); // When set, the main area swaps the terminal grid for the read-only // conversation viewer (LS7) — pure local UI state, same mechanic as the // terminal↔gitGraph swap; **not** a backend layout kind. const [viewerConversationId, setViewerConversationId] = useState< string | null >(null); const [taskToasts, setTaskToasts] = useState([]); const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null; // Reset the active layout whenever the active project changes. `activeLayout` // is only repopulated asynchronously by `LayoutTabs` (which re-fetches the new // project's layouts). Without this reset, the stale id of the *previous* // project would be handed to `LayoutGrid`/`GitGraphView` during the gap, and // loading it against the new project's store fails with "not found: layout X". useEffect(() => { setActiveLayout(null); setViewerConversationId(null); }, [active?.id]); useEffect(() => { let unsubscribe: (() => void) | undefined; let cancelled = false; void system.onDomainEvent((event) => { if (!isTerminalBackgroundTaskEvent(event)) return; const toast: BackgroundTaskToast = { id: `${event.taskId}-${event.state}-${Date.now()}`, projectId: event.projectId, agentId: event.agentId, taskId: event.taskId, state: event.state, }; setTaskToasts((prev) => [...prev.slice(-2), toast]); window.setTimeout(() => { setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id)); }, 7000); }).then((u) => { if (cancelled) u(); else unsubscribe = u; }); return () => { cancelled = true; unsubscribe?.(); }; }, [system]); const activeLayoutKind = activeLayout?.kind ?? "terminal"; const canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy; async function submit(e: React.FormEvent) { e.preventDefault(); if (!canCreate) return; await vm.createProject(name.trim(), root.trim()); setName(""); setRoot(""); } async function handleBrowse() { const picked = await system.pickFolder(); if (picked !== null) setRoot(picked); } // ── View placement (#22) ──────────────────────────────────────────────── // Move a view to a specific slot (floating or docked). Enforces the // one-slot-per-view invariant implicitly (a panel has a single entry) and the // floating surface stays single-window (the #16 modal contract): promoting a // view to floating demotes any other floating view to closed. Docks hold as // many views as fit — that's the multi-view surface. function setPlacement(panel: PanelId, placement: ViewPlacement) { setPlacements((prev) => { const next: ViewPlacements = { ...prev }; if (placement === "floating") { for (const other of Object.keys(next) as PanelId[]) { if (other !== panel && next[other] === "floating") delete next[other]; } } next[panel] = placement; return next; }); } // Close a view (remove its slot → "closed"). function closePanel(panel: PanelId) { setPlacements((prev) => { if (!(panel in prev)) return prev; const next = { ...prev }; delete next[panel]; return next; }); } // Dismiss every floating view (docked views stay put). Used when a full-main // surface takes over so a modal window doesn't obscure it. function dismissFloating() { setPlacements((prev) => { let changed = false; const next = { ...prev }; for (const panel of Object.keys(next) as PanelId[]) { if (next[panel] === "floating") { delete next[panel]; changed = true; } } return changed ? next : prev; }); } // 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) { setViewerConversationId(conversationId); dismissFloating(); } // ── Menus (#26) ───────────────────────────────────────────────────────── // A single « Panneaux » menu: one entry per panel, each opening a submenu with // the placement actions directly (closed / floating / docked left/right / // detached OS window). One place to pick the panel *and* its mode; the current // placement is marked in the submenu (and the parent entry shows ● when the // panel is open somewhere). Order per UX: content panels first, Projects last. const panelOrder: PanelId[] = [ "context", "work", "tickets", "agents", "templates", "skills", "permissions", "memory", "git", "projects", ]; // Placement actions for one panel, marking the active slot. `projects` is // main-window-only chrome, so it never offers the detached-window action. function placementSubmenu(panel: PanelId): MenuBarItem[] { const placement = placementOf(placements, panel); const items: MenuBarItem[] = [ { id: "closed", label: "Fermé", active: placement === "closed", onSelect: () => closePanel(panel), }, { id: "floating", label: "Flottant", active: placement === "floating", onSelect: () => setPlacement(panel, "floating"), }, { id: "dock-left", label: "Ancré à gauche", active: isDockedTo(placement, "left"), onSelect: () => setPlacement(panel, { dock: "left" }), }, { id: "dock-right", label: "Ancré à droite", active: isDockedTo(placement, "right"), onSelect: () => setPlacement(panel, { dock: "right" }), }, ]; if (panel !== "projects") { items.push({ id: "detached", label: "Fenêtre détachée", active: isDetached(placement), disabled: !active, onSelect: () => detachPanel(panel), }); } return items; } const menus: MenuBarMenu[] = [ { id: "panels", label: "Panneaux", items: panelOrder.map((panel) => ({ id: panel, label: PANEL_TITLE[panel], active: placementOf(placements, panel) !== "closed", onSelect: () => {}, submenu: placementSubmenu(panel), })), }, { id: "settings", label: "Settings", items: [ { id: "ai-profiles", label: showSettings ? "Close AI Profiles" : "AI Profiles", active: showSettings, onSelect: () => { setShowSettings((v) => !v); dismissFloating(); }, }, ], }, ]; // The create-project form + known-projects list. Rendered inline in the // welcome area (no active project) or inside the Projects floating window. const projectsManager: ReactNode = (

New project

setName(e.target.value)} />
setRoot(e.target.value)} className="flex-1" />
{vm.projects.length === 0 ? (

No projects yet.

) : (
    {vm.projects.map((p) => (
  • {p.name} {p.root}
  • ))}
)}
); // 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) { return (

Open a project to use this panel.

); } return ( ); } // Projects manager renders inline in the welcome area only when it is not // already shown as a panel (avoids duplicate form inputs). const showInlineProjects = !active && placementOf(placements, "projects") === "closed"; // Views by slot, in stable declaration order. const leftPanels = panelsDockedTo(placements, "left"); const rightPanels = panelsDockedTo(placements, "right"); const floatingList = floatingPanels(placements); // Placement controls for a view's header — labelled icon-buttons aligned 1:1 // with the « Panneaux » menu wording (#42). Order: ⇤ gauche · ⇥ droite · □ // flottant · ↗ fenêtre détachée · × fermé. The *current* placement reads as // ACTIVE (aria-pressed + ring), never disabled — a disabled control loses its // tooltip/keyboard focus and conflates "impossible" with "already selected". // Only genuinely-impossible actions are disabled (detach with no active // project). All labelling flows through `title` + `aria-label`; no visible // text. The detach control is hidden for the main-window-only `projects` view. function DockControls({ panel }: { panel: PanelId }) { const placement = placementOf(placements, panel); const title = PANEL_TITLE[panel]; // Active (current-placement) styling — visually distinct, still clickable. const activeClass = "bg-raised text-content ring-1 ring-border-strong"; return (
setPlacement(panel, { dock: "left" })} > ⇤ setPlacement(panel, { dock: "right" })} > ⇥ setPlacement(panel, "floating")} > □ {panel !== "projects" && ( detachPanel(panel)} > ↗ )} closePanel(panel)} > ×
); } // A single docked view inside a DockRegion column: header (title + placement // controls) over the existing view component, reused as-is. function renderDockedView(panel: PanelId): ReactNode { return (
{PANEL_TITLE[panel]}
{renderPanel(panel)}
); } return (
{/* ── Error alert ── */} {vm.error && (

{vm.error}

)} {/* ── Project tab bar (#26): tabs = open projects, "+" opens Projects ── */} ({ id: t.id, label: t.name }))} activeTabId={vm.activeTabId} onSelect={(id) => vm.activateTab(id)} onClose={(id) => void vm.closeTab(id)} projectsPanelOpen={placementOf(placements, "projects") !== "closed"} onOpenProjectsPanel={() => { setShowSettings(false); setPlacement("projects", "floating"); }} /> {/* ── Menu bar (replaces the former left sidebar) ── */} {/* ── Chrome row: left dock │ main │ right dock (#22). Docks are in-flow resizable columns, not overlays — they sit beside the main surface. */}
{leftPanels.length > 0 && ( {leftPanels.map(renderDockedView)} )} {/* ── Main: AI Profiles / terminal grid / git graph / welcome ── */}
{showSettings ? ( // Top-level view switch (#16): AI Profiles settings takes over the main // area while the menu bar above stays visible to toggle back.
) : active && viewerConversationId ? ( setViewerConversationId(null)} /> ) : active ? ( <> {activeLayoutKind === "gitGraph" ? ( ) : ( )} ) : (
{showInlineProjects ? ( projectsManager ) : (

Select or create a project to get started.

)}
)}
{rightPanels.length > 0 && ( {rightPanels.map(renderDockedView)} )}
{/* ── Floating panel windows (modal overlays) ── */} {floatingList.map((panel) => ( closePanel(panel)} >
{renderPanel(panel)}
))} {/* ── Background-task toasts (above floating windows) ── */} {taskToasts.length > 0 && (
{taskToasts.map((toast) => ( ))}
)}
); }