428519335d
- The run form's prompt box offers suggestions again: `/` lists the run
cwd's project and user commands and skills plus the CLI built-ins
(claude lanes only), `@` lists files under the cwd; Enter picks while
the list is open and sends otherwise. The 2f39f4e rewrite had left a
bare textarea that ignored both.
- Active runs lists only the current lane's runs and external sessions
(sessions matched to the lane by longest path-boundary cwd), instead of
every lane's.
- The Changes panel's git line and its actions are one compact row in one
card, and the mode/range toolbar is one wrapping row.
- The lane card hides itself when it has nothing to show, instead of
leaving an empty frame where the git row used to be.
600 lines
23 KiB
TypeScript
600 lines
23 KiB
TypeScript
/**
|
|
* @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.
|
|
*
|
|
* A lane's pane is one tmux session, so starting or resuming into a lane
|
|
* 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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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, LaneTool } from "../../lib/types";
|
|
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<LaneTool, { found: boolean; path: string | null }> | 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;
|
|
}
|
|
|
|
export function LaneConsolePane({
|
|
lanes,
|
|
laneId,
|
|
showLaneSelector,
|
|
onLaneIdChange,
|
|
onLaneCreated,
|
|
binaryStatus,
|
|
cwdSuggestions,
|
|
activeRuns,
|
|
externalSessions,
|
|
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<PermissionMode>("auto");
|
|
const [effort, setEffort] = useState<EffortLevel>("");
|
|
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? defaultCwd ?? "");
|
|
const [resumeSession, setResumeSession] = useState<Session | null>(null);
|
|
const [handle, setHandle] = useState<RunHandle | null>(null);
|
|
const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
|
|
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
|
|
const [approval, setApproval] = useState<CodexApproval>("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) },
|
|
[activeRuns, laneId]
|
|
);
|
|
const laneSessions = useMemo(
|
|
() =>
|
|
laneId === null
|
|
? externalSessions
|
|
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
|
|
[externalSessions, laneId, lanes]
|
|
);
|
|
const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
|
|
|
|
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]);
|
|
|
|
// `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<number | null>(null);
|
|
useEffect(() => {
|
|
const { activeRuns: runs } = latest.current;
|
|
setPrompt("");
|
|
setResumeSession(null);
|
|
setError(null);
|
|
setBusy(null);
|
|
setHandle(runs?.items.find((r) => r.laneId === laneId && r.status === "running") ?? null);
|
|
autoAttachedForLane.current = null;
|
|
refreshList();
|
|
}, [laneId, 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 running = activeRuns?.items.find((r) => r.laneId === laneId && r.status === "running");
|
|
if (running) {
|
|
autoAttachedForLane.current = laneId;
|
|
setHandle(running);
|
|
}
|
|
}, [activeRuns, laneId, 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", {
|
|
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,
|
|
});
|
|
} 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.
|
|
if ((laneErr as { code?: string })?.code === "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);
|
|
setError(t("errors.attachedToLiveInstead"));
|
|
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, laneTool, 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", {
|
|
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") {
|
|
const fresh = await api.lanes.list().catch(() => null);
|
|
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
|
|
if (updatedLane?.run_id) {
|
|
onLaneIdChange(targetLaneId);
|
|
await attachToRun(updatedLane.run_id);
|
|
setError(t("errors.attachedToLiveInstead"));
|
|
return;
|
|
}
|
|
}
|
|
throw laneErr;
|
|
}
|
|
|
|
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, 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 (
|
|
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
|
<select
|
|
data-testid="pane-lane-select"
|
|
aria-label={tLanes("splitView.paneLaneLabel")}
|
|
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
|
|
value=""
|
|
onChange={(e) => e.target.value && onLaneIdChange(Number(e.target.value))}
|
|
>
|
|
<option value="">{tLanes("splitView.pickLane")}</option>
|
|
{lanes.map((l) => (
|
|
<option key={l.id} value={l.id}>
|
|
{l.title || l.cwd}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="text-xs text-fg-muted">{tLanes("splitView.emptyPane")}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
|
|
{showLaneSelector && (
|
|
<select
|
|
data-testid="pane-lane-select"
|
|
aria-label={tLanes("splitView.paneLaneLabel")}
|
|
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
|
|
value={laneId ?? ""}
|
|
onChange={(e) => e.target.value && onLaneIdChange(Number(e.target.value))}
|
|
>
|
|
<option value="">{tLanes("splitView.pickLane")}</option>
|
|
{lanes.map((l) => (
|
|
<option key={l.id} value={l.id}>
|
|
{l.title || l.cwd}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
|
|
{/* 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. */}
|
|
<header className="flex items-center gap-2.5">
|
|
<div className="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0">
|
|
<Play className="w-3.5 h-3.5 text-accent" />
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<h1 className="text-base font-semibold text-fg-primary">
|
|
{t("title", { tool: toolLabel })}
|
|
</h1>
|
|
{wsConnected ? (
|
|
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
|
|
{tCommon("live")}
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
|
|
{tCommon("offline")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block">
|
|
{t("subtitle", { tool: toolLabel })}
|
|
</p>
|
|
</div>
|
|
<ActiveRunsSwitcher
|
|
activeRuns={laneRuns}
|
|
currentHandleId={handle?.id || null}
|
|
onAttach={attachToRun}
|
|
runHistory={runHistory}
|
|
externalSessions={laneSessions}
|
|
onResumeFromHistory={onResumeFromHistory}
|
|
onViewFromHistory={onViewFromHistory}
|
|
onKill={killRun}
|
|
onRefresh={refreshList}
|
|
/>
|
|
</header>
|
|
|
|
{binaryStatus && !binaryStatus[laneTool]?.found && (
|
|
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
|
<span>{t("binary.missing", { cli: laneTool, tool: toolLabel })}</span>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
|
<span className="flex-1 break-all">{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{!handle ? (
|
|
<RunSetup
|
|
laneId={laneId ?? 0}
|
|
prompt={prompt}
|
|
onPromptChange={setPrompt}
|
|
cwd={cwd}
|
|
onCwdChange={setCwd}
|
|
cwdSuggestions={cwdSuggestions}
|
|
model={model}
|
|
onModelChange={setModel}
|
|
permissionMode={permissionMode}
|
|
onPermissionModeChange={setPermissionMode}
|
|
effort={effort}
|
|
onEffortChange={setEffort}
|
|
binaryFound={binaryStatus?.[laneTool]?.found ?? true}
|
|
busy={busy === "start"}
|
|
onStart={onStartFromSetup}
|
|
activeRuns={activeRuns}
|
|
laneCwd={currentLane?.cwd}
|
|
resumeSession={resumeSession}
|
|
onResumeSessionChange={setResumeSession}
|
|
runHistory={runHistory}
|
|
onResumeFromHistory={onResumeFromHistory}
|
|
tool={laneTool}
|
|
sandbox={sandbox}
|
|
onSandboxChange={setSandbox}
|
|
approval={approval}
|
|
onApprovalChange={setApproval}
|
|
onResumeLast={() => onStartFromSetup({ laneId: laneId ?? 0, cwd, resumeLast: true })}
|
|
/>
|
|
) : (
|
|
<div className="flex-1 min-h-0 flex flex-col">
|
|
<TerminalView
|
|
runId={handle!.id}
|
|
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
|
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
|
// go straight back to the setup form rather than leaving a dead
|
|
// terminal the user has to click "New run" out of.
|
|
onExit={() => {
|
|
newRun();
|
|
refreshList();
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={newRun}
|
|
className="mt-3 px-3 py-1.5 text-sm rounded border border-border hover:border-border-light text-fg-secondary hover:text-fg-primary transition-colors"
|
|
>
|
|
{t("actions.newRun")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|