2036 lines
72 KiB
TypeScript
2036 lines
72 KiB
TypeScript
/**
|
||
* 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 (
|
||
<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)
|
||
*
|
||
* 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<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: 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<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]);
|
||
|
||
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). Custom CLI cells also keep these controls available
|
||
// while busy so the cell remains a view that can be split or retargeted
|
||
// without stopping the running agent session.
|
||
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 && (inboxDepth > 0 || completedBackgroundTasks.length > 0) && (
|
||
<div
|
||
role="status"
|
||
aria-live="polite"
|
||
style={{
|
||
position: "absolute",
|
||
top: 24,
|
||
left: 4,
|
||
zIndex: CELL_Z.banner,
|
||
display: "flex",
|
||
maxWidth: "calc(100% - 8px)",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
overflow: "hidden",
|
||
pointerEvents: "auto",
|
||
}}
|
||
>
|
||
{!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}
|
||
onOpenNativeTui={() => requestMode("tui")}
|
||
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). */}
|
||
{!agentRestart &&
|
||
!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. 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 && (
|
||
<TargetAnnouncementsOverlay
|
||
projectId={projectId}
|
||
agentId={agentId}
|
||
busyFallbackActive={busyByWorkState}
|
||
fallbackText={targetAnnouncementsFallback}
|
||
/>
|
||
)}
|
||
{/* 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 && (
|
||
<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>
|
||
)}
|
||
{cellMode !== "custom" && agentId != null && agentRestart && (
|
||
<AgentRestartOverlay agentId={agentId} />
|
||
)}
|
||
</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"
|
||
);
|
||
}
|