/** * @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 has Full screen (Restore at full screen) and Collapse * buttons, and resizes by dragging (or arrow keys on) its splitter; a file in * a chat's "Edited N files" card opens in it (`OPEN_IN_CHANGES_EVENT`); 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). Its bottom fold, Workspace context, * is the home's context column compact (`WorkspaceContext`). Errors are toasts. * Settings opened on a workspace (`?settings=
`, `lib/settingsLink.ts`) * is a tab of its own at the end of the tab bar, kept until its X: the * Settings pages (`SettingsHub`) shown instead of the panes, which stay * mounted behind them. * * @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 { PanelLeftClose, PanelRightClose, Maximize2, FolderOpen, Minimize2, ChartColumn, ChevronDown, ChevronRight, } from "lucide-react"; import { api } from "../lib/api"; import { usePermissions } from "../components/AuthGate"; import type { CwdSuggestion, LaneSource, LaneSourcesView, RunHandle, RunListResponse, } from "../lib/api"; import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types"; import { toast } from "../lib/toast"; import { endRuns } from "../lib/stopSession"; import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu"; import { confirmDiscard } from "../lib/confirm"; import { keepIfSame } from "../lib/keepIfSame"; import { laneChangeMatcher, runKeyOf } from "../lib/laneChange"; import { sessionChangeMatcher } from "../lib/sessionChange"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; 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 GroupsScope, type PaneGroups, type PaneLayout, MAX_PANES, MAX_SLOTS, activeLayout, closeInLayout, groupOf, layoutRects, leaves, moveInLayout, openInGroups, pane, paneKey, pruneGroups, removeSlot, slotOfKey, updateActive, } from "../lib/paneLayout"; import { readAllTabs } from "../lib/workspaceTabs"; import { settingsHref } from "../lib/settingsLink"; import { SettingsHub } from "./SettingsHub"; import { useAiTools } from "../hooks/useAiTools"; import { freeSlot, laneOfRun, 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 { FileExplorer } from "../components/lanes/FileExplorer"; import { WorkspaceContext } from "../components/lanes/WorkspaceContext"; import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges"; 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 width the user dragged the panel to — what * it reopens at after the step control closes it. */ const CHANGES_DRAGGED_KEY = "agenthub.workspace.changesDragged"; /** localStorage key for whether the Changes panel's Workspace context fold is open. */ const CONTEXT_OPEN_KEY = "agenthub.workspace.contextOpen"; /** localStorage key for the last workspace shown, restored on a bare /workspace. */ const SELECTED_KEY = "agenthub.workspace.selected"; /** Workspaces left most recently whose live panes stay mounted (hidden), so * switching back to one shows its terminals as they were, with no re-attach. */ const KEEP_LANES = 4; /** The Changes panel's two preset widths. Its header has two controls, like a * window's: Full screen (Restore once at full screen, back to the last * dragged width) and Collapse (to the rail). */ const CHANGES_SIZES = ["compact", "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-[25%]", // 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 panel's share of the row, for the splitter. */ const changesRatio = (s: ChangesSize) => (typeof s === "number" ? s : s === "compact" ? 0.25 : 1); const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r)); /** The width the user last dragged the panel to, else `compact`. */ function lastDraggedChanges(): ChangesSize { try { const n = Number(window.localStorage.getItem(CHANGES_DRAGGED_KEY) || NaN); return Number.isFinite(n) ? clampChanges(n) : "compact"; } catch { return "compact"; } } /** 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. With `allTabs` * (Settings → Appearance) the tabs hold EVERY workspace's live runs, stored * once (`all`): a leaf is a `paneKey` (lane × 10 + slot), each pane is bound * to its own run's lane, and focusing one selects its workspace (the URL's * `?lane=` follows `?run=`). Without it, the live panes of the last * `KEEP_LANES` workspaces left stay mounted too — hidden siblings in the same * grid, each keyed by its `paneKey` — so a workspace switch neither disposes * nor re-attaches a terminal. */ function ConsoleArea({ lanes, selectedLaneId, binaryStatus, activeRuns, onHasActiveRunChange, attachRequest, onNewSession, onOpenRun, onRunsChanged, allTabs = false, settings = null, }: { 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; /** Tabs from every workspace, not only the selected one's. */ allTabs?: boolean; /** The Settings tab while it is open: its section, and whether it is the one * on screen (the panes are then hidden, never unmounted). */ settings?: { section: string; active: boolean; onOpen: () => void; onClose: () => void; } | null; }) { const { t } = useTranslation("lanes"); const aiTools = useAiTools(); const lane = lanes.find((l) => l.id === selectedLaneId) ?? null; // Every workspace's runs, each `slot` rewritten to its `paneKey`, so the tab // bar and the grid — which key everything by slot — need no other change. const tabs = useMemo( () => (activeRuns?.items ?? []) .filter((r) => allTabs ? r.laneId !== null && r.status === "running" : r.laneId === selectedLaneId && r.status === "running" ) .map((r) => (allTabs ? { ...r, slot: paneKey(r.laneId as number, r.slot ?? 0) } : r)) .sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)), [activeRuns, selectedLaneId, allTabs] ); // A run id's leaf: its slot, or with `allTabs` its `paneKey`. const keyOf = (runId: string) => { const l = laneOfRun(runId); return allTabs && l !== null ? paneKey(l, slotOf(runId)) : slotOf(runId); }; const scope: GroupsScope | null = allTabs ? "all" : selectedLaneId; const [groups, setGroups] = usePaneGroups(scope); // 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(","); // The workspaces the tabs belong to: the selected one, or with `allTabs` // every one holding a tab. const tabLaneIds = allTabs ? [...new Set(tabs.map((r) => r.laneId as number))].join(",") : String(selectedLaneId ?? ""); useEffect(() => { if (!tabLaneIds || !tabSessionIds.replace(/,/g, "")) return; let live = true; Promise.all( tabLaneIds.split(",").map((id) => api.sessions.list({ lane: Number(id), limit: 50 })) ) .then( (rs) => live && setTabSessions(new Map(rs.flatMap((r) => r.sessions.map((x) => [x.id, x])))) ) .catch(() => undefined); return () => { live = false; }; }, [tabLaneIds, tabSessionIds, lane?.status, lane?.needs_action, sessionsVersion]); const laneName = (r: RunHandle) => { const l = lanes.find((x) => x.id === r.laneId); return l ? l.title || l.cwd.split("/").filter(Boolean).pop() || l.cwd : ""; }; const sessionOf = (r: RunHandle) => (r.sessionId ? tabSessions.get(r.sessionId) : undefined); const label = (r: RunHandle) => { const name = runName(r, sessionOf(r), aiTools.label(r.tool), t("tabs.terminal")); return allTabs ? `${laneName(r)} · ${name}` : name; }; 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's tabs: nothing pending or exited carries over. useEffect(() => { pending.current.clear(); exited.current.clear(); }, [scope]); // 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; const own = allTabs ? "\\d+" : String(selectedLaneId); if (!new RegExp(`^agenthub-lane-${own}(-[1-9])?$`).test(attachRequest.runId)) return; const slot = keyOf(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 !== scope; listed.current = scope; 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 (and archives) that one session, its moon sleeps // it — as a one-pane tab's buttons do (`endRuns` asks first for an AI pane). const stopPane = async (slot: number, sleep = false) => { const run = runOf(slot); if (!run) return; if ((await endRuns([run], { sleep, name: label(run), t })).length) ended(slot); }; // 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) => { // With `allTabs`, the split starts in the target pane's own workspace. const into = allTabs ? lanes.find((l) => l.id === Math.floor(target / MAX_SLOTS)) : lane; if (!into || freeSlot(into.id, tabs) === null) return; const laneId = into.id; try { const body = tool ? newTabBody(tool, into, aiTools) : { shell: true }; // The server picks the slot: the list `free` came from may be stale. const slot = keyOf(await startInFreeTab(laneId, body)); onRunsChanged(); if (!allTabs && laneRef.current !== laneId) return; pending.current.set(slot, activeRuns); change((l) => moveInLayout(l, slot, target, edge)); } catch (err) { toast.error(err); } }; const requested = attachRequest ? [keyOf(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] ?? (allTabs ? paneKey(selectedLaneId ?? 0, 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); // A pane spanning the grid's full width or height has room for its chat's // right panel to start open; a quarter-sized one starts it collapsed. const spanning = new Set( layoutRects(view.root) .panes.filter((r) => r.w === 1 || r.h === 1) .map((r) => r.slot) ); const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b); const focusSlot = view.focus; // The workspaces just left, newest first — updated DURING the render that // switches, because the switch's own commit would otherwise unmount the // panes before an effect could keep them. Idempotent on a repeat render. const leftLanes = useRef([]); const lastLane = useRef(selectedLaneId); if (lastLane.current !== selectedLaneId) { const prev = lastLane.current; if (prev !== null) leftLanes.current = [prev, ...leftLanes.current.filter((id) => id !== prev)].slice( 0, KEEP_LANES ); lastLane.current = selectedLaneId; } const kept = allTabs ? [] : (activeRuns?.items ?? []) .filter( (r) => r.laneId !== null && r.laneId !== selectedLaneId && r.status === "running" && leftLanes.current.includes(r.laneId) ) .map((r) => { const id = r.laneId as number; const slot = r.slot ?? 0; return { key: paneKey(id, slot), node: ( ), }; }); // 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 || settings?.active || !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} workspaceOf={allTabs ? laneName : undefined} onRenamed={() => setSessionsVersion((v) => v + 1)} onClosed={(runId) => ended(keyOf(runId))} onHome={onNewSession} settings={settings} // While one pane is shown, its split menus (a split pane has its own). actions={ !settings?.active && shown.length === 1 && shown[0] !== undefined ? (
void newSplit(target, edge, tool)} />
) : undefined } /> )} {settings && (
)}
void newSplit(target, edge, tool)} stateOf={stateOf} onStop={(slot) => void stopPane(slot)} onSleep={(slot) => void stopPane(slot, true)} // Keyed by workspace too, so a kept pane coming back is the same one. keyOf={allTabs ? undefined : (slot) => paneKey(selectedLaneId ?? 0, slot)} kept={kept} renderPane={(slot) => ( ended(slot)} // Only the focused pane takes the keyboard when it appears. autoFocus={slot === focusSlot} panelOpen={spanning.has(slot)} attachRequest={ attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null } /> )} />
); } export function Workspace() { const { t: tLanes } = useTranslation("lanes"); const { local } = usePermissions(); const [searchParams, setSearchParams] = useSearchParams(); // The server's tmux is older than this platform needs (psmux < 3.4.0 on // Windows, tmux < 3.7 elsewhere): say how to upgrade it, until it is. // Sticky (an error toast) so the command can be copied. An older server or // no answer says nothing. useEffect(() => { let live = true; (async () => { try { const { upgrade } = await api.run.tmuxAvailable(); if (live && upgrade) toast.error(tLanes("tmuxUpgrade", { advice: upgrade })); } catch { /* nothing to say */ } })(); return () => { live = false; }; }, [tLanes]); // 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"); // `?settings=
` — the Settings tab is on screen ("" = the section it // was last on). It stays open (`settingsTab`, its last section) until its X. const settingsParam = searchParams.get("settings"); const [settingsTab, setSettingsTab] = useState(null); const settingsSection = settingsParam || settingsTab || "appearance"; useEffect(() => { if (settingsParam !== null) setSettingsTab(settingsSection); }, [settingsParam, settingsSection]); 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); // Settings → Appearance: tabs from every workspace. Read on mount — the // switch lives on another page. const [allTabs] = useState(readAllTabs); // 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). // The File Explorer dialog, opened from the Changes panel header. const [explorerOpen, setExplorerOpen] = useState(false); // Bumped when the File Explorer changed files: the Files tree reads again. const [filesChanged, setFilesChanged] = useState(0); 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]); // The Changes panel's bottom fold (instructions, files, folders): folded by // default so the git sections keep the height. const [contextOpen, setContextOpen] = useState(() => { try { return window.localStorage.getItem(CONTEXT_OPEN_KEY) === "1"; } catch { return false; } }); useEffect(() => { try { window.localStorage.setItem(CONTEXT_OPEN_KEY, contextOpen ? "1" : "0"); } catch { /* disabled storage: the fold just forgets between visits */ } }, [contextOpen]); // 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)); if (typeof changesSize === "number") window.localStorage.setItem(CHANGES_DRAGGED_KEY, String(changesSize)); } catch { /* same as above: a forgotten width, not an error */ } }, [changesSize]); const changesFull = changesSize === "full"; const changesSizeLabel = changesFull ? tLanes("changes.restore") : tLanes("changes.size.full"); 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 a Changes panel section, by section key. While // one 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 [dirtyBy, setDirtyBy] = useState>({}); const changesDirty = Object.values(dirtyBy).some(Boolean); const dirtyFor = useCallback( (key: string) => (dirty: boolean) => setDirtyBy((d) => ({ ...d, [key]: dirty })), [] ); const wantPanel = selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession }; const keyOf = (p: typeof wantPanel) => (p ? `${p.laneId}:${p.session ?? ""}` : ""); const wantPanelKey = keyOf(wantPanel); const [panelAt, setPanelAt] = useState(wantPanel); const panelKey = keyOf(panelAt); // The workspace's extra folders: each is its own repo, stacked under the // workspace folder's in the Changes panel (`?source=`). Read for the // workspace the panel shows, and again from the home's Folder list whenever // it adds or removes one. const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>( null ); const takeGitFolders = useCallback( (laneId: number, view: LaneSourcesView) => setGitFolders({ laneId, items: view.sources.filter((s) => s.kind === "folder" && !s.missing), }), [] ); const panelLaneId = panelAt?.laneId ?? null; useEffect(() => { if (panelLaneId === null) return; api.lanes.sources(panelLaneId).then( (v) => takeGitFolders(panelLaneId, v), () => setGitFolders({ laneId: panelLaneId, items: [] }) ); }, [panelLaneId, takeGitFolders]); const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : []; // Sections folded shut ("" = the workspace folder, else the folder's id). const [foldedSources, setFoldedSources] = useState>(new Set()); // Right-click on a section: close extra folders — which REMOVES them from the // workspace (the folder stays on disk). The workspace folder never closes. const sourceCtx = useContextMenu(); // Bumped after a close here or an edit in either context list (the home's, // the Changes panel's fold), so every one on screen re-reads. const [sourcesVersion, setSourcesVersion] = useState(0); const sourcesChanged = (laneId: number, view: LaneSourcesView) => { takeGitFolders(laneId, view); setSourcesVersion((n) => n + 1); }; const closeSources = async (laneId: number, keys: string[]) => { if (keys.some((k) => dirtyBy[k]) && !(await confirmDiscard())) return; try { let view: LaneSourcesView | null = null; for (const k of keys) view = await api.lanes.removeSource(laneId, Number(k)); if (view) takeGitFolders(laneId, view); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); api.lanes.sources(laneId).then( (v) => takeGitFolders(laneId, v), () => undefined ); } setSourcesVersion((n) => n + 1); }; 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; setDirtyBy({}); setPanelAt(wantPanel); }); // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys }, [wantPanelKey, panelKey, changesDirty]); // A chat's "Edited N files" card asks for a file: open the panel on it, if // the panel shows that workspace. const [changesRequest, setChangesRequest] = useState<{ path: string; nonce: number } | null>( null ); const panelLaneRef = useRef(null); panelLaneRef.current = panelAt?.laneId ?? null; useEffect(() => { const onOpen = (e: Event) => { const { laneId, path } = (e as CustomEvent).detail; if (laneId !== panelLaneRef.current) return; // Its path is the workspace folder's: unfold that section. setFoldedSources((prev) => { const next = new Set(prev); next.delete(""); return next; }); setChangesOpen(true); setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 })); }; window.addEventListener(OPEN_IN_CHANGES_EVENT, onOpen); return () => window.removeEventListener(OPEN_IN_CHANGES_EVENT, onOpen); }, []); // Asked once: a panel reopened or moved to another tree must not replay it. useEffect(() => { if (!changesOpen) setChangesRequest(null); }, [changesOpen]); useEffect(() => setChangesRequest(null), [panelKey]); /** 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 re-reads on run-moving messages and a // fallback poll below, and a fresh-but-identical payload would otherwise // re-render the lane strip, the lane card and the whole console area 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]); // The run list follows the WebSocket instead of a blind 5 s poll: a pane // starting, ending or learning its session in slot 0 moves the lane's run // columns (`lane_update`); an agent starting in a free tab (slots 1–9) // shows as a new session, one ending as a session status change, and a pane // dying under its agent as `runs_interrupted`. This page's own starts, stops // and a shown terminal's exit call `refreshList` directly // (`onRunsChanged`). What none of those cover — a shell tab opened or closed // from another browser, server-boot reconciliation — is caught by the // 30 s fallback poll, on reconnect, and when the tab is shown or focused. const [laneRunChanged] = useState(() => laneChangeMatcher(runKeyOf)); const [sessionRunChanged] = useState(() => sessionChangeMatcher((s) => `${s.status}`)); useEffect(() => { refreshList(); }, [refreshList]); useLiveRefresh(refreshList, { intervalMs: 30_000, throttleMs: 1500, match: (msg) => msg.type === "runs_interrupted" || laneRunChanged(msg) || sessionRunChanged(msg), }); useEffect(() => { const onFocus = () => refreshList(); window.addEventListener("focus", onFocus); return () => window.removeEventListener("focus", onFocus); }, [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 settingsOn = !!currentLane && settingsParam !== null; const home = !!currentLane && !runParam && !sessionParam && !settingsOn; const replaying = !!currentLane && !!sessionParam && !settingsOn; // 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