f887c16309
Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.
Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.
Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.
Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.
DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
653 lines
25 KiB
TypeScript
653 lines
25 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.
|
||
*
|
||
* Each pane is one tmux session on its lane — split panes all show the same
|
||
* lane, pane `slot` N owning `agenthub-lane-<id>[-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ĩ <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 } 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<string, { 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;
|
||
/** 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-<id>-<slot>`. */
|
||
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<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 && !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<number | null>(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 (
|
||
<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>
|
||
|
||
{/* Not probed yet (a lane on a tool the page has not probed) is not "missing". */}
|
||
{binaryStatus?.[laneTool] && !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: aiTools.byId(laneTool)?.command ?? 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={(bypass) =>
|
||
onStartFromSetup({ laneId: laneId ?? 0, cwd, resumeLast: true, bypass })
|
||
}
|
||
/>
|
||
) : (
|
||
<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>
|
||
);
|
||
}
|