/**
 * Recursive terminal-layout grid (L4). Renders a {@link LayoutTree} as nested
 * `Split` / `Grid` containers down to `Leaf` cells, each hosting a
 * {@link TerminalView} (L3). Provides the spreadsheet-style interactions:
 *
 * - **resize**: drag the separator between two split children → recomputes the
 *   two adjacent weights (`resizeAdjacent`, pure) → `mutateLayout` Resize;
 * - **split**: per-cell buttons split a leaf into rows/columns → Split;
 * - **merge**: when a split has > 1 child, a cell can collapse its parent split
 *   onto itself (spreadsheet-like cell merge) → Merge.
 * - **agent**: each leaf cell has a dropdown to pin an agent; persisted via
 *   `setCellAgent` in the layout (#3).
 *
 * Pure presentation: all behaviour comes from {@link useLayout}, which speaks to
 * the {@link LayoutGateway} port — no `invoke()` here. Track *sizing* is the pure
 * {@link normalizeWeights} function, kept out of the render for testability.
 */

import { useCallback, useEffect, useRef, useState } from "react";

import type { Agent, AgentProfile } from "@/domain";
import type { LayoutNode } from "@/domain";
import type { ProjectWorkState } from "@/domain";
import type {
  ConversationDetails,
  LiveAgent,
  OpenTerminalOptions,
  TerminalHandle,
} from "@/ports";
import {
  ResumeConversationPopup,
  TerminalView,
  useWritePortal,
} from "@/features/terminals";
import { useGateways } from "@/app/di";
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
import {
  TargetAnnouncementsOverlay,
  useTargetAnnouncements,
} from "@/features/announcements";
import { PluginLayoutCellView } from "@/features/plugins";
import {
  CustomAgentChatView,
  modelServerOverlayText,
  describeModelServerDownload,
  useModelServerLaunchState,
} from "@/features/agents";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
import { useLayout, type LayoutViewModel } from "./useLayout";

/**
 * Full-cell veil exclusion (ticket #4, Architect arbitrage): the write-portal
 * veil (delegation injection into the PTY) and the F3 target overlay (another
 * agent is talking to this one) share the same relative container and both cover
 * `inset:0`. On the inter-agent injection path the write-portal `overlay` flag and
 * the target's `busy` state rise *together*, which would stack two veils with
 * near-duplicate banners.
 *
 * Rule: exactly one veil at a time, F3 has priority. So the write-portal veil is
 * shown only for a pinned agent, when the portal asks for it, **and** the target
 * is not busy-active (F3 owns the cell then). When busy is not yet set — the brief
 * window before the mediator marks it — the write-portal veil is the fallback.
 *
 * Purely visual: this gates rendering only. The keystroke suspension during
 * injection (`portal.isSuspended()` in `TerminalView`) is untouched.
 */
export function shouldShowWritePortalVeil(
  agentPinned: boolean,
  writePortalOverlay: boolean,
  busyActive: boolean,
): boolean {
  return agentPinned && writePortalOverlay && !busyActive;
}

interface LayoutGridProps {
  /** Project whose layout to render. */
  projectId: string;
  /** Working directory new terminals open in (the project root). */
  cwd: string;
  /** Active layout id; when provided the grid loads/mutates this layout. */
  layoutId?: string;
  /** Opens the read-only canonical transcript for a conversation. */
  onOpenConversation?: (conversationId: string) => void;
  /**
   * Navigates to `Paramètres > Plugins` (#43, F4) — the "Ouvrir Plugins" action
   * of a plugin layout's unavailable fallback. Threaded down to
   * `PluginLayoutCellView` the same way `onOpenConversation` is.
   */
  onOpenPluginsSettings?: () => void;
}

export function LayoutGrid({
  projectId,
  cwd,
  layoutId,
  onOpenConversation,
  onOpenPluginsSettings,
}: LayoutGridProps) {
  const vm = useLayout(projectId, layoutId);
  const work = useProjectWorkState(projectId);
  const [refitEpoch, setRefitEpoch] = useState(0);

  useEffect(() => {
    setRefitEpoch((epoch) => epoch + 1);
  }, [projectId, layoutId, cwd, vm.layoutVersion]);

  if (!vm.layout) {
    return (
      <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) => 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}
          agentWasRunning={node.node.agentWasRunning ?? false}
          cwd={cwd}
          vm={vm}
          parentSplit={parentSplit}
          projectId={projectId}
          workState={workState}
          refreshWorkState={refreshWorkState}
          refitSignal={refitSignal}
        />
      );
    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;
}

