Files
AgentHub/client/src/pages/Workspace.tsx
T

1256 lines
49 KiB
TypeScript

/**
* @file Workspace.tsx
* @description Merged workspace page combining lanes (agent work units) and runs
* (Claude Code spawns). Displays a horizontal lane strip at the top with counters,
* the selected lane's pipeline map, and run configuration/console/history below.
* Runs are tied to lanes: starting a run posts to POST /api/lanes/:id/start.
* When a cwd isn't owned by any lane, calls POST /api/lanes/ensure first.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
* - The console never writes a lane's stage: no POST /api/lanes/:id/stage calls.
*
* ## Internal dependencies
* - `../lib/api` — REST client including lanes and run endpoints.
* - `../lib/types` — Lane, Lane Counts, run handles, etc.
* - `../lib/eventBus` — WebSocket subscription.
* - `../components/lanes/` — LaneCard, PipelineMap, DestructiveLaneModal.
* - `../components/run/` — RunConsole, RunSetup, RunHistory, ActiveRunsSwitcher.
*
* ## Public surface
* - `Workspace` — merged page; see TSDoc on the symbol for behavior.
*
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG
* **Workspace**
* The main exported page component. Merges lanes and runs into one workspace.
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Play, AlertCircle, X, Plus } from "lucide-react";
import { api } from "../lib/api";
import type {
CwdSuggestion,
DashboardRunHistoryItem,
EffortLevel,
PermissionMode,
RunHandle,
RunListResponse,
RunMode,
} from "../lib/api";
import type {
Session,
TranscriptMessage,
TranscriptContent,
Lane,
LaneFeature,
LaneCounts,
ProofFeature,
WSMessage,
} from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { useRunStream } from "../hooks/useRunStream";
import { BUILTIN_SLASH_COMMANDS, RunConsole } from "../components/run/RunConsole";
import type { SlashCommand } from "../components/run/RunConsole";
import { RunSetup } from "../components/run/RunSetup";
import { ActiveRunsSwitcher } from "../components/run/RunHistory";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard from "../components/lanes/LaneCard";
import LaneStripCard from "../components/lanes/LaneStripCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import type { ContentBlock, Envelope, UserMessage } from "../hooks/useRunStream";
// Convert past-session transcript messages into envelope shapes so the chat
// view can render the prior conversation alongside live output from the
// resumed run. The shapes are close but not identical (`thinking.text` vs
// `thinking.thinking`, tool_result `id`/`output` vs `tool_use_id`/`content`),
// so each block is mapped individually.
function transcriptToEnvelopes(messages: TranscriptMessage[]): Envelope[] {
const mapBlock = (b: TranscriptContent): ContentBlock | null => {
if (b.type === "text") return { type: "text", text: b.text || "" };
if (b.type === "thinking") return { type: "thinking", thinking: b.text || "" };
if (b.type === "tool_use") {
return { type: "tool_use", id: b.id || "", name: b.name || "", input: b.input };
}
if (b.type === "tool_result") {
return {
type: "tool_result",
tool_use_id: b.id || "",
content: b.output || "",
is_error: !!b.is_error,
};
}
return null;
};
const out: Envelope[] = [];
// Prepend a synthetic system/init envelope carrying the model so the
// context-window heuristic in computeTokens can size the meter correctly
// (e.g., [1m] tag → 1M cap) even when no live `system` envelope has
// arrived yet because the run was loaded from history.
const firstModel = messages.find((m) => m.type === "assistant" && m.model)?.model;
if (firstModel) {
out.push({ type: "system", subtype: "init", model: firstModel } as Envelope);
}
for (const m of messages) {
const content = m.content.map(mapBlock).filter((x): x is ContentBlock => x !== null);
if (content.length === 0) continue;
if (m.type === "assistant") {
out.push({ type: "assistant", message: { content, usage: m.usage } });
} else {
out.push({ type: "user", message: { content } });
}
}
return out;
}
// ── Page ──────────────────────────────────────────────────────────────
export function Workspace() {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
const [searchParams, setSearchParams] = useSearchParams();
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
// Lane state
const [lanes, setLanes] = useState<Lane[]>([]);
const [counts, setCounts] = useState<LaneCounts>({ total: 0, running: 0, needs_you: 0, dead: 0 });
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
const [laneActionError, setLaneActionError] = useState<string | null>(null);
const [addLaneOpen, setAddLaneOpen] = useState(false);
const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
const [features, setFeatures] = useState<LaneFeature[]>([]);
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
// Run state
const [mode, setMode] = useState<RunMode>("conversation");
const [prompt, setPrompt] = useState("");
const [model, setModel] = useState("");
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
const [effort, setEffort] = useState<EffortLevel>("");
const [cwd, setCwd] = useState("");
const [resumeSession, setResumeSession] = useState<Session | null>(null);
const [handle, setHandle] = useState<RunHandle | null>(null);
// Live stream state (envelopes + typewriter view) and the WS subscription
// live in useRunStream; the page keeps the handle and the run-list refresh.
// `onAnyStatus` fires for every run_status regardless of id - that is what
// the old inline subscription did with refreshList().
const { setEnvelopes, displayEnvelopes } = useRunStream(handle?.id ?? null, {
onStatus: (p) =>
setHandle((h) =>
h
? {
...h,
status: p.status,
endedAt: p.at,
exitCode: p.exitCode ?? h.exitCode,
sessionId: p.sessionId ?? h.sessionId,
error: p.error ?? h.error,
}
: h
),
onInputAck: () => {
// Optimistically add the user envelope so the chat shows it
// immediately (the spawned `claude` won't echo our user input
// back on stdout in stream-json; we own that side).
setEnvelopes((prev) => [
...prev,
{ type: "user", message: { content: followUpRef.current || "" } } as UserMessage,
]);
},
onAnyStatus: () => refreshList(),
});
const [followUp, setFollowUp] = useState("");
const [busy, setBusy] = useState<"start" | "send" | "stop" | "attach" | null>(null);
const [error, setError] = useState<string | null>(null);
const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>(
null
);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>(BUILTIN_SLASH_COMMANDS);
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
const refreshLanes = useCallback(async () => {
try {
const r = await api.lanes.list();
setLanes(r.lanes);
setCounts(r.counts);
setSelectedLaneId((cur) =>
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
);
} catch {
// Silent fail for lanes load
}
}, []);
useEffect(() => {
api.run
.binary()
.then(setBinaryStatus)
.catch(() => setBinaryStatus({ found: false, path: null }));
api.run
.list()
.then(setActiveRuns)
.catch(() => undefined);
api.run
.history(50)
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
void refreshLanes();
api.run
.cwds()
.then((r) => {
setCwdSuggestions(r.items);
// Pre-fill cwd with the user's home directory — a neutral default.
// Spawning in the dashboard's own cwd would make ad-hoc runs inherit
// this repo's project context (.claude/agents, skills, rules,
// CLAUDE.md, .mcp.json), which is almost never what an ad-hoc run
// wants and can bloat the initial request (issue #202). Fall back to
// the dashboard cwd when no home suggestion exists. The user can
// change it; we just don't want an invisible default.
const home = r.items.find((s) => s.kind === "home");
const dashboard = r.items.find((s) => s.kind === "dashboard");
const preferred = home || dashboard;
if (preferred) {
setCwd((current) => current || preferred.path);
}
})
.catch(() => undefined);
// Discover user / project / plugin slash commands. The CLI's built-ins
// are appended client-side.
Promise.all([api.ccConfig.commands(), api.ccConfig.plugins()])
.then(([cmdsResp, pluginsResp]) => {
const userProject = cmdsResp.items.map<SlashCommand>((c) => ({
name: c.name,
description: (c.frontmatter?.description as string | undefined) || c.preview.slice(0, 80),
source: c.scope === "project" ? "project" : "user",
filePath: c.file,
}));
const pluginCmds: SlashCommand[] = [];
for (const p of pluginsResp.plugins || []) {
const cmds = p.contributes?.commands ?? 0;
if (!cmds || !p.installPath) continue;
}
setSlashCommands([...userProject, ...pluginCmds, ...BUILTIN_SLASH_COMMANDS]);
})
.catch(() => undefined);
// Subscribe to lane updates from the event bus
return eventBus.subscribe((msg: WSMessage) => {
if (msg.type !== "lane_update") return;
const payload = msg.data as { lane?: Lane; removed?: number };
if (payload.removed !== undefined) {
void refreshLanes();
return;
}
const lane = payload.lane;
if (!lane) return;
setLanes((cur) => {
const i = cur.findIndex((l) => l.id === lane.id);
if (i === -1) {
void refreshLanes();
return cur;
}
const next = [...cur];
next[i] = lane;
return next;
});
});
}, [refreshLanes]);
// A reconnect (e.g. the dashboard server restarting) resumes the WS but does
// not replay missed lane_update diffs, so a lane whose fields changed while
// disconnected — status, needs_action — would keep showing its pre-restart
// badges forever with no further server-side change to broadcast. Refetch
// the full list whenever the socket comes back up.
useEffect(
() =>
eventBus.onConnection((isConnected) => {
if (isConnected) void refreshLanes();
}),
[refreshLanes]
);
const refreshList = useCallback(() => {
api.run
.list()
.then(setActiveRuns)
.catch(() => undefined);
// Fetch history for the selected lane only
if (selectedLaneId !== null) {
api.run
.history(50, { laneId: selectedLaneId })
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
} else {
api.run
.history(50)
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
}
}, [selectedLaneId]);
// Background poll so the run list and history reflect external changes
// (server-boot reconciliation, sibling tabs, direct DB edits) even when
// no WS event fires. Lighter than typical WS gaps; aggressive enough that
// status flips appear within seconds without needing a manual refresh.
useEffect(() => {
const tick = setInterval(() => {
refreshList();
}, 5000);
return () => clearInterval(tick);
}, [refreshList]);
// Refresh whenever the tab regains focus / visibility - typical when the
// user comes back from running `claude` in a terminal and wants to see the
// current state of every run without waiting for the next poll.
useEffect(() => {
const onFocus = () => refreshList();
const onVis = () => {
if (document.visibilityState === "visible") refreshList();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVis);
return () => {
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVis);
};
}, [refreshList]);
// Resume a run from the persistent history list. Routes through the lane
// system: ensure a lane for the cwd, then start a new claude process
// in resumeSessionId mode. If cwd is null, resume as a non-lane run
// (non-lane runs are those created before this feature or via the CLI).
const onResumeFromHistory = useCallback(
async (item: DashboardRunHistoryItem) => {
if (!item.session_id) return;
if (busy) return;
setBusy("start");
setError(null);
try {
const transcript = await api.sessions
.transcript(item.session_id, { limit: 200 })
.catch(() => ({ messages: [] as TranscriptMessage[] }));
let fetched: RunHandle;
if (item.cwd) {
// Resume through a lane: ensure the lane exists, then start on it
const effectiveCwd = item.cwd;
let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd)?.id;
if (!targetLaneId) {
// Ensure lane for this cwd
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
targetLaneId = ensureResult.lane.id;
setLanes((prev) => {
const exists = prev.some((l) => l.id === ensureResult.lane.id);
return exists ? prev : [...prev, ensureResult.lane];
});
}
// Start on the lane with resume
const laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: "",
mode: "conversation",
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, { envelopes: true });
} else {
// No cwd: resume as a non-lane run (backward compatibility).
// These runs stay outside the lane system and are cleaned up
// by their own expiry, not by lane release.
fetched = await api.run.start({
prompt: "",
mode: "conversation",
cwd: undefined,
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
}
setHandle(fetched);
setEnvelopes(transcriptToEnvelopes(transcript.messages));
setFollowUp("");
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]
);
// View a past run inline (no spawn). Headless runs are single-shot, so
// there's no resume - but the transcript is still worth seeing without
// navigating away. Seeds the chat view with the past messages and a
// synthetic completed handle so the UI renders as read-only (no Stop
// button, no follow-up input - both are gated on isLive).
const onViewFromHistory = useCallback(
async (item: DashboardRunHistoryItem) => {
if (!item.session_id) return;
if (busy) return;
setError(null);
try {
const transcript = await api.sessions.transcript(item.session_id, { limit: 200 });
const synthetic: RunHandle = {
id: item.id,
pid: null,
mode: item.mode,
cwd: item.cwd,
model: item.model,
permissionMode: item.permission_mode || "acceptEdits",
effort: item.effort,
prompt: item.prompt_preview || "",
argv: [],
resumeSessionId: item.resume_session_id,
status: item.status,
startedAt: new Date(item.started_at).getTime(),
endedAt: item.ended_at ? new Date(item.ended_at).getTime() : null,
exitCode: item.exit_code,
signal: null,
error: null,
sessionId: item.session_id,
envelopeCount: transcript.messages.length,
stdoutTail: "",
stderrTail: "",
};
setHandle(synthetic);
setEnvelopes(transcriptToEnvelopes(transcript.messages));
setMode(item.mode);
setFollowUp("");
setResumeSession(null);
} catch (err) {
const msg = err instanceof Error ? err.message : "unknown";
setError(t("errors.attachFailed", { message: msg }));
}
},
[busy, t]
);
// Keep latest follow-up in a ref so the WS handler can read it without
// closure staleness during ack injection.
const followUpRef = useRef("");
useEffect(() => {
followUpRef.current = followUp;
}, [followUp]);
const start = useCallback(async () => {
if (!prompt.trim() || busy) return;
setBusy("start");
setError(null);
setEnvelopes([]);
try {
// Resume always uses conversation mode (server enforces this too).
const effectiveMode: RunMode = resumeSession ? "conversation" : mode;
const effectiveCwd = resumeSession?.cwd || cwd || undefined;
// Expand /user-or-project slash commands client-side so the model
// receives the rendered template, matching what the CLI does.
const expandedPrompt = await maybeExpandSlashCommand(prompt, slashCommands);
// Determine which lane to use. If no lane is selected or the cwd
// doesn't belong to the selected lane, ensure a lane for this cwd first.
if (!effectiveCwd) {
throw new Error(t("errors.cwdRequired"));
}
let targetLaneId = selectedLaneId;
if (
targetLaneId === null ||
(selectedLaneId !== null &&
lanes.find((l) => l.id === selectedLaneId)?.cwd !== effectiveCwd)
) {
// Check if any existing lane owns this cwd
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
if (ownedLane) {
targetLaneId = ownedLane.id;
setSelectedLaneId(ownedLane.id);
} else {
// Ensure a new lane for this cwd
try {
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
targetLaneId = ensureResult.lane.id;
setSelectedLaneId(ensureResult.lane.id);
setLanes((prev) => {
const exists = prev.some((l) => l.id === ensureResult.lane.id);
return exists ? prev : [...prev, ensureResult.lane];
});
} catch (err) {
throw new Error(
t("errors.laneCreateFailed", {
message: err instanceof Error ? err.message : "unknown",
})
);
}
}
}
if (targetLaneId === null) {
throw new Error(t("errors.noLaneSelected"));
}
// Start the run on the target lane
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: expandedPrompt,
mode: effectiveMode,
model: model || undefined,
permissionMode,
resumeSessionId: resumeSession?.id,
effort: effort || undefined,
});
} catch (laneErr: unknown) {
// Check for 409 ERUNLIVE — the lane already has a live run
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
// Re-fetch the lane to get its current run_id. Read the run id off
// the response, not off `lanes` - refreshLanes() only schedules a
// setState, so the render-scope `lanes` array here is still the
// pre-409 snapshot and would never carry the live run.
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
await refreshLanes();
if (updatedLane?.run_id) {
// Attach to the already-running run
await attachToRun(updatedLane.run_id);
return;
}
}
throw laneErr;
}
// The response is { lane? } per the API. Read the run_id from the lane.
if (!laneStartResult.lane?.run_id) {
throw new Error(t("errors.noRunIdReturned"));
}
// Fetch the full RunHandle for the new run; fall back to attachToRun if fetch fails
try {
const handle = await api.run.get(laneStartResult.lane.run_id, { envelopes: true });
setHandle(handle);
// Optimistic user-turn injection so the chat shows your prompt right away.
setEnvelopes([{ type: "user", message: { content: prompt } } as UserMessage]);
refreshList();
} catch (attachErr: unknown) {
// Run started but we can't fetch the handle. Attach to the run via the existing path.
try {
await attachToRun(laneStartResult.lane.run_id);
refreshList();
} catch (fallbackErr: unknown) {
// Even attach failed. Refresh lanes and report the attach error.
await refreshLanes();
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);
}
}, [
prompt,
mode,
cwd,
model,
permissionMode,
busy,
refreshList,
t,
resumeSession,
selectedLaneId,
lanes,
]);
const attachToRun = useCallback(
async (id: string) => {
if (busy) return;
setBusy("attach");
setError(null);
try {
const fetched = await api.run.get(id, { envelopes: true });
const spawnerEnvs = ((fetched.envelopes as Envelope[]) || []).slice();
let envelopesToUse = spawnerEnvs;
// The spawner's in-memory envelope log only contains envelopes that
// came over stdout for this specific spawn. For a resumed run, that
// means prior history is missing - claude --resume reads the prior
// transcript as context but doesn't replay it on stdout. Without
// this, re-attaching to a resumed run after navigating away loses
// everything from before the resume. The session's JSONL transcript
// on disk has the full story (prior + current), so we use it
// whenever it has more user/assistant messages than the spawner has
// seen; otherwise we keep the spawner's log (which is authoritative
// for in-progress streaming since stream_event deltas don't land in
// the transcript file until the turn finishes).
if (fetched.sessionId) {
try {
const transcript = await api.sessions.transcript(fetched.sessionId, { limit: 200 });
const transcriptEnvs = transcriptToEnvelopes(transcript.messages);
const spawnerCanonicalCount = spawnerEnvs.filter((e) => {
const t = (e as { type?: string }).type;
return t === "user" || t === "assistant";
}).length;
if (transcriptEnvs.length > spawnerCanonicalCount) {
envelopesToUse = transcriptEnvs;
}
} catch {
/* transcript fetch failed - keep the spawner's log */
}
}
setHandle(fetched);
setEnvelopes(envelopesToUse);
setFollowUp("");
} catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.attachFailed", { message: m }));
} finally {
setBusy(null);
}
},
[busy, t]
);
// Honor `?session=<id>` deep-links from /sessions and /sessions/:id -
// map the session id to a live run handle and attach to it instead of
// dropping the user on the new-run config card. Strip the param once
// consumed so a refresh of the Run page doesn't keep re-attaching.
const attachAttemptedRef = useRef<Set<string>>(new Set());
useEffect(() => {
const sid = searchParams.get("session");
if (!sid) return;
if (handle && handle.sessionId === sid) {
// Already attached to this session - just clean the URL.
const next = new URLSearchParams(searchParams);
next.delete("session");
setSearchParams(next, { replace: true });
return;
}
if (attachAttemptedRef.current.has(sid)) return;
attachAttemptedRef.current.add(sid);
api.run
.list()
.then((list) => {
const target = list.items.find(
(h) => h.sessionId === sid && (h.status === "running" || h.status === "spawning")
);
if (target) {
void attachToRun(target.id);
} else {
setError(
t(
"errors.sessionRunNotFound",
"No active dashboard run is driving this session right now."
)
);
}
})
.catch(() => undefined)
.finally(() => {
const next = new URLSearchParams(searchParams);
next.delete("session");
setSearchParams(next, { replace: true });
});
}, [searchParams, setSearchParams, handle, attachToRun, t]);
// Prefill the prompt box from `?prompt=<text>` (e.g. Tabby's Ask handoff).
// Apply once, then strip the param so a later refresh doesn't overwrite edits
// the user has since made to the prompt. When `?autostart=1` is also present
// (Tabby's "ask" path), arm a pending flag so the run fires automatically
// once preflight is ready - see the autostart effect below.
const promptPrefilledRef = useRef(false);
const pendingAutostartRef = useRef(false);
useEffect(() => {
if (promptPrefilledRef.current) return;
const p = searchParams.get("prompt");
if (!p) return;
promptPrefilledRef.current = true;
if (searchParams.get("autostart") === "1") pendingAutostartRef.current = true;
setPrompt(p);
const next = new URLSearchParams(searchParams);
next.delete("prompt");
next.delete("autostart");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
// Autostart a deep-linked prompt once preflight has settled. We wait for the
// binary probe (can't spawn without `claude`), the prefilled prompt, and the
// defaulted cwd so the spawn matches exactly what the manual Start button
// would do. Fires at most once; if `claude` isn't found or a run is already
// in flight, it disarms and leaves the prompt prefilled for a manual Start.
useEffect(() => {
if (!pendingAutostartRef.current) return;
if (binaryStatus === null) return; // probe still pending
if (!binaryStatus.found) {
pendingAutostartRef.current = false;
return;
}
if (busy || handle) {
pendingAutostartRef.current = false;
return;
}
if (!prompt.trim() || !cwd) return; // wait for prefill + cwd default
pendingAutostartRef.current = false;
void start();
}, [binaryStatus, prompt, cwd, busy, handle, start]);
const send = useCallback(async () => {
if (!handle || !followUp.trim() || busy) return;
setBusy("send");
setError(null);
try {
const expanded = await maybeExpandSlashCommand(followUp, slashCommands);
await api.run.send(handle.id, expanded);
// The user envelope is appended optimistically when the WS ack arrives
// (so deduping is consistent with stream order). Clear the input now.
setFollowUp("");
} catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.sendFailed", { message: m }));
} finally {
setBusy(null);
}
}, [handle, followUp, busy, t, slashCommands]);
const stop = useCallback(async () => {
if (!handle || busy) return;
setBusy("stop");
setError(null);
try {
await api.run.kill(handle.id);
} catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.killFailed", { message: m }));
} finally {
setBusy(null);
}
}, [handle, busy, t]);
const newRun = useCallback(() => {
setHandle(null);
setEnvelopes([]);
setFollowUp("");
setPrompt("");
setResumeSession(null);
setError(null);
}, []);
const status = handle?.status ?? "idle";
const isLive = status === "spawning" || status === "running";
const hasFinished = status === "completed" || status === "error" || status === "killed";
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
// Feature list follows the selected lane, resets the viewer on lane switch.
useEffect(() => {
setViewedFeatureSlug(null);
setViewedFeature(null);
if (currentLane === null || currentLane === undefined) {
setFeatures([]);
return;
}
api.lanes.features
.list(currentLane.id)
.then((data) => setFeatures(data.features))
.catch(() => setFeatures([]));
}, [currentLane?.id]);
// Fetch the archived snapshot when the picker selects one — read-only, never
// touches the live lane.
useEffect(() => {
if (!currentLane || !viewedFeatureSlug) {
setViewedFeature(null);
return;
}
let cancelled = false;
api.lanes.features
.show(currentLane.id, viewedFeatureSlug)
.then((data) => {
if (!cancelled) setViewedFeature(data.feature);
})
.catch(() => {
if (!cancelled) setViewedFeature(null);
});
return () => {
cancelled = true;
};
}, [currentLane?.id, viewedFeatureSlug]);
// Proof gallery follows the selected lane.
useEffect(() => {
if (!currentLane) {
setProofFeatures([]);
return;
}
api.lanes.proof
.list(currentLane.id)
.then((data) => setProofFeatures(data.features))
.catch(() => setProofFeatures([]));
}, [currentLane?.id, viewedFeatureSlug]);
// Resolve the active feature slug: either the user-selected one, or the lane's active_feature_id
const activeFeatureSlug =
viewedFeatureSlug ??
(currentLane?.active_feature_id
? features.find((f) => f.id === currentLane?.active_feature_id)?.slug
: null);
const proofFeature = activeFeatureSlug
? proofFeatures.find((f) => f.slug === activeFeatureSlug)
: null;
// Only lock the page to the viewport when we're showing a live run session.
// The config-card screen needs normal page flow so the form is fully
// reachable on short windows. The run-session screen, however, owns the
// chat panel and we want long chats to scroll inside the panel - never the
// page - so we constrain only that case.
// Only a live console needs the viewport-locked shell that lets its chat
// panel scroll internally. Collapsed, the page is an ordinary scrolling
// document and the lane grid gets the whole height.
const viewportLocked = !!handle;
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
setLaneActionError(null);
try {
if (action === "reset" || action === "remove" || action === "purge") {
await api.lanes.action(id, action, {
confirm: true,
expect: body?.expect,
...(body?.force === true ? { force: true } : {}),
});
} else {
await api.lanes.action(id, action, body);
}
if (action === "remove") await refreshLanes();
} catch (err) {
setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
}
};
const consoleSection = (
<>
{/* Always attached under the pipeline - no header, no collapse. The
pipeline panel above already names the lane; unmounting RunConsole
would throw away a live run's rendered history and scroll
position, so this stays mounted for the page's whole life. */}
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
<Header
activeRuns={activeRuns}
currentHandleId={handle?.id || null}
onAttach={attachToRun}
wsConnected={wsConnected}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onRefresh={refreshList}
/>
{binaryStatus && !binaryStatus.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")}</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>
<button
onClick={() => setError(null)}
className="text-status-danger/70 hover:text-status-danger p-0.5"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{!handle ? (
// Config card uses normal page flow - page scrolls if needed.
<RunSetup
mode={mode}
onModeChange={(m) => {
setMode(m);
// Headless can't resume - clearing keeps the UI honest if the
// user had a session pinned and then switched mode.
if (m === "headless") setResumeSession(null);
}}
prompt={prompt}
onPromptChange={setPrompt}
cwd={cwd}
onCwdChange={setCwd}
cwdSuggestions={cwdSuggestions}
model={model}
onModelChange={setModel}
permissionMode={permissionMode}
onPermissionModeChange={setPermissionMode}
effort={effort}
onEffortChange={setEffort}
binaryFound={binaryStatus?.found ?? true}
busy={busy === "start"}
onStart={start}
activeRuns={activeRuns}
laneCwd={currentLane?.cwd}
resumeSession={resumeSession}
onResumeSessionChange={setResumeSession}
slashCommands={slashCommands}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
/>
) : (
// Run session is wrapped in a flex container so its inner chat panel
// can take all remaining viewport height; long chats scroll inside.
<div className="flex-1 min-h-0 flex flex-col">
<RunConsole
handle={handle}
envelopes={displayEnvelopes}
mode={handle.mode}
isLive={isLive}
hasFinished={hasFinished}
followUp={followUp}
onFollowUpChange={setFollowUp}
busy={busy}
onSend={send}
onStop={stop}
onNewRun={newRun}
slashCommands={slashCommands}
/>
</div>
)}
</div>
</>
);
return (
<div
className={
viewportLocked
? "h-[calc(100vh-2.5rem)] lg:h-[calc(100vh-3rem)] flex flex-col gap-5"
: "space-y-5"
}
>
{/* Page header: what this screen is, and the four numbers that say
whether anything needs a human right now. */}
<div className="flex flex-wrap items-center gap-3 border-b border-border pb-3">
<h2 className="text-base font-semibold tracking-tight text-fg-primary">
{tLanes("title")}
</h2>
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<span
data-testid="count-total"
className="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
>
{counts.total} {tLanes("countTotal")}
</span>
<span
data-testid="count-running"
className="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
>
{counts.running} {tLanes("countRunning")}
</span>
{counts.needs_you > 0 && (
<span
data-testid="count-needs-you"
className="rounded-full bg-status-warning/20 px-2.5 py-0.5 text-status-warning"
>
{counts.needs_you} {tLanes("countNeedsYou")}
</span>
)}
{counts.dead > 0 && (
<span
data-testid="count-dead"
className="rounded-full bg-status-danger/20 px-2.5 py-0.5 text-status-danger"
>
{counts.dead} {tLanes("countDead")}
</span>
)}
</div>
<button
onClick={() => setAddLaneOpen(true)}
className="ml-auto flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
title={tLanes("addLane")}
>
<Plus className="h-3.5 w-3.5" />
{tLanes("add")}
</button>
</div>
{/* Lane carousel: pick a lane here, read it below. Horizontal scroll with
snap so a dozen lanes stay in one row instead of a wall of cards. */}
<div
data-testid="lane-strip"
className="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1"
>
{lanes.map((l) => (
<LaneStripCard
key={l.id}
lane={l}
selected={selectedLaneId === l.id}
onSelect={() => setSelectedLaneId(l.id)}
/>
))}
{!lanes.length && (
<p className="text-sm text-fg-muted">
{tLanes("emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
</p>
)}
</div>
{/* The selected lane's pipeline, full width — the thing you actually
come to this page to read. */}
{currentLane && (
<section data-testid="lane-detail" className="card p-4">
<div className="mb-3 flex flex-wrap items-baseline gap-2">
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
{tLanes("cardId", { id: currentLane.id })}
</span>
<span className="truncate text-sm font-semibold text-fg-primary">
{currentLane.title || currentLane.cwd}
</span>
<span className="text-[11px] text-fg-muted">{currentLane.pipeline_name}</span>
{/* `stage` defaults to the DB sentinel "idle" until the driving
session ever calls `ccam stage` — that string collides with
`status`'s own "idle"/"running" vocabulary, so a lane that is
actively running but has never declared a stage read as if it
were sitting idle. Show a distinct label instead of the raw
sentinel whenever it doesn't match any node this pipeline
actually has. */}
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
? currentLane.stage
: tLanes("stageUndeclared")}
</span>
{currentLane.detected_stage && (
<span
data-testid="detail-auto-stage"
title={currentLane.detected_signal || undefined}
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
>
{tLanes("autoStage", { stage: currentLane.detected_stage })}
</span>
)}
{features.length > 0 && (
<select
data-testid="feature-picker"
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
value={viewedFeatureSlug ?? ""}
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
>
<option value="">{tLanes("features.live")}</option>
{features.map((f) => (
<option key={f.slug} value={f.slug}>
{f.slug}
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
</option>
))}
</select>
)}
</div>
<div className="mb-3">
<LaneCard
lane={currentLane}
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
/>
</div>
<div className="mb-3">
<PipelineMap
nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
/>
{viewedFeature && (
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
</p>
)}
</div>
{proofFeature &&
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
<div data-testid="proof-gallery" className="mt-2">
{proofFeature.ticket_report && (
<a
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
target="_blank"
rel="noreferrer"
className="text-xs text-fg-muted"
>
{tLanes("proof.ticketReport")}
</a>
)}
{Object.entries(proofFeature.groups).map(([group, images]) => (
<div key={group} className="mt-1">
<span className="text-xs text-fg-muted">
{group} · {images.length}
</span>
<div className="flex flex-wrap gap-1">
{images.slice(0, 8).map((img) => (
<img
key={img}
loading="lazy"
className="h-16 w-16 rounded object-cover"
src={api.lanes.proof.imageUrl(
currentLane.id,
proofFeature.slug,
group,
img
)}
alt={img}
/>
))}
{images.length > 8 && (
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
)}
</div>
</div>
))}
</div>
)}
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
{consoleSection}
</div>
</section>
)}
<AddLaneModal
open={addLaneOpen}
cwdSuggestions={cwdSuggestions}
onClose={() => setAddLaneOpen(false)}
onAdded={(lane) => {
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]));
setSelectedLaneId(lane.id);
void refreshLanes();
}}
/>
{laneActionError && (
<p
role="alert"
className="rounded border border-status-danger bg-status-danger/40 px-3 py-2 text-sm text-status-danger"
>
{tLanes("actionError", { message: laneActionError })}
</p>
)}
{/* No lane selected (none exist, or nothing picked yet): the console has
nowhere to attach, so it falls back to page level. Without this the
start form would be unreachable on a fresh install. */}
{!currentLane && <div className="flex min-h-0 flex-col gap-2">{consoleSection}</div>}
</div>
);
}
/**
* Expand a user/project/plugin slash command client-side. Reads the command
* markdown body via /api/cc-config/file, strips frontmatter, and substitutes
* `$ARGUMENTS` with whatever the user typed after the command name. If the
* command isn't user-defined (built-in or unknown), returns the original
* text unchanged so it still gets sent (the model will see it as text).
*/
async function maybeExpandSlashCommand(text: string, commands: SlashCommand[]): Promise<string> {
const trimmed = text.trimStart();
if (!trimmed.startsWith("/")) return text;
const m = trimmed.match(/^\/([\w:-]+)(?:\s+([\s\S]*))?$/);
if (!m) return text;
const [, name, args = ""] = m;
const cmd = commands.find((c) => c.name === name);
if (!cmd || cmd.source === "builtin" || !cmd.filePath) return text;
try {
const body = await api.ccConfig.file(cmd.filePath);
let content = body.text;
// Strip frontmatter if present
if (content.startsWith("---")) {
const end = content.indexOf("\n---", 3);
if (end >= 0) content = content.slice(end + 4).replace(/^\s*\n/, "");
}
return content.replace(/\$ARGUMENTS/g, args);
} catch {
return text;
}
}
// ── Header ────────────────────────────────────────────────────────────
function Header({
activeRuns,
currentHandleId,
onAttach,
wsConnected,
runHistory,
onResumeFromHistory,
onViewFromHistory,
onRefresh,
}: {
activeRuns: RunListResponse | null;
currentHandleId: string | null;
onAttach: (id: string) => void;
wsConnected: boolean;
runHistory: DashboardRunHistoryItem[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
onRefresh: () => void;
}) {
const { t } = useTranslation("run");
const { t: tCommon } = useTranslation("common");
return (
<header className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
<Play className="w-4.5 h-4.5 text-accent" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</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="text-xs text-fg-muted max-w-3xl">{t("subtitle")}</p>
</div>
<ActiveRunsSwitcher
activeRuns={activeRuns}
currentHandleId={currentHandleId}
onAttach={onAttach}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onRefresh={onRefresh}
/>
</header>
);
}