/** * @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ĩ */ /* ============================================================================= * 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([]); const [counts, setCounts] = useState({ total: 0, running: 0, needs_you: 0, dead: 0 }); const [selectedLaneId, setSelectedLaneId] = useState(null); const [laneActionError, setLaneActionError] = useState(null); const [addLaneOpen, setAddLaneOpen] = useState(false); const [viewedFeatureSlug, setViewedFeatureSlug] = useState(null); const [features, setFeatures] = useState([]); const [viewedFeature, setViewedFeature] = useState(null); const [proofFeatures, setProofFeatures] = useState([]); // Run state const [mode, setMode] = useState("conversation"); const [prompt, setPrompt] = useState(""); const [model, setModel] = useState(""); const [permissionMode, setPermissionMode] = useState("acceptEdits"); const [effort, setEffort] = useState(""); const [cwd, setCwd] = useState(""); const [resumeSession, setResumeSession] = useState(null); const [handle, setHandle] = useState(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(null); const [activeRuns, setActiveRuns] = useState(null); const [runHistory, setRunHistory] = useState([]); const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>( null ); const [cwdSuggestions, setCwdSuggestions] = useState([]); const [slashCommands, setSlashCommands] = useState(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((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=` 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>(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=` (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) => { 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. */}
{binaryStatus && !binaryStatus.found && (
{t("binary.missing")}
)} {error && (
{error}
)} {!handle ? ( // Config card uses normal page flow - page scrolls if needed. { 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.
)}
); return (
{/* Page header: what this screen is, and the four numbers that say whether anything needs a human right now. */}

{tLanes("title")}

{counts.total} {tLanes("countTotal")} {counts.running} {tLanes("countRunning")} {counts.needs_you > 0 && ( {counts.needs_you} {tLanes("countNeedsYou")} )} {counts.dead > 0 && ( {counts.dead} {tLanes("countDead")} )}
{/* 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. */}
{lanes.map((l) => ( setSelectedLaneId(l.id)} /> ))} {!lanes.length && (

{tLanes("emptyState")} ccam lanes add --cwd $(pwd)

)}
{/* The selected lane's pipeline, full width — the thing you actually come to this page to read. */} {currentLane && (
{tLanes("cardId", { id: currentLane.id })} {currentLane.title || currentLane.cwd} {currentLane.pipeline_name} {/* `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. */} {currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage) ? currentLane.stage : tLanes("stageUndeclared")} {currentLane.detected_stage && ( {tLanes("autoStage", { stage: currentLane.detected_stage })} )} {features.length > 0 && ( )}
handleLaneAction(currentLane.id, a, b)} />
{viewedFeature && (

{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}

)}
{proofFeature && (Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
{proofFeature.ticket_report && ( {tLanes("proof.ticketReport")} )} {Object.entries(proofFeature.groups).map(([group, images]) => (
{group} · {images.length}
{images.slice(0, 8).map((img) => ( {img} ))} {images.length > 8 && ( +{images.length - 8} )}
))}
)}
{consoleSection}
)} setAddLaneOpen(false)} onAdded={(lane) => { setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane])); setSelectedLaneId(lane.id); void refreshLanes(); }} /> {laneActionError && (

{tLanes("actionError", { message: laneActionError })}

)} {/* 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 &&
{consoleSection}
}
); } /** * 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 { 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 (

{t("title")}

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

{t("subtitle")}

); }