/** * @file LaneConsolePane.tsx * @description One console tab's body: a TerminalView on the tab's tmux * session (`agenthub-lane-[-]`), or — with no run — an empty pane * whose button goes back to the workspace home, where a session starts with a * prompt (a pane has no run form; the tab bar's `+`, `ConsoleTabs.tsx`, starts * a bare CLI or a shell). No header of its own: the tab bar is the console's * header. The Workspace page keeps one per live session (slot 0–9) mounted, the * inactive ones hidden with CSS. * * The pane is bound to the lane it shows: switching `laneId` swaps it to the * new lane's own live session for its slot, or an empty pane — never the * previous lane's terminal. `attachRequest` (a run picked in the sidebar or a * tab) attaches to that tmux session only; nothing is started or resumed. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertCircle, Plus } from "lucide-react"; import { api } from "../../lib/api"; import type { RunHandle, RunListResponse } from "../../lib/api"; import type { Lane } from "../../lib/types"; import { useAiTools } from "../../hooks/useAiTools"; import { TerminalView } from "./TerminalView"; export interface LaneConsolePaneProps { lanes: Lane[]; laneId: number | null; binaryStatus: Record | null; activeRuns: RunListResponse | null; onHasActiveRunChange?: (active: boolean) => void; /** Which tab this is: slot 0 is the lane's own session, 1–3 * `agenthub-lane--`. */ slot?: number; /** A live run to show in this pane (a session picked in the sidebar or a * tab). It attaches to that tmux session only. */ attachRequest?: { runId: string; nonce: string } | null; /** Opens the workspace home, where a new session is started. */ onNewSession?: () => void; /** The pane's run exited (the tab bar opens another tab, or the home). */ onRunExit?: () => void; /** The terminal takes the keyboard when it appears (default). */ autoFocus?: boolean; } /** The tmux session a lane's tab owns (see server/lib/pty-run.js). */ function paneRunId(laneId: number, slot: number): string { return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`; } export function LaneConsolePane({ lanes, laneId, binaryStatus, activeRuns, onHasActiveRunChange, slot = 0, attachRequest, onNewSession, onRunExit, autoFocus, }: LaneConsolePaneProps) { const { t } = useTranslation("run"); const [handle, setHandle] = useState(null); const [error, setError] = useState(null); const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null; const aiTools = useAiTools(); // The tool of the run on screen — a session's own, which need not be the // workspace's; the workspace's with no run. const paneTool = handle?.tool ?? currentLane?.tool ?? "claude"; useEffect(() => { onHasActiveRunChange?.(handle !== null); }, [handle, onHasActiveRunChange]); // `activeRuns` is re-fetched every few seconds by the page, so reading it // through a ref keeps the lane-switch effect below off its identity. const latest = useRef(activeRuns); latest.current = activeRuns; // A pane's terminal belongs to the lane it shows, so switching lanes swaps // it — to the new lane's own live session for this slot, if it has one. const autoAttachedForLane = useRef(null); useEffect(() => { setError(null); const own = laneId === null ? null : paneRunId(laneId, slot); setHandle(latest.current?.items.find((r) => r.id === own && r.status === "running") ?? null); autoAttachedForLane.current = null; }, [laneId, slot]); // Runs after the lane-switch effect above, so a sidebar pick that changes // lane AND run at once lands on the picked run, not the lane's own. Keyed on // `laneId` too: on a cold load the pane mounts before the lane list does, // and the switch effect then resets the pane to its own slot once `laneId` // arrives — the request must land after that. Only this lane's own tabs. useEffect(() => { if (!attachRequest || laneId === null) return; if (!new RegExp(`^agenthub-lane-${laneId}(-[1-9])?$`).test(attachRequest.runId)) return; let live = true; autoAttachedForLane.current = laneId; api.run .get(attachRequest.runId) .then((h) => { if (!live) return; setHandle(h); // A later attach that worked clears an earlier one's failure. setError(null); }) .catch( (err: unknown) => live && setError( t("errors.attachFailed", { message: err instanceof Error ? err.message : "unknown" }) ) ); return () => { live = false; }; // A new request (nonce), or the lane it names arriving, attaches; a switch // to another lane drops `?run=` (Workspace), so it never replays one. // eslint-disable-next-line react-hooks/exhaustive-deps }, [attachRequest?.nonce, laneId]); // The switch effect only sees whatever `activeRuns` the page had loaded at // that instant — null right after a remount. Re-check once the poll lands, // once per lane. useEffect(() => { if (handle || laneId === null) return; if (autoAttachedForLane.current === laneId) return; const own = paneRunId(laneId, slot); const running = activeRuns?.items.find((r) => r.id === own && r.status === "running"); if (running) { autoAttachedForLane.current = laneId; setHandle(running); } }, [activeRuns, laneId, slot, handle]); return (
{/* Not probed yet (a tool the page has not probed, or a shell) is not "missing". */} {binaryStatus?.[paneTool] && !binaryStatus[paneTool].found && (
{t("binary.missing", { cli: aiTools.byId(paneTool)?.command ?? paneTool, tool: aiTools.label(paneTool), })}
)} {error && (
{error}
)} {!handle ? (

{t("emptyPane")}

{onNewSession && ( )}
) : (
{ setHandle(null); onRunExit?.(); }} />
)}
); }