Files
IdeA/frontend/src/features/layout/LayoutGrid.tsx

2090 lines
73 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 {
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 (
<div data-testid="layout-grid" style={{ width: "100%", height: "100%" }}>
{vm.error ? (
<p role="alert" style={{ color: "crimson" }}>
{vm.error}
</p>
) : (
<p>Loading layout…</p>
)}
</div>
);
}
return (
<div
data-testid="layout-grid"
style={{ width: "100%", height: "100%", position: "relative" }}
>
{vm.error && (
<p role="alert" style={{ color: "crimson", margin: 0 }}>
{vm.error}
</p>
)}
<NodeView
node={vm.layout.root}
cwd={cwd}
vm={vm}
parentSplit={null}
projectId={projectId}
layoutId={layoutId}
workState={work.state}
workStateBusy={work.busy}
refreshWorkState={work.refresh}
refitSignal={refitEpoch}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
</div>
);
}
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<void>;
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 (
<PluginLayoutCellView
projectId={projectId}
cell={node.node}
onStateChange={(nextState) => void vm.setPluginLayoutState(node.node.id, nextState)}
onOpenPlugins={() => onOpenPluginsSettings?.()}
onChooseAnotherLayout={() => vm.replacePluginLayoutWithTerminal(node.node.id)}
/>
);
case "leaf":
return (
<LeafView
id={node.node.id}
session={node.node.session ?? null}
agent={node.node.agent ?? null}
conversationId={node.node.conversationId ?? null}
cwd={cwd}
vm={vm}
parentSplit={parentSplit}
projectId={projectId}
layoutId={layoutId}
workState={workState}
workStateBusy={workStateBusy}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
/>
);
case "split":
return (
<SplitView
split={node.node}
cwd={cwd}
vm={vm}
projectId={projectId}
layoutId={layoutId}
workState={workState}
workStateBusy={workStateBusy}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
);
case "grid":
return (
<GridView
grid={node.node}
cwd={cwd}
vm={vm}
projectId={projectId}
layoutId={layoutId}
workState={workState}
workStateBusy={workStateBusy}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
);
}
}
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<void>;
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<HTMLElement>(`[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 (
<section
className="stopped-agent-cell"
aria-label={`${agentName} arrêté`}
>
<div className="stopped-agent-cell__header">
<h3 className="stopped-agent-cell__name">{agentName}</h3>
<span className="stopped-agent-cell__badge">Arrêté</span>
</div>
<div className="stopped-agent-cell__content">
<p className="stopped-agent-cell__message">
{error ? "Erreur de relance" : restarting ? "Relance…" : "Cette session est terminée."}
</p>
{error ? (
<p className="stopped-agent-cell__help">
Impossible de relancer l’agent. Réessayez.
</p>
) : (
<p className="stopped-agent-cell__help">
Ouvre une nouvelle session pour cet agent.
</p>
)}
</div>
<div className="stopped-agent-cell__actions">
<button
type="button"
className="stopped-agent-cell__button stopped-agent-cell__button--primary"
onClick={onRestart}
disabled={restarting}
>
{restarting ? "Relance…" : "Relancer"}
</button>
<button
type="button"
className="stopped-agent-cell__button"
onClick={onRefresh}
disabled={restarting}
>
Actualiser
</button>
</div>
</section>
);
}
/**
* 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)
* turn actions 6 (busy-state actions; Cancel must stay visible)
*
* 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,
turnActions: 6,
} 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,
input,
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<Agent[]>([]);
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<AgentProfile[]>([]);
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<AgentCellMode>(() => {
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<PendingModeSwitch | null>(null);
const [trustedCustomCli, setTrustedCustomCli] =
useState<TrustedCustomCliBinding | null>(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<LiveAgent[]>([]);
const refreshLive = async (): Promise<LiveAgent[]> => {
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: busyActive } = useTargetAnnouncements(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 &&
pinnedProfile?.structuredAdapter &&
agentGateway?.launchAgentChat &&
agentGateway?.reattachAgentChat &&
agentGateway?.sendAgentChat &&
agentGateway?.cancelAgentChat &&
agentGateway?.closeAgentChat,
);
const trustedCustomCliAvailable = Boolean(
cellMode === "custom" &&
agentId &&
trustedCustomCli?.agent.id === agentId &&
trustedCustomCli.profile.structuredAdapter &&
agentGateway?.launchAgentChat &&
agentGateway?.reattachAgentChat &&
agentGateway?.sendAgentChat &&
agentGateway?.cancelAgentChat &&
agentGateway?.closeAgentChat,
);
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 [restartPending, setRestartPending] = useState(false);
const [restartError, setRestartError] = useState(false);
const [freshRestartedSession, setFreshRestartedSession] =
useState<FreshRestartedSession | null>(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]);
async function interruptCurrentTurn(): Promise<void> {
if (!agentId || !input) return;
try {
await input.interrupt(projectId, agentId);
await refreshWorkState();
} catch (err) {
setBusyNotice({ message: describeNotice(err) });
}
}
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<void> {
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<void> {
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<void> {
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<void> {
setRestartError(false);
await Promise.all([refreshWorkState(), refreshLive()]);
}
async function restartStoppedAgent(): Promise<void> {
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<CellNotice> => {
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<CellNotice | null>(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<TerminalHandle> => {
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<TerminalHandle> => {
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 (
<div
data-testid="layout-leaf"
data-node-id={id}
style={{
position: "relative",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
border: "1px solid #2a2a2a",
boxSizing: "border-box",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
top: 2,
left: 4,
// Clear the xterm viewport scrollbar (rendered flush against the right
// edge, ~15px wide). Without this offset the right-most control — the
// close button — sits *behind* the scrollbar and is hard to click.
right: 16,
// Above every banner/veil so a cell in an error state stays manageable
// (ticket #48).
zIndex: CELL_Z.controls,
display: "flex",
gap: 2,
alignItems: "center",
justifyContent: "flex-end",
overflow: "hidden",
}}
>
{/* Agent selector */}
<select
aria-label={`agent selector ${id}`}
value={agentId ?? ""}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
setBusyNotice(null);
setCellMode("tui");
void vm.setCellAgent(id, null);
return;
}
void (async () => {
const current = agentGateway?.listLiveAgents
? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents)
: liveAgents;
setLiveAgents(current);
// Visibility is derived from the CURRENT layout, not the live
// registry's stale last-known nodeId (ticket #56): the agent is
// "visible ailleurs" only while a visible cell still pins it.
const elsewhere = visibleElsewhere(val);
if (elsewhere) {
setBusyNotice({
message: "Cet agent est déjà actif dans une autre cellule.",
goToNodeId: elsewhere.nodeId,
});
return;
}
const live = current.find((la) => la.agentId === val);
const isBackground =
live && live.kind === "pty" && live.nodeId !== id;
if (isBackground) {
if (!agentGateway?.attachLiveAgent || !live.sessionId) {
setBusyNotice({
message: "Session active introuvable pour cet agent.",
});
return;
}
setBusyNotice(null);
setCellMode("tui");
const attached = await agentGateway.attachLiveAgent(projectId, val, id);
await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId);
refreshLive();
return;
}
setBusyNotice(null);
setCellMode("tui");
await vm.setCellAgent(id, val);
})().catch(async (err: unknown) =>
setBusyNotice(await noticeFromError(err, val)),
);
}}
style={{
fontSize: 11,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
padding: "1px 2px",
width: 96,
minWidth: 56,
}}
>
<option value="">Plain</option>
{agents.map((a) => {
const elsewhere = visibleElsewhere(a.id);
const background = backgroundLive(a.id);
return (
<option key={a.id} value={a.id} disabled={Boolean(elsewhere)}>
{a.name}
{elsewhere ? " (visible ailleurs)" : background ? " (arrière-plan)" : ""}
</option>
);
})}
</select>
{effectiveCustomCliAvailable && (
<div
role="group"
aria-label={`mode CLI agent ${id}`}
style={{
display: "inline-flex",
overflow: "hidden",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
maxWidth: 128,
}}
>
<button
type="button"
aria-pressed={cellMode === "tui"}
title="TUI native"
onClick={() => requestMode("tui")}
style={{
border: 0,
borderRight: "1px solid var(--color-border, #3a3a3a)",
background:
cellMode === "tui"
? "var(--color-primary, #5b9bd5)"
: "transparent",
color:
cellMode === "tui"
? "var(--color-on-primary, #ffffff)"
: "var(--color-content, #e0e0e0)",
fontSize: 11,
padding: "1px 6px",
cursor: "pointer",
maxWidth: 66,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
TUI native
</button>
<button
type="button"
aria-pressed={cellMode === "custom"}
title="CLI custom"
onClick={() => requestMode("custom")}
style={{
border: 0,
background:
cellMode === "custom"
? "var(--color-primary, #5b9bd5)"
: "transparent",
color:
cellMode === "custom"
? "var(--color-on-primary, #ffffff)"
: "var(--color-content, #e0e0e0)",
fontSize: 11,
padding: "1px 6px",
cursor: "pointer",
maxWidth: 70,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
CLI custom
</button>
</div>
)}
<button
type="button"
title="Split into columns"
aria-label={`split ${id} columns`}
onClick={() => void vm.split(id, "row")}
>
⬌
</button>
<button
type="button"
title="Split into rows"
aria-label={`split ${id} rows`}
onClick={() => void vm.split(id, "column")}
>
⬍
</button>
{canClose && (
<button
type="button"
title="Close this terminal"
aria-label={`close ${id}`}
onClick={() => void vm.merge(parentSplit.container, siblingIndex)}
>
✕
</button>
)}
</div>
{agentId && busyByWorkState && (
<button
type="button"
aria-label={`cancel current turn ${id}`}
title="Cancel current turn"
onClick={() => void interruptCurrentTurn()}
disabled={!input}
style={{
position: "absolute",
top: 24,
right: 20,
zIndex: CELL_Z.turnActions,
flexShrink: 0,
border: "1px solid var(--color-danger, #d45a5a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-danger, #d45a5a)",
cursor: input ? "pointer" : "default",
fontSize: 11,
padding: "1px 6px",
pointerEvents: "auto",
}}
>
Cancel
</button>
)}
{agentId && (busyByWorkState || inboxDepth > 0 || completedBackgroundTasks.length > 0) && (
<div
role="status"
aria-live="polite"
style={{
position: "absolute",
top: 24,
left: 4,
right: busyByWorkState ? 76 : undefined,
zIndex: CELL_Z.banner,
display: "flex",
maxWidth: busyByWorkState ? undefined : "calc(100% - 8px)",
alignItems: "center",
gap: 4,
overflow: "hidden",
pointerEvents: "auto",
}}
>
{busyByWorkState && (
<span
title={delegatedTicket?.requesterLabel ?? activeTicket?.taskPreview}
style={{
maxWidth: 180,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
border: "1px solid var(--color-warning, #d49b3a)",
borderRadius: 3,
background: "rgba(212, 155, 58, 0.16)",
color: "var(--color-warning, #d49b3a)",
padding: "1px 6px",
fontSize: 11,
fontWeight: 600,
}}
>
{delegatedTicket
? `Busy · ${delegatedTicket.requesterLabel}`
: "Busy"}
</span>
)}
{!busyByWorkState && inboxDepth > 0 && (
<span
title={`${inboxDepth} pending message${inboxDepth === 1 ? "" : "s"}`}
style={{
maxWidth: 180,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
border: "1px solid var(--color-primary, #5b9bd5)",
borderRadius: 3,
background: "rgba(91, 155, 213, 0.14)",
color: "var(--color-primary, #5b9bd5)",
padding: "1px 6px",
fontSize: 11,
fontWeight: 600,
}}
>
Queued · {inboxDepth}
</span>
)}
{busyByWorkState && inboxDepth > 0 && (
<span
title={`${inboxDepth} queued message${inboxDepth === 1 ? "" : "s"}`}
style={{
flexShrink: 0,
border: "1px solid var(--color-primary, #5b9bd5)",
borderRadius: 3,
background: "rgba(91, 155, 213, 0.14)",
color: "var(--color-primary, #5b9bd5)",
padding: "1px 6px",
fontSize: 11,
fontWeight: 600,
}}
>
Queued {inboxDepth}
</span>
)}
{completedBackgroundTasks.length > 0 && (
<span
title={`${completedBackgroundTasks.length} background task result${completedBackgroundTasks.length === 1 ? "" : "s"}`}
style={{
flexShrink: 0,
border: "1px solid var(--color-success, #57b36a)",
borderRadius: 3,
background: "rgba(87, 179, 106, 0.14)",
color: "var(--color-success, #57b36a)",
padding: "1px 6px",
fontSize: 11,
fontWeight: 600,
}}
>
Task done {completedBackgroundTasks.length}
</span>
)}
</div>
)}
{/* 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. */}
<div
data-testid="layout-leaf-body"
style={{
position: "relative",
zIndex: 0,
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
}}
>
{agentId && agentCellHydrating ? (
<div
data-testid="agent-cell-runtime-loading"
role="status"
aria-live="polite"
style={{
position: "absolute",
inset: 0,
zIndex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--color-canvas, #0e1116)",
color: "var(--color-muted, #9aa7b4)",
fontSize: 12,
}}
>
Chargement…
</div>
) : agentId && stoppedAgentCell ? (
<StoppedAgentCellPanel
agentName={pinnedAgent?.name ?? "Agent"}
restarting={restartPending}
error={restartError}
onRestart={() => void restartStoppedAgent()}
onRefresh={() => void refreshStoppedAgentState()}
/>
) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
<CustomAgentChatView
key={`${id}-${agentId}-custom`}
projectId={projectId}
agentId={agentId}
agentName={customCliAgent.name}
profile={customCliProfile}
cwd={cwd}
layoutId={layoutId}
nodeId={id}
sessionId={session}
conversationId={conversationId}
composerHeightPx={customChatComposerHeight}
composerDefaultHeightPx={CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT}
onComposerHeightPxChange={setCustomChatComposerHeight}
onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)}
onConversationId={(cid) => void vm.setCellConversation(id, cid)}
/>
) : (
<TerminalView
key={`${id}-${agentId ?? "plain"}-${attachGen}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => 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). */}
{!modelServerOverlay &&
cellMode !== "custom" &&
shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
<div
data-testid="write-portal-overlay"
role="status"
aria-live="polite"
style={{
position: "absolute",
inset: 0,
zIndex: CELL_Z.veil,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0, 0, 0, 0.45)",
color: "var(--color-content, #e0e0e0)",
fontSize: 13,
pointerEvents: "none",
// veil tier — below the cell controls (ticket #48).
userSelect: "none",
}}
>
<span
style={{
background: "var(--color-surface, #1e1e1e)",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 4,
padding: "6px 12px",
}}
>
Un agent est en train de parler…
</span>
</div>
)}
{/* F3 (ticket #4): while another agent is talking to THIS agent (target),
an availability veil showing its live réflexions. Driven by the target's
busy state (agentBusyChanged + read-model hydration), never by the raw
PTY — it retracts at idle even when a turn ends without a completion
event. Self-guards on `active` (busy) and renders null otherwise. */}
{!modelServerOverlay && cellMode !== "custom" && agentId != null && (
<TargetAnnouncementsOverlay projectId={projectId} agentId={agentId} />
)}
{/* 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). */}
{modelServerOverlay && (
<div
data-testid="model-server-overlay"
role="status"
aria-live="polite"
aria-label="model server launch"
style={{
position: "absolute",
inset: 0,
zIndex: CELL_Z.veil,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0, 0, 0, 0.55)",
color: "var(--color-content, #e0e0e0)",
fontSize: 13,
pointerEvents: "none",
userSelect: "none",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
minWidth: 0,
maxWidth: "80%",
background: "var(--color-surface, #1e1e1e)",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 4,
padding: "8px 14px",
}}
>
<span>{modelServerOverlay}</span>
{modelServerDownload && (
<>
{/* Determinate bar when the total is known; an indeterminate
track (no aria-valuenow → ARIA indeterminate) otherwise. */}
<div
data-testid="model-server-progress"
role="progressbar"
aria-label="progression du téléchargement"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={
modelServerDownload.percent != null
? Math.round(modelServerDownload.percent)
: undefined
}
style={{
position: "relative",
width: 220,
maxWidth: "100%",
height: 6,
borderRadius: 3,
overflow: "hidden",
background: "var(--color-raised, #2a2a2a)",
}}
>
<div
style={
modelServerDownload.percent != null
? {
height: "100%",
width: `${modelServerDownload.percent}%`,
background: "var(--color-primary, #5b9bd5)",
transition: "width 120ms linear",
}
: {
// Indeterminate: a sliding sliver.
position: "absolute",
top: 0,
bottom: 0,
width: "40%",
background: "var(--color-primary, #5b9bd5)",
animation:
"model-server-indeterminate 1.1s ease-in-out infinite",
}
}
/>
</div>
{(modelServerDownload.percent != null ||
modelServerDownload.bytesLabel) && (
<span
data-testid="model-server-progress-label"
style={{ fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}
>
{modelServerDownload.percent != null
? `${Math.round(modelServerDownload.percent)} %`
: ""}
{modelServerDownload.percent != null &&
modelServerDownload.bytesLabel
? " · "
: ""}
{modelServerDownload.bytesLabel ?? ""}
</span>
)}
{modelServerDownload.source && (
<span
data-testid="model-server-source"
style={{
fontSize: 11,
color: "var(--color-content-muted, #9a9a9a)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={modelServerDownload.source}
>
{modelServerDownload.source}
</span>
)}
</>
)}
</div>
</div>
)}
</div>
{busyNotice && (
<div
role="status"
style={{
position: "absolute",
bottom: 4,
left: 4,
right: 4,
margin: 0,
fontSize: 11,
color: "var(--color-content-muted, #9a9a9a)",
background: "var(--color-surface, #1e1e1e)",
padding: "2px 6px",
borderRadius: 3,
zIndex: CELL_Z.banner,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 6,
}}
>
<span style={{ minWidth: 0 }}>{busyNotice.message}</span>
{busyNotice.goToNodeId && (
<button
type="button"
aria-label="aller à la cellule"
onClick={() => {
goToCell(busyNotice.goToNodeId!);
setBusyNotice(null);
}}
style={{
flexShrink: 0,
fontSize: 11,
cursor: "pointer",
background: "transparent",
color: "var(--color-primary, #5b9bd5)",
border: "1px solid var(--color-primary, #5b9bd5)",
borderRadius: 3,
padding: "1px 6px",
}}
>
Aller à la cellule
</button>
)}
</div>
)}
{pendingModeSwitch && (
<div
role="alertdialog"
aria-modal="true"
aria-label="Confirmer le changement de CLI"
style={{
position: "absolute",
inset: 0,
zIndex: CELL_Z.controls + 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0, 0, 0, 0.62)",
padding: 12,
}}
>
<div
style={{
width: 360,
maxWidth: "100%",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 6,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
padding: 12,
boxShadow: "0 12px 36px rgba(0,0,0,0.35)",
}}
>
<h2 style={{ margin: 0, fontSize: 14 }}>
Changer de CLI agent
</h2>
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}>
IdeA va basculer cette cellule vers{" "}
{pendingModeSwitch.target === "custom"
? "la CLI custom"
: "la TUI native"}
{" "}et reprendre la session depuis cette surface.
</p>
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-warning, #d49b3a)" }}>
{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."}
</p>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 12,
}}
>
<button
type="button"
onClick={() => setPendingModeSwitch(null)}
style={{
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 4,
background: "transparent",
color: "var(--color-content, #e0e0e0)",
padding: "4px 8px",
cursor: "pointer",
}}
>
Annuler
</button>
<button
type="button"
onClick={() => void confirmModeSwitch()}
style={{
border: "1px solid var(--color-danger, #d45a5a)",
borderRadius: 4,
background: "rgba(212, 90, 90, 0.18)",
color: "var(--color-danger, #d45a5a)",
padding: "4px 8px",
cursor: "pointer",
}}
>
Basculer
</button>
</div>
</div>
</div>
)}
</div>
);
}
interface SplitViewProps {
split: Extract<LayoutNode, { type: "split" }>["node"];
cwd: string;
vm: LayoutViewModel;
projectId: string;
layoutId?: string;
workState: ProjectWorkState | null;
workStateBusy: boolean;
refreshWorkState: () => Promise<void>;
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<HTMLDivElement | null>(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<number[] | null>(null);
const sizes = normalizeWeights(dragWeights ?? baseWeights);
return (
<div
ref={containerRef}
data-testid="layout-split"
data-direction={split.direction}
style={{
display: "flex",
flexDirection: isRow ? "row" : "column",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
}}
>
{split.children.map((child, i) => (
<div key={keyOf(child.node, i)} style={{ display: "contents" }}>
<div
style={{
flexBasis: `${sizes[i]}%`,
flexGrow: 0,
flexShrink: 0,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
}}
>
<NodeView
node={child.node}
cwd={cwd}
vm={vm}
projectId={projectId}
layoutId={layoutId}
workState={workState}
workStateBusy={workStateBusy}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
parentSplit={{
container: split.id,
index: i,
siblings: split.children.length,
}}
/>
</div>
{i < split.children.length - 1 && (
<Separator
isRow={isRow}
container={containerRef}
onDragMove={(deltaFraction) =>
setDragWeights(resizeAdjacent(baseWeights, i, deltaFraction))
}
onDragEnd={(deltaFraction) => {
const weights = resizeAdjacent(baseWeights, i, deltaFraction);
setDragWeights(null);
void vm.resize(split.id, weights);
}}
/>
)}
</div>
))}
</div>
);
}
interface SeparatorProps {
isRow: boolean;
container: React.RefObject<HTMLDivElement | null>;
/** 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<number | null>(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<HTMLDivElement>) {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
startRef.current = isRow ? e.clientX : e.clientY;
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (startRef.current === null) return;
const f = fraction(isRow ? e.clientX : e.clientY);
if (f !== null) onDragMove(f);
}
function onPointerUp(e: React.PointerEvent<HTMLDivElement>) {
const f = fraction(isRow ? e.clientX : e.clientY);
startRef.current = null;
if (f !== null) onDragEnd(f);
}
return (
<div
role="separator"
aria-orientation={isRow ? "vertical" : "horizontal"}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={{
flex: "0 0 6px",
cursor: isRow ? "col-resize" : "row-resize",
background: "#3a3a3a",
touchAction: "none",
}}
/>
);
}
interface GridViewProps {
grid: Extract<LayoutNode, { type: "grid" }>["node"];
cwd: string;
vm: LayoutViewModel;
projectId: string;
layoutId?: string;
workState: ProjectWorkState | null;
workStateBusy: boolean;
refreshWorkState: () => Promise<void>;
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 (
<div
data-testid="layout-grid-container"
style={{
display: "grid",
gridTemplateColumns: cols,
gridTemplateRows: rows,
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
}}
>
{grid.cells.map((cell, i) => (
<div
key={keyOf(cell.node, i)}
style={{
gridColumn: `${cell.col + 1} / span ${cell.colSpan}`,
gridRow: `${cell.row + 1} / span ${cell.rowSpan}`,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
}}
>
<NodeView
node={cell.node}
cwd={cwd}
vm={vm}
parentSplit={null}
projectId={projectId}
layoutId={layoutId}
workState={workState}
workStateBusy={workStateBusy}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
</div>
))}
</div>
);
}
/** 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"
);
}