Files
AgentHub/client/src/pages/Workspace.tsx
T
nntrivi2001 22de5009ff feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the
  Artifact tool's hook), pinned workspace files, skills and plugins, each
  kept as a snapshot; shared ones appear in a Hub and a skill or plugin
  installs into the viewer's own account (server/lib/artifacts.js,
  /api/artifacts, docs/plans/artifacts/).
- Workspace extra folders: clone one or more related repos beside the
  workspace and add them in one step (POST /api/lanes/:id/sources/clone).
- A pane that ended by itself while tmux kept running is no longer offered
  for resume (tmux.serverStartedAt in reconcileOrphans).
- Changes panel: a folder that is not a git repo opens on Files, and its
  files open from there.
- Plus the parallel sessions' work in this tree: File Explorer button in the
  panel header, user delete, RSA SSH keys, chat/terminal and pet fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:06:54 +07:00

1812 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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=<id>` (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=<section>`, `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ĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* 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 { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
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 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<ChangesPreset, string> = {
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-<id>[-<slot>]`). */
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<string, { found: boolean; path: string | null }> | 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<number, RunListResponse | null>());
// 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<GroupsScope | null>(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<string>());
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<Map<string, Session>>(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<number>();
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 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) => {
// 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<number[]>([]);
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: (
<LaneConsolePane
lanes={lanes}
laneId={id}
slot={slot}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
onNewSession={onNewSession}
onRunExit={onRunsChanged}
autoFocus={false}
attachRequest={null}
/>
),
};
});
// 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<HTMLDivElement>(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 (
<div ref={areaRef} className="flex min-h-0 flex-1 flex-col">
{lane && (
<ConsoleTabs
lane={lane}
tabs={tabs}
groups={viewGroups}
onGroupsChange={changeGroups}
onOpen={(runId, split) => {
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 ? (
<div className="flex items-center gap-1">
<SplitMenus
slot={shown[0]}
shown={shown}
runs={tabs}
label={label}
onChange={change}
canStart={free !== null}
tools={splitTools}
toolLabel={aiTools.label}
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
/>
</div>
) : undefined
}
/>
)}
{settings && (
<div
data-testid="workspace-settings"
className={settings.active ? "min-h-0 flex-1 overflow-y-auto p-5 lg:p-6" : "hidden"}
>
<SettingsHub section={settings.section} />
</div>
)}
<div className={settings?.active ? "hidden" : "contents"}>
<PaneGrid
layout={view}
mounted={mounted}
runs={tabs}
label={label}
onChange={change}
canStart={free !== null}
tools={splitTools}
toolLabel={aiTools.label}
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
onStop={(slot) => void stopPane(slot)}
// 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) => (
<LaneConsolePane
lanes={lanes}
// With `allTabs` a pane is bound to its own run's workspace.
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
slot={slotOfKey(slot)}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
onNewSession={onNewSession}
onRunExit={() => 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
}
/>
)}
/>
</div>
</div>
);
}
export function Workspace() {
const { t: tLanes } = useTranslation("lanes");
const { local } = usePermissions();
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");
// `?settings=<section>` — 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<string | null>(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<string | null>(null);
const [features, setFeatures] = useState<LaneFeature[]>([]);
const [pipelineTemplates, setPipelineTemplates] = useState<
{ id: string; name: string; nodes: { id: string }[] }[]
>([]);
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
// 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<RunListResponse | null>(null);
const [binaryStatus, setBinaryStatus] = useState<Record<
string,
{ found: boolean; path: string | null }
> | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
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<ChangesSize>(() => {
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<HTMLDivElement>(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<RunWorktree | null>(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<Record<string, boolean>>({});
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<Set<string>>(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<string>();
// 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<number | null>(null);
panelLaneRef.current = panelAt?.laneId ?? null;
useEffect(() => {
const onOpen = (e: Event) => {
const { laneId, path } = (e as CustomEvent<OpenInChanges>).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 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 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<string, unknown>) => {
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 <template> <id>`: same PATCH, same
// stage-mismatch warning when the current declared stage matches no node
// in the newly chosen template.
const handlePipelineChange = async (id: number, pipeline: string) => {
setLaneActionError(null);
try {
const { lane } = await api.lanes.update(id, { pipeline });
await refreshLanes();
// `stage` defaults to the DB sentinel "idle" until a driving session ever
// calls `agenthub stage` — that's the normal state for most lanes and never
// matches a real pipeline node, so it isn't a mismatch worth surfacing.
if (lane.stage !== "idle" && !lane.pipeline_nodes.some((n) => n.state === "current")) {
setLaneActionError(
tLanes("pipelinePicker.stageMismatch", {
stage: lane.stage,
pipeline: lane.pipeline,
nodes: lane.pipeline_nodes.map((n) => n.id).join(", "),
})
);
}
} catch (err) {
setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
}
};
return (
<div
className={
sessionView
? // Cancels Layout's page padding; the 1.5rem left is the status bar's,
// plus the drawer's 2.75rem menu bar below `md` (issue #61).
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col max-md:h-[calc(100vh-4.25rem)] lg:-m-6"
: viewportLocked
? "h-[calc(100vh-4rem)] max-md:h-[calc(100vh-6.75rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
: "space-y-5"
}
>
{/* Workspaces are picked — and added, with the + — in the sidebar's
project tree; this page shows the selected one. */}
<div className="flex min-h-0 flex-1 flex-col gap-4">
{/* The selected lane's pipeline — the thing you actually come to this
page to read, beside the list rather than stacked under it.
The section itself is ALWAYS rendered, even with no lane selected:
`ConsoleArea` lives inside it, and a conditional wrapper would give
the console two render sites, so every flip of `currentLane` (lanes
arriving on mount, a lane removed, a refetch re-picking the
selection) unmounted the terminal and wiped a half-typed prompt.
Same rule as RunConsole's own collapse — hide with CSS, never by
unmounting. */}
{/* Detail and the Changes panel share one row: the panel is a reading
surface for the same lane the console is driving, so it sits beside
it rather than under it. It collapses to a rail instead of
unmounting nothing — there is no live subscription inside it, so
unmounting is safe and keeps a closed panel from polling. */}
{home && currentLane && (
<WorkspaceHome
lane={currentLane}
onRenamed={() => void refreshLanes()}
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
sourcesVersion={sourcesVersion}
onMenuAction={(action) => {
if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
setAddLaneOpen(true);
} else if (action === "reset" || action === "remove") {
setDestructive({ lane: currentLane, action });
}
}}
/>
)}
<div ref={changesRowRef} className={`relative flex min-h-0 flex-1${home ? " hidden" : ""}`}>
<section
data-testid="lane-detail"
className={`flex min-h-0 min-w-0 flex-1 flex-col${
currentLane && changesOpen && changesFull ? " hidden" : ""
}`}
>
{/* No header row: the console's tab bar is the header. Only a
workspace with a pipeline template (dormant: none ships) keeps
its time-at-stage, pipeline and feature pickers here. */}
{currentLane && !noPipeline && (
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{since(currentLane.stage_seconds)}
</span>
<select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
className="select !w-auto !rounded !py-0.5 !pl-2 !pr-6 !text-xs text-fg-secondary"
value={currentLane.pipeline}
disabled={!!viewedFeature}
title={
viewedFeature
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
: undefined
}
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
>
{(pipelineTemplates.length
? pipelineTemplates
: [
{
id: currentLane.pipeline,
name: currentLane.pipeline_name,
nodes: [],
},
]
).map((p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
))}
</select>
{/* No stage chip and no "auto: <stage>" chip here: the
PipelineMap right below this header already draws both
— the declared node as the current ring, the detected
one as its amber badge — so the chips were a second,
smaller copy of the same two facts. */}
{features.length > 0 && (
<select
data-testid="feature-picker"
className="select !w-auto !rounded !py-0.5 !pl-2 !pr-6 !text-xs"
value={viewedFeatureSlug ?? ""}
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
>
<option value="">{tLanes("features.live")}</option>
{features.map((f) => (
<option key={f.slug} value={f.slug}>
{f.slug}
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
</option>
))}
</select>
)}
</div>
)}
{currentLane && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto px-3">
{/* No margin when the card rendered nothing (see LaneCard). */}
<div className="mb-3 has-[>div:empty]:mb-0">
<LaneCard
lane={currentLane}
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
childWorktrees={lanes.filter(
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
)}
onSelectLane={setSelectedLaneId}
/>
</div>
{!noPipeline && (
<div className="mb-3">
<PipelineMap
nodes={
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
}
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
/>
{viewedFeature && (
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
</p>
)}
</div>
)}
{proofFeature &&
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
<div data-testid="proof-gallery" className="mt-2">
{proofFeature.ticket_report && (
<a
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
target="_blank"
rel="noreferrer"
className="text-xs text-fg-muted"
>
{tLanes("proof.ticketReport")}
</a>
)}
{Object.entries(proofFeature.groups).map(([group, images]) => (
<div key={group} className="mt-1">
<span className="text-xs text-fg-muted">
{group} · {images.length}
</span>
<div className="flex flex-wrap gap-1">
{images.slice(0, 8).map((img) => (
<img
key={img}
loading="lazy"
className="h-16 w-16 rounded object-cover"
src={api.lanes.proof.imageUrl(
currentLane.id,
proofFeature.slug,
group,
img
)}
alt={img}
/>
))}
{images.length > 8 && (
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
<div className="flex min-h-0 flex-1 flex-col">
{replaying && (
<div className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-3">
<SessionReplay
lane={currentLane}
sessionId={sessionParam}
liveRuns={activeRuns?.items ?? []}
onResumed={(runId, laneId) =>
setSearchParams({ lane: String(laneId ?? currentLane.id), run: runId })
}
/>
</div>
)}
{/* A replay only HIDES the console: it has one render site and a
live terminal must never unmount. */}
<div className={replaying ? "hidden" : "contents"}>
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
onHasActiveRunChange={setPaneHasActiveRun}
attachRequest={attachRequest}
onNewSession={
currentLane
? () => setSearchParams({ lane: String(currentLane.id) })
: undefined
}
allTabs={allTabs}
onOpenRun={(runId, opts) =>
currentLane &&
setSearchParams(
// A run names its own workspace (another one's with `allTabs`).
{ lane: String(laneOfRun(runId) ?? currentLane.id), run: runId },
{
// Following the focus, or a tab click, is not a new
// history entry.
replace: opts?.focusOnly || opts?.replace,
state: opts?.split
? { split: true }
: opts?.focusOnly
? { focusOnly: true }
: undefined,
}
)
}
onRunsChanged={refreshList}
settings={
currentLane && (settingsOn || settingsTab !== null)
? {
section: settingsSection,
active: settingsOn,
onOpen: () =>
navigate(settingsHref(settingsTab ?? "", location), {
replace: true,
}),
onClose: () => {
setSettingsTab(null);
if (!settingsOn) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete("settings");
next.delete("tab");
return next;
},
{ replace: true }
);
},
}
: null
}
/>
</div>
</div>
</section>
{currentLane && changesOpen && !changesFull && (
// Drag (or arrow keys) to any width; the button keeps its presets.
<Divider
gridRef={changesRowRef}
dir="row"
at={1 - Math.max(changesRatio(changesSize), minChanges)}
box={{ x: 0, y: 0, w: 1, h: 1 }}
label={tLanes("changes.splitter")}
onRatio={(r) => setChangesSize(clampChanges(Math.max(1 - r, minChanges)))}
/>
)}
{currentLane &&
(changesOpen ? (
<aside
id="lane-changes-panel"
data-testid="lane-changes-card"
aria-label={tLanes("changes.title")}
style={
typeof changesSize === "number" ? { width: `${changesSize * 100}%` } : undefined
}
className={`flex shrink-0 flex-col bg-surface-1 ${
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
>
{/* Header: the title, Analyst, then Full screen and Collapse — the
same height as the console's tab bar, so the two read as
one header strip. Files and Changes are ONE panel now; its
own mode switcher moves between them. */}
<div
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
role="toolbar"
>
<span className="px-1 text-xs font-medium text-fg-secondary">
{tLanes("panel.changes")}
</span>
{/* The opened session's analysis page — a page of its own. */}
<button
type="button"
data-testid="panel-analyst"
disabled={!analystSessionId}
onClick={() =>
analystSessionId &&
navigate(`/sessions/${encodeURIComponent(analystSessionId)}`)
}
title={tLanes("panel.analyst")}
aria-label={tLanes("panel.analyst")}
className="flex h-7 w-7 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent"
>
<ChartColumn className="h-4 w-4" />
</button>
{/* The File Explorer, beside the window controls — on this
workspace (the session's own worktree when it has one).
Not in local mode: the desktop's file manager is there. */}
{local ? (
<span className="ml-auto" />
) : (
<>
<button
type="button"
data-testid="panel-file-explorer"
disabled={!panelAt}
onClick={() => setExplorerOpen(true)}
aria-label={tLanes("changes.openExplorer")}
title={tLanes("changes.openExplorer")}
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40"
>
<FolderOpen className="h-4 w-4" />
</button>
{panelAt && (
<FileExplorer
open={explorerOpen}
laneId={panelAt.laneId}
session={panelAt.session}
onChanged={() => setFilesChanged((n) => n + 1)}
onClose={() => setExplorerOpen(false)}
/>
)}
</>
)}
{/* A window's two controls: Full screen (Restore at full
screen, to the last dragged width) and Collapse. */}
<button
type="button"
data-testid="lane-changes-resize"
onClick={() => setChangesSize(changesFull ? lastDraggedChanges() : "full")}
aria-label={changesSizeLabel}
title={changesSizeLabel}
className="flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
>
{changesFull ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</button>
<button
type="button"
data-testid="lane-changes-collapse"
// Closing drops an unsaved edit, so that asks first; it
// reopens from the rail at the last dragged width.
onClick={() =>
afterDrop(() => {
setChangesOpen(false);
setChangesSize(lastDraggedChanges());
})
}
// The same region the rail button names, so a screen
// reader reads one panel that is open or closed.
aria-expanded
aria-controls="lane-changes-panel"
aria-label={tLanes("changes.collapse")}
title={tLanes("changes.collapse")}
className="flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
>
<PanelRightClose className="h-4 w-4" />
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
{panelAt &&
[
{ key: "", source: undefined as number | undefined },
...folders.map((f) => ({ key: String(f.id), source: f.id })),
].map(({ key, source }, i) => {
const panel = (
<LaneChangesPanel
// A new tree is a new panel: no read for the previous
// one can land in it. `panelAt` holds still while an
// edit is unsaved (see above).
key={`${panelKey}:${key}`}
laneId={panelAt.laneId}
session={panelAt.session}
source={source}
full={changesFull}
wide={changesRatio(changesSize) >= 0.45}
onDirtyChange={dirtyFor(key)}
openRequest={source ? null : changesRequest}
filesChanged={source ? 0 : filesChanged}
// A sibling worktree forks from the lane's SOURCE repo
// when it has one (this lane is itself a worktree of
// it); an adopted lane is its own repo.
onNewLane={(branch) => {
setAddLanePrefill({
sourceRepo: currentLane.source_repo || currentLane.cwd,
branch,
});
setAddLaneOpen(true);
}}
/>
);
// No extra folders: the one panel, as it always was.
if (!folders.length) return panel;
// Else every repo is a section, stacked like VS Code's
// Source Control; the open ones share the height. A
// folded one stays mounted (CSS), so an unsaved edit in
// it survives.
const folded = foldedSources.has(key);
const name = source
? folders[i - 1]!.name
: currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd;
return (
<section
key={key}
data-testid="panel-git-source"
// A row dragged from an extra folder lies outside
// the pane's folder: `dragProjectPath` makes it absolute.
data-drag-root={source ? folders[i - 1]!.path : undefined}
className={`flex flex-col border-b border-border-light ${
folded ? "shrink-0" : "min-h-0 flex-1"
}`}
>
<button
type="button"
aria-expanded={!folded}
onContextMenu={(e) => sourceCtx.open(e, key)}
onClick={() =>
setFoldedSources((prev) => {
const next = new Set(prev);
if (!next.delete(key)) next.add(key);
return next;
})
}
className="flex shrink-0 items-center gap-1 py-1 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
>
{folded ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
)}
<span className="truncate">{name}</span>
</button>
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
{panel}
</div>
</section>
);
})}
{panelAt && sourceCtx.menu && (
<ContextMenu
x={sourceCtx.menu.x}
y={sourceCtx.menu.y}
onClose={sourceCtx.close}
testId="panel-git-source-menu"
>
{sourceCtx.menu.item !== "" && (
<MenuItem
testId="panel-git-source-close"
onSelect={() => {
const key = sourceCtx.menu!.item;
sourceCtx.close();
void closeSources(panelAt.laneId, [key]);
}}
>
{tLanes("changes.closeRepo")}
</MenuItem>
)}
<MenuItem
testId="panel-git-source-close-others"
disabled={!folders.some((f) => String(f.id) !== sourceCtx.menu!.item)}
onSelect={() => {
const key = sourceCtx.menu!.item;
sourceCtx.close();
void closeSources(
panelAt.laneId,
folders.map((f) => String(f.id)).filter((k) => k !== key)
);
}}
>
{tLanes("changes.closeOtherRepos")}
</MenuItem>
</ContextMenu>
)}
{/* The workspace's context — the home's right column, compact. */}
<section
data-testid="panel-workspace-context"
className={`-mx-3 flex shrink-0 flex-col border-t border-border px-3 ${
contextOpen ? "max-h-[45%]" : ""
}`}
>
<button
type="button"
aria-expanded={contextOpen}
onClick={() => setContextOpen((v) => !v)}
className="flex shrink-0 items-center gap-1 py-1.5 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
>
{contextOpen ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
)}
{tLanes("home.contextPanel")}
</button>
{contextOpen && (
<div className="min-h-0 overflow-y-auto pb-2">
<WorkspaceContext
lane={currentLane}
compact
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
sourcesVersion={sourcesVersion}
/>
</div>
)}
</section>
</div>
</aside>
) : (
// The rail also carries the uncommitted counts, vertical, with
// the per-file list on hover — a sibling of the expand button,
// since a button may not hold another.
<div className="flex w-9 shrink-0 flex-col items-center gap-3 border-l border-border bg-surface-1 py-3">
<button
type="button"
data-testid="lane-changes-expand"
onClick={() => setChangesOpen(true)}
aria-expanded={false}
aria-controls="lane-changes-panel"
aria-label={tLanes("changes.expand")}
title={tLanes("changes.expand")}
className="flex flex-col items-center gap-2 text-fg-muted transition-colors hover:text-fg-primary"
>
<PanelLeftClose className="h-4 w-4" />
<span className="text-[10px] uppercase tracking-wide [writing-mode:vertical-rl]">
{tLanes("changes.title")}
</span>
</button>
{panelAt &&
[undefined, ...folders.map((f) => f.id)].map((source) => (
<LaneGitRailCounts
key={source ?? ""}
laneId={panelAt.laneId}
session={panelAt.session}
source={source}
onShowAll={() => setChangesOpen(true)}
/>
))}
</div>
))}
</div>
</div>
<AddLaneModal
open={addLaneOpen}
cwdSuggestions={cwdSuggestions}
prefill={addLanePrefill}
onClose={() => {
setAddLaneOpen(false);
setAddLanePrefill(null);
}}
onAdded={(lane) => {
void refreshLanes();
setSelectedLaneId(lane.id);
}}
/>
{destructive && (
<DestructiveLaneModal
lane={destructive.lane}
action={destructive.action}
open
onClose={() => setDestructive(null)}
onConfirm={async (body) => {
const { lane, action } = destructive;
setDestructive(null);
setLaneActionError(null);
try {
await api.lanes.action(lane.id, action, {
confirm: true,
expect: body.expect,
...(body.force === true ? { force: true } : {}),
});
await refreshLanes();
if (action === "remove") setSearchParams({});
} catch (err) {
setLaneActionError(err instanceof Error ? err.message : String(err));
}
}}
/>
)}
</div>
);
}