/** * @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 group of up to four split panes over the live tmux slots 0–9, 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. * Tabs and pane headers name each live session (`runName`) and show its state; * a tab click replaces the history entry (Back leaves the session view, not * the last tab). Keyboard, outside a terminal: Alt+1–4 focus a pane, Alt+\\ * splits the focused one right with a new terminal, Alt+W hides it. The * Changes panel resizes by its button's presets or by dragging (or arrow keys * on) its splitter; an unsaved edit in it is asked about before the panel * closes, switches tab, or moves to another workspace or session (it stays on * the old one until saved or discarded). Errors are toasts. * * @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 { runWorktreeSession, type RunWorktree } from "../lib/runWorktree"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Plus, Columns2, Layers, PanelLeftClose, PanelRightClose, Maximize2, ChartColumn, } from "lucide-react"; import { api } from "../lib/api"; import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api"; import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types"; import { toast } from "../lib/toast"; import { confirmDialog, confirmDiscard } from "../lib/confirm"; import { keepIfSame } from "../lib/keepIfSame"; import { useWorkLanes } from "../hooks/useLanes"; import { LaneConsolePane } from "../components/run/LaneConsolePane"; import { ConsoleTabs, newTabBody, newTabTools, runName, runState, } from "../components/run/ConsoleTabs"; import { Divider, type LayoutUpdate, PaneGrid, SplitMenus, usePaneGroups, } from "../components/run/PaneGrid"; import { type Edge, type PaneGroups, type PaneLayout, MAX_PANES, activeLayout, closeInLayout, groupOf, leaves, moveInLayout, openInGroups, pane, pruneGroups, removeSlot, updateActive, } from "../lib/paneLayout"; import { useAiTools } from "../hooks/useAiTools"; import { freeSlot, startInFreeTab } from "../components/lanes/startSession"; import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList"; import { LIVE_RUNS_EVENT } from "../components/WorkspaceTree"; import PipelineMap from "../components/lanes/PipelineMap"; import LaneCard, { since } from "../components/lanes/LaneCard"; import { AddLaneModal } from "../components/lanes/AddLaneModal"; import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel"; import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader"; 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 last workspace shown, restored on a bare /workspace. */ const SELECTED_KEY = "agenthub.workspace.selected"; /** 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 ChangesPreset = (typeof CHANGES_SIZES)[number]; /** A preset, or a width (panel fraction 0.15–0.85) the splitter was dragged to. */ type ChangesSize = ChangesPreset | 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, }; /** The panel's share of the row, for the splitter. */ const changesRatio = (s: ChangesSize) => typeof s === "number" ? s : s === "compact" ? 0.3 : s === "half" ? 0.5 : 1; /** The preset a size counts as for the button: under ~45% is narrow. */ const changesStep = (s: ChangesSize): ChangesPreset => s === "full" ? "full" : changesRatio(s) < 0.45 ? "compact" : "half"; const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r)); /** The panel's own minimum (`min-w-[18rem]`), in px. */ const CHANGES_MIN_PX = 288; // ── 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-9]))?$/); return m ? Number(m[1] || 0) : 0; } /** The session view's console: a tab bar whose every tab is a group of up to * four split panes (`PaneGrid`; model in `lib/paneLayout.ts`), one * `LaneConsolePane` per slot 0–9, each its own tmux session on the selected * lane. The focused pane of the tab on screen is the URL's `?run=`: a plain * open (a tab, a sidebar row) shows the tab holding that run — a session in * no tab gets one of its own — and a split open (Ctrl/Cmd+click) brings it * into the tab on screen, at the 2×2 grid's next place. Every live run * stays MOUNTED, hidden with CSS while its tab is not shown, so neither * switching tabs nor reshaping the panes ever drops a terminal. A pane whose * run ends closes; the last tab gone leaves for the home. */ 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 pane is the focused one. `split` opens it * in a split (the grid's next place) instead; `focusOnly` (the URL following a * focus change made here) never opens anything. */ attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null; /** Back to the workspace home, where a new session is started. */ onNewSession?: () => void; /** Opens a run (`?run=`); `split` splits, `focusOnly` only follows the focus, * `replace` adds no history entry (a tab click). */ onOpenRun: ( runId: string, opts?: { split?: boolean; focusOnly?: boolean; replace?: boolean } ) => void; /** A tab was opened or closed: re-read the run list now. */ onRunsChanged: () => void; }) { const { t } = useTranslation("lanes"); const aiTools = useAiTools(); 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 [groups, setGroups] = usePaneGroups(selectedLaneId); // The latest tabs, updated the moment a change is made: two changes in one // commit (an open request and a prune) must build on each other. const groupsRef = useRef(groups); groupsRef.current = groups; const layout = activeLayout(groups); // Slots opened here whose run the polled list has not caught up with yet, // each with the list it was opened against: pruning spares one until a // NEWER list still lacks it (a run that died before it was ever listed). const pending = useRef(new Map()); // The workspace whose first run list was read. That first read prunes, so a // remembered layout loses its dead panes on arrival; after it a shown pane // closes on its terminal's exit, not on the polled list — one bad read (the // server answers an empty list on any tmux error) must not wipe a split. const listed = useRef(null); // Runs seen ending here (`id@startedAt`): a list not yet refreshed must not // send the last pane to one of them. const exited = useRef(new Set()); const laneRef = useRef(selectedLaneId); laneRef.current = selectedLaneId; // The sessions the tabs hold — their names and states — re-read when a tab // opens or learns its session, when the lane's status moves, or on a rename. const [tabSessions, setTabSessions] = useState>(new Map()); const [sessionsVersion, setSessionsVersion] = useState(0); const tabSessionIds = tabs.map((r) => r.sessionId ?? "").join(","); useEffect(() => { if (selectedLaneId === null || !tabSessionIds.replace(/,/g, "")) return; let live = true; api.sessions .list({ lane: selectedLaneId, limit: 50 }) .then((r) => live && setTabSessions(new Map(r.sessions.map((x) => [x.id, x])))) .catch(() => undefined); return () => { live = false; }; }, [selectedLaneId, tabSessionIds, lane?.status, lane?.needs_action, sessionsVersion]); const sessionOf = (r: RunHandle) => (r.sessionId ? tabSessions.get(r.sessionId) : undefined); const label = (r: RunHandle) => runName(r, sessionOf(r), aiTools.label(r.tool), t("tabs.terminal")); const stateOf = (r: RunHandle) => (r.tool === "shell" ? null : runState(sessionOf(r))); const runOf = (slot: number | null) => tabs.find((r) => (r.slot ?? 0) === slot); const runKey = (r: RunHandle) => `${r.id}@${r.startedAt}`; // The URL's `?run=` names the focused pane, so the right panel and the // sidebar read it. A change made HERE that moves the focus pushes it; there // is no effect watching the focus — an open request and that effect would // keep overriding each other. Session view only: the home and a replay // keep their URL. const followFocus = (next: PaneGroups) => { if (!attachRequest) return; const run = runOf(activeLayout(next).focus); if (run && run.id !== attachRequest.runId) onOpenRun(run.id, { focusOnly: true }); }; const changeGroups = (fn: (g: PaneGroups) => PaneGroups) => { const next = fn(groupsRef.current); if (next === groupsRef.current) return; groupsRef.current = next; setGroups(() => next); followFocus(next); }; // Another workspace: nothing pending or exited carries over. useEffect(() => { pending.current.clear(); exited.current.clear(); }, [selectedLaneId]); // An open request (a new nonce): focus, replace or split. Re-run when the // lane arrives on a cold load; only this lane's own tabs. useEffect(() => { if (!attachRequest || selectedLaneId === null) return; if (!new RegExp(`^agenthub-lane-${selectedLaneId}(-[1-9])?$`).test(attachRequest.runId)) return; const slot = slotOf(attachRequest.runId); // The URL catching up with a focus change: by now that pane may be closed // or swapped, and re-opening it would undo what the user just did. Back/ // Forward onto such an entry puts the real focus back in the URL instead. if (attachRequest.focusOnly) { const g = groupsRef.current; const at = groupOf(g, slot); if (at >= 0) changeGroups(() => at === g.active && g.groups[at]?.focus === slot ? g : openInGroups(g, slot, false) ); else followFocus(g); return; } if (!tabs.some((r) => (r.slot ?? 0) === slot)) pending.current.set(slot, activeRuns); changeGroups((g) => openInGroups(g, slot, attachRequest.split === true)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [attachRequest?.nonce, selectedLaneId]); // The run list caught up: on a workspace's first read every pane whose run // is gone closes; after that only a pane opened here whose run never got // listed (a newer list still lacks it) does. A pane opened here whose run // just got listed takes the URL if it holds the focus. useEffect(() => { if (!activeRuns || selectedLaneId === null) return; const live = new Set(tabs.map((r) => r.slot ?? 0)); const arrived: number[] = []; const expired = new Set(); for (const [s, list] of pending.current) { if (live.has(s)) arrived.push(s); else if (list !== activeRuns) expired.add(s); if (live.has(s) || list !== activeRuns) pending.current.delete(s); } const first = listed.current !== selectedLaneId; listed.current = selectedLaneId; const tabbed = groupsRef.current.groups.flatMap((l) => leaves(l.root)); const keep = first ? new Set(pending.current.keys()) : new Set(tabbed.filter((s) => !expired.has(s))); const before = groupsRef.current; changeGroups((g) => pruneGroups(g, live, keep)); const focus = activeLayout(before).focus; if (groupsRef.current === before && focus !== null && arrived.includes(focus)) followFocus(before); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeRuns, tabs, selectedLaneId]); // A pane's run ended (it exited, or its tab was closed): its pane closes, // and in the session view the last one leaves for another tab, else the // home — the home and a replay only lose the pane, never their URL. const ended = (slot: number) => { onRunsChanged(); const dead = runOf(slot); if (dead) exited.current.add(runKey(dead)); if (groupOf(groupsRef.current, slot) < 0) return; changeGroups((g) => removeSlot(g, slot)); if (groupsRef.current.groups.length || !attachRequest) return; const other = tabs.find((r) => (r.slot ?? 0) !== slot && !exited.current.has(runKey(r))); if (other) onOpenRun(other.id); else onNewSession?.(); }; // A pane header's X: stops that one session, as a one-pane tab's X does — // an AI session may be mid-turn, so it asks first; a shell holds no agent. const stopPane = async (slot: number) => { const run = runOf(slot); if (!run) return; if ( run.tool !== "shell" && !(await confirmDialog({ title: t("tabs.stopTitle", { name: label(run) }), message: t("tabs.stopBody"), confirmLabel: t("tabs.stopConfirm"), destructive: true, })) ) return; try { await api.run.kill(run.id); ended(slot); } catch (err) { toast.error(err); } }; // A split menu's "New terminal" or AI tool (bare, as the tab bar's `+` // starts it) in the first free slot, placed on that side of the pane — // unless the workspace changed while it started. const free = lane ? freeSlot(lane.id, tabs) : null; const splitTools = lane ? newTabTools(lane, aiTools) : []; const newSplit = async (target: number, edge: Edge, tool?: string) => { if (!lane || free === null) return; const laneId = lane.id; try { const body = tool ? newTabBody(tool, lane, aiTools) : { shell: true }; // The server picks the slot: the list `free` came from may be stale. const slot = slotOf(await startInFreeTab(laneId, body)); onRunsChanged(); if (laneRef.current !== laneId) return; pending.current.set(slot, activeRuns); change((l) => moveInLayout(l, slot, target, edge)); } catch (err) { toast.error(err); } }; const requested = attachRequest ? [slotOf(attachRequest.runId)] : []; // No layout yet (a first visit, a request not yet applied, or no run at // all): one pane, so the console always has its single render site. const fallback = requested[0] ?? 0; const view: PaneLayout = layout.root ? layout : { root: pane(fallback), focus: fallback }; const viewGroups: PaneGroups = groups.groups.length ? groups : { groups: [view], active: 0 }; // A change to the tab on screen (`PaneGrid`, the split menus). Tabs never // saved are the one-pane `view`: change that. const change: LayoutUpdate = (fn) => changeGroups((g) => updateActive(g.groups.length ? g : viewGroups, fn)); const shown = leaves(view.root); const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b); const focusSlot = view.focus; // Alt+1–4 focus a pane, Alt+\ splits the focused one right with a new // terminal, Alt+W takes it out into a tab of its own. Session view only, and never from inside a // terminal or a field, whose keys are their own (Ctrl+M lets Tab out of a // terminal first). Read through a ref: one listener, always current. const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined); const areaRef = useRef(null); onKeys.current = (e) => { if (!attachRequest || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; // Not under a dialog, and not while the console is hidden (the Changes // panel at full width, a replay, the home) — CSS-hidden, so by class. if (document.querySelector('[role="dialog"]')) return; if (!areaRef.current || areaRef.current.closest(".hidden")) return; const el = e.target as HTMLElement | null; if (el?.closest?.(".xterm")) return; if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) return; const digit = /^Digit([1-4])$/.exec(e.code); if (digit) { const s = shown[Number(digit[1]) - 1]; if (s === undefined) return; e.preventDefault(); change((l) => (l.root && l.focus !== s ? { ...l, focus: s } : l)); } else if (e.code === "Backslash" && focusSlot !== null) { e.preventDefault(); if (free !== null && shown.length < MAX_PANES) void newSplit(focusSlot, "right"); } else if (e.code === "KeyW" && focusSlot !== null && shown.length > 1) { e.preventDefault(); change((l) => closeInLayout(l, focusSlot)); } }; useEffect(() => { const on = (e: KeyboardEvent) => onKeys.current(e); document.addEventListener("keydown", on); return () => document.removeEventListener("keydown", on); }, []); return (
{lane && ( { onRunsChanged(); // A tab click is not a new history entry: Back leaves the // session view instead of walking back through every tab. onOpenRun(runId, { split, replace: true }); }} sessions={tabSessions} onRenamed={() => setSessionsVersion((v) => v + 1)} onClosed={(runId) => ended(slotOf(runId))} onHome={onNewSession} // While one pane is shown, its split menus (a split pane has its own). actions={ shown.length === 1 && shown[0] !== undefined ? (
void newSplit(target, edge, tool)} />
) : undefined } /> )} void newSplit(target, edge, tool)} stateOf={stateOf} onStop={(slot) => void stopPane(slot)} renderPane={(slot) => ( ended(slot)} // Only the focused pane takes the keyboard when it appears. autoFocus={slot === focusSlot} 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 } = useWorkLanes(); 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(); // A Ctrl/Cmd+click (tab or sidebar) opens the run in a split; // it rides on the navigation's state, never in the URL. const navState = location.state as { split?: unknown; focusOnly?: unknown } | null; const splitOpen = navState?.split === true; const focusOnly = navState?.focusOnly === true; const attachRequest = useMemo( () => (runParam ? { runId: runParam, nonce: location.key, split: splitOpen, focusOnly } : null), [runParam, location.key, splitOpen, focusOnly] ); 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"); next.delete("session"); } 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]); // A lane action's failure, as a toast (it stays until dismissed). const setLaneActionError = (message: string | null) => { if (message) toast.error(tLanes("actionError", { message })); }; 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); // 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]); // A preset name, or the fraction a splitter drag left (stored as a number). const [changesSize, setChangesSize] = useState(() => { try { const saved = window.localStorage.getItem(CHANGES_SIZE_KEY); if (saved && (CHANGES_SIZES as readonly string[]).includes(saved)) return saved as ChangesPreset; const n = Number(saved); return saved && Number.isFinite(n) ? clampChanges(n) : "compact"; } catch { return "compact"; } }); useEffect(() => { try { window.localStorage.setItem(CHANGES_SIZE_KEY, String(changesSize)); } catch { /* same as above: a forgotten width, not an error */ } }, [changesSize]); const changesFull = changesSize === "full"; const changesPreset = changesStep(changesSize); const nextChangesSize = CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesPreset) + 1) % CHANGES_SIZES.length]!; const changesNextLabel = changesFull ? tLanes("changes.collapse") : tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) }); const changesRowRef = useRef(null); // The row's width: the panel never gets narrower than its 18rem minimum, // so the splitter is drawn — and dragged — no further than that edge. const [rowWidth, setRowWidth] = useState(0); useEffect(() => { const el = changesRowRef.current; if (!el || typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver(() => setRowWidth(el.clientWidth)); ro.observe(el); setRowWidth(el.clientWidth); return () => ro.disconnect(); }, []); const minChanges = rowWidth ? Math.min(0.85, CHANGES_MIN_PX / rowWidth) : 0.15; // The session the opened run holds, when that run works in a worktree of // its own (`claude --worktree`): the Changes panel shows that tree. A // replayed session (`?session=`) shows its own worktree the same way. const openedRun = runParam ? activeRuns?.items.find((r) => r.id === runParam) : undefined; // Its worktree is checked once (`GET …/git?session=`): an ended session's // worktree Claude already removed answers ENOWORKTREE, and the panel then // shows the workspace's own folder instead of an error. const [replayed, setReplayed] = useState<{ id: string; worktree: boolean } | null>(null); const replayLane = lanes.find((l) => l.id === selectedLaneId); useEffect(() => { setReplayed(null); if (!sessionParam || !replayLane) return; let live = true; void (async () => { try { const { session } = await api.sessions.get(sessionParam, { events: false }); if (!sessionWorktreeName(replayLane, session)) { if (live) setReplayed({ id: sessionParam, worktree: false }); return; } const ok = await api.lanes .git({ lane: replayLane.id, session: sessionParam }) .then(() => true) .catch((err: { code?: string }) => err?.code !== "ENOWORKTREE"); if (live) setReplayed({ id: sessionParam, worktree: ok }); } catch { /* no row: the workspace's folder */ } })(); return () => { live = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps -- the lane by id }, [sessionParam, replayLane?.id]); const runWorktree = useRef(null); runWorktree.current = runWorktreeSession(runWorktree.current, runParam, openedRun); const gitSession = runWorktree.current ? runWorktree.current.session : sessionParam && replayed?.id === sessionParam && replayed.worktree ? sessionParam : undefined; // An unsaved whole-file edit in the Changes panel. While it is open the // panel stays on the tree it was opened in — moving it (another workspace, // session or worktree) would remount it and drop the edit — until the user // says to discard it, or saves. const [changesDirty, setChangesDirty] = useState(false); const wantPanel = selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession }; const wantPanelKey = wantPanel ? `${wantPanel.laneId}:${wantPanel.session ?? ""}` : ""; const [panelAt, setPanelAt] = useState(wantPanel); const panelKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : ""; const asking = useRef(false); useEffect(() => { if (wantPanelKey === panelKey) return; if (!changesDirty) { setPanelAt(wantPanel); return; } if (asking.current) return; asking.current = true; void confirmDiscard().then((ok) => { asking.current = false; if (!ok) return; setChangesDirty(false); setPanelAt(wantPanel); }); // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys }, [wantPanelKey, panelKey, changesDirty]); /** Runs `fn` once an unsaved edit (if any) may be dropped. */ const afterDrop = (fn: () => void) => { if (!changesDirty) return fn(); void confirmDiscard().then((ok) => ok && fn()); }; // 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); }, []); // The sidebar's live panes follow this page's poll: a tab in slots 1–3 // moves no lane column, so the sidebar would not re-read on its own. useEffect(() => { if (activeRuns) window.dispatchEvent(new CustomEvent(LIVE_RUNS_EVENT, { detail: activeRuns.items })); }, [activeRuns]); // 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(() => { // Hidden tab: skip; the visibility handler below re-reads on show. if (document.visibilityState === "visible") 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; // The session view (console or replay) fills the page edge to edge, like an // editor: no page padding, no cards, dividers between its regions. const sessionView = !!currentLane && !home; const viewportLocked = sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull))); 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