6c605849d8
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
1405 lines
61 KiB
TypeScript
1405 lines
61 KiB
TypeScript
/**
|
||
* @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 resizes by its button's presets or by dragging (or arrow keys
|
||
* on) its splitter; an unsaved edit in it is asked about before the panel
|
||
* closes, switches tab, or moves to another workspace or session (it stays on
|
||
* the old one until saved or discarded). Errors are toasts.
|
||
*
|
||
* @author Nguyễn Ngọc Trí Vĩ <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 {
|
||
Plus,
|
||
Columns2,
|
||
Layers,
|
||
PanelLeftClose,
|
||
PanelRightClose,
|
||
Maximize2,
|
||
FolderTree,
|
||
GitCompare,
|
||
ChartColumn,
|
||
} from "lucide-react";
|
||
import { api } from "../lib/api";
|
||
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
|
||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||
import { toast } from "../lib/toast";
|
||
import { confirmDiscard } from "../lib/confirm";
|
||
import { keepIfSame } from "../lib/keepIfSame";
|
||
import { useLanes } from "../hooks/useLanes";
|
||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||
import {
|
||
ConsoleTabs,
|
||
newTabBody,
|
||
newTabTools,
|
||
runName,
|
||
runState,
|
||
} from "../components/run/ConsoleTabs";
|
||
import {
|
||
Divider,
|
||
type LayoutUpdate,
|
||
PaneGrid,
|
||
SplitMenus,
|
||
usePaneGroups,
|
||
} from "../components/run/PaneGrid";
|
||
import {
|
||
type Edge,
|
||
type PaneGroups,
|
||
type PaneLayout,
|
||
MAX_PANES,
|
||
activeLayout,
|
||
closeInLayout,
|
||
groupOf,
|
||
leaves,
|
||
moveInLayout,
|
||
openInGroups,
|
||
pane,
|
||
pruneGroups,
|
||
removeSlot,
|
||
updateActive,
|
||
} from "../lib/paneLayout";
|
||
import { useAiTools } from "../hooks/useAiTools";
|
||
import { freeSlot, startInFreeTab } from "../components/lanes/startSession";
|
||
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
|
||
import { LIVE_RUNS_EVENT } from "../components/WorkspaceTree";
|
||
import PipelineMap from "../components/lanes/PipelineMap";
|
||
import LaneCard, { since } from "../components/lanes/LaneCard";
|
||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||
import { SessionReplay } from "../components/lanes/SessionReplay";
|
||
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||
|
||
/** localStorage key for the Changes panel's open/closed state. */
|
||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||
/** localStorage key for how wide the Changes panel is. */
|
||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||
/** localStorage key for the right panel's activity-bar tab. */
|
||
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
|
||
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
||
const SELECTED_KEY = "agenthub.workspace.selected";
|
||
|
||
const PANEL_TABS = [
|
||
{ id: "files", Icon: FolderTree },
|
||
{ id: "changes", Icon: GitCompare },
|
||
] as const;
|
||
type PanelTab = (typeof PANEL_TABS)[number]["id"];
|
||
|
||
/** The three widths the Changes panel cycles through, then closed. Reading a
|
||
* diff and watching a console want opposite amounts of room, and which one is
|
||
* "work" changes several times an hour — hence ONE control that steps
|
||
* 30/70 → 50/50 → full → closed, rather than a width button beside a
|
||
* collapse button that both live in the panel's tightest corner. */
|
||
const CHANGES_SIZES = ["compact", "half", "full"] as const;
|
||
type 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-[30%]",
|
||
half: "w-1/2",
|
||
// The console beside it is HIDDEN, not unmounted: a live run's terminal
|
||
// and a half-typed prompt must survive a round trip through full width.
|
||
full: "w-full",
|
||
};
|
||
/** The icon the one resize control shows AT each size: what the next click
|
||
* gives you — an even split, the full width, then closed. */
|
||
const CHANGES_NEXT_ICON: Record<ChangesPreset, typeof Columns2> = {
|
||
compact: Columns2,
|
||
half: Maximize2,
|
||
full: PanelRightClose,
|
||
};
|
||
/** The panel's share of the row, for the splitter. */
|
||
const changesRatio = (s: ChangesSize) =>
|
||
typeof s === "number" ? s : s === "compact" ? 0.3 : s === "half" ? 0.5 : 1;
|
||
/** The preset a size counts as for the button: under ~45% is narrow. */
|
||
const changesStep = (s: ChangesSize): ChangesPreset =>
|
||
s === "full" ? "full" : changesRatio(s) < 0.45 ? "compact" : "half";
|
||
const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r));
|
||
/** The panel's own minimum (`min-w-[18rem]`), in px. */
|
||
const CHANGES_MIN_PX = 288;
|
||
|
||
// ── Page ──────────────────────────────────────────────────────────────
|
||
|
||
/** The pane slot a run id names (`agenthub-lane-<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, to the right of the focused pane. Every live run
|
||
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither
|
||
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose
|
||
* run ends closes; the last tab gone leaves for the home. */
|
||
function ConsoleArea({
|
||
lanes,
|
||
selectedLaneId,
|
||
binaryStatus,
|
||
activeRuns,
|
||
onHasActiveRunChange,
|
||
attachRequest,
|
||
onNewSession,
|
||
onOpenRun,
|
||
onRunsChanged,
|
||
}: {
|
||
lanes: Lane[];
|
||
selectedLaneId: number | null;
|
||
binaryStatus: Record<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
|
||
* beside the focused pane instead of in it; `focusOnly` (the URL following a
|
||
* focus change made here) never opens anything. */
|
||
attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null;
|
||
/** Back to the workspace home, where a new session is started. */
|
||
onNewSession?: () => void;
|
||
/** Opens a run (`?run=`); `split` splits, `focusOnly` only follows the focus,
|
||
* `replace` adds no history entry (a tab click). */
|
||
onOpenRun: (
|
||
runId: string,
|
||
opts?: { split?: boolean; focusOnly?: boolean; replace?: boolean }
|
||
) => void;
|
||
/** A tab was opened or closed: re-read the run list now. */
|
||
onRunsChanged: () => void;
|
||
}) {
|
||
const { t } = useTranslation("lanes");
|
||
const aiTools = useAiTools();
|
||
const lane = lanes.find((l) => l.id === selectedLaneId) ?? null;
|
||
const tabs = useMemo(
|
||
() =>
|
||
(activeRuns?.items ?? [])
|
||
.filter((r) => r.laneId === selectedLaneId && r.status === "running")
|
||
.sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)),
|
||
[activeRuns, selectedLaneId]
|
||
);
|
||
const [groups, setGroups] = usePaneGroups(selectedLaneId);
|
||
// The latest tabs, updated the moment a change is made: two changes in one
|
||
// commit (an open request and a prune) must build on each other.
|
||
const groupsRef = useRef(groups);
|
||
groupsRef.current = groups;
|
||
const layout = activeLayout(groups);
|
||
// Slots opened here whose run the polled list has not caught up with yet,
|
||
// each with the list it was opened against: pruning spares one until a
|
||
// NEWER list still lacks it (a run that died before it was ever listed).
|
||
const pending = useRef(new Map<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<number | 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(",");
|
||
useEffect(() => {
|
||
if (selectedLaneId === null || !tabSessionIds.replace(/,/g, "")) return;
|
||
let live = true;
|
||
api.sessions
|
||
.list({ lane: selectedLaneId, limit: 50 })
|
||
.then((r) => live && setTabSessions(new Map(r.sessions.map((x) => [x.id, x]))))
|
||
.catch(() => undefined);
|
||
return () => {
|
||
live = false;
|
||
};
|
||
}, [selectedLaneId, tabSessionIds, lane?.status, lane?.needs_action, sessionsVersion]);
|
||
const sessionOf = (r: RunHandle) => (r.sessionId ? tabSessions.get(r.sessionId) : undefined);
|
||
const label = (r: RunHandle) =>
|
||
runName(r, sessionOf(r), aiTools.label(r.tool), t("tabs.terminal"));
|
||
const stateOf = (r: RunHandle) => (r.tool === "shell" ? null : runState(sessionOf(r)));
|
||
const runOf = (slot: number | null) => tabs.find((r) => (r.slot ?? 0) === slot);
|
||
const runKey = (r: RunHandle) => `${r.id}@${r.startedAt}`;
|
||
|
||
// The URL's `?run=` names the focused pane, so the right panel and the
|
||
// sidebar read it. A change made HERE that moves the focus pushes it; there
|
||
// is no effect watching the focus — an open request and that effect would
|
||
// keep overriding each other. Session view only: the home and a replay
|
||
// keep their URL.
|
||
const followFocus = (next: PaneGroups) => {
|
||
if (!attachRequest) return;
|
||
const run = runOf(activeLayout(next).focus);
|
||
if (run && run.id !== attachRequest.runId) onOpenRun(run.id, { focusOnly: true });
|
||
};
|
||
const changeGroups = (fn: (g: PaneGroups) => PaneGroups) => {
|
||
const next = fn(groupsRef.current);
|
||
if (next === groupsRef.current) return;
|
||
groupsRef.current = next;
|
||
setGroups(() => next);
|
||
followFocus(next);
|
||
};
|
||
|
||
// Another workspace: nothing pending or exited carries over.
|
||
useEffect(() => {
|
||
pending.current.clear();
|
||
exited.current.clear();
|
||
}, [selectedLaneId]);
|
||
|
||
// An open request (a new nonce): focus, replace or split. Re-run when the
|
||
// lane arrives on a cold load; only this lane's own tabs.
|
||
useEffect(() => {
|
||
if (!attachRequest || selectedLaneId === null) return;
|
||
if (!new RegExp(`^agenthub-lane-${selectedLaneId}(-[1-9])?$`).test(attachRequest.runId)) return;
|
||
const slot = slotOf(attachRequest.runId);
|
||
// The URL catching up with a focus change: by now that pane may be closed
|
||
// or swapped, and re-opening it would undo what the user just did. Back/
|
||
// Forward onto such an entry puts the real focus back in the URL instead.
|
||
if (attachRequest.focusOnly) {
|
||
const g = groupsRef.current;
|
||
const at = groupOf(g, slot);
|
||
if (at >= 0)
|
||
changeGroups(() =>
|
||
at === g.active && g.groups[at]?.focus === slot ? g : openInGroups(g, slot, false)
|
||
);
|
||
else followFocus(g);
|
||
return;
|
||
}
|
||
if (!tabs.some((r) => (r.slot ?? 0) === slot)) pending.current.set(slot, activeRuns);
|
||
changeGroups((g) => openInGroups(g, slot, attachRequest.split === true));
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [attachRequest?.nonce, selectedLaneId]);
|
||
|
||
// The run list caught up: on a workspace's first read every pane whose run
|
||
// is gone closes; after that only a pane opened here whose run never got
|
||
// listed (a newer list still lacks it) does. A pane opened here whose run
|
||
// just got listed takes the URL if it holds the focus.
|
||
useEffect(() => {
|
||
if (!activeRuns || selectedLaneId === null) return;
|
||
const live = new Set(tabs.map((r) => r.slot ?? 0));
|
||
const arrived: number[] = [];
|
||
const expired = new Set<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 !== selectedLaneId;
|
||
listed.current = selectedLaneId;
|
||
const tabbed = groupsRef.current.groups.flatMap((l) => leaves(l.root));
|
||
const keep = first
|
||
? new Set(pending.current.keys())
|
||
: new Set(tabbed.filter((s) => !expired.has(s)));
|
||
const before = groupsRef.current;
|
||
changeGroups((g) => pruneGroups(g, live, keep));
|
||
const focus = activeLayout(before).focus;
|
||
if (groupsRef.current === before && focus !== null && arrived.includes(focus))
|
||
followFocus(before);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [activeRuns, tabs, selectedLaneId]);
|
||
|
||
// A pane's run ended (it exited, or its tab was closed): its pane closes,
|
||
// and in the session view the last one leaves for another tab, else the
|
||
// home — the home and a replay only lose the pane, never their URL.
|
||
const ended = (slot: number) => {
|
||
onRunsChanged();
|
||
const dead = runOf(slot);
|
||
if (dead) exited.current.add(runKey(dead));
|
||
if (groupOf(groupsRef.current, slot) < 0) return;
|
||
changeGroups((g) => removeSlot(g, slot));
|
||
if (groupsRef.current.groups.length || !attachRequest) return;
|
||
const other = tabs.find((r) => (r.slot ?? 0) !== slot && !exited.current.has(runKey(r)));
|
||
if (other) onOpenRun(other.id);
|
||
else onNewSession?.();
|
||
};
|
||
|
||
// A split menu's "New terminal" or AI tool (bare, as the tab bar's `+`
|
||
// starts it) in the first free slot, placed on that side of the pane —
|
||
// unless the workspace changed while it started.
|
||
const free = lane ? freeSlot(lane.id, tabs) : null;
|
||
const splitTools = lane ? newTabTools(lane, aiTools) : [];
|
||
const newSplit = async (target: number, edge: Edge, tool?: string) => {
|
||
if (!lane || free === null) return;
|
||
const laneId = lane.id;
|
||
try {
|
||
const body = tool ? newTabBody(tool, lane, aiTools) : { shell: true };
|
||
// The server picks the slot: the list `free` came from may be stale.
|
||
const slot = slotOf(await startInFreeTab(laneId, body));
|
||
onRunsChanged();
|
||
if (laneRef.current !== laneId) return;
|
||
pending.current.set(slot, activeRuns);
|
||
change((l) => moveInLayout(l, slot, target, edge));
|
||
} catch (err) {
|
||
toast.error(err);
|
||
}
|
||
};
|
||
|
||
const requested = attachRequest ? [slotOf(attachRequest.runId)] : [];
|
||
// No layout yet (a first visit, a request not yet applied, or no run at
|
||
// all): one pane, so the console always has its single render site.
|
||
const fallback = requested[0] ?? 0;
|
||
const view: PaneLayout = layout.root ? layout : { root: pane(fallback), focus: fallback };
|
||
const viewGroups: PaneGroups = groups.groups.length ? groups : { groups: [view], active: 0 };
|
||
// A change to the tab on screen (`PaneGrid`, the split menus). Tabs never
|
||
// saved are the one-pane `view`: change that.
|
||
const change: LayoutUpdate = (fn) =>
|
||
changeGroups((g) => updateActive(g.groups.length ? g : viewGroups, fn));
|
||
const shown = leaves(view.root);
|
||
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b);
|
||
const focusSlot = view.focus;
|
||
|
||
// Alt+1–4 focus a pane, Alt+\ splits the focused one right with a new
|
||
// terminal, Alt+W takes it out into a tab of its own. Session view only, and never from inside a
|
||
// terminal or a field, whose keys are their own (Ctrl+M lets Tab out of a
|
||
// terminal first). Read through a ref: one listener, always current.
|
||
const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined);
|
||
const areaRef = useRef<HTMLDivElement>(null);
|
||
onKeys.current = (e) => {
|
||
if (!attachRequest || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||
// Not under a dialog, and not while the console is hidden (the Changes
|
||
// panel at full width, a replay, the home) — CSS-hidden, so by class.
|
||
if (document.querySelector('[role="dialog"]')) return;
|
||
if (!areaRef.current || areaRef.current.closest(".hidden")) return;
|
||
const el = e.target as HTMLElement | null;
|
||
if (el?.closest?.(".xterm")) return;
|
||
if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) return;
|
||
const digit = /^Digit([1-4])$/.exec(e.code);
|
||
if (digit) {
|
||
const s = shown[Number(digit[1]) - 1];
|
||
if (s === undefined) return;
|
||
e.preventDefault();
|
||
change((l) => (l.root && l.focus !== s ? { ...l, focus: s } : l));
|
||
} else if (e.code === "Backslash" && focusSlot !== null) {
|
||
e.preventDefault();
|
||
if (free !== null && shown.length < MAX_PANES) void newSplit(focusSlot, "right");
|
||
} else if (e.code === "KeyW" && focusSlot !== null && shown.length > 1) {
|
||
e.preventDefault();
|
||
change((l) => closeInLayout(l, focusSlot));
|
||
}
|
||
};
|
||
useEffect(() => {
|
||
const on = (e: KeyboardEvent) => onKeys.current(e);
|
||
document.addEventListener("keydown", on);
|
||
return () => document.removeEventListener("keydown", on);
|
||
}, []);
|
||
|
||
return (
|
||
<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}
|
||
onRenamed={() => setSessionsVersion((v) => v + 1)}
|
||
onClosed={(runId) => ended(slotOf(runId))}
|
||
onHome={onNewSession}
|
||
// While one pane is shown, its split menus (a split pane has its own).
|
||
actions={
|
||
shown.length === 1 && shown[0] !== undefined ? (
|
||
<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
|
||
}
|
||
/>
|
||
)}
|
||
<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}
|
||
renderPane={(slot) => (
|
||
<LaneConsolePane
|
||
lanes={lanes}
|
||
laneId={selectedLaneId}
|
||
slot={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}
|
||
attachRequest={
|
||
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
||
}
|
||
/>
|
||
)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Workspace() {
|
||
const { t: tLanes } = useTranslation("lanes");
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
|
||
// Lane state: the list is the shared store the sidebar reads too; the
|
||
// selection is the URL's `?lane=` (the sidebar sets it, so does a
|
||
// notification click), falling back to the last one shown, then the first.
|
||
const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useLanes();
|
||
const laneParam = searchParams.get("lane");
|
||
// `?run=` — a live session picked in the sidebar; the location key makes a
|
||
// second pick of the same session a new request.
|
||
const location = useLocation();
|
||
const runParam = searchParams.get("run");
|
||
// `?session=` — an ENDED session picked in the sidebar or Recents: its
|
||
// conversation, with a prompt box whose message resumes it (SessionReplay).
|
||
const sessionParam = runParam ? null : searchParams.get("session");
|
||
const navigate = useNavigate();
|
||
// A Ctrl/Cmd+click (tab or sidebar) opens the run beside the focused pane;
|
||
// 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);
|
||
|
||
const [panelTab, setPanelTab] = useState<PanelTab>(() => {
|
||
try {
|
||
const saved = window.localStorage.getItem(PANEL_TAB_KEY) as PanelTab | null;
|
||
return saved && PANEL_TABS.some((x) => x.id === saved) ? saved : "changes";
|
||
} catch {
|
||
return "changes";
|
||
}
|
||
});
|
||
useEffect(() => {
|
||
try {
|
||
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
|
||
} catch {
|
||
/* the panel just forgets its tab between visits */
|
||
}
|
||
}, [panelTab]);
|
||
// Whether the Changes panel is open. A viewer preference, so it lives in
|
||
// localStorage and never reaches the server — same rule as the lane strip's
|
||
// order (client/src/lib/laneOrderStorage.ts).
|
||
const [changesOpen, setChangesOpen] = useState(() => {
|
||
try {
|
||
return window.localStorage.getItem(CHANGES_OPEN_KEY) === "1";
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
useEffect(() => {
|
||
try {
|
||
window.localStorage.setItem(CHANGES_OPEN_KEY, changesOpen ? "1" : "0");
|
||
} catch {
|
||
/* private mode / disabled storage: the panel just forgets between visits */
|
||
}
|
||
}, [changesOpen]);
|
||
// 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));
|
||
} catch {
|
||
/* same as above: a forgotten width, not an error */
|
||
}
|
||
}, [changesSize]);
|
||
const changesFull = changesSize === "full";
|
||
const changesPreset = changesStep(changesSize);
|
||
const nextChangesSize =
|
||
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesPreset) + 1) % CHANGES_SIZES.length]!;
|
||
const changesNextLabel = changesFull
|
||
? tLanes("changes.collapse")
|
||
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
|
||
const changesRowRef = useRef<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 the Changes panel. While it is open the
|
||
// panel stays on the tree it was opened in — moving it (another workspace,
|
||
// session or worktree) would remount it and drop the edit — until the user
|
||
// says to discard it, or saves.
|
||
const [changesDirty, setChangesDirty] = useState(false);
|
||
const wantPanel =
|
||
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
|
||
const wantPanelKey = wantPanel ? `${wantPanel.laneId}:${wantPanel.session ?? ""}` : "";
|
||
const [panelAt, setPanelAt] = useState(wantPanel);
|
||
const panelKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
|
||
const asking = useRef(false);
|
||
useEffect(() => {
|
||
if (wantPanelKey === panelKey) return;
|
||
if (!changesDirty) {
|
||
setPanelAt(wantPanel);
|
||
return;
|
||
}
|
||
if (asking.current) return;
|
||
asking.current = true;
|
||
void confirmDiscard().then((ok) => {
|
||
asking.current = false;
|
||
if (!ok) return;
|
||
setChangesDirty(false);
|
||
setPanelAt(wantPanel);
|
||
});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
|
||
}, [wantPanelKey, panelKey, changesDirty]);
|
||
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
|
||
const afterDrop = (fn: () => void) => {
|
||
if (!changesDirty) return fn();
|
||
void confirmDiscard().then((ok) => ok && fn());
|
||
};
|
||
|
||
// Probe the CLI of every tool a lane uses — fresh on the server, so a CLI
|
||
// installed since the page loaded clears its missing-binary banner. Claude is
|
||
// always probed: the console's no-lane state runs it.
|
||
const laneToolsKey = Array.from(new Set(["claude", ...lanes.map((l) => l.tool || "claude")]))
|
||
.sort()
|
||
.join(",");
|
||
useEffect(() => {
|
||
const tools = laneToolsKey.split(",");
|
||
Promise.all(tools.map((tool) => api.run.binary(tool)))
|
||
.then((found) =>
|
||
setBinaryStatus(Object.fromEntries(tools.map((tool, i) => [tool, found[i]!])))
|
||
)
|
||
// Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
|
||
// a CLI is missing from PATH — leave the probe unresolved rather than
|
||
// showing a misleading "binary missing" banner for an unrelated fault.
|
||
.catch(() => undefined);
|
||
}, [laneToolsKey]);
|
||
|
||
useEffect(() => {
|
||
api.lanes
|
||
.pipelines()
|
||
.then((r) => setPipelineTemplates(r.pipelines))
|
||
.catch(() => undefined);
|
||
api.run
|
||
.cwds()
|
||
.then((r) => setCwdSuggestions(r.items))
|
||
.catch(() => undefined);
|
||
}, []);
|
||
|
||
// Goes through `keepIfSame`: this fires every 5 s below, and a
|
||
// fresh-but-identical payload would otherwise re-render the lane strip, the
|
||
// lane card and the whole console area on every tick for nothing.
|
||
const refreshList = useCallback(() => {
|
||
api.run
|
||
.list()
|
||
.then((r) => setActiveRuns(keepIfSame(r)))
|
||
.catch(() => undefined);
|
||
}, []);
|
||
|
||
// The sidebar's live panes follow this page's poll: a tab in slots 1–3
|
||
// moves no lane column, so the sidebar would not re-read on its own.
|
||
useEffect(() => {
|
||
if (activeRuns)
|
||
window.dispatchEvent(new CustomEvent(LIVE_RUNS_EVENT, { detail: activeRuns.items }));
|
||
}, [activeRuns]);
|
||
|
||
// Background poll so the run list and history reflect external changes
|
||
// (server-boot reconciliation, sibling tabs, direct DB edits) even when
|
||
// no WS event fires. Lighter than typical WS gaps; aggressive enough that
|
||
// status flips appear within seconds without needing a manual refresh.
|
||
useEffect(() => {
|
||
refreshList();
|
||
const tick = setInterval(() => {
|
||
refreshList();
|
||
}, 5000);
|
||
return () => clearInterval(tick);
|
||
}, [refreshList]);
|
||
|
||
// Refresh whenever the tab regains focus / visibility - typical when the
|
||
// user comes back from running `claude` in a terminal and wants to see the
|
||
// current state of every run without waiting for the next poll.
|
||
useEffect(() => {
|
||
const onFocus = () => refreshList();
|
||
const onVis = () => {
|
||
if (document.visibilityState === "visible") refreshList();
|
||
};
|
||
window.addEventListener("focus", onFocus);
|
||
document.addEventListener("visibilitychange", onVis);
|
||
return () => {
|
||
window.removeEventListener("focus", onFocus);
|
||
document.removeEventListener("visibilitychange", onVis);
|
||
};
|
||
}, [refreshList]);
|
||
|
||
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
|
||
// Only claude lanes carry a pipeline (codex and antigravity emit no hooks),
|
||
// and only while a template is installed — none ships, so it is dormant.
|
||
const noPipeline =
|
||
!!currentLane && (currentLane.tool !== "claude" || currentLane.pipeline_nodes.length === 0);
|
||
|
||
// Feature list follows the selected lane, resets the viewer on lane switch.
|
||
useEffect(() => {
|
||
setViewedFeatureSlug(null);
|
||
setViewedFeature(null);
|
||
if (currentLane === null || currentLane === undefined) {
|
||
setFeatures([]);
|
||
return;
|
||
}
|
||
api.lanes.features
|
||
.list(currentLane.id)
|
||
.then((data) => setFeatures(data.features))
|
||
.catch(() => setFeatures([]));
|
||
}, [currentLane?.id]);
|
||
|
||
// Fetch the archived snapshot when the picker selects one — read-only, never
|
||
// touches the live lane.
|
||
useEffect(() => {
|
||
if (!currentLane || !viewedFeatureSlug) {
|
||
setViewedFeature(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
api.lanes.features
|
||
.show(currentLane.id, viewedFeatureSlug)
|
||
.then((data) => {
|
||
if (!cancelled) setViewedFeature(data.feature);
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setViewedFeature(null);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [currentLane?.id, viewedFeatureSlug]);
|
||
|
||
// Proof gallery follows the selected lane.
|
||
useEffect(() => {
|
||
if (!currentLane) {
|
||
setProofFeatures([]);
|
||
return;
|
||
}
|
||
api.lanes.proof
|
||
.list(currentLane.id)
|
||
.then((data) => setProofFeatures(data.features))
|
||
.catch(() => setProofFeatures([]));
|
||
}, [currentLane?.id, viewedFeatureSlug]);
|
||
|
||
// Resolve the active feature slug: either the user-selected one, or the lane's active_feature_id
|
||
const activeFeatureSlug =
|
||
viewedFeatureSlug ??
|
||
(currentLane?.active_feature_id
|
||
? features.find((f) => f.id === currentLane?.active_feature_id)?.slug
|
||
: null);
|
||
const proofFeature = activeFeatureSlug
|
||
? proofFeatures.find((f) => f.slug === activeFeatureSlug)
|
||
: null;
|
||
|
||
// Viewport locked only when a live run is showing (TerminalView needs locked
|
||
// viewport for chat scrolling). The config-card screen needs normal page flow
|
||
// so the form is fully reachable on short windows. A full-width Changes panel
|
||
// hides that form anyway, and without a locked height it shrinks to its
|
||
// content — the editor inside it collapsed to a few lines (no live run).
|
||
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
|
||
// the console, panes and Changes panel, which the home only hides (CSS), so
|
||
// a live terminal never unmounts behind it.
|
||
const home = !!currentLane && !runParam && !sessionParam;
|
||
const replaying = !!currentLane && !!sessionParam;
|
||
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
|
||
// or the one the opened pane holds.
|
||
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
|
||
// The session view (console or replay) fills the page edge to edge, like an
|
||
// editor: no page padding, no cards, dividers between its regions.
|
||
const sessionView = !!currentLane && !home;
|
||
const viewportLocked =
|
||
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
|
||
|
||
const handleLaneAction = async (id: number, action: string, body?: Record<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.
|
||
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col lg:-m-6"
|
||
: viewportLocked
|
||
? "h-[calc(100vh-4rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
|
||
: "space-y-5"
|
||
}
|
||
>
|
||
{/* Workspaces are picked in the sidebar's project tree; this page shows
|
||
the selected one. With none at all, one banner offers to create it. */}
|
||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||
{lanesLoaded && lanes.length === 0 && (
|
||
/* With no lanes the strip was a dashed tile beside a stray sentence,
|
||
which reads as a half-rendered row rather than a state. One banner
|
||
instead — and a compact one on purpose: the run setup form below
|
||
is what the user actually needs the height for. */
|
||
<button
|
||
type="button"
|
||
data-testid="lane-empty"
|
||
onClick={() => setAddLaneOpen(true)}
|
||
className="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
|
||
>
|
||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted">
|
||
<Layers className="h-5 w-5" />
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<span className="block text-sm font-medium text-fg-primary">
|
||
{tLanes("empty.title")}
|
||
</span>
|
||
<p className="mt-0.5 text-xs text-fg-muted">
|
||
{tLanes("empty.body")}{" "}
|
||
<code className="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary">
|
||
agenthub workspace add --cwd $(pwd)
|
||
</code>
|
||
</p>
|
||
</div>
|
||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent">
|
||
<Plus className="h-4 w-4" />
|
||
{tLanes("add")}
|
||
</span>
|
||
</button>
|
||
)}
|
||
|
||
{/* 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()}
|
||
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
|
||
}
|
||
onOpenRun={(runId, opts) =>
|
||
currentLane &&
|
||
setSearchParams(
|
||
{ lane: String(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}
|
||
/>
|
||
</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"}`}
|
||
>
|
||
{/* Activity bar: Files · Changes · Sessions, then the resize
|
||
control — the same height as the console's tab bar, so the
|
||
two read as one header strip. */}
|
||
<div
|
||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
|
||
role="toolbar"
|
||
>
|
||
{PANEL_TABS.map(({ id, Icon }) => (
|
||
<button
|
||
key={id}
|
||
type="button"
|
||
data-testid={`panel-tab-${id}`}
|
||
aria-pressed={panelTab === id}
|
||
// Narrow, a tab switch leaves the open file (and its
|
||
// edit); at full width the editor stays put.
|
||
onClick={() =>
|
||
panelTab !== id &&
|
||
(changesFull ? setPanelTab(id) : afterDrop(() => setPanelTab(id)))
|
||
}
|
||
title={tLanes(`panel.${id}`)}
|
||
aria-label={tLanes(`panel.${id}`)}
|
||
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
|
||
panelTab === id
|
||
? "bg-accent/15 text-accent"
|
||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||
}`}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
</button>
|
||
))}
|
||
{/* 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>
|
||
<button
|
||
type="button"
|
||
data-testid="lane-changes-resize"
|
||
onClick={() => {
|
||
// Past the widest step the next state is "gone", which
|
||
// is what the rail button reopens at `compact` — and
|
||
// what drops an unsaved edit, so that asks first.
|
||
if (changesFull) {
|
||
afterDrop(() => {
|
||
setChangesOpen(false);
|
||
setChangesSize("compact");
|
||
});
|
||
} else {
|
||
setChangesSize(nextChangesSize);
|
||
}
|
||
}}
|
||
// The control names the same region the rail button does,
|
||
// so a screen reader reads one panel that is open or
|
||
// closed rather than two unrelated buttons.
|
||
aria-expanded
|
||
aria-controls="lane-changes-panel"
|
||
aria-label={changesNextLabel}
|
||
title={changesNextLabel}
|
||
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"
|
||
>
|
||
{/* Icon-only, and it draws the width the click LANDS on,
|
||
not the current one — the label the tooltip carries. */}
|
||
{(() => {
|
||
const Next = CHANGES_NEXT_ICON[changesPreset];
|
||
return <Next className="h-4 w-4" />;
|
||
})()}
|
||
</button>
|
||
</div>
|
||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||
{panelAt && (
|
||
<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}
|
||
laneId={panelAt.laneId}
|
||
session={panelAt.session}
|
||
tab={panelTab}
|
||
full={changesFull}
|
||
onDirtyChange={setChangesDirty}
|
||
// 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);
|
||
}}
|
||
/>
|
||
)}
|
||
</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 && <LaneGitRailCounts laneId={panelAt.laneId} session={panelAt.session} />}
|
||
</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>
|
||
);
|
||
}
|