/** * @file Workspace.tsx * @description Merged workspace page combining lanes (agent work units) and runs * (CLI spawns: Claude Code or Codex, determined per lane). Displays a horizontal * lane strip at the top (its first tile creates a lane), 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 with tool-appropriate fields. When a cwd * isn't owned by any lane, calls POST /api/lanes/ensure first. Polls GET * /api/sessions?status=active alongside the run list so Claude Code sessions * started outside the dashboard also appear in the active-runs list. * * @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, useMemo, useState, useSyncExternalStore } from "react"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Plus, Square, Columns2, Grid2x2, Pencil, Layers, PanelLeftClose, PanelRightClose, Maximize2, } from "lucide-react"; import { api } from "../lib/api"; import type { CwdSuggestion, RunListResponse } from "../lib/api"; import type { Lane, LaneFeature, ProofFeature, Session, WSMessage } from "../lib/types"; import { eventBus } from "../lib/eventBus"; import { keepIfSame } from "../lib/keepIfSame"; import { readLaneOrder, writeLaneOrder, sortByStoredOrder, moveLane, } from "../lib/laneOrderStorage"; import { LaneConsolePane } from "../components/run/LaneConsolePane"; import PipelineMap from "../components/lanes/PipelineMap"; import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu, LaneRouterRow, } from "../components/lanes/LaneCard"; import LaneStripCard from "../components/lanes/LaneStripCard"; import { AddLaneModal } from "../components/lanes/AddLaneModal"; import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel"; import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal"; import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage"; import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage"; /** localStorage key for the Changes panel's open/closed state. */ const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen"; /** localStorage key for how wide the Changes panel is. */ const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize"; /** The three widths the Changes panel cycles through, then closed. Reading a * diff and watching a console want opposite amounts of room, and which one is * "work" changes several times an hour — hence ONE control that steps * 30/70 → 50/50 → full → closed, rather than a width button beside a * collapse button that both live in the panel's tightest corner. */ const CHANGES_SIZES = ["compact", "half", "full"] as const; type ChangesSize = (typeof CHANGES_SIZES)[number]; const CHANGES_WIDTH: Record = { compact: "w-[30%]", half: "w-1/2", // The console beside it is HIDDEN, not unmounted: a live run's terminal // and a half-typed prompt must survive a round trip through full width. full: "w-full", }; /** The icon the one resize control shows AT each size: what the next click * gives you — an even split, the full width, then closed. */ const CHANGES_NEXT_ICON: Record = { compact: Columns2, half: Maximize2, full: PanelRightClose, }; // ── Page ────────────────────────────────────────────────────────────── /** One pane, two side by side, four in a grid — the conventional icons for * each, so the toggle reads at a glance without spending three words on it. */ const LAYOUT_CHOICES = [ { n: 1 as const, Icon: Square }, { n: 2 as const, Icon: Columns2 }, { n: 4 as const, Icon: Grid2x2 }, ]; function SplitLayoutToggle({ splitView, setLayout, }: { splitView: SplitViewState; setLayout: (layout: SplitLayout) => void; }) { const { t: tLanes } = useTranslation("lanes"); return (
{LAYOUT_CHOICES.map(({ n, Icon }) => { // The count still reaches assistive tech and the tooltip — only the // rendered word is gone, and the icon says the same thing in the // shape every other app uses for it. const label = tLanes("splitView.paneCount", { count: n }); return ( ); })}
); } function ConsoleArea({ lanes, selectedLaneId, splitView, setLayout, binaryStatus, cwdSuggestions, activeRuns, externalSessions, wsConnected, defaultCwd, onHasActiveRunChange, onLaneCreated, onLaneIdChange, showToggle = true, }: { lanes: Lane[]; selectedLaneId: number | null; splitView: SplitViewState; setLayout: (layout: SplitLayout) => void; binaryStatus: Record | null; cwdSuggestions: CwdSuggestion[]; activeRuns: RunListResponse | null; externalSessions: Session[]; wsConnected: boolean; defaultCwd: string; onHasActiveRunChange: (val: boolean) => void; onLaneCreated: (lane: Lane) => void; onLaneIdChange: (id: number | null) => void; showToggle?: boolean; }) { // Every split pane shows the SELECTED lane, each in its own tmux session // (slot = pane index). Tracking what each pane shows lets the others leave // that run out of their Active runs, so no terminal is attached twice. const [paneRunIds, setPaneRunIds] = useState<(string | null)[]>([]); const onPaneHandle = useCallback((i: number, id: string | null) => { setPaneRunIds((prev) => { if (prev[i] === id) return prev; const next = [...prev]; next[i] = id; return next; }); }, []); const paneHandlers = useMemo( () => [0, 1, 2, 3].map((i) => (id: string | null) => onPaneHandle(i, id)), [onPaneHandle] ); const hiddenFor = useMemo( () => [0, 1, 2, 3].map((i) => paneRunIds.filter((id, j): id is string => j !== i && j < splitView.layout && !!id) ), [paneRunIds, splitView.layout] ); return ( <> {showToggle && } {splitView.layout === 1 ? ( ) : (
{Array.from({ length: splitView.layout }, (_, i) => ( ))}
)} ); } export function Workspace() { const { t: tLanes } = useTranslation("lanes"); const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); const [searchParams, setSearchParams] = useSearchParams(); // Lane state const [lanes, setLanes] = useState([]); const [selectedLaneId, setSelectedLaneId] = useState(null); const [laneActionError, setLaneActionError] = useState(null); const [addLaneOpen, setAddLaneOpen] = useState(false); // Fields the Add-lane modal opens with when it was opened from a lane's git // bar ("new lane here"): one provisioning path for every lane, prefilled. const [addLanePrefill, setAddLanePrefill] = useState<{ sourceRepo?: string; branch?: string; } | null>(null); 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([]); const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false); // Inline rename of the selected lane's title, in the detail header. const [renamingLaneId, setRenamingLaneId] = useState(null); const [renameDraft, setRenameDraft] = 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); // Claude Code sessions running outside the dashboard (a `claude` the user // started in a terminal tab). They are not dashboard runs, so the run list // would otherwise show nothing while two agents are working. const [externalSessions, setExternalSessions] = useState([]); const [binaryStatus, setBinaryStatus] = useState | null>(null); const [cwdSuggestions, setCwdSuggestions] = useState([]); const [defaultCwd, setDefaultCwd] = useState(""); const [paneHasActiveRun, setPaneHasActiveRun] = useState(false); // Split view state const [splitView, setSplitView] = useState(() => readSplitViewState()); // Info block (pickers, LaneCard, PipelineMap, proof gallery) starts shown in // single-pane view and collapsed in split view, but the toggle below can // override that in either mode — the user decides, not the layout. const [infoExpanded, setInfoExpanded] = useState(() => splitView.layout === 1); // Whether the Changes panel is open. A viewer preference, so it lives in // localStorage and never reaches the server — same rule as the lane strip's // order (client/src/lib/laneOrderStorage.ts). const [changesOpen, setChangesOpen] = useState(() => { try { return window.localStorage.getItem(CHANGES_OPEN_KEY) === "1"; } catch { return false; } }); useEffect(() => { try { window.localStorage.setItem(CHANGES_OPEN_KEY, changesOpen ? "1" : "0"); } catch { /* private mode / disabled storage: the panel just forgets between visits */ } }, [changesOpen]); const [changesSize, setChangesSize] = useState(() => { try { const saved = window.localStorage.getItem(CHANGES_SIZE_KEY) as ChangesSize | null; return saved && CHANGES_SIZES.includes(saved) ? saved : "compact"; } catch { return "compact"; } }); useEffect(() => { try { window.localStorage.setItem(CHANGES_SIZE_KEY, changesSize); } catch { /* same as above: a forgotten width, not an error */ } }, [changesSize]); const nextChangesSize = CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesSize) + 1) % CHANGES_SIZES.length]!; const changesNextLabel = changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1] ? tLanes("changes.collapse") : tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) }); const showInfo = infoExpanded; const setLayout = useCallback((layout: SplitLayout) => { setSplitView((prev) => { const paneLaneIds = Array.from({ length: layout }, (_, i) => prev.paneLaneIds[i] ?? null); const next = { layout, paneLaneIds }; writeSplitViewState(next); return next; }); }, []); // Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount const refreshLanes = useCallback(async () => { try { const r = await api.lanes.list(); // The `lanes` state IS the display order: the server answers in id order, // the viewer's stored strip order is applied once here, and the in-place // lane_update merge below preserves whatever order this produced. setLanes(keepIfSame(sortByStoredOrder(r.lanes, readLaneOrder()))); setSelectedLaneId((cur) => cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null) ); } catch { // Silent fail for lanes load } }, []); // Deep link from a notification click (`/workspace?lane=`, see // useNotifications' laneUrlForSession): once that lane is in the loaded // list, select it and drop the param so a later manual lane switch sticks. useEffect(() => { const laneParam = searchParams.get("lane"); if (laneParam === null || lanes.length === 0) return; const paramId = Number(laneParam); if (lanes.some((l) => l.id === paramId)) { setSelectedLaneId(paramId); } setSearchParams( (prev) => { const next = new URLSearchParams(prev); next.delete("lane"); return next; }, { replace: true } ); }, [lanes, searchParams, setSearchParams]); // Probe the CLI of every tool a lane uses — fresh on the server, so a CLI // installed since the page loaded clears its missing-binary banner. Claude is // always probed: the console's no-lane state runs it. const laneToolsKey = Array.from(new Set(["claude", ...lanes.map((l) => l.tool || "claude")])) .sort() .join(","); useEffect(() => { const tools = laneToolsKey.split(","); Promise.all(tools.map((tool) => api.run.binary(tool))) .then((found) => setBinaryStatus(Object.fromEntries(tools.map((tool, i) => [tool, found[i]!]))) ) // Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof // a CLI is missing from PATH — leave the probe unresolved rather than // showing a misleading "binary missing" banner for an unrelated fault. .catch(() => undefined); }, [laneToolsKey]); useEffect(() => { 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; } // The server re-broadcasts a lane on hook activity that often changed // nothing this page renders; replacing the array anyway would re-render // the strip and the console on every such event. if (JSON.stringify(cur[i]) === JSON.stringify(lane)) 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] ); // Clean up stale pane lane IDs when lanes load useEffect(() => { if (!lanes.length) return; setSplitView((prev) => { const paneLaneIds = prev.paneLaneIds.map((id) => id !== null && lanes.some((l) => l.id === id) ? id : null ); if (paneLaneIds.every((id, i) => id === prev.paneLaneIds[i])) return prev; const next = { ...prev, paneLaneIds }; writeSplitViewState(next); return next; }); }, [lanes]); // Both setters go through `keepIfSame`: these fire every 5 s below, and a // fresh-but-identical payload would otherwise re-render the lane strip, the // lane card and the whole console area on every tick for nothing. const refreshList = useCallback(() => { api.run .list() .then((r) => setActiveRuns(keepIfSame(r))) .catch(() => undefined); api.sessions // Attach/resume go through `claude`, so a Codex session is not offered // (filtered by the server, before its limit). .list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true, tool: "claude" }) .then((r) => setExternalSessions(keepIfSame(r.sessions))) .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(() => { refreshList(); 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; // Only claude lanes carry a pipeline (codex and antigravity emit no hooks), // and only while a template is installed — none ships, so it is dormant. const noPipeline = !!currentLane && (currentLane.tool !== "claude" || currentLane.pipeline_nodes.length === 0); // 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. A full-width Changes panel // hides that form anyway, and without a locked height it shrinks to its // content — the editor inside it collapsed to a few lines (no live run). const viewportLocked = paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full"); 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")); } }; /** * Move a lane card within the strip. Purely a viewer preference — it writes * localStorage, never the lane row, so nothing the CLI or another session * reads changes. */ const handleLaneReorder = (from: number, to: number) => { if (from === to || from < 0 || to < 0 || from >= lanes.length || to >= lanes.length) return; const ids = moveLane( lanes.map((l) => l.id), from, to ); writeLaneOrder(ids); setLanes(sortByStoredOrder(lanes, ids)); }; /** Commit an inline lane rename. An empty name clears the title, which puts * the cwd back in its place (`lane.title || lane.cwd` everywhere). */ const commitRename = async (id: number) => { const title = renameDraft.trim(); setRenamingLaneId(null); const lane = lanes.find((l) => l.id === id); if (!lane || title === (lane.title || "")) return; try { await api.lanes.update(id, { title }); await refreshLanes(); } catch (err) { setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown")); } }; // Mirrors `agenthub lanes pipeline