/** * Recursive terminal-layout grid (L4). Renders a {@link LayoutTree} as nested * `Split` / `Grid` containers down to `Leaf` cells, each hosting a * {@link TerminalView} (L3). Provides the spreadsheet-style interactions: * * - **resize**: drag the separator between two split children → recomputes the * two adjacent weights (`resizeAdjacent`, pure) → `mutateLayout` Resize; * - **split**: per-cell buttons split a leaf into rows/columns → Split; * - **merge**: when a split has > 1 child, a cell can collapse its parent split * onto itself (spreadsheet-like cell merge) → Merge. * - **agent**: each leaf cell has a dropdown to pin an agent; persisted via * `setCellAgent` in the layout (#3). * * Pure presentation: all behaviour comes from {@link useLayout}, which speaks to * the {@link LayoutGateway} port — no `invoke()` here. Track *sizing* is the pure * {@link normalizeWeights} function, kept out of the render for testability. */ import { useCallback, useEffect, useRef, useState } from "react"; import type { Agent, AgentProfile } from "@/domain"; import type { LayoutNode } from "@/domain"; import type { ProjectWorkState } from "@/domain"; import type { ConversationDetails, LiveAgent, OpenTerminalOptions, TerminalHandle, } from "@/ports"; import { ResumeConversationPopup, TerminalView, useWritePortal, } from "@/features/terminals"; import { useGateways } from "@/app/di"; import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; import { TargetAnnouncementsOverlay, useTargetAnnouncements, } from "@/features/announcements"; import { PluginLayoutCellView } from "@/features/plugins"; import { CustomAgentChatView, modelServerOverlayText, describeModelServerDownload, useModelServerLaunchState, } from "@/features/agents"; import { leaves, normalizeWeights, resizeAdjacent } from "./layout"; import { useLayout, type LayoutViewModel } from "./useLayout"; /** * Full-cell veil exclusion (ticket #4, Architect arbitrage): the write-portal * veil (delegation injection into the PTY) and the F3 target overlay (another * agent is talking to this one) share the same relative container and both cover * `inset:0`. On the inter-agent injection path the write-portal `overlay` flag and * the target's `busy` state rise *together*, which would stack two veils with * near-duplicate banners. * * Rule: exactly one veil at a time, F3 has priority. So the write-portal veil is * shown only for a pinned agent, when the portal asks for it, **and** the target * is not busy-active (F3 owns the cell then). When busy is not yet set — the brief * window before the mediator marks it — the write-portal veil is the fallback. * * Purely visual: this gates rendering only. The keystroke suspension during * injection (`portal.isSuspended()` in `TerminalView`) is untouched. */ export function shouldShowWritePortalVeil( agentPinned: boolean, writePortalOverlay: boolean, busyActive: boolean, ): boolean { return agentPinned && writePortalOverlay && !busyActive; } interface LayoutGridProps { /** Project whose layout to render. */ projectId: string; /** Working directory new terminals open in (the project root). */ cwd: string; /** Active layout id; when provided the grid loads/mutates this layout. */ layoutId?: string; /** Opens the read-only canonical transcript for a conversation. */ onOpenConversation?: (conversationId: string) => void; /** * Navigates to `Paramètres > Plugins` (#43, F4) — the "Ouvrir Plugins" action * of a plugin layout's unavailable fallback. Threaded down to * `PluginLayoutCellView` the same way `onOpenConversation` is. */ onOpenPluginsSettings?: () => void; } export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation, onOpenPluginsSettings, }: LayoutGridProps) { const vm = useLayout(projectId, layoutId); const work = useProjectWorkState(projectId); const [refitEpoch, setRefitEpoch] = useState(0); useEffect(() => { setRefitEpoch((epoch) => epoch + 1); }, [projectId, layoutId, cwd, vm.layoutVersion]); if (!vm.layout) { return (
{vm.error}
) : (Loading layout…
)}{vm.error}
)}La session courante va être arrêtée avant de relancer{" "} {pendingModeSwitch.target === "custom" ? "la CLI custom" : "la TUI native"} .
{conversationId ? "Une reprise est possible si le profil et le backend conservent cette conversation." : "Aucune conversation reprenable n'est enregistrée pour cette cellule; la relance repartira à neuf."}