/**
 * 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;
}

type AgentCellMode = "tui" | "custom";

interface PendingModeSwitch {
  target: AgentCellMode;
}

interface TrustedCustomCliBinding {
  agent: Agent;
  profile: AgentProfile;
}

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);
}

/**
 * Local z-index scale for a single cell (ticket #48). Intra-cell layering only —
 * unrelated to the global {@link zIndex} scale used for app-level surfaces.
 *
 * The cell controls (agent selector, split, close) must stay reachable even when
 * a non-modal banner is shown, otherwise a cell stuck in an error state (e.g. an
 * agent launch failure) can no longer be managed. Full-cell veils (write-portal
 * injection, F3 busy) deliberately cover the terminal and sit above the banners,
 * but they are `pointer-events:none` and stay *below* the controls so the user
 * can always re-pick an agent / split / close the cell.
 *
 *   terminal          0   (implicit — the xterm surface)
 *   status/error      3   (in-cell status strip, launch-error banner, notices)
 *   full-cell veils   4   (write-portal + F3 overlays; mutually exclusive)
 *   cell controls     5   (always on top and clickable)
 *   turn actions      6   (busy-state actions; Cancel must stay visible)
 */
const CELL_Z = {
  banner: 3,
  veil: 4,
  controls: 5,
  turnActions: 6,
} as const;

function LeafView({
  id,
  session,
  agent,
  conversationId,
  agentWasRunning,
  cwd,
  vm,
  parentSplit,
  projectId,
  workState,
  refreshWorkState,
  refitSignal,
}: LeafViewProps) {
  // A cell can be closed only when it lives inside a (binary) split: closing it
  // collapses the parent split, keeping the *sibling*. Splits are always binary
  // in this model (a split wraps a leaf into a 2-child container), so the kept
  // child is simply the other index. Guarding on `siblings === 2` keeps the
  // operation correct (merge keeps exactly one child) and avoids ever dropping
  // the wrong terminal. The root cell (no parent split) cannot be closed.
  const canClose = parentSplit !== null && parentSplit.siblings === 2;
  const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
  const {
    agent: agentGateway,
    input,
    profile: profileGateway,
    system,
    terminal,
  } = useGateways();

  // The single write-portal of this cell (ARCHITECTURE §20). It owns the human
  // line counter, the local delegation FIFO, the handshake (b→e) and the overlay
  // shown while a delegation is being injected. For a plain (agent-less) cell
  // `agent` is null: the portal stays inert (no subscription, no overlay) and is
  // simply not passed to the terminal.
  const { portal, overlay } = useWritePortal(projectId, agent ?? null);

  // Load the project's agents for the dropdown and for the pinned
  // agent->profile correlation. The catalogue can change after this cell has
  // mounted (agent creation/profile hot-swap), so the loader is reusable by the
  // event refreshes below.
  const [agents, setAgents] = useState<Agent[]>([]);
  const [agentsLoaded, setAgentsLoaded] = useState(false);
  const agentsRequestRef = useRef(0);
  const refreshAgents = useCallback((markLoading = false): void => {
    const request = ++agentsRequestRef.current;
    if (markLoading) setAgentsLoaded(false);
    if (!agentGateway) {
      setAgents([]);
      setAgentsLoaded(true);
      return;
    }
    agentGateway.listAgents(projectId).then((list) => {
      if (agentsRequestRef.current === request) {
        setAgents(list);
        setAgentsLoaded(true);
      }
    }).catch(() => {
      if (agentsRequestRef.current === request) setAgentsLoaded(true);
      /* ignore — dropdown stays empty */
    });
  }, [agentGateway, projectId]);
  useEffect(() => {
    refreshAgents(true);
  }, [refreshAgents]);

  const [profiles, setProfiles] = useState<AgentProfile[]>([]);
  const [profilesLoaded, setProfilesLoaded] = useState(false);
  const profilesRequestRef = useRef(0);
  const refreshProfiles = useCallback((markLoading = false): void => {
    const request = ++profilesRequestRef.current;
    if (markLoading) setProfilesLoaded(false);
    if (!profileGateway) {
      setProfiles([]);
      setProfilesLoaded(true);
      return;
    }
    profileGateway
      .listProfiles()
      .then((list) => {
        if (profilesRequestRef.current === request) {
          setProfiles(list);
          setProfilesLoaded(true);
        }
      })
      .catch(() => {
        if (profilesRequestRef.current === request) {
          setProfiles([]);
          setProfilesLoaded(true);
        }
      });
  }, [profileGateway]);
  useEffect(() => {
    refreshProfiles(true);
  }, [refreshProfiles]);
  const cellModeStorageKey = `idea.agent-cell-mode.${projectId}.${id}`;
  const [cellMode, setCellModeState] = useState<AgentCellMode>(() => {
    if (typeof window === "undefined") return "tui";
    try {
      return window.localStorage.getItem(cellModeStorageKey) === "custom"
        ? "custom"
        : "tui";
    } catch {
      return "tui";
    }
  });
  const setCellMode = useCallback(
    (mode: AgentCellMode) => {
      setCellModeState(mode);
      if (typeof window === "undefined") return;
      try {
        window.localStorage.setItem(cellModeStorageKey, mode);
      } catch {
        /* local preference only */
      }
    },
    [cellModeStorageKey],
  );
  const [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 = () => {
    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 pinnedProfile = pinnedAgent
    ? profiles.find((p) => p.id === pinnedAgent.profileId)
    : undefined;
  const customCliAvailable = Boolean(
    agentId &&
      pinnedProfile?.structuredAdapter &&
      agentGateway?.launchAgentChat &&
      agentGateway?.reattachAgentChat &&
      agentGateway?.sendAgentChat &&
      agentGateway?.cancelAgentChat &&
      agentGateway?.closeAgentChat,
  );
  const trustedCustomCliAvailable = Boolean(
    cellMode === "custom" &&
      agentId &&
      trustedCustomCli?.agent.id === agentId &&
      trustedCustomCli.profile.structuredAdapter &&
      agentGateway?.launchAgentChat &&
      agentGateway?.reattachAgentChat &&
      agentGateway?.sendAgentChat &&
      agentGateway?.cancelAgentChat &&
      agentGateway?.closeAgentChat,
  );
  const effectiveCustomCliAvailable =
    customCliAvailable || trustedCustomCliAvailable;
  const customCliAgent =
    customCliAvailable && pinnedAgent ? pinnedAgent : trustedCustomCli?.agent;
  const customCliProfile =
    customCliAvailable && pinnedProfile ? pinnedProfile : trustedCustomCli?.profile;
  useEffect(() => {
    if (cellMode !== "custom") {
      setTrustedCustomCli(null);
      return;
    }
    if (!customCliAvailable || !pinnedAgent || !pinnedProfile) return;
    setTrustedCustomCli((prev) => {
      if (
        prev?.agent.id === pinnedAgent.id &&
        prev.profile.id === pinnedProfile.id &&
        prev.profile.structuredAdapter === pinnedProfile.structuredAdapter
      ) {
        return prev;
      }
      return { agent: pinnedAgent, profile: pinnedProfile };
    });
  }, [cellMode, customCliAvailable, pinnedAgent, pinnedProfile]);
  useEffect(() => {
    if (!agentId) return;
    refreshAgents();
  }, [agentId, refreshAgents]);
  useEffect(() => {
    if (!pinnedAgent?.profileId) return;
    refreshProfiles();
  }, [pinnedAgent?.profileId, refreshProfiles]);
  useEffect(() => {
    if (!system || !agentId) return;
    let unsubscribe: (() => void) | undefined;
    let cancelled = false;
    void system
      .onDomainEvent((event) => {
        if (event.type !== "agentProfileChanged" || event.agentId !== agentId) return;
        const seq = legitProfileChangeRef.current?.seq ?? 0;
        legitProfileChangeRef.current = {
          agentId: event.agentId,
          profileId: event.profileId,
          seq: seq + 1,
        };
        setLegitProfileChangeSeq(seq + 1);
        refreshAgents();
        refreshProfiles();
      })
      .then((un) => {
        if (cancelled) un();
        else unsubscribe = un;
      });
    return () => {
      cancelled = true;
      unsubscribe?.();
    };
  }, [agentId, refreshAgents, refreshProfiles, system]);
  useEffect(() => {
    const legitChange = legitProfileChangeRef.current;
    const hasLegitDowngrade = Boolean(
      pinnedAgent &&
        legitChange?.agentId === pinnedAgent.id &&
        legitChange.profileId === pinnedAgent.profileId,
    );
    const fallbackInput = {
      agentsLoaded,
      profilesLoaded,
      cellMode,
      hasPinnedAgent: Boolean(pinnedAgent),
      hasPinnedProfile: Boolean(pinnedProfile),
      customCliAvailable,
      effectiveCustomCliAvailable,
      hasTrustedCustomCli: Boolean(trustedCustomCli),
      hasLegitProfileChange: hasLegitDowngrade,
    };
    const shouldFallback = shouldFallbackCustomCliMode(fallbackInput);
    console.debug("[ticket149] shouldFallbackCustomCliMode", {
      timestamp: new Date().toISOString(),
      input: fallbackInput,
      result: shouldFallback,
    });
    if (shouldFallback) {
      setTrustedCustomCli(null);
      setCellMode("tui");
    }
  }, [
    agentsLoaded,
    cellMode,
    customCliAvailable,
    effectiveCustomCliAvailable,
    legitProfileChangeSeq,
    pinnedAgent,
    pinnedProfile,
    profilesLoaded,
    setCellMode,
    trustedCustomCli,
  ]);
  const modelServerStatus = statusForAgent(pinnedAgent);
  const modelServerOverlay = modelServerOverlayText(modelServerStatus);
  // F2 — download progress (bar/%/bytes/source) when the status carries it; null
  // for every non-`downloading` state, so `starting`/`probing` show just the
  // title. An indeterminate download (unknown total) yields `percent: null`.
  const modelServerDownload = describeModelServerDownload(modelServerStatus);
  const agentWork = agentId
    ? workState?.agents.find((row) => row.agentId === agentId)
    : undefined;
  const activeTicket = agentWork?.tickets.find((ticket) => ticket.status === "inProgress");
  const queuedTickets = agentWork?.tickets.filter((ticket) => ticket.status === "queued") ?? [];
  const inboxDepth = Math.max(
    agentWork?.inboxDepth ?? 0,
    agentWork?.inbox?.length ?? 0,
    queuedTickets.length,
  );
  const completedBackgroundTasks =
    agentWork?.backgroundTasks?.filter((task) =>
      task.status === "completed" || task.status === "failed" || task.status === "cancelled",
    ) ?? [];
  const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket);
  const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined;
  async function interruptCurrentTurn(): Promise<void> {
    if (!agentId || !input) return;
    try {
      await input.interrupt(projectId, agentId);
      await refreshWorkState();
    } catch (err) {
      setBusyNotice({ message: describeNotice(err) });
    }
  }

  function requestMode(target: AgentCellMode): void {
    if (!effectiveCustomCliAvailable || target === cellMode) return;
    if (session) setPendingModeSwitch({ target });
    else setCellMode(target);
  }

  async function setSessionWithInstrumentation(
    site: "custom" | "plain" | "plain-background-attach" | "mode-switch",
    nextSession: string | null,
  ): Promise<void> {
    console.debug("[ticket149] vm.setSession", {
      timestamp: new Date().toISOString(),
      nodeId: id,
      site,
      previousSession: session,
      nextSession,
      cellMode,
      agentId,
    });
    await vm.setSession(id, nextSession);
  }

  async function stopCurrentSessionForSwitch(): Promise<void> {
    if (!session) 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 stopCurrentSessionForSwitch();
      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);

  /**
   * 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 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> => {
        // 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);
        }
        // If the cell was closed/remounted while the agent was running, resume
        // silently. Layout/project changes must not interrupt an ongoing turn
        // with a modal choice; the persisted conversation id is the continuity
        // signal and `agentWasRunning` tells us this was not an intentional stop.
        if (agentWasRunning) {
          return doLaunch(opts, onData, conversationId);
        }
        // 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,
          left: 4,
          // Clear the xterm viewport scrollbar (rendered flush against the right
          // edge, ~15px wide). Without this offset the right-most control — the
          // close button — sits *behind* the scrollbar and is hard to click.
          right: 16,
          // Above every banner/veil so a cell in an error state stays manageable
          // (ticket #48).
          zIndex: CELL_Z.controls,
          display: "flex",
          gap: 2,
          alignItems: "center",
          justifyContent: "flex-end",
          overflow: "hidden",
        }}
      >
        {/* Agent selector */}
        <select
          aria-label={`agent selector ${id}`}
          value={agentId ?? ""}
          onChange={(e) => {
            const val = e.target.value;
            if (val === "") {
              setBusyNotice(null);
              setCellMode("tui");
              void vm.setCellAgent(id, null);
              return;
            }
            void (async () => {
              const current = agentGateway?.listLiveAgents
                ? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents)
                : liveAgents;
              setLiveAgents(current);
              // Visibility is derived from the CURRENT layout, not the live
              // registry's stale last-known nodeId (ticket #56): the agent is
              // "visible ailleurs" only while a visible cell still pins it.
              const elsewhere = visibleElsewhere(val);
              if (elsewhere) {
                setBusyNotice({
                  message: "Cet agent est déjà actif dans une autre cellule.",
                  goToNodeId: elsewhere.nodeId,
                });
                return;
              }
              const live = current.find((la) => la.agentId === val);
              const isBackground =
                live && live.kind === "pty" && live.nodeId !== id;
              if (isBackground) {
                if (!agentGateway?.attachLiveAgent || !live.sessionId) {
                  setBusyNotice({
                    message: "Session active introuvable pour cet agent.",
                  });
                  return;
                }
                setBusyNotice(null);
                setCellMode("tui");
                const attached = await agentGateway.attachLiveAgent(projectId, val, id);
                await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId);
                refreshLive();
                return;
              }
              setBusyNotice(null);
              setCellMode("tui");
              await vm.setCellAgent(id, val);
            })().catch(async (err: unknown) =>
              setBusyNotice(await noticeFromError(err, val)),
            );
          }}
          style={{
            fontSize: 11,
            background: "var(--color-surface, #1e1e1e)",
            color: "var(--color-content, #e0e0e0)",
            border: "1px solid var(--color-border, #3a3a3a)",
            borderRadius: 3,
            padding: "1px 2px",
            width: 96,
            minWidth: 56,
          }}
        >
          <option value="">Plain</option>
          {agents.map((a) => {
            const elsewhere = visibleElsewhere(a.id);
            const background = backgroundLive(a.id);
            return (
              <option key={a.id} value={a.id} disabled={Boolean(elsewhere)}>
                {a.name}
                {elsewhere ? " (visible ailleurs)" : background ? " (arrière-plan)" : ""}
              </option>
            );
          })}
        </select>

        {effectiveCustomCliAvailable && (
          <div
            role="group"
            aria-label={`mode CLI agent ${id}`}
            style={{
              display: "inline-flex",
              overflow: "hidden",
              border: "1px solid var(--color-border, #3a3a3a)",
              borderRadius: 3,
              background: "var(--color-surface, #1e1e1e)",
              maxWidth: 128,
            }}
          >
            <button
              type="button"
              aria-pressed={cellMode === "tui"}
              title="TUI native"
              onClick={() => requestMode("tui")}
              style={{
                border: 0,
                borderRight: "1px solid var(--color-border, #3a3a3a)",
                background:
                  cellMode === "tui"
                    ? "var(--color-primary, #5b9bd5)"
                    : "transparent",
                color:
                  cellMode === "tui"
                    ? "var(--color-on-primary, #ffffff)"
                    : "var(--color-content, #e0e0e0)",
                fontSize: 11,
                padding: "1px 6px",
                cursor: "pointer",
                maxWidth: 66,
                overflow: "hidden",
                textOverflow: "ellipsis",
                whiteSpace: "nowrap",
              }}
            >
              TUI native
            </button>
            <button
              type="button"
              aria-pressed={cellMode === "custom"}
              title="CLI custom"
              onClick={() => requestMode("custom")}
              style={{
                border: 0,
                background:
                  cellMode === "custom"
                    ? "var(--color-primary, #5b9bd5)"
                    : "transparent",
                color:
                  cellMode === "custom"
                    ? "var(--color-on-primary, #ffffff)"
                    : "var(--color-content, #e0e0e0)",
                fontSize: 11,
                padding: "1px 6px",
                cursor: "pointer",
                maxWidth: 70,
                overflow: "hidden",
                textOverflow: "ellipsis",
                whiteSpace: "nowrap",
              }}
            >
              CLI custom
            </button>
          </div>
        )}

        <button
          type="button"
          title="Split into columns"
          aria-label={`split ${id} columns`}
          onClick={() => void vm.split(id, "row")}
        >
          ⬌
        </button>
        <button
          type="button"
          title="Split into rows"
          aria-label={`split ${id} rows`}
          onClick={() => void vm.split(id, "column")}
        >
          ⬍
        </button>
        {canClose && (
          <button
            type="button"
            title="Close this terminal"
            aria-label={`close ${id}`}
            onClick={() => void vm.merge(parentSplit.container, siblingIndex)}
          >
            ✕
          </button>
        )}
      </div>
      {agentId && busyByWorkState && (
        <button
          type="button"
          aria-label={`cancel current turn ${id}`}
          title="Cancel current turn"
          onClick={() => void interruptCurrentTurn()}
          disabled={!input}
          style={{
            position: "absolute",
            top: 24,
            right: 20,
            zIndex: CELL_Z.turnActions,
            flexShrink: 0,
            border: "1px solid var(--color-danger, #d45a5a)",
            borderRadius: 3,
            background: "var(--color-surface, #1e1e1e)",
            color: "var(--color-danger, #d45a5a)",
            cursor: input ? "pointer" : "default",
            fontSize: 11,
            padding: "1px 6px",
            pointerEvents: "auto",
          }}
        >
          Cancel
        </button>
      )}
      {agentId && (busyByWorkState || inboxDepth > 0 || completedBackgroundTasks.length > 0) && (
        <div
          role="status"
          aria-live="polite"
          style={{
            position: "absolute",
            top: 24,
            left: 4,
            right: busyByWorkState ? 76 : undefined,
            zIndex: CELL_Z.banner,
            display: "flex",
            maxWidth: busyByWorkState ? undefined : "calc(100% - 8px)",
            alignItems: "center",
            gap: 4,
            overflow: "hidden",
            pointerEvents: "auto",
          }}
        >
          {busyByWorkState && (
            <span
              title={delegatedTicket?.requesterLabel ?? activeTicket?.taskPreview}
              style={{
                maxWidth: 180,
                overflow: "hidden",
                textOverflow: "ellipsis",
                whiteSpace: "nowrap",
                border: "1px solid var(--color-warning, #d49b3a)",
                borderRadius: 3,
                background: "rgba(212, 155, 58, 0.16)",
                color: "var(--color-warning, #d49b3a)",
                padding: "1px 6px",
                fontSize: 11,
                fontWeight: 600,
              }}
            >
              {delegatedTicket
                ? `Busy · ${delegatedTicket.requesterLabel}`
                : "Busy"}
            </span>
          )}
          {!busyByWorkState && inboxDepth > 0 && (
            <span
              title={`${inboxDepth} pending message${inboxDepth === 1 ? "" : "s"}`}
              style={{
                maxWidth: 180,
                overflow: "hidden",
                textOverflow: "ellipsis",
                whiteSpace: "nowrap",
                border: "1px solid var(--color-primary, #5b9bd5)",
                borderRadius: 3,
                background: "rgba(91, 155, 213, 0.14)",
                color: "var(--color-primary, #5b9bd5)",
                padding: "1px 6px",
                fontSize: 11,
                fontWeight: 600,
              }}
            >
              Queued · {inboxDepth}
            </span>
          )}
          {busyByWorkState && inboxDepth > 0 && (
            <span
              title={`${inboxDepth} queued message${inboxDepth === 1 ? "" : "s"}`}
              style={{
                flexShrink: 0,
                border: "1px solid var(--color-primary, #5b9bd5)",
                borderRadius: 3,
                background: "rgba(91, 155, 213, 0.14)",
                color: "var(--color-primary, #5b9bd5)",
                padding: "1px 6px",
                fontSize: 11,
                fontWeight: 600,
              }}
            >
              Queued {inboxDepth}
            </span>
          )}
          {completedBackgroundTasks.length > 0 && (
            <span
              title={`${completedBackgroundTasks.length} background task result${completedBackgroundTasks.length === 1 ? "" : "s"}`}
              style={{
                flexShrink: 0,
                border: "1px solid var(--color-success, #57b36a)",
                borderRadius: 3,
                background: "rgba(87, 179, 106, 0.14)",
                color: "var(--color-success, #57b36a)",
                padding: "1px 6px",
                fontSize: 11,
                fontWeight: 600,
              }}
            >
              Task done {completedBackgroundTasks.length}
            </span>
          )}
        </div>
      )}
      {/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the
          raw xterm {@link TerminalView}. Agent cells are **native terminals**
          (ARCHITECTURE §20): every human keystroke (Enter included) reaches the
          PTY. There is no mediated-input strip; cross-model delegation flows
          through the write-portal (which injects at a clean line boundary) and
          MCP tools. Re-key on the agent so the right opener is captured at mount;
          the persisted session drives re-attach (never a re-spawn) when
          navigating. The agent cell passes its {@link WritePortal} so keystroke
          counting + injection suspension are wired; a plain cell passes none. */}
      <div
        style={{
          position: "relative",
          width: "100%",
          height: "100%",
          minHeight: 0,
          minWidth: 0,
        }}
      >
        {agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
          <CustomAgentChatView
            key={`${id}-${agentId}-custom`}
            projectId={projectId}
            agentId={agentId}
            agentName={customCliAgent.name}
            profile={customCliProfile}
            cwd={cwd}
            nodeId={id}
            sessionId={session}
            conversationId={conversationId}
            onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)}
            onConversationId={(cid) => void vm.setCellConversation(id, cid)}
          />
        ) : (
          <TerminalView
            key={`${id}-${agentId ?? "plain"}-${attachGen}`}
            cwd={cwd}
            open={terminalOpener}
            reattach={reattachOpener}
            sessionId={session}
            onSessionId={(sid) => void setSessionWithInstrumentation("plain", sid)}
            agentMode={agentId != null}
            portal={agentId != null ? portal : undefined}
            refitSignal={refitSignal}
          />
        )}
        {/* Write-portal overlay (ARCHITECTURE §20.3 step b/e): while a delegation
            is being injected into the agent's PTY, a grey veil with a centred
            message sits above the terminal. Only ever shown for an agent cell —
            and never together with the F3 overlay (exactly one veil, F3 first). */}
        {!modelServerOverlay &&
          cellMode !== "custom" &&
          shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
          <div
            data-testid="write-portal-overlay"
            role="status"
            aria-live="polite"
            style={{
              position: "absolute",
              inset: 0,
              zIndex: CELL_Z.veil,
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              background: "rgba(0, 0, 0, 0.45)",
              color: "var(--color-content, #e0e0e0)",
              fontSize: 13,
              pointerEvents: "none",
              // veil tier — below the cell controls (ticket #48).
              userSelect: "none",
            }}
          >
            <span
              style={{
                background: "var(--color-surface, #1e1e1e)",
                border: "1px solid var(--color-border, #3a3a3a)",
                borderRadius: 4,
                padding: "6px 12px",
              }}
            >
              Un agent est en train de parler…
            </span>
          </div>
        )}
        {/* F3 (ticket #4): while another agent is talking to THIS agent (target),
            an availability veil showing its live réflexions. Driven by the target's
            busy state (agentBusyChanged + read-model hydration), never by the raw
            PTY — it retracts at idle even when a turn ends without a completion
            event. Self-guards on `active` (busy) and renders null otherwise. */}
        {!modelServerOverlay && cellMode !== "custom" && agentId != null && (
          <TargetAnnouncementsOverlay projectId={projectId} agentId={agentId} />
        )}
        {/* Ticket #54 — model-server launch veil. Top-priority full-cell overlay
            (suppresses the write-portal + F3 veils above): while the pinned
            agent's bound local server is downloading its model / starting /
            probing, the empty terminal is covered rather than left to time out.
            Retracts at `ready`/`failed` (the predicate returns null then). */}
        {modelServerOverlay && (
          <div
            data-testid="model-server-overlay"
            role="status"
            aria-live="polite"
            aria-label="model server launch"
            style={{
              position: "absolute",
              inset: 0,
              zIndex: CELL_Z.veil,
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              background: "rgba(0, 0, 0, 0.55)",
              color: "var(--color-content, #e0e0e0)",
              fontSize: 13,
              pointerEvents: "none",
              userSelect: "none",
            }}
          >
            <div
              style={{
                display: "flex",
                flexDirection: "column",
                gap: 6,
                minWidth: 0,
                maxWidth: "80%",
                background: "var(--color-surface, #1e1e1e)",
                border: "1px solid var(--color-border, #3a3a3a)",
                borderRadius: 4,
                padding: "8px 14px",
              }}
            >
              <span>{modelServerOverlay}</span>
              {modelServerDownload && (
                <>
                  {/* Determinate bar when the total is known; an indeterminate
                      track (no aria-valuenow → ARIA indeterminate) otherwise. */}
                  <div
                    data-testid="model-server-progress"
                    role="progressbar"
                    aria-label="progression du téléchargement"
                    aria-valuemin={0}
                    aria-valuemax={100}
                    aria-valuenow={
                      modelServerDownload.percent != null
                        ? Math.round(modelServerDownload.percent)
                        : undefined
                    }
                    style={{
                      position: "relative",
                      width: 220,
                      maxWidth: "100%",
                      height: 6,
                      borderRadius: 3,
                      overflow: "hidden",
                      background: "var(--color-raised, #2a2a2a)",
                    }}
                  >
                    <div
                      style={
                        modelServerDownload.percent != null
                          ? {
                              height: "100%",
                              width: `${modelServerDownload.percent}%`,
                              background: "var(--color-primary, #5b9bd5)",
                              transition: "width 120ms linear",
                            }
                          : {
                              // Indeterminate: a sliding sliver.
                              position: "absolute",
                              top: 0,
                              bottom: 0,
                              width: "40%",
                              background: "var(--color-primary, #5b9bd5)",
                              animation:
                                "model-server-indeterminate 1.1s ease-in-out infinite",
                            }
                      }
                    />
                  </div>
                  {(modelServerDownload.percent != null ||
                    modelServerDownload.bytesLabel) && (
                    <span
                      data-testid="model-server-progress-label"
                      style={{ fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}
                    >
                      {modelServerDownload.percent != null
                        ? `${Math.round(modelServerDownload.percent)} %`
                        : ""}
                      {modelServerDownload.percent != null &&
                      modelServerDownload.bytesLabel
                        ? " · "
                        : ""}
                      {modelServerDownload.bytesLabel ?? ""}
                    </span>
                  )}
                  {modelServerDownload.source && (
                    <span
                      data-testid="model-server-source"
                      style={{
                        fontSize: 11,
                        color: "var(--color-content-muted, #9a9a9a)",
                        overflow: "hidden",
                        textOverflow: "ellipsis",
                        whiteSpace: "nowrap",
                      }}
                      title={modelServerDownload.source}
                    >
                      {modelServerDownload.source}
                    </span>
                  )}
                </>
              )}
            </div>
          </div>
        )}
      </div>
      {busyNotice && (
        <div
          role="status"
          style={{
            position: "absolute",
            bottom: 4,
            left: 4,
            right: 4,
            margin: 0,
            fontSize: 11,
            color: "var(--color-content-muted, #9a9a9a)",
            background: "var(--color-surface, #1e1e1e)",
            padding: "2px 6px",
            borderRadius: 3,
            zIndex: CELL_Z.banner,
            display: "flex",
            alignItems: "center",
            justifyContent: "space-between",
            gap: 6,
          }}
        >
          <span style={{ minWidth: 0 }}>{busyNotice.message}</span>
          {busyNotice.goToNodeId && (
            <button
              type="button"
              aria-label="aller à la cellule"
              onClick={() => {
                goToCell(busyNotice.goToNodeId!);
                setBusyNotice(null);
              }}
              style={{
                flexShrink: 0,
                fontSize: 11,
                cursor: "pointer",
                background: "transparent",
                color: "var(--color-primary, #5b9bd5)",
                border: "1px solid var(--color-primary, #5b9bd5)",
                borderRadius: 3,
                padding: "1px 6px",
              }}
            >
              Aller à la cellule
            </button>
          )}
        </div>
      )}
      {pendingModeSwitch && (
        <div
          role="alertdialog"
          aria-modal="true"
          aria-label="Confirmer le changement de CLI"
          style={{
            position: "absolute",
            inset: 0,
            zIndex: CELL_Z.controls + 1,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            background: "rgba(0, 0, 0, 0.62)",
            padding: 12,
          }}
        >
          <div
            style={{
              width: 360,
              maxWidth: "100%",
              border: "1px solid var(--color-border, #3a3a3a)",
              borderRadius: 6,
              background: "var(--color-surface, #1e1e1e)",
              color: "var(--color-content, #e0e0e0)",
              padding: 12,
              boxShadow: "0 12px 36px rgba(0,0,0,0.35)",
            }}
          >
            <h2 style={{ margin: 0, fontSize: 14 }}>
              Changer de CLI agent
            </h2>
            <p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}>
              La session courante va être arrêtée avant de relancer{" "}
              {pendingModeSwitch.target === "custom"
                ? "la CLI custom"
                : "la TUI native"}
              .
            </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 relance repartira à neuf."}
            </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",
                }}
              >
                Arrêter et relancer
              </button>
            </div>
          </div>
        </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"
  );
}
