/** * @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 pipeline map (when a template is installed) and run console (a * tab per live tmux slot, 0–3, with a `+` menu — `ConsoleTabs.tsx`), 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. * * @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. * - `../components/lanes/` — LaneCard, PipelineMap, DestructiveLaneModal. * - `../components/run/` — ConsoleTabs, LaneConsolePane. * * ## 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 } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Plus, Columns2, Layers, PanelLeftClose, PanelRightClose, Maximize2, FolderTree, GitCompare, ChartColumn, } from "lucide-react"; import { api } from "../lib/api"; import type { CwdSuggestion, RunListResponse } from "../lib/api"; import type { Lane, LaneFeature, ProofFeature } from "../lib/types"; import { keepIfSame } from "../lib/keepIfSame"; import { useLanes } from "../hooks/useLanes"; import { LaneConsolePane } from "../components/run/LaneConsolePane"; import { ConsoleTabs } from "../components/run/ConsoleTabs"; import PipelineMap from "../components/lanes/PipelineMap"; import LaneCard, { since, LaneToolsMenu } from "../components/lanes/LaneCard"; import { AddLaneModal } from "../components/lanes/AddLaneModal"; import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel"; import { WorkspaceHome } from "../components/lanes/WorkspaceHome"; import { SessionReplay } from "../components/lanes/SessionReplay"; import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal"; /** 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 }, ] 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 ────────────────────────────────────────────────────────────── /** The pane slot a run id names (`agenthub-lane-[-]`). */ function slotOf(runId: string | null | undefined): number { const m = runId?.match(/^agenthub-lane-\d+(?:-([1-3]))?$/); return m ? Number(m[1] || 0) : 0; } /** The session view's console: a tab bar over one `LaneConsolePane` per slot * (0–3, each its own tmux session on the selected lane). The active tab is * the URL's `?run=`; every other tab with a live run stays MOUNTED, hidden * with CSS, so switching tabs never drops a terminal. */ function ConsoleArea({ lanes, selectedLaneId, binaryStatus, activeRuns, onHasActiveRunChange, attachRequest, onNewSession, onOpenRun, onRunsChanged, }: { lanes: Lane[]; selectedLaneId: number | null; binaryStatus: Record | null; activeRuns: RunListResponse | null; onHasActiveRunChange: (val: boolean) => void; /** The opened run (`?run=`): its tab is the active one. */ attachRequest: { runId: string; nonce: string } | null; /** Back to the workspace home, where a new session is started. */ onNewSession?: () => void; /** Opens a tab's run (`?run=`). */ onOpenRun: (runId: string) => void; /** A tab was opened or closed: re-read the run list now. */ onRunsChanged: () => void; }) { const lane = lanes.find((l) => l.id === selectedLaneId) ?? null; const tabs = useMemo( () => (activeRuns?.items ?? []) .filter((r) => r.laneId === selectedLaneId && r.status === "running") .sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)), [activeRuns, selectedLaneId] ); const activeSlot = slotOf(attachRequest?.runId); const slots = [...new Set([activeSlot, ...tabs.map((r) => r.slot ?? 0)])].sort(); // Off the active tab: the next one still open, else the home. const leave = (slot: number) => { const next = tabs.find((r) => (r.slot ?? 0) !== slot); if (next) onOpenRun(next.id); else onNewSession?.(); }; return (
{lane && ( { onRunsChanged(); onOpenRun(runId); }} onClosed={(runId) => { onRunsChanged(); if (slotOf(runId) === activeSlot) leave(activeSlot); }} // Per-workspace actions (mcp sync, sync-base check, readme). actions={} /> )}
{slots.map((slot) => (
slot === activeSlot && leave(slot)} attachRequest={ attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null } />
))}
); } export function Workspace() { const { t: tLanes } = useTranslation("lanes"); 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"); // `?session=` — an ENDED session picked in the sidebar or Recents: its // conversation, with a prompt box whose message resumes it (SessionReplay). const sessionParam = runParam ? null : searchParams.get("session"); const navigate = useNavigate(); 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([]); // 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 | null>(null); const [cwdSuggestions, setCwdSuggestions] = useState([]); const [paneHasActiveRun, setPaneHasActiveRun] = useState(false); 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}`) }); // The session the opened run holds, when that run works in a worktree of // its own (`claude --worktree`): the Changes panel shows that tree. const openedRun = runParam ? activeRuns?.items.find((r) => r.id === runParam) : undefined; const gitSession = openedRun?.worktree && openedRun.sessionId ? openedRun.sessionId : undefined; // 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)) .catch(() => undefined); }, []); // Goes through `keepIfSame`: this fires 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); }, []); // 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 && !sessionParam; const replaying = !!currentLane && !!sessionParam; // The session the Analyst button opens (`/sessions/:id`): the replayed one, // or the one the opened pane holds. const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null; 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