/** * @file LaneConsolePane.tsx * @description One lane's run console: the RunSetup ↔ TerminalView switcher, * moved out of Workspace.tsx so the Workspace page can render 1, 2, or 4 of * these side by side (split terminal view). Owns its own prompt/cwd/model/ * permissionMode/effort/sandbox/approval/resumeSession/handle/busy/runHistory * state — nothing is shared between panes. `lanes`, `binaryStatus`, * `cwdSuggestions`, `activeRuns`, and `externalSessions` are supplied as props * because they are global, not lane-specific, and fetching them per pane would * mean N redundant identical requests for an N-pane layout. `binaryStatus` is * keyed by tool (claude | codex) and probed once globally, so gating the Start * button uses the binary status for the lane's configured tool. * * That state is bound to the lane the pane currently shows: switching `laneId` * swaps the whole pane over to the new lane — its cwd, its tool, its history, * and its live tmux session — instead of leaving the previous lane's terminal * on screen under a new lane's header. * * Each pane is one tmux session on its lane — split panes all show the same * lane, pane `slot` N owning `agenthub-lane-[-N]` — so starting or resuming * into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a * prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch * that and attach to the still-live session instead of failing outright * (with a notice explaining what happened), since that session is still * reachable and the user did not ask to kill it. `killRun` is the other half: * the Active runs list's Kill button (`RunHistory.tsx`), for when the user * DOES want that previous session gone so a fresh start/resume can proceed. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Play, AlertCircle } from "lucide-react"; import { api } from "../../lib/api"; import type { CwdSuggestion, DashboardRunHistoryItem, EffortLevel, PermissionMode, RunHandle, RunListResponse, RunStartArgs, CodexSandbox, CodexApproval, } from "../../lib/api"; import type { Session, Lane } from "../../lib/types"; import { useAiTools } from "../../hooks/useAiTools"; import { TerminalView } from "./TerminalView"; import { RunSetup } from "./RunSetup"; import { ActiveRunsSwitcher } from "./RunHistory"; /** The claude lane a session's cwd belongs to: the longest path-boundary * prefix, the same rule the server binds hook sessions with. External * sessions are all claude — Codex emits no hooks. */ function owningLaneId(cwd: string, lanes: Lane[]): number | null { let best: Lane | null = null; for (const l of lanes) { if (l.tool !== "claude") continue; const root = l.cwd.replace(/\/+$/, ""); if (cwd !== root && !cwd.startsWith(root + "/")) continue; if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l; } return best?.id ?? null; } export interface LaneConsolePaneProps { lanes: Lane[]; laneId: number | null; showLaneSelector: boolean; onLaneIdChange: (id: number) => void; onLaneCreated: (lane: Lane) => void; binaryStatus: Record | null; cwdSuggestions: CwdSuggestion[]; activeRuns: RunListResponse | null; /** Active Claude Code sessions started outside the dashboard, listed in the * active-runs switcher alongside dashboard runs. */ externalSessions?: Session[]; wsConnected: boolean; defaultCwd?: string; onHasActiveRunChange?: (active: boolean) => void; /** Which split pane this is. Every pane shows the SAME lane, each in its own * tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane--`. */ slot?: number; /** Runs the OTHER panes are showing — left out of this pane's Active runs so * one terminal is never attached twice side by side. */ hiddenRunIds?: string[]; /** Reports the run this pane shows (null on the setup form). */ onHandleChange?: (runId: string | null) => void; } /** The tmux session a lane's split pane 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, showLaneSelector, onLaneIdChange, onLaneCreated, binaryStatus, cwdSuggestions, activeRuns, externalSessions, wsConnected, defaultCwd, onHasActiveRunChange, slot = 0, hiddenRunIds, onHandleChange, }: LaneConsolePaneProps) { const { t } = useTranslation("run"); const { t: tLanes } = useTranslation("lanes"); const { t: tCommon } = useTranslation("common"); const [prompt, setPrompt] = useState(""); const [model, setModel] = useState(""); const [permissionMode, setPermissionMode] = useState("auto"); const [effort, setEffort] = useState(""); const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? defaultCwd ?? ""); const [resumeSession, setResumeSession] = useState(null); const [handle, setHandle] = useState(null); const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null); const [error, setError] = useState(null); const [runHistory, setRunHistory] = useState([]); const [sandbox, setSandbox] = useState("workspace-write"); const [approval, setApproval] = useState("on-request"); const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null; const laneTool = currentLane?.tool ?? "claude"; // The page polls runs and sessions for EVERY lane; this pane's Active runs // list is this lane's only. No lane selected keeps the full lists. const laneRuns = useMemo( () => laneId === null || !activeRuns ? activeRuns : { ...activeRuns, items: activeRuns.items.filter( (r) => r.laneId === laneId && !hiddenRunIds?.includes(r.id) ), }, [activeRuns, laneId, hiddenRunIds] ); const laneSessions = useMemo( () => laneId === null ? externalSessions : (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId), [externalSessions, laneId, lanes] ); const aiTools = useAiTools(); const toolLabel = aiTools.label(laneTool); // A new run starts on the tool's default model (AI Tools settings), and a // lane switch to another tool drops the previous tool's pick. const defaultModel = aiTools.byId(laneTool)?.defaultModel ?? ""; useEffect(() => { setModel(defaultModel); }, [laneTool, defaultModel]); useEffect(() => { onHasActiveRunChange?.(handle !== null); }, [handle, onHasActiveRunChange]); useEffect(() => { onHandleChange?.(handle?.id ?? null); }, [handle, onHandleChange]); useEffect(() => { if (!currentLane && defaultCwd && cwd === "") { setCwd(defaultCwd); } }, [defaultCwd, currentLane, cwd]); const refreshList = useCallback(() => { if (laneId !== null) { api.run .history(50, { laneId }) .then((r) => setRunHistory(r.items)) .catch(() => undefined); } else { api.run .history(50) .then((r) => setRunHistory(r.items)) .catch(() => undefined); } }, [laneId]); // `lanes` and `activeRuns` are re-fetched every few seconds by the page, so // reading them through a ref keeps the lane-switch effect below off their // identity — a background poll must not wipe a half-typed prompt. const latest = useRef({ lanes, activeRuns, defaultCwd }); latest.current = { lanes, activeRuns, defaultCwd }; // A pane's prompt, cwd, history and terminal all belong to the lane it // shows, so switching lanes has to swap every one of them. Re-attach right // away when the new lane already has a live run: each lane sticks to its own // tmux session, and the switch should land on that session rather than on an // empty setup form the user then has to Start out of. const autoAttachedForLane = useRef(null); useEffect(() => { const { activeRuns: runs } = latest.current; setPrompt(""); setResumeSession(null); setError(null); setBusy(null); const own = laneId === null ? null : paneRunId(laneId, slot); setHandle(runs?.items.find((r) => r.id === own && r.status === "running") ?? null); autoAttachedForLane.current = null; refreshList(); }, [laneId, slot, refreshList]); // Picking a pipeline template writes NOTHING into the prompt box: a prefilled // slash command (or the codex stage-checklist prose) is sent as soon as the // run starts, which is an extra prompt nobody asked for. The prompt is the // user's to type; the pipeline is declared by `agenthub stage` instead. // The switch effect above only sees whatever `activeRuns` the page already // had loaded at that instant. Remounting this page (navigating away and // back) starts `activeRuns` at null again, so a lane with a live run would // otherwise show the setup form until the user switched lanes and back — // the only path that re-ran the effect after the poll caught up. Re-check // once `activeRuns` actually arrives, but only once per lane so it never // fights a user-initiated "New Run". 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]); // The cwd tracks the lane's own folder separately, keyed on the resolved // path rather than on `laneId` alone: the pane can mount before the lane // list has loaded (split view restores its pane lanes from localStorage), // and `laneId` never changes afterwards, so a laneId-only effect would leave // the console pointing at the default directory. RunSetup submits this // string verbatim, so a stale one starts the run in the wrong folder. const laneCwd = currentLane?.cwd ?? null; useEffect(() => { if (laneCwd) setCwd(laneCwd); else if (laneId === null) setCwd(latest.current.defaultCwd ?? ""); }, [laneId, laneCwd]); const attachToRun = useCallback( async (id: string) => { if (busy) return; setBusy("attach"); setError(null); try { const fetched = await api.run.get(id); setHandle(fetched); } catch (err: unknown) { const m = err instanceof Error ? err.message : "unknown"; setError(t("errors.attachFailed", { message: m })); } finally { setBusy(null); } }, [busy, t] ); const onStartFromSetup = useCallback( async (args: RunStartArgs) => { if (busy) return; setBusy("start"); setError(null); try { const effectiveCwd = args.cwd || undefined; if (!effectiveCwd) { throw new Error(t("errors.cwdRequired")); } // Resolve the lane from the cwd the user actually typed, not from // args.laneId — RunSetup always supplies this pane's laneId (a // required prop), which would otherwise silently start a run in the // wrong lane whenever the user types a cwd different from the one // this pane currently shows. A cwd can now back both a claude lane // and a codex lane, so match this pane's own tool too (laneTool, // which already defaults to "claude" when no lane is selected — // the same default RunSetup renders) — otherwise the wrong sibling // lane could be picked. const ownedLane = lanes.find((l) => l.cwd === effectiveCwd && l.tool === laneTool); let targetLaneId: number; if (ownedLane) { targetLaneId = ownedLane.id; if (ownedLane.id !== laneId) onLaneIdChange(ownedLane.id); } else { try { const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd, tool: laneTool, }); targetLaneId = ensureResult.lane.id; onLaneIdChange(ensureResult.lane.id); onLaneCreated(ensureResult.lane); } catch (err) { throw new Error( t("errors.laneCreateFailed", { message: err instanceof Error ? err.message : "unknown", }) ); } } let laneStartResult; try { laneStartResult = await api.lanes.action(targetLaneId, "start", { slot, prompt: args.initialPrompt || "", model: args.model || undefined, permissionMode: args.permissionMode, resumeSessionId: args.resumeSessionId, effort: args.effort || undefined, sandbox: args.sandbox, approval: args.approval, resumeLast: args.resumeLast, bypass: args.bypass, }); } catch (laneErr: unknown) { // A code-only check: the server's message is human prose ("lane // already has a live run"), never the literal strings "409" or // "ERUNLIVE" — matching against `.message` here is dead code that // always falls through to the raw error below. // This pane's own session is the live one — land on it. if ((laneErr as { code?: string })?.code === "ERUNLIVE") { await attachToRun(paneRunId(targetLaneId, slot)); setError(t("errors.attachedToLiveInstead")); return; } throw laneErr; } const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id; if (!startedId) { throw new Error(t("errors.noRunIdReturned")); } try { const fetched = await api.run.get(startedId); setHandle(fetched); refreshList(); } catch { try { await attachToRun(startedId); refreshList(); } catch (fallbackErr: unknown) { const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown"; throw new Error(t("errors.runStartedButNotAttached", { message: attachMsg })); } } } catch (err: unknown) { const m = err instanceof Error ? err.message : "unknown"; setError(t("errors.startFailed", { message: m })); } finally { setBusy(null); } }, [ busy, t, lanes, laneId, laneTool, slot, onLaneIdChange, onLaneCreated, attachToRun, refreshList, ] ); const onResumeFromHistory = useCallback( async (item: DashboardRunHistoryItem) => { if (!item.session_id) return; if (busy) return; setBusy("start"); setError(null); try { let fetched: RunHandle; if (item.cwd) { const effectiveCwd = item.cwd; // A cwd can now back both a claude lane and a codex lane — match // this history item's own tool, the ground truth for which lane it // belongs to, rather than an arbitrary lane at that cwd. let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd && l.tool === item.tool)?.id; if (!targetLaneId) { const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd, tool: item.tool }); targetLaneId = ensureResult.lane.id; onLaneCreated(ensureResult.lane); } let laneStartResult; try { laneStartResult = await api.lanes.action(targetLaneId, "start", { slot, prompt: "", model: item.model || undefined, permissionMode: item.permission_mode || undefined, effort: item.effort || undefined, resumeSessionId: item.session_id, }); } catch (laneErr: unknown) { // The lane's own pane is still busy with a PREVIOUS live session — // that session is still active and reachable, so land on it // instead of failing outright. Killing it (Active runs → Kill) is // what actually frees the lane for this resume. Checked by `.code` // — the server's `.message` is prose, never the literal string // "ERUNLIVE" or "409". if ((laneErr as { code?: string })?.code === "ERUNLIVE") { onLaneIdChange(targetLaneId); await attachToRun(paneRunId(targetLaneId, slot)); setError(t("errors.attachedToLiveInstead")); return; } throw laneErr; } const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id; if (!startedId) { throw new Error("No run_id returned from lane start"); } fetched = await api.run.get(startedId); onLaneIdChange(targetLaneId); } else { fetched = await api.run.start({ laneId: 0, initialPrompt: "", cwd: undefined, model: item.model || undefined, permissionMode: item.permission_mode || undefined, effort: item.effort || undefined, resumeSessionId: item.session_id, }); } setHandle(fetched); setResumeSession(null); refreshList(); } catch (err) { const msg = err instanceof Error ? err.message : "unknown"; setError(t("errors.startFailed", { message: msg })); } finally { setBusy(null); } }, [busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun] ); const onViewFromHistory = useCallback( (item: DashboardRunHistoryItem) => { if (item.session_id) void onResumeFromHistory(item); }, [onResumeFromHistory] ); const newRun = useCallback(() => { setHandle(null); setPrompt(""); setResumeSession(null); setError(null); }, []); // Frees a lane blocked by a live run nobody wants anymore (see Active // runs' Kill button) — the lane's own `run_id` self-heals to null the next // time it's read (routes/lanes.js's healRunId), so no lane-side call here. const killRun = useCallback( async (id: string) => { setBusy("kill"); setError(null); try { await api.run.kill(id); if (handle?.id === id) newRun(); refreshList(); } catch (err) { setError( t("errors.killFailed", { message: err instanceof Error ? err.message : "unknown" }) ); } finally { setBusy(null); } }, [handle, newRun, refreshList, t] ); if (laneId === null && showLaneSelector) { return (

{tLanes("splitView.emptyPane")}

); } return (
{showLaneSelector && ( )} {/* Title, live badge and blurb on ONE line: the blurb is a standing description of the panel, not a message, so it does not earn the second row it used to wrap onto. It drops out entirely below `sm`, where the width would wrap it again. */}

{t("title", { tool: toolLabel })}

{wsConnected ? ( {tCommon("live")} ) : ( {tCommon("offline")} )}

{t("subtitle", { tool: toolLabel })}

{/* Not probed yet (a lane on a tool the page has not probed) is not "missing". */} {binaryStatus?.[laneTool] && !binaryStatus[laneTool].found && (
{t("binary.missing", { cli: aiTools.byId(laneTool)?.command ?? laneTool, tool: toolLabel, })}
)} {error && (
{error}
)} {!handle ? ( onStartFromSetup({ laneId: laneId ?? 0, cwd, resumeLast: true, bypass }) } /> ) : (
{ newRun(); refreshList(); }} />
)}
); }