/** * @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, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { Plus } from "lucide-react"; import { api } from "../lib/api"; import type { CwdSuggestion, RunListResponse } from "../lib/api"; import type { Lane, LaneFeature, LaneCounts, ProofFeature, WSMessage } from "../lib/types"; import { eventBus } from "../lib/eventBus"; import { LaneConsolePane } from "../components/run/LaneConsolePane"; import PipelineMap from "../components/lanes/PipelineMap"; import LaneCard from "../components/lanes/LaneCard"; import LaneStripCard from "../components/lanes/LaneStripCard"; import { AddLaneModal } from "../components/lanes/AddLaneModal"; // ── Page ────────────────────────────────────────────────────────────── export function Workspace() { const { t: tLanes } = useTranslation("lanes"); 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 [pipelineTemplates, setPipelineTemplates] = useState< { id: string; name: string; nodes: { id: string }[] }[] >([]); const [viewedFeature, setViewedFeature] = useState(null); const [proofFeatures, setProofFeatures] = useState([]); // Run state kept at page level: shared across every pane, or drives the // lane strip itself rather than any one pane's form. const [activeRuns, setActiveRuns] = useState(null); const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>( null ); const [cwdSuggestions, setCwdSuggestions] = useState([]); const [defaultCwd, setDefaultCwd] = useState(""); const [paneHasActiveRun, setPaneHasActiveRun] = useState(false); // 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) // Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof // `claude` is missing from PATH — leave the probe unresolved rather than // showing a misleading "claude missing" banner for an unrelated fault. .catch(() => undefined); api.run .list() .then(setActiveRuns) .catch(() => undefined); api.lanes .pipelines() .then((r) => setPipelineTemplates(r.pipelines)) .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) { setDefaultCwd(preferred.path); } }) .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); }, []); // 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]); 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; // Viewport locked only when a live run is showing (TerminalView needs locked // viewport for chat scrolling). The config-card screen needs normal page flow // so the form is fully reachable on short windows. const viewportLocked = paneHasActiveRun; 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")); } }; // Mirrors `ccam lanes pipeline