1440 lines
54 KiB
TypeScript
1440 lines
54 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 { useEffect, useRef, useState } from "react";
|
|
|
|
import type { Agent } from "@/domain";
|
|
import type { LayoutNode } from "@/domain";
|
|
import type { ProjectWorkState } from "@/domain";
|
|
import type {
|
|
ConversationDetails,
|
|
LiveAgent,
|
|
OpenTerminalOptions,
|
|
TerminalHandle,
|
|
} from "@/ports";
|
|
import {
|
|
ResumeConversationPopup,
|
|
TerminalView,
|
|
useWritePortal,
|
|
} from "@/features/terminals";
|
|
import { useGateways } from "@/app/di";
|
|
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
|
import {
|
|
TargetAnnouncementsOverlay,
|
|
useTargetAnnouncements,
|
|
} from "@/features/announcements";
|
|
import { PluginLayoutCellView } from "@/features/plugins";
|
|
import {
|
|
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}
|
|
workState={work.state}
|
|
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;
|
|
workState: ProjectWorkState | null;
|
|
refreshWorkState: () => Promise<void>;
|
|
refitSignal: number;
|
|
onOpenConversation?: (conversationId: string) => void;
|
|
onOpenPluginsSettings?: () => void;
|
|
}
|
|
|
|
function NodeView({
|
|
node,
|
|
cwd,
|
|
vm,
|
|
parentSplit,
|
|
projectId,
|
|
workState,
|
|
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) => 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}
|
|
agentWasRunning={node.node.agentWasRunning ?? false}
|
|
cwd={cwd}
|
|
vm={vm}
|
|
parentSplit={parentSplit}
|
|
projectId={projectId}
|
|
workState={workState}
|
|
refreshWorkState={refreshWorkState}
|
|
refitSignal={refitSignal}
|
|
onOpenConversation={onOpenConversation}
|
|
/>
|
|
);
|
|
case "split":
|
|
return (
|
|
<SplitView
|
|
split={node.node}
|
|
cwd={cwd}
|
|
vm={vm}
|
|
projectId={projectId}
|
|
workState={workState}
|
|
refreshWorkState={refreshWorkState}
|
|
refitSignal={refitSignal}
|
|
onOpenConversation={onOpenConversation}
|
|
onOpenPluginsSettings={onOpenPluginsSettings}
|
|
/>
|
|
);
|
|
case "grid":
|
|
return (
|
|
<GridView
|
|
grid={node.node}
|
|
cwd={cwd}
|
|
vm={vm}
|
|
projectId={projectId}
|
|
workState={workState}
|
|
refreshWorkState={refreshWorkState}
|
|
refitSignal={refitSignal}
|
|
onOpenConversation={onOpenConversation}
|
|
onOpenPluginsSettings={onOpenPluginsSettings}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
interface LeafViewProps {
|
|
id: string;
|
|
session: string | null;
|
|
agent: string | null;
|
|
conversationId: string | null;
|
|
agentWasRunning: boolean;
|
|
cwd: string;
|
|
vm: LayoutViewModel;
|
|
parentSplit: { container: string; index: number; siblings: number } | null;
|
|
projectId: string;
|
|
workState: ProjectWorkState | null;
|
|
refreshWorkState: () => Promise<void>;
|
|
refitSignal: number;
|
|
onOpenConversation?: (conversationId: string) => void;
|
|
}
|
|
|
|
/**
|
|
* A launch deferred by the resume popup (T7): TerminalView asked the opener to
|
|
* launch (fresh open or reattach-failed fallback) for a cell that carries a
|
|
* `conversationId`. We hold the open request here until the user picks
|
|
* Reprendre / Nouvelle conversation, then resolve the promise with the handle.
|
|
*/
|
|
interface PendingResume {
|
|
opts: OpenTerminalOptions;
|
|
onData: (bytes: Uint8Array) => void;
|
|
resolve: (handle: TerminalHandle) => void;
|
|
reject: (e: unknown) => void;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
*/
|
|
const CELL_Z = {
|
|
banner: 3,
|
|
veil: 4,
|
|
controls: 5,
|
|
} as const;
|
|
|
|
function LeafView({
|
|
id,
|
|
session,
|
|
agent,
|
|
conversationId,
|
|
agentWasRunning,
|
|
cwd,
|
|
vm,
|
|
parentSplit,
|
|
projectId,
|
|
workState,
|
|
refreshWorkState,
|
|
refitSignal,
|
|
onOpenConversation,
|
|
}: 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, system } = 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.
|
|
const [agents, setAgents] = useState<Agent[]>([]);
|
|
useEffect(() => {
|
|
if (!agentGateway) return;
|
|
let cancelled = false;
|
|
agentGateway.listAgents(projectId).then((list) => {
|
|
if (!cancelled) setAgents(list);
|
|
}).catch(() => {/* ignore — dropdown stays empty */});
|
|
return () => { cancelled = true; };
|
|
}, [agentGateway, projectId]);
|
|
|
|
// 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 = () => {
|
|
if (!agentGateway?.listLiveAgents) return;
|
|
agentGateway.listLiveAgents(projectId).then(setLiveAgents).catch(() => {
|
|
/* ignore — treat as none live */
|
|
});
|
|
};
|
|
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 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 historyConversationId =
|
|
activeTicket?.conversationId ?? conversationId ?? null;
|
|
|
|
async function interruptCurrentTurn(): Promise<void> {
|
|
if (!agentId || !input) return;
|
|
try {
|
|
await input.interrupt(projectId, agentId);
|
|
await refreshWorkState();
|
|
} 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);
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// ── Resume popup state (T7) ───────────────────────────────────────────────
|
|
// When an agent cell carries a persisted conversation id and its PTY session
|
|
// is dead, the opener is about to relaunch in Resume mode. We intercept that
|
|
// launch with a popup so the user can choose Resume vs a fresh conversation.
|
|
// The reattach path (live PTY) never goes through this opener, so the popup is
|
|
// naturally skipped there.
|
|
const [pendingResume, setPendingResume] = useState<PendingResume | null>(null);
|
|
const [resumeDetails, setResumeDetails] = useState<ConversationDetails | 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;
|
|
// Mid resume decision: let the popup own the (re)launch — don't race it.
|
|
if (pendingResume) 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 vm.setSession(id, 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> => {
|
|
// No persisted conversation ⇒ fresh cell: open straight away (the launch
|
|
// may assign a new id). No popup on this path.
|
|
if (!conversationId) {
|
|
return doLaunch(opts, onData, undefined);
|
|
}
|
|
// Resume case: defer the launch behind the popup. Fetch the best-effort
|
|
// enriched details (last topic + tokens) to enrich it; failure or empty
|
|
// ⇒ degraded mode (status only). Inspection never blocks the resume.
|
|
Promise.resolve(
|
|
agentGateway.inspectConversation?.(projectId, agentId, conversationId),
|
|
)
|
|
.then((details) => setResumeDetails(details ?? {}))
|
|
.catch(() => setResumeDetails({}));
|
|
return new Promise<TerminalHandle>((resolve, reject) => {
|
|
setPendingResume({ opts, onData, resolve, reject });
|
|
});
|
|
}
|
|
: undefined;
|
|
|
|
/** "Reprendre" → launch resuming the existing conversation id. */
|
|
const onResume = () => {
|
|
const p = pendingResume;
|
|
if (!p) return;
|
|
setPendingResume(null);
|
|
setResumeDetails(null);
|
|
doLaunch(p.opts, p.onData, conversationId ?? undefined).then(p.resolve, p.reject);
|
|
};
|
|
|
|
/** "Nouvelle conversation" → clear the id first (Assign), then launch fresh. */
|
|
const onNewConversation = () => {
|
|
const p = pendingResume;
|
|
if (!p) return;
|
|
setPendingResume(null);
|
|
setResumeDetails(null);
|
|
// Clear the persisted conversation id BEFORE launching so the backend treats
|
|
// it as a fresh cell and assigns a new conversation.
|
|
void vm.setCellConversation(id, null);
|
|
doLaunch(p.opts, p.onData, undefined).then(p.resolve, p.reject);
|
|
};
|
|
// 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,
|
|
// 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",
|
|
}}
|
|
>
|
|
{/* Agent selector */}
|
|
<select
|
|
aria-label={`agent selector ${id}`}
|
|
value={agentId ?? ""}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
if (val === "") {
|
|
setBusyNotice(null);
|
|
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);
|
|
const attached = await agentGateway.attachLiveAgent(projectId, val, id);
|
|
await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId);
|
|
refreshLive();
|
|
return;
|
|
}
|
|
setBusyNotice(null);
|
|
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",
|
|
maxWidth: 100,
|
|
}}
|
|
>
|
|
<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>
|
|
|
|
<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 || inboxDepth > 0 || completedBackgroundTasks.length > 0 || historyConversationId) && (
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
style={{
|
|
position: "absolute",
|
|
top: 30,
|
|
left: 4,
|
|
zIndex: CELL_Z.banner,
|
|
display: "flex",
|
|
maxWidth: "calc(100% - 8px)",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{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>
|
|
)}
|
|
{busyByWorkState && (
|
|
<button
|
|
type="button"
|
|
aria-label={`cancel current turn ${id}`}
|
|
title="Cancel current turn"
|
|
onClick={() => void interruptCurrentTurn()}
|
|
disabled={!input}
|
|
style={{
|
|
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",
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
{historyConversationId && onOpenConversation && (
|
|
<button
|
|
type="button"
|
|
aria-label={`open cell conversation ${id}`}
|
|
title="Open conversation history"
|
|
onClick={() => onOpenConversation(historyConversationId)}
|
|
style={{
|
|
flexShrink: 0,
|
|
border: "1px solid var(--color-border, #3a3a3a)",
|
|
borderRadius: 3,
|
|
background: "var(--color-surface, #1e1e1e)",
|
|
color: "var(--color-content, #e0e0e0)",
|
|
cursor: "pointer",
|
|
fontSize: 11,
|
|
padding: "1px 6px",
|
|
}}
|
|
>
|
|
History
|
|
</button>
|
|
)}
|
|
</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
|
|
style={{
|
|
position: "relative",
|
|
width: "100%",
|
|
height: "100%",
|
|
minHeight: 0,
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<TerminalView
|
|
key={`${id}-${agentId ?? "plain"}-${attachGen}`}
|
|
cwd={cwd}
|
|
open={terminalOpener}
|
|
reattach={reattachOpener}
|
|
sessionId={session}
|
|
onSessionId={(sid) => void vm.setSession(id, 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 &&
|
|
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 && 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>
|
|
)}
|
|
{pendingResume && (
|
|
<ResumeConversationPopup
|
|
agentWasRunning={agentWasRunning}
|
|
details={resumeDetails}
|
|
onResume={onResume}
|
|
onNewConversation={onNewConversation}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SplitViewProps {
|
|
split: Extract<LayoutNode, { type: "split" }>["node"];
|
|
cwd: string;
|
|
vm: LayoutViewModel;
|
|
projectId: string;
|
|
workState: ProjectWorkState | null;
|
|
refreshWorkState: () => Promise<void>;
|
|
refitSignal: number;
|
|
onOpenConversation?: (conversationId: string) => void;
|
|
onOpenPluginsSettings?: () => void;
|
|
}
|
|
|
|
function SplitView({
|
|
split,
|
|
cwd,
|
|
vm,
|
|
projectId,
|
|
workState,
|
|
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}
|
|
workState={workState}
|
|
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;
|
|
workState: ProjectWorkState | null;
|
|
refreshWorkState: () => Promise<void>;
|
|
refitSignal: number;
|
|
onOpenConversation?: (conversationId: string) => void;
|
|
onOpenPluginsSettings?: () => void;
|
|
}
|
|
|
|
function GridView({
|
|
grid,
|
|
cwd,
|
|
vm,
|
|
projectId,
|
|
workState,
|
|
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}
|
|
workState={workState}
|
|
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"
|
|
);
|
|
}
|