/**
* 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 {
LiveAgent,
OpenTerminalOptions,
TerminalHandle,
} from "@/ports";
import { TerminalView, useWritePortal } from "@/features/terminals";
import { useGateways } from "@/app/di";
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
import {
AgentRestartOverlay,
TargetAnnouncementsOverlay,
useAgentRestart,
useTargetAnnouncements,
} from "@/features/announcements";
import { PluginLayoutCellView } from "@/features/plugins";
import {
CustomAgentChatView,
isCustomAgentCliAvailable,
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 ? (
{vm.error}
) : (
Loading layout…
)}
);
}
return (
{vm.error && (
{vm.error}
)}
);
}
interface NodeViewProps {
node: LayoutNode;
cwd: string;
vm: LayoutViewModel;
/** The enclosing split + this node's index in it, for the merge action. */
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
layoutId?: string;
workState: ProjectWorkState | null;
workStateBusy: boolean;
refreshWorkState: () => Promise;
refitSignal: number;
onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void;
}
function NodeView({
node,
cwd,
vm,
parentSplit,
projectId,
layoutId,
workState,
workStateBusy,
refreshWorkState,
refitSignal,
onOpenConversation,
onOpenPluginsSettings,
}: NodeViewProps) {
switch (node.type) {
case "customPluginLayout":
// A true top-level `LayoutNode` variant (#43, F4, carnet v2 §3) — a
// plugin layout occupies a slot in the tree at the same level as a
// terminal leaf, split or grid. Rendered separately from `LeafView`
// (which owns a lot of terminal-only concerns — write-portal,
// model-server overlay, agent dropdown — none of which apply here).
return (
void vm.setPluginLayoutState(node.node.id, nextState)}
onOpenPlugins={() => onOpenPluginsSettings?.()}
onChooseAnotherLayout={() => vm.replacePluginLayoutWithTerminal(node.node.id)}
/>
);
case "leaf":
return (
);
case "split":
return (
);
case "grid":
return (
);
}
}
interface LeafViewProps {
id: string;
session: string | null;
agent: string | null;
conversationId: string | null;
cwd: string;
vm: LayoutViewModel;
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
layoutId?: string;
workState: ProjectWorkState | null;
workStateBusy: boolean;
refreshWorkState: () => Promise;
refitSignal: number;
}
/**
* A transient in-cell notice. `goToNodeId`, when present, identifies the layout
* leaf that already hosts the conflicting live agent; the notice then renders an
* "aller à la cellule" button that focuses that cell. An agent is a singleton
* ("1 agent = 1 employé"): we surface where it already lives instead of cloning.
*/
interface CellNotice {
message: string;
goToNodeId?: string;
}
type AgentCellMode = "tui" | "custom";
interface PendingModeSwitch {
target: AgentCellMode;
}
interface TrustedCustomCliBinding {
agent: Agent;
profile: AgentProfile;
}
interface FreshRestartedSession {
agentId: string;
sessionId: string;
}
export function shouldFallbackCustomCliMode(input: {
agentsLoaded: boolean;
profilesLoaded: boolean;
cellMode: AgentCellMode;
hasPinnedAgent: boolean;
hasPinnedProfile: boolean;
customCliAvailable: boolean;
effectiveCustomCliAvailable: boolean;
hasTrustedCustomCli: boolean;
hasLegitProfileChange: boolean;
}): boolean {
if (!input.agentsLoaded || !input.profilesLoaded) return false;
if (input.cellMode !== "custom") return false;
if (!input.hasPinnedAgent || !input.hasPinnedProfile) return false;
if (!input.customCliAvailable && input.hasLegitProfileChange) return true;
if (input.effectiveCustomCliAvailable) return false;
return !input.hasTrustedCustomCli;
}
/**
* Focuses the layout leaf with the given node id: scrolls it into view and
* flashes a brief outline so the user sees where the agent already lives. Works
* off the `data-node-id` attribute each {@link LeafView} renders. Best-effort —
* a no-op when the node is not currently mounted (e.g. on another tab).
*/
function goToCell(nodeId: string): void {
if (typeof document === "undefined") return;
const el = document.querySelector(`[data-node-id="${nodeId}"]`);
if (!el) return;
// `scrollIntoView` is absent in some headless DOMs (jsdom); the highlight is
// the essential affordance, so guard the scroll and still flash the outline.
el.scrollIntoView?.({ block: "nearest", inline: "nearest" });
const previous = el.style.outline;
el.style.outline = "2px solid var(--color-primary, #5b9bd5)";
window.setTimeout(() => {
el.style.outline = previous;
}, 1200);
}
function StoppedAgentCellPanel({
agentName,
restarting,
error,
onRestart,
onRefresh,
}: {
agentName: string;
restarting: boolean;
error: boolean;
onRestart: () => void;
onRefresh: () => void;
}) {
return (
{agentName}
Arrêté
{error ? "Erreur de relance" : restarting ? "Relance…" : "Cette session est terminée."}
{error ? (
Impossible de relancer l’agent. Réessayez.
) : (
Ouvre une nouvelle session pour cet agent.
)}
);
}
/**
* Local z-index scale for a single cell (ticket #48). Intra-cell layering only —
* unrelated to the global {@link zIndex} scale used for app-level surfaces.
*
* The cell controls (agent selector, split, close) must stay reachable even when
* a non-modal banner is shown, otherwise a cell stuck in an error state (e.g. an
* agent launch failure) can no longer be managed. Full-cell veils (write-portal
* injection, F3 busy) deliberately cover the terminal and sit above the banners,
* but they are `pointer-events:none` and stay *below* the controls so the user
* can always re-pick an agent / split / close the cell.
*
* terminal 0 (implicit — the xterm surface)
* status/error 3 (in-cell status strip, launch-error banner, notices)
* full-cell veils 4 (write-portal + F3 overlays; mutually exclusive)
* cell controls 5 (always on top and clickable)
*
* The terminal/chat body must create its own z-index 0 stacking context. Custom
* CLI views can legitimately contain local high-z children (for example their
* own toolbar), but those children must stay below the cell controls.
*/
const CELL_Z = {
banner: 3,
veil: 4,
controls: 5,
} as const;
const CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT = 64;
function readCustomChatComposerHeight(storageKey: string): number {
if (typeof window === "undefined") return CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT;
try {
const parsed = Number(window.localStorage.getItem(storageKey));
if (!Number.isFinite(parsed)) return CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT;
return Math.max(CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT, Math.round(parsed));
} catch {
return CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT;
}
}
function LeafView({
id,
session,
agent,
conversationId,
cwd,
vm,
parentSplit,
projectId,
layoutId,
workState,
workStateBusy,
refreshWorkState,
refitSignal,
}: LeafViewProps) {
// A cell can be closed only when it lives inside a (binary) split: closing it
// collapses the parent split, keeping the *sibling*. Splits are always binary
// in this model (a split wraps a leaf into a 2-child container), so the kept
// child is simply the other index. Guarding on `siblings === 2` keeps the
// operation correct (merge keeps exactly one child) and avoids ever dropping
// the wrong terminal. The root cell (no parent split) cannot be closed.
const canClose = parentSplit !== null && parentSplit.siblings === 2;
const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
const {
agent: agentGateway,
profile: profileGateway,
system,
terminal,
} = useGateways();
// The single write-portal of this cell (ARCHITECTURE §20). It owns the human
// line counter, the local delegation FIFO, the handshake (b→e) and the overlay
// shown while a delegation is being injected. For a plain (agent-less) cell
// `agent` is null: the portal stays inert (no subscription, no overlay) and is
// simply not passed to the terminal.
const { portal, overlay } = useWritePortal(projectId, agent ?? null);
// Load the project's agents for the dropdown and for the pinned
// agent->profile correlation. The catalogue can change after this cell has
// mounted (agent creation/profile hot-swap), so the loader is reusable by the
// event refreshes below.
const [agents, setAgents] = useState([]);
const [agentsLoaded, setAgentsLoaded] = useState(false);
const agentsRequestRef = useRef(0);
const refreshAgents = useCallback((markLoading = false): void => {
const request = ++agentsRequestRef.current;
if (markLoading) setAgentsLoaded(false);
if (!agentGateway) {
setAgents([]);
setAgentsLoaded(true);
return;
}
agentGateway.listAgents(projectId).then((list) => {
if (agentsRequestRef.current === request) {
setAgents(list);
setAgentsLoaded(true);
}
}).catch(() => {
if (agentsRequestRef.current === request) setAgentsLoaded(true);
/* ignore — dropdown stays empty */
});
}, [agentGateway, projectId]);
useEffect(() => {
refreshAgents(true);
}, [refreshAgents]);
const [profiles, setProfiles] = useState([]);
const [profilesLoaded, setProfilesLoaded] = useState(false);
const profilesRequestRef = useRef(0);
const refreshProfiles = useCallback((markLoading = false): void => {
const request = ++profilesRequestRef.current;
if (markLoading) setProfilesLoaded(false);
if (!profileGateway) {
setProfiles([]);
setProfilesLoaded(true);
return;
}
profileGateway
.listProfiles()
.then((list) => {
if (profilesRequestRef.current === request) {
setProfiles(list);
setProfilesLoaded(true);
}
})
.catch(() => {
if (profilesRequestRef.current === request) {
setProfiles([]);
setProfilesLoaded(true);
}
});
}, [profileGateway]);
useEffect(() => {
refreshProfiles(true);
}, [refreshProfiles]);
const cellModeStorageKey = `idea.agent-cell-mode.${projectId}.${id}`;
const [cellMode, setCellModeState] = useState(() => {
if (typeof window === "undefined") return "tui";
try {
return window.localStorage.getItem(cellModeStorageKey) === "custom"
? "custom"
: "tui";
} catch {
return "tui";
}
});
const setCellMode = useCallback(
(mode: AgentCellMode) => {
setCellModeState(mode);
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(cellModeStorageKey, mode);
} catch {
/* local preference only */
}
},
[cellModeStorageKey],
);
const composerHeightStorageKey = `idea.custom-chat-composer-height.${projectId}.${id}`;
const [customChatComposerHeight, setCustomChatComposerHeightState] = useState(() =>
readCustomChatComposerHeight(composerHeightStorageKey),
);
const setCustomChatComposerHeight = useCallback(
(height: number) => {
const nextHeight = Math.max(
CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT,
Math.round(height),
);
setCustomChatComposerHeightState(nextHeight);
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(composerHeightStorageKey, String(nextHeight));
} catch {
/* local preference only */
}
},
[composerHeightStorageKey],
);
const [pendingModeSwitch, setPendingModeSwitch] =
useState(null);
const [trustedCustomCli, setTrustedCustomCli] =
useState(null);
const legitProfileChangeRef = useRef<{
agentId: string;
profileId: string;
seq: number;
} | null>(null);
const [legitProfileChangeSeq, setLegitProfileChangeSeq] = useState(0);
// Load the agents currently running (and where), so the dropdown can disable an
// agent already live in another cell — it cannot run in two cells at once. The
// backend refuses such a launch (`AGENT_ALREADY_RUNNING`); disabling it here is
// the matching UI affordance.
const [liveAgents, setLiveAgents] = useState([]);
const refreshLive = async (): Promise => {
if (!agentGateway?.listLiveAgents) return [];
return agentGateway.listLiveAgents(projectId).then((list) => {
setLiveAgents(list);
return list;
}).catch(() => {
/* ignore — treat as none live */
setLiveAgents([]);
return [];
});
};
useEffect(() => {
if (!agentGateway?.listLiveAgents) return;
let cancelled = false;
agentGateway.listLiveAgents(projectId).then((list) => {
if (!cancelled) setLiveAgents(list);
}).catch(() => {/* ignore */});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agentGateway, projectId]);
// Live refresh on agent lifecycle events: a launch/exit in any cell changes
// who is running where, so re-pull the live set to keep every dropdown in sync.
useEffect(() => {
if (!system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (event.type === "agentLaunched" || event.type === "agentExited") {
refreshLive();
}
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [system, agentGateway, projectId]);
// Build the terminal opener based on whether an agent is pinned.
const agentId = agent ?? null;
// F3 lifecycle state (busy) for this cell's agent — shared here so the
// write-portal veil can be mutually excluded with the F3 overlay (exactly one
// full-cell veil at a time, F3 first). Same source the overlay itself reads.
const { active: announcementBusyActive } = useTargetAnnouncements(agentId ?? "");
const { restart: agentRestart } = useAgentRestart(agentId ?? "");
// Ticket #54 — local model-server launch state for this cell's pinned agent.
// Correlated `agentId → profileId → opencode.localModelServerId → status` via
// the shared hook (same reduction as `useAgents`). While the bound server is
// still preparing (downloading a model / starting / probing), a full-cell
// overlay replaces the timeout/error the empty terminal used to show. It has
// the HIGHEST veil priority: nothing else in the cell is usable until the
// server is ready, so it suppresses the write-portal and F3 veils.
const { statusForAgent } = useModelServerLaunchState(projectId);
const pinnedAgent = agentId
? agents.find((a) => a.id === agentId)
: undefined;
const pinnedProfile = pinnedAgent
? profiles.find((p) => p.id === pinnedAgent.profileId)
: undefined;
const customCliAvailable = Boolean(
agentId && isCustomAgentCliAvailable(pinnedProfile, agentGateway),
);
const trustedCustomCliAvailable = Boolean(
cellMode === "custom" &&
agentId &&
trustedCustomCli?.agent.id === agentId &&
isCustomAgentCliAvailable(trustedCustomCli.profile, agentGateway),
);
const effectiveCustomCliAvailable =
customCliAvailable || trustedCustomCliAvailable;
const customCliAgent =
customCliAvailable && pinnedAgent ? pinnedAgent : trustedCustomCli?.agent;
const customCliProfile =
customCliAvailable && pinnedProfile ? pinnedProfile : trustedCustomCli?.profile;
useEffect(() => {
if (cellMode !== "custom") {
setTrustedCustomCli(null);
return;
}
if (!customCliAvailable || !pinnedAgent || !pinnedProfile) return;
setTrustedCustomCli((prev) => {
if (
prev?.agent.id === pinnedAgent.id &&
prev.profile.id === pinnedProfile.id &&
prev.profile.structuredAdapter === pinnedProfile.structuredAdapter
) {
return prev;
}
return { agent: pinnedAgent, profile: pinnedProfile };
});
}, [cellMode, customCliAvailable, pinnedAgent, pinnedProfile]);
useEffect(() => {
if (!agentId) return;
refreshAgents();
}, [agentId, refreshAgents]);
useEffect(() => {
if (!pinnedAgent?.profileId) return;
refreshProfiles();
}, [pinnedAgent?.profileId, refreshProfiles]);
useEffect(() => {
if (!system || !agentId) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (event.type !== "agentProfileChanged" || event.agentId !== agentId) return;
const seq = legitProfileChangeRef.current?.seq ?? 0;
legitProfileChangeRef.current = {
agentId: event.agentId,
profileId: event.profileId,
seq: seq + 1,
};
setLegitProfileChangeSeq(seq + 1);
refreshAgents();
refreshProfiles();
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [agentId, refreshAgents, refreshProfiles, system]);
useEffect(() => {
const legitChange = legitProfileChangeRef.current;
const hasLegitDowngrade = Boolean(
pinnedAgent &&
legitChange?.agentId === pinnedAgent.id &&
legitChange.profileId === pinnedAgent.profileId,
);
const fallbackInput = {
agentsLoaded,
profilesLoaded,
cellMode,
hasPinnedAgent: Boolean(pinnedAgent),
hasPinnedProfile: Boolean(pinnedProfile),
customCliAvailable,
effectiveCustomCliAvailable,
hasTrustedCustomCli: Boolean(trustedCustomCli),
hasLegitProfileChange: hasLegitDowngrade,
};
const shouldFallback = shouldFallbackCustomCliMode(fallbackInput);
console.debug("[ticket149] shouldFallbackCustomCliMode", {
timestamp: new Date().toISOString(),
input: fallbackInput,
result: shouldFallback,
});
if (shouldFallback) {
setTrustedCustomCli(null);
setCellMode("tui");
}
}, [
agentsLoaded,
cellMode,
customCliAvailable,
effectiveCustomCliAvailable,
legitProfileChangeSeq,
pinnedAgent,
pinnedProfile,
profilesLoaded,
setCellMode,
trustedCustomCli,
]);
const modelServerStatus = statusForAgent(pinnedAgent);
const modelServerOverlay = modelServerOverlayText(modelServerStatus);
// F2 — download progress (bar/%/bytes/source) when the status carries it; null
// for every non-`downloading` state, so `starting`/`probing` show just the
// title. An indeterminate download (unknown total) yields `percent: null`.
const modelServerDownload = describeModelServerDownload(modelServerStatus);
const agentWork = agentId
? workState?.agents.find((row) => row.agentId === agentId)
: undefined;
const activeTicket = agentWork?.tickets.find((ticket) => ticket.status === "inProgress");
const queuedTickets = agentWork?.tickets.filter((ticket) => ticket.status === "queued") ?? [];
const inboxDepth = Math.max(
agentWork?.inboxDepth ?? 0,
agentWork?.inbox?.length ?? 0,
queuedTickets.length,
);
const completedBackgroundTasks =
agentWork?.backgroundTasks?.filter((task) =>
task.status === "completed" || task.status === "failed" || task.status === "cancelled",
) ?? [];
const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket);
const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined;
const busyActive = announcementBusyActive || busyByWorkState;
const targetAnnouncementsFallback = delegatedTicket?.requesterLabel
? `Travaille sur une demande de ${delegatedTicket.requesterLabel}.`
: "Travaille sur une demande en cours.";
const [restartPending, setRestartPending] = useState(false);
const [restartError, setRestartError] = useState(false);
const [freshRestartedSession, setFreshRestartedSession] =
useState(null);
const runtimeState = agentWork?.runtimeState;
const livePinnedAgent = agentId
? liveAgents.find((la) => la.agentId === agentId)
: undefined;
const hasFreshRestartedLiveSession = Boolean(
agentId &&
session &&
freshRestartedSession?.agentId === agentId &&
freshRestartedSession.sessionId === session &&
livePinnedAgent?.sessionId === session,
);
const stoppedAgentCell = Boolean(
agentId &&
(runtimeState === "stopped" || runtimeState === "failedStart") &&
!hasFreshRestartedLiveSession,
);
const agentCellHydrating = Boolean(
agentId &&
session &&
(!agentsLoaded ||
!profilesLoaded ||
(workState === null && workStateBusy)),
);
useEffect(() => {
if (!freshRestartedSession || !agentId) return;
if (freshRestartedSession.agentId !== agentId) {
setFreshRestartedSession(null);
return;
}
const live = liveAgents.find((la) => la.agentId === agentId);
if (!live || live.sessionId !== freshRestartedSession.sessionId) {
setFreshRestartedSession(null);
}
}, [agentId, freshRestartedSession, liveAgents]);
function requestMode(target: AgentCellMode): void {
if (!effectiveCustomCliAvailable || target === cellMode) return;
if (session) setPendingModeSwitch({ target });
else setCellMode(target);
}
async function setSessionWithInstrumentation(
site: "custom" | "plain" | "plain-background-attach" | "mode-switch",
nextSession: string | null,
): Promise {
console.debug("[ticket149] vm.setSession", {
timestamp: new Date().toISOString(),
nodeId: id,
site,
previousSession: session,
nextSession,
cellMode,
agentId,
});
await vm.setSession(id, nextSession);
}
async function prepareSessionForModeSwitch(target: AgentCellMode): Promise {
if (!session) return;
if (target === "custom") {
await setSessionWithInstrumentation("mode-switch", null);
refreshLive();
return;
}
if (cellMode === "custom" && agentGateway?.closeAgentChat) {
await agentGateway.closeAgentChat(session);
} else if (agentId && agentGateway?.stopLiveAgent) {
await agentGateway.stopLiveAgent(projectId, agentId).catch(async () => {
await terminal?.closeTerminal(session);
});
} else {
await terminal?.closeTerminal(session);
}
await setSessionWithInstrumentation("mode-switch", null);
refreshLive();
}
async function confirmModeSwitch(): Promise {
const target = pendingModeSwitch?.target;
if (!target) return;
setBusyNotice(null);
try {
await prepareSessionForModeSwitch(target);
setCellMode(target);
setPendingModeSwitch(null);
} catch (err) {
setBusyNotice({ message: describeNotice(err) });
}
}
/** The live session for `candidate`, if any. */
const liveFor = (candidate: string): LiveAgent | undefined =>
liveAgents.find((la) => la.agentId === candidate);
async function refreshStoppedAgentState(): Promise {
setRestartError(false);
await Promise.all([refreshWorkState(), refreshLive()]);
}
async function restartStoppedAgent(): Promise {
if (!agentId || !agentGateway) return;
setRestartPending(true);
setRestartError(false);
try {
await vm.setSession(id, null);
await vm.setCellConversation(id, null);
if (
cellMode === "custom" &&
effectiveCustomCliAvailable &&
agentGateway.launchAgentChat
) {
const handle = await agentGateway.launchAgentChat(projectId, agentId, {
cwd,
rows: 24,
cols: 80,
nodeId: id,
freshStart: true,
});
await setSessionWithInstrumentation("custom", handle.sessionId);
await vm.setCellConversation(id, handle.assignedConversationId ?? null);
const live = await refreshLive();
setFreshRestartedSession({
agentId,
sessionId:
live.find((entry) => entry.agentId === agentId)?.sessionId ??
handle.sessionId,
});
await refreshWorkState();
return;
}
if (!agentGateway.launchAgent) throw new Error("Agent launch unavailable");
const handle = await agentGateway.launchAgent(
projectId,
agentId,
{
cwd,
rows: 24,
cols: 80,
nodeId: id,
freshStart: true,
},
() => {},
);
await setSessionWithInstrumentation("plain", handle.sessionId);
await vm.setCellConversation(id, handle.assignedConversationId ?? null);
handle.detach();
const live = await refreshLive();
setFreshRestartedSession({
agentId,
sessionId:
live.find((entry) => entry.agentId === agentId)?.sessionId ??
handle.sessionId,
});
await refreshWorkState();
} catch {
setRestartError(true);
setFreshRestartedSession(null);
} finally {
setRestartPending(false);
}
}
/**
* Maps an error into a {@link CellNotice}. For the singleton-invariant refusal
* (`AGENT_ALREADY_RUNNING`) it resolves the host cell from the live-agents set
* (the source of truth exposed by R0b — PTY *and* chat) so the notice can
* offer "aller à la cellule" rather than relaunch a clone. The lookup is done
* against a *fresh* `listLiveAgents` call (the refusal often races the launch
* on mount, before the cached `liveAgents` state has settled), falling back to
* the cached set. The error message embeds the host node id too, but the
* structured live-agents lookup is preferred.
*/
const noticeFromError = async (
err: unknown,
candidate: string,
): Promise => {
const message = describeNotice(err);
if (!isAlreadyRunning(err)) return { message };
const fresh = agentGateway?.listLiveAgents
? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents)
: liveAgents;
const host = fresh.find((la) => la.agentId === candidate);
return {
message: "Cet agent est déjà actif dans une autre cellule.",
goToNodeId: host && host.nodeId !== id ? host.nodeId : undefined,
};
};
/**
* The cell that still displays `candidate` in the CURRENT layout, if any: a
* visible leaf other than this one whose pinned agent IS `candidate`.
*
* Ticket #56 — derived from the layout, NOT from `liveAgents[].nodeId`. The
* live registry keeps pointing at an agent's LAST host cell even after that
* cell swapped to another agent (or none): reading `live.nodeId` as "displayed
* here" wrongly disabled the agent everywhere else, although it is no longer
* shown anywhere. An agent truly shown in a visible cell stays non-selectable
* elsewhere (singleton display invariant); a stale live association does not.
*/
const visibleElsewhere = (candidate: string): { nodeId: string } | undefined => {
if (!vm.layout) return undefined;
const host = leaves(vm.layout).find(
(leaf) => leaf.id !== id && (leaf.agent ?? null) === candidate,
);
return host ? { nodeId: host.id } : undefined;
};
/**
* A live PTY session for `candidate` that this cell can re-attach without
* re-spawning: it exists, is not recorded on THIS cell (`nodeId === id`, so a
* fresh self-launch never triggers a re-attach loop), and is not currently
* displayed by another visible cell ({@link visibleElsewhere} — the singleton
* double-display case). A session whose former host cell now shows a different
* agent (or none) is a background session → selectable here (ticket #56).
*/
const backgroundLive = (candidate: string): LiveAgent | undefined => {
const live = liveFor(candidate);
if (!live || live.kind !== "pty" || live.nodeId === id) return undefined;
return visibleElsewhere(candidate) ? undefined : live;
};
// A transient notice shown when an action is blocked by the singleton
// invariant (selecting / launching an agent already live in another cell).
// `goToNodeId`, when set, is the host cell of the conflicting live agent: the
// notice then offers an "aller à la cellule" action that focuses that cell —
// we never silently relaunch a clone (product rule "1 agent = 1 employé").
const [busyNotice, setBusyNotice] = useState(null);
// ── Live re-attach on background wake (inter-agent delegation) ─────────────
// Bumped to re-key the TerminalView and force a re-attach to the cell's
// pinned agent when that agent becomes live in the *background* AFTER this
// cell has mounted — the classic case being an inter-agent delegation: the
// orchestrator wakes a cold target with `node_id = None` (background) and
// publishes `AgentLaunched`. Without this, the cell keeps showing its
// previous (often dead) session and only re-subscribes to the live output
// once the user toggles the view (Plain↔agent), which remounts the terminal.
// We do that re-attach declaratively instead, so the delegated turn is
// visible live with no manual toggle. The counter is bumped ONLY here (the
// background-wake path), never on a normal fresh launch — so ordinary mounts
// are untouched and never thrash.
const [attachGen, setAttachGen] = useState(0);
useEffect(() => {
if (!agentId || !agentGateway?.attachLiveAgent) return;
// Only act when the pinned agent is live in ANOTHER (non-visible) cell and
// this cell is not already bound to that session. `backgroundLive` is the
// same guard `doLaunch` and the dropdown use; crucially it ignores a session
// hosted by THIS cell (nodeId === id), so a fresh self-launch never triggers
// a re-attach loop.
const bg = backgroundLive(agentId);
if (!bg?.sessionId || bg.sessionId === session) return;
let cancelled = false;
void (async () => {
const attached = await agentGateway.attachLiveAgent!(projectId, agentId, id);
if (cancelled) return;
await vm.attachLiveAgentToCell(id, agentId, attached.sessionId ?? bg.sessionId);
if (!cancelled) setAttachGen((g) => g + 1);
})().catch(() => {
/* ignore — the view toggle remains the manual fallback */
});
return () => {
cancelled = true;
};
// `liveAgents` is the trigger (refreshed by the agentLaunched event below);
// `session` guards against re-firing once attached.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [liveAgents, agentId, session]);
// ── Cell routing (Option 1 — Terminal + MCP) ──────────────────────────────
// Every agent cell renders the raw {@link TerminalView}: the human view is the
// native interactive PTY (live reasoning + Échap are native CLI behaviours,
// zero model parsing), and cross-model delegation flows through MCP tools, not
// a structured chat view. The former `AgentChatView`/`cellKind:"chat"` routing
// has been removed (F-2 cleanup); any `cellKind` field still emitted by the
// backend on the wire is simply ignored.
/** Performs the actual launch and persists any assigned id (T4b loop). */
const doLaunch = async (
opts: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
convId: string | undefined,
): Promise => {
const background = backgroundLive(agentId!);
if (background?.sessionId && agentGateway!.attachLiveAgent) {
const attached = await agentGateway!.attachLiveAgent(projectId, agentId!, id);
const sessionId = attached.sessionId ?? background.sessionId;
void setSessionWithInstrumentation("plain-background-attach", sessionId);
const result = await agentGateway!.reattach(sessionId, onData);
refreshLive();
return result.handle;
}
const handle = await agentGateway!
.launchAgent(
projectId,
agentId!,
{ ...opts, conversationId: convId, nodeId: id },
onData,
)
.catch(async (err: unknown) => {
// A neat backend refusal (`AGENT_ALREADY_RUNNING`) means the agent is a
// singleton already alive in another cell: surface a clear notice with
// an "aller à la cellule" action (host resolved from the live set), then
// re-throw so the view layer does not treat the launch as succeeded.
if (isAlreadyRunning(err)) {
setBusyNotice(await noticeFromError(err, agentId!));
}
throw err;
});
// First launch on a fresh cell mints a conversation id: persist it on the
// leaf so the next open resumes (T4b — closes the persistence loop).
if (handle.assignedConversationId) {
void vm.setCellConversation(id, handle.assignedConversationId);
}
return handle;
};
const terminalOpener = agentGateway && agentId
? (opts: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise => {
return doLaunch(opts, onData, conversationId ?? undefined);
}
: undefined;
// Agent cells re-attach through the agent gateway; plain cells fall back to
// the terminal gateway's reattach (handled by TerminalView's default).
const reattachOpener = agentGateway && agentId
? (sessionId: string, onData: (bytes: Uint8Array) => void) =>
agentGateway.reattach(sessionId, onData)
: undefined;
return (
)}
{/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the
raw xterm {@link TerminalView}. Agent cells are **native terminals**
(ARCHITECTURE §20): every human keystroke (Enter included) reaches the
PTY. There is no mediated-input strip; cross-model delegation flows
through the write-portal (which injects at a clean line boundary) and
MCP tools. Re-key on the agent so the right opener is captured at mount;
the persisted session drives re-attach (never a re-spawn) when
navigating. The agent cell passes its {@link WritePortal} so keystroke
counting + injection suspension are wired; a plain cell passes none. */}
{agentId && agentCellHydrating ? (
Chargement…
) : agentId && stoppedAgentCell ? (
void restartStoppedAgent()}
onRefresh={() => void refreshStoppedAgentState()}
/>
) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
requestMode("tui")}
onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)}
onConversationId={(cid) => void vm.setCellConversation(id, cid)}
/>
) : (
void setSessionWithInstrumentation("plain", sid)}
agentMode={agentId != null}
portal={agentId != null ? portal : undefined}
refitSignal={refitSignal}
/>
)}
{/* Write-portal overlay (ARCHITECTURE §20.3 step b/e): while a delegation
is being injected into the agent's PTY, a grey veil with a centred
message sits above the terminal. Only ever shown for an agent cell —
and never together with the F3 overlay (exactly one veil, F3 first). */}
{!agentRestart &&
!modelServerOverlay &&
cellMode !== "custom" &&
shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
Un agent est en train de parler…
)}
{/* F3 (ticket #4): while another agent is talking to THIS agent (target),
an availability veil showing its live réflexions. Native TUI cells
need the full-cell guard; custom CLI cells keep the canonical busy
badge and composer lock only, matching their normal chat turn flow. */}
{!agentRestart && !modelServerOverlay && cellMode !== "custom" && agentId != null && (
)}
{/* Ticket #54 — model-server launch veil. Top-priority full-cell overlay
(suppresses the write-portal + F3 veils above): while the pinned
agent's bound local server is downloading its model / starting /
probing, the empty terminal is covered rather than left to time out.
Retracts at `ready`/`failed` (the predicate returns null then). */}
{!agentRestart && modelServerOverlay && (
{modelServerOverlay}
{modelServerDownload && (
<>
{/* Determinate bar when the total is known; an indeterminate
track (no aria-valuenow → ARIA indeterminate) otherwise. */}
IdeA va basculer cette cellule vers{" "}
{pendingModeSwitch.target === "custom"
? "la CLI custom"
: "la TUI native"}
{" "}et reprendre la session depuis cette surface.
{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 bascule repartira à neuf si le backend ne peut pas reprendre."}
)}
);
}
interface SplitViewProps {
split: Extract["node"];
cwd: string;
vm: LayoutViewModel;
projectId: string;
layoutId?: string;
workState: ProjectWorkState | null;
workStateBusy: boolean;
refreshWorkState: () => Promise;
refitSignal: number;
onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void;
}
function SplitView({
split,
cwd,
vm,
projectId,
layoutId,
workState,
workStateBusy,
refreshWorkState,
refitSignal,
onOpenConversation,
onOpenPluginsSettings,
}: SplitViewProps) {
const isRow = split.direction === "row";
const baseWeights = split.children.map((c) => c.weight);
const containerRef = useRef(null);
// Live drag preview: while a separator is dragged we override the rendered
// sizes locally (so the split moves under the cursor) and only commit the new
// weights to the backend on release (avoids a mutate round-trip per mousemove).
const [dragWeights, setDragWeights] = useState(null);
const sizes = normalizeWeights(dragWeights ?? baseWeights);
return (
);
}
interface SeparatorProps {
isRow: boolean;
container: React.RefObject;
/** Called continuously during the drag with the signed fraction of the container extent (live preview). */
onDragMove: (deltaFraction: number) => void;
/** Called on release with the final fraction (commit). */
onDragEnd: (deltaFraction: number) => void;
}
/**
* A draggable separator between two split children. It captures the pointer and
* reports the dragged distance (as a fraction of the container extent) **live**
* on every move so the split tracks the cursor, then once more on release so the
* parent can commit the new weights.
*/
function Separator({ isRow, container, onDragMove, onDragEnd }: SeparatorProps) {
const startRef = useRef(null);
function fraction(clientPos: number): number | null {
if (startRef.current === null) return null;
const rect = container.current?.getBoundingClientRect();
const extent = rect ? (isRow ? rect.width : rect.height) : 0;
if (extent <= 0) return null;
return (clientPos - startRef.current) / extent;
}
function onPointerDown(e: React.PointerEvent) {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
startRef.current = isRow ? e.clientX : e.clientY;
}
function onPointerMove(e: React.PointerEvent) {
if (startRef.current === null) return;
const f = fraction(isRow ? e.clientX : e.clientY);
if (f !== null) onDragMove(f);
}
function onPointerUp(e: React.PointerEvent) {
const f = fraction(isRow ? e.clientX : e.clientY);
startRef.current = null;
if (f !== null) onDragEnd(f);
}
return (
);
}
interface GridViewProps {
grid: Extract["node"];
cwd: string;
vm: LayoutViewModel;
projectId: string;
layoutId?: string;
workState: ProjectWorkState | null;
workStateBusy: boolean;
refreshWorkState: () => Promise;
refitSignal: number;
onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void;
}
function GridView({
grid,
cwd,
vm,
projectId,
layoutId,
workState,
workStateBusy,
refreshWorkState,
refitSignal,
onOpenConversation,
onOpenPluginsSettings,
}: GridViewProps) {
const cols = normalizeWeights(grid.colWeights)
.map((p) => `${p}fr`)
.join(" ");
const rows = normalizeWeights(grid.rowWeights)
.map((p) => `${p}fr`)
.join(" ");
return (
{grid.cells.map((cell, i) => (
))}
);
}
/** A stable-ish React key for a node (its id when it has one). */
function keyOf(node: LayoutNode, fallback: number): string {
return node.node.id ?? String(fallback);
}
function describeNotice(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as { message: unknown }).message);
}
return String(e);
}
/** True when `e` is the backend singleton-invariant refusal (`GatewayError`). */
function isAlreadyRunning(e: unknown): boolean {
return (
typeof e === "object" &&
e !== null &&
"code" in e &&
(e as { code: unknown }).code === "AGENT_ALREADY_RUNNING"
);
}