/** * @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/resumeSession/handle/busy/runHistory state — nothing * is shared between panes. `lanes`, `binaryStatus`, `cwdSuggestions`, and * `activeRuns` 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. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, 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, } from "../../lib/api"; import type { Session, Lane } from "../../lib/types"; import { TerminalView } from "./TerminalView"; import { RunSetup } from "./RunSetup"; import { ActiveRunsSwitcher } from "./RunHistory"; export interface LaneConsolePaneProps { lanes: Lane[]; laneId: number | null; showLaneSelector: boolean; onLaneIdChange: (id: number) => void; onLaneCreated: (lane: Lane) => void; binaryStatus: { found: boolean; path: string | null } | null; cwdSuggestions: CwdSuggestion[]; activeRuns: RunListResponse | null; wsConnected: boolean; defaultCwd?: string; onHasActiveRunChange?: (active: boolean) => void; } export function LaneConsolePane({ lanes, laneId, showLaneSelector, onLaneIdChange, onLaneCreated, binaryStatus, cwdSuggestions, activeRuns, wsConnected, defaultCwd, onHasActiveRunChange, }: 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("acceptEdits"); 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 currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null; useEffect(() => { onHasActiveRunChange?.(handle !== null); }, [handle, onHasActiveRunChange]); 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]); 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. const ownedLane = lanes.find((l) => l.cwd === effectiveCwd); 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 }); 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", { prompt: args.initialPrompt || "", model: args.model || undefined, permissionMode: args.permissionMode, resumeSessionId: args.resumeSessionId, effort: args.effort || undefined, }); } catch (laneErr: unknown) { const msg = laneErr instanceof Error ? laneErr.message : String(laneErr); if (msg.includes("409") || msg.includes("ERUNLIVE")) { const fresh = await api.lanes.list().catch(() => null); const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId); if (updatedLane?.run_id) { await attachToRun(updatedLane.run_id); return; } } throw laneErr; } if (!laneStartResult.lane?.run_id) { throw new Error(t("errors.noRunIdReturned")); } try { const fetched = await api.run.get(laneStartResult.lane.run_id); setHandle(fetched); refreshList(); } catch { try { await attachToRun(laneStartResult.lane.run_id); 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, 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; let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd)?.id; if (!targetLaneId) { const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd }); targetLaneId = ensureResult.lane.id; onLaneCreated(ensureResult.lane); } const laneStartResult = await api.lanes.action(targetLaneId, "start", { prompt: "", model: item.model || undefined, permissionMode: item.permission_mode || undefined, effort: item.effort || undefined, resumeSessionId: item.session_id, }); if (!laneStartResult.lane?.run_id) { throw new Error("No run_id returned from lane start"); } fetched = await api.run.get(laneStartResult.lane.run_id); 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, onLaneCreated, onLaneIdChange] ); const onViewFromHistory = useCallback( (item: DashboardRunHistoryItem) => { if (item.session_id) void onResumeFromHistory(item); }, [onResumeFromHistory] ); const newRun = useCallback(() => { setHandle(null); setPrompt(""); setResumeSession(null); setError(null); }, []); if (laneId === null && showLaneSelector) { return (

{tLanes("splitView.emptyPane")}

); } return (
{showLaneSelector && ( )}

{t("title")}

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

{t("subtitle")}

{binaryStatus && !binaryStatus.found && (
{t("binary.missing")}
)} {error && (
{error}
)} {!handle ? ( ) : (
)}
); }