/** * @file Workspace.tsx * @description Merged workspace page combining lanes (agent work units) and runs * (CLI spawns: Claude Code or Codex, determined per lane). The workspace shown * is picked in the sidebar's project tree and named by `?lane=` (mirrored to * `agenthub.workspace.selected`, so a bare /workspace reopens the last one). * Shows the selected lane's one-row header, pipeline map and run console (1, 2 * or 4 panes, each split pane with its own header), and the right panel — * Files · Changes · Sessions. 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 { useLocation, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { ArrowLeft, Plus, Square, Columns2, Grid2x2, Layers, PanelLeftClose, PanelRightClose, Maximize2, FolderTree, GitCompare, History, X, } from "lucide-react"; import { api } from "../lib/api"; import type { CwdSuggestion, RunListResponse } from "../lib/api"; import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types"; import { eventBus } from "../lib/eventBus"; import { keepIfSame } from "../lib/keepIfSame"; import { useLanes } from "../hooks/useLanes"; import { useAiTools } from "../hooks/useAiTools"; import { AiToolIcon } from "../components/AiToolIcon"; import { LaneConsolePane } from "../components/run/LaneConsolePane"; import PipelineMap from "../components/lanes/PipelineMap"; import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu, LaneRouterRow, } from "../components/lanes/LaneCard"; import { AddLaneModal } from "../components/lanes/AddLaneModal"; import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel"; import { WorkspaceSessionsTab } from "../components/lanes/WorkspaceSessionsTab"; import { WorkspaceHome } from "../components/lanes/WorkspaceHome"; 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"; /** localStorage key for the right panel's activity-bar tab. */ const PANEL_TAB_KEY = "agenthub.workspace.panelTab"; /** localStorage key for the last workspace shown, restored on a bare /workspace. */ const SELECTED_KEY = "agenthub.workspace.selected"; const PANEL_TABS = [ { id: "files", Icon: FolderTree }, { id: "changes", Icon: GitCompare }, { id: "sessions", Icon: History }, ] as const; type PanelTab = (typeof PANEL_TABS)[number]["id"]; /** 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 ( ); })}
); } /** A split pane's one-line header: which lane, which slot, the run's model, * and — for slots 1–3 — a button that kills that pane's own tmux session * (`DELETE /api/run/`). Slot 0 is the lane's run and has no close. */ function PaneHeader({ lane, slot, runId, model, }: { lane: Lane | null; slot: number; runId: string | null; model: string | null; }) { const { t } = useTranslation("lanes"); const tools = useAiTools(); return (
{lane && ( )} {lane ? lane.title || lane.cwd.split("/").pop() : ""} {t("pane.slot", { n: slot })} {model && {model}} {slot > 0 && runId && ( )}
); } function ConsoleArea({ lanes, selectedLaneId, splitView, setLayout, binaryStatus, cwdSuggestions, activeRuns, externalSessions, wsConnected, defaultCwd, onHasActiveRunChange, onLaneCreated, onLaneIdChange, showToggle = true, resumeRequest, attachRequest, }: { 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; /** A session to resume in slot 0, from the Sessions tab. */ resumeRequest: { sessionId: string; model: string | null; nonce: number } | null; /** A live run to attach slot 0 to, from the sidebar (`?run=`). */ attachRequest: { runId: string; nonce: string } | null; }) { // 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) => (
l.id === selectedLaneId) ?? null} slot={i} runId={paneRunIds[i] ?? null} model={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.model ?? null} />
))}
)} ); } export function Workspace() { const { t: tLanes } = useTranslation("lanes"); const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); const [searchParams, setSearchParams] = useSearchParams(); // Lane state: the list is the shared store the sidebar reads too; the // selection is the URL's `?lane=` (the sidebar sets it, so does a // notification click), falling back to the last one shown, then the first. const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useLanes(); const laneParam = searchParams.get("lane"); // `?run=` — a live session picked in the sidebar; the location key makes a // second pick of the same session a new request. const location = useLocation(); const runParam = searchParams.get("run"); const attachRequest = useMemo( () => (runParam ? { runId: runParam, nonce: location.key } : null), [runParam, location.key] ); const paramId = laneParam === null ? null : Number(laneParam); const fallbackId = (() => { if (!lanesLoaded) return null; let stored: number | null = null; try { stored = Number(window.localStorage.getItem(SELECTED_KEY)); } catch { /* disabled storage */ } return lanes.find((l) => l.id === stored)?.id ?? lanes[0]?.id ?? null; })(); const selectedLaneId = paramId !== null && lanes.some((l) => l.id === paramId) ? paramId : fallbackId; const setSelectedLaneId = useCallback( (id: number | null) => setSearchParams( (prev) => { const next = new URLSearchParams(prev); // A run belongs to its lane: another lane opens on its home. if (next.get("lane") !== String(id)) next.delete("run"); if (id === null) next.delete("lane"); else next.set("lane", String(id)); return next; }, { replace: true } ), [setSearchParams] ); // Name the fallback in the URL, so the sidebar highlights it and F5 keeps it. // Only a URL naming NO lane is rewritten: one naming a lane the list has not // caught up with yet (just created) must survive until the refetch lands. useEffect(() => { if (!lanesLoaded || selectedLaneId === null) return; try { window.localStorage.setItem(SELECTED_KEY, String(selectedLaneId)); } catch { /* disabled storage */ } if (paramId === null) setSelectedLaneId(selectedLaneId); }, [lanesLoaded, selectedLaneId, paramId, setSelectedLaneId]); const [laneActionError, setLaneActionError] = useState(null); const [destructive, setDestructive] = useState<{ lane: Lane; action: "reset" | "remove"; } | null>(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 [resumeRequest, setResumeRequest] = useState<{ sessionId: string; model: string | null; nonce: number; } | null>(null); // 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()); const [panelTab, setPanelTab] = useState(() => { try { const saved = window.localStorage.getItem(PANEL_TAB_KEY) as PanelTab | null; return saved && PANEL_TABS.some((x) => x.id === saved) ? saved : "changes"; } catch { return "changes"; } }); useEffect(() => { try { window.localStorage.setItem(PANEL_TAB_KEY, panelTab); } catch { /* the panel just forgets its tab between visits */ } }, [panelTab]); // 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 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; }); }, []); // 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); 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); }, []); // 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). // A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session — // the console, panes and Changes panel, which the home only hides (CSS), so // a live terminal never unmounts behind it. const home = !!currentLane && !runParam; const viewportLocked = !home && (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")); } }; // Mirrors `agenthub lanes pipeline