/** * @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. * A Claude or Codex pane also has a Chat view (`PaneChat`): the session's * conversation laid OVER the terminal, which stays mounted and attached under * it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle * left of the pane's split buttons); the Chat UI setting (Settings → * Appearance, `lib/chatUi.ts`) is only what a session shows first. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, 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"; import { PaneChat } from "./PaneChat"; import { useSessionChat } from "../../lib/chatUi"; const CHAT_TOOLS = new Set(["claude", "codex"]); 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 pane spans the grid's full width or height: its Chat view's right * panel starts open (collapsed in a quarter-sized pane). */ panelOpen?: 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, panelOpen = false, }: 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"; // A run learns its session id after it starts (Codex): the page's poll has it. const chatSession = handle ? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId) : null; const chat = useSessionChat(chatSession); const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool); // Stable, so the memoized terminal is not re-rendered for a new closure. const onRunExitRef = useRef(onRunExit); onRunExitRef.current = onRunExit; const onTerminalExit = useCallback(() => { setHandle(null); onRunExitRef.current?.(); }, []); 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 && ( )}
) : (
{/* Over the terminal, never instead of it: unmounting would drop the attach. */} {chatable && chat && (
)}
)}
); }