/** * `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 Settings / the projects manager / welcome │ * └───────────────────────────────────────────────────────┘ * * **Settings** (`Settings → AI Profiles | Deployment`, #68) is a main surface * with its own internal section nav — it has no `PanelId` and is outside the * `viewPlacement` model, unlike every panel below. * * 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 { Component, useEffect, useMemo, useState, type ErrorInfo, type ReactNode, } from "react"; import type { Agent, DomainEvent, LayoutInfo } from "@/domain"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; import { ConversationViewer } from "@/features/conversations"; import { SettingsView, SETTINGS_SECTIONS, SETTINGS_SECTION_LABEL, type SettingsSection, } from "@/features/settings"; import { GitGraphView } from "@/features/git"; import { AnnouncementsProvider } from "@/features/announcements"; import { usePluginMenus, usePluginRuntime } from "@/features/plugins"; import type { WhenContext } from "@/plugins/runtime"; 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 { useProjectActivity } from "./useProjectActivity"; import { ProjectTabs } from "./ProjectTabs"; import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody"; import { PANEL_TITLE, floatingPanels, isDetached, isDockedTo, panelsDockedTo, placementOf, reconcileDetachedWindows, 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", mcp: "lg", permissions: "md", memory: "lg", git: "lg", }; interface BackgroundTaskToast { id: string; projectId: string; agentId: string; taskId: string; state: string; title: string; projectName: string; subtitle: string; conversationId?: string; } interface PendingConversationOpen { projectId: string; conversationId: string; } interface OpenConversationViewer { projectId: string; conversationId: string; } interface PluginMenuRenderBoundaryProps { resetKey: string; fallbackMenus: MenuBarMenu[]; children: ReactNode; } class PluginMenuRenderBoundary extends Component< PluginMenuRenderBoundaryProps, { hasError: boolean } > { state = { hasError: false }; static getDerivedStateFromError(): { hasError: boolean } { return { hasError: true }; } componentDidCatch(error: Error, errorInfo: ErrorInfo): void { console.error("[plugins] menu render failed; using native menus only", { error, componentStack: errorInfo.componentStack, }); } componentDidUpdate(prevProps: PluginMenuRenderBoundaryProps): void { if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { this.setState({ hasError: false }); } } render(): ReactNode { if (this.state.hasError) return ; return this.props.children; } } function isTerminalBackgroundTaskEvent( event: DomainEvent, ): event is Extract { return ( event.type === "backgroundTaskChanged" && (event.state === "completed" || event.state === "failed" || event.state === "cancelled" || event.state === "delivered") ); } function shortTaskId(id: string): string { return id.slice(0, 8); } function fallbackAgentLabel(id: string): string { return shortTaskId(id); } function agentLabel(agents: Agent[], agentId: string): string { return ( agents.find((candidate) => candidate.id === agentId)?.name ?? fallbackAgentLabel(agentId) ); } function projectToastName( projectNameById: ReadonlyMap, projectId: string, ): string { return projectNameById.get(projectId) ?? "Projet inconnu"; } function rendezvousToastStateLabel(state: string): string { switch (state) { case "completed": case "delivered": return "terminé"; case "failed": return "en échec"; case "cancelled": return "annulé"; default: return state; } } export function ProjectsView() { const vm = useProjects(); const projectActivity = useProjectActivity(vm.openTabs.map((tab) => tab.id)); const { system, window: windowGateway, focusedProject, git, agent, } = 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, extended in #68): the open Settings section, or // null when the main area shows the project surface. Settings is a main // surface with its own internal nav — deliberately not a placeable panel. The // menu bar stays visible above it. const [settingsSection, setSettingsSection] = useState( null, ); // 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 [viewerConversation, setViewerConversation] = useState(null); const [pendingConversationOpen, setPendingConversationOpen] = useState(null); const [taskToasts, setTaskToasts] = useState([]); const [detachErrorPanel, setDetachErrorPanel] = useState(null); const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null; const activeViewerConversation = viewerConversation?.projectId === active?.id ? viewerConversation : null; const projectNameById = useMemo(() => { const names = new Map(); for (const project of vm.projects) names.set(project.id, project.name); for (const tab of vm.openTabs) names.set(tab.id, tab.name); return names; }, [vm.projects, vm.openTabs]); // `gitRepository` signal for the plugin `when` mini-language (#43, F3 — // Architect-mandated, blocking for #43 closure): whether the *current* // project is a git repository, checked for real via `GitGateway.branches` // (a non-repo project rejects this call; a repo resolves it). Re-checked on // every project switch. `agentSelected`/`terminalFocused`/ // `layoutCellFocused` remain accepted v1 debt — no global focus signal // exists at the menu-bar level yet — and stay `false` (see the F3 report's // open point). const [gitRepository, setGitRepository] = useState(false); useEffect(() => { if (!active) { setGitRepository(false); return; } let cancelled = false; git .branches(active.id) .then(() => { if (!cancelled) setGitRepository(true); }) .catch(() => { if (!cancelled) setGitRepository(false); }); return () => { cancelled = true; }; }, [git, active?.id]); // Plugin contribution menus (#43, F3). const { registry: pluginRegistry } = usePluginRuntime(); const pluginWhenCtx: WhenContext = { projectOpen: active !== null, gitRepository, agentSelected: false, terminalFocused: false, layoutCellFocused: false, }; const pluginMenus = usePluginMenus(pluginRegistry, pluginWhenCtx); // 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); }, [active?.id]); useEffect(() => { if (!active || pendingConversationOpen?.projectId !== active.id) return; setSettingsSection(null); setViewerConversation(pendingConversationOpen); dismissFloating(); setPendingConversationOpen(null); }, [active?.id, pendingConversationOpen]); // Publish the focused project (#47) so detached panel-only windows follow the // main window: they render this project, or an "open a project" shell when // none is active. Publish on EVERY change of `active` — including // switching-away to null — so a restored panel window never shows a stale // project. Optional-chained: unit tests may omit this gateway. useEffect(() => { void focusedProject?.setFocusedProject( active ? { id: active.id, name: active.name, root: active.root } : null, ); }, [focusedProject, active?.id, active?.name, active?.root]); useEffect(() => { let unsubscribe: (() => void) | undefined; let cancelled = false; void system.onDomainEvent((event) => { if (!isTerminalBackgroundTaskEvent(event)) return; void (async () => { const hasRendezvousAgents = Boolean( event.requesterAgentId && event.targetAgentId, ); const labels = hasRendezvousAgents ? await agent .listAgents(event.projectId) .then((agents) => ({ requester: agentLabel(agents, event.requesterAgentId!), target: agentLabel(agents, event.targetAgentId!), })) .catch(() => ({ requester: fallbackAgentLabel(event.requesterAgentId!), target: fallbackAgentLabel(event.targetAgentId!), })) : null; if (cancelled) return; const toast: BackgroundTaskToast = { id: `${event.taskId}-${event.state}-${Date.now()}`, projectId: event.projectId, agentId: event.agentId, taskId: event.taskId, state: event.state, title: labels ? `Appel ${labels.requester} -> ${labels.target} ${rendezvousToastStateLabel(event.state)}` : `Background task ${event.state}`, projectName: projectToastName(projectNameById, event.projectId), subtitle: labels ? `Task ${shortTaskId(event.taskId)}` : `${shortTaskId(event.agentId)} · ${shortTaskId(event.taskId)}`, ...(event.conversationId ? { conversationId: event.conversationId } : {}), }; 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?.(); }; }, [agent, projectNameById, 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, panel-only in #47): ask the // backend to open the window, then mark the slot "detached" so nothing renders // for it in the main window. The window is panel-only and follows the focused // project (published above), so no project id is passed. Requires an active // project only so a detached window opens onto something rather than the empty // shell. On failure the placement is left untouched (no ghost "detached" slot). function detachPanel(panel: PanelId) { if (!active) return; setDetachErrorPanel(null); void windowGateway .openViewWindow(panel) .then(() => { setDetachErrorPanel(null); setPlacement(panel, "detached"); }) .catch(() => { // Keep the current placement (no ghost detached slot), but surface the // failure so the action never appears to do nothing (#299). setDetachErrorPanel(panel); }); } // 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]); // On mount, reconcile `placements` with detached windows already open at the // OS level (#50): a panel-only window restored by the OS before the main // window mounted is unknown to `placements`, so the Panneaux menu would wrongly // show it "Fermé". Enumerate them once and mark each as "detached" — but only // best-effort and without clobbering more specific state: skip non-visible // snapshots, skip `projects`/unknown panels, and never overwrite a placement // the user (or a UI restore) already set to something other than "closed". A // failed enumeration leaves `placements` untouched (no ghost slots). Independent // of `active`: a panel-only window can be open with no active project. useEffect(() => { let cancelled = false; void windowGateway .listOpenViewWindows() .then((snapshots) => { if (cancelled) return; setPlacements((prev) => reconcileDetachedWindows(prev, snapshots)); }) .catch(() => { /* best-effort only: no reconciliation on failure */ }); return () => { cancelled = true; }; }, [windowGateway]); // Opening a conversation viewer takes over the project content area — dismiss // floating windows so they don't obscure the viewer (LS7). Docked views and // the layout tabs stay available beside/above it. function openConversation(conversationId: string) { if (!active) return; setViewerConversation({ projectId: active.id, conversationId }); dismissFloating(); } async function handleTaskToastClick(toast: BackgroundTaskToast) { setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id)); const projectOpen = vm.openTabs.some((tab) => tab.id === toast.projectId); if (toast.conversationId) { setPendingConversationOpen({ projectId: toast.projectId, conversationId: toast.conversationId, }); if (projectOpen) { vm.activateTab(toast.projectId); } else { await vm.openProject(toast.projectId); } return; } if (projectOpen) vm.activateTab(toast.projectId); setSettingsSection(null); setPlacement("work", "floating"); } // ── 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", "mcp", "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 nativePanelItems: MenuBarItem[] = panelOrder.map((panel) => ({ id: panel, label: PANEL_TITLE[panel], active: placementOf(placements, panel) !== "closed", onSelect: () => {}, submenu: placementSubmenu(panel), })); const nativeSettingsItems: MenuBarItem[] = SETTINGS_SECTIONS.map((section) => ({ id: section, label: SETTINGS_SECTION_LABEL[section], active: settingsSection === section, onSelect: () => { setSettingsSection(section); dismissFloating(); }, })); const pluginPanelItems = pluginMenus.itemsFor("panels"); const pluginSettingsItems = pluginMenus.itemsFor("settings"); const nativeMenus: MenuBarMenu[] = [ { id: "panels", label: "Panneaux", items: nativePanelItems, }, { id: "settings", label: "Paramètres", items: nativeSettingsItems, }, ]; const menus: MenuBarMenu[] = [ { id: "panels", label: "Panneaux", // Native items first, plugin-contributed items after in their own // (discreetly-labelled) group (#43, F3, carnet §7.2). items: [...nativePanelItems, ...pluginPanelItems], }, // Top-level plugin menus render between Panneaux and Paramètres (#43, // carnet §0 UX decision + §7.1). ...pluginMenus.topLevelMenus, { id: "settings", label: "Paramètres", // One entry per section (#68). The entries name sections and mark the open // one; closing lives in the view ("Fermer les paramètres"), so no label // alternates. Plugin-contributed items are appended after (#43, F3). items: [...nativeSettingsItems, ...pluginSettingsItems], }, ]; const pluginMenuResetKey = [ pluginMenus.topLevelMenus.map((menu) => menu.id).join(","), pluginPanelItems.map((item) => item.id).join(","), pluginSettingsItems.map((item) => item.id).join(","), ].join("|"); // 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.

); } const body = ( ); return panel === "agents" ? ( {body} ) : ( body ); } // 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}

)} {detachErrorPanel && (
Le panneau {PANEL_TITLE[detachErrorPanel]} ne peut pas être détaché pour le moment.
)} {/* ── 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)} activityByProjectId={projectActivity} projectsPanelOpen={placementOf(placements, "projects") !== "closed"} onOpenProjectsPanel={() => { setSettingsSection(null); 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 ── */}
{settingsSection ? ( // Top-level view switch (#16/#68): Settings takes over the main area // while the menu bar above stays visible. { setSettingsSection(null); if (placementOf(placements, "skills") === "closed") { setPlacement("skills", "floating"); } }} onClose={() => setSettingsSection(null)} /> ) : active ? ( <> {activeViewerConversation ? ( setViewerConversation(null)} onOpenConversation={openConversation} /> ) : activeLayoutKind === "gitGraph" ? ( ) : ( { setSettingsSection("plugins"); dismissFloating(); }} /> )} ) : (
{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) => ( ))}
)}
); }