Files
AgentHub/client/src/pages/Workspace.tsx
T
nntrivi2001 b7f3735b29 feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
2026-10-08 23:04:57 +07:00

1393 lines
61 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 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,
ChartColumn,
} from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast";
import { confirmDialog, confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { useWorkLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import {
ConsoleTabs,
newTabBody,
newTabTools,
runName,
runState,
} from "../components/run/ConsoleTabs";
import {
Divider,
type LayoutUpdate,
PaneGrid,
SplitMenus,
usePaneGroups,
} from "../components/run/PaneGrid";
import {
type Edge,
type PaneGroups,
type PaneLayout,
MAX_PANES,
activeLayout,
closeInLayout,
groupOf,
leaves,
moveInLayout,
openInGroups,
pane,
pruneGroups,
removeSlot,
updateActive,
} from "../lib/paneLayout";
import { useAiTools } from "../hooks/useAiTools";
import { freeSlot, startInFreeTab } from "../components/lanes/startSession";
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
import { LIVE_RUNS_EVENT } from "../components/WorkspaceTree";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since } from "../components/lanes/LaneCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
import { SessionReplay } from "../components/lanes/SessionReplay";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
/** localStorage key for the Changes panel's open/closed state. */
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
/** localStorage key for how wide the Changes panel is. */
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
const SELECTED_KEY = "agenthub.workspace.selected";
/** The three widths the Changes panel cycles through, then closed. Reading a
* diff and watching a console want opposite amounts of room, and which one is
* "work" changes several times an hour — hence ONE control that steps
* 30/70 → 50/50 → full → closed, rather than a width button beside a
* collapse button that both live in the panel's tightest corner. */
const CHANGES_SIZES = ["compact", "half", "full"] as const;
type ChangesPreset = (typeof CHANGES_SIZES)[number];
/** A preset, or a width (panel fraction 0.15–0.85) the splitter was dragged to. */
type ChangesSize = ChangesPreset | number;
const CHANGES_WIDTH: Record<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, at the 2×2 grid's next place. Every live run
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose
* run ends closes; the last tab gone leaves for the home. */
function ConsoleArea({
lanes,
selectedLaneId,
binaryStatus,
activeRuns,
onHasActiveRunChange,
attachRequest,
onNewSession,
onOpenRun,
onRunsChanged,
}: {
lanes: Lane[];
selectedLaneId: number | null;
binaryStatus: Record<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;
}) {
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 pane header's X: stops that one session, as a one-pane tab's X does —
// an AI session may be mid-turn, so it asks first; a shell holds no agent.
const stopPane = async (slot: number) => {
const run = runOf(slot);
if (!run) return;
if (
run.tool !== "shell" &&
!(await confirmDialog({
title: t("tabs.stopTitle", { name: label(run) }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(run.id);
ended(slot);
} catch (err) {
toast.error(err);
}
};
// A split menu's "New terminal" or AI tool (bare, as the tab bar's `+`
// starts it) in the first free slot, placed on that side of the pane —
// unless the workspace changed while it started.
const free = lane ? freeSlot(lane.id, tabs) : null;
const splitTools = lane ? newTabTools(lane, aiTools) : [];
const newSplit = async (target: number, edge: Edge, tool?: string) => {
if (!lane || free === null) return;
const laneId = lane.id;
try {
const body = tool ? newTabBody(tool, lane, aiTools) : { shell: true };
// The server picks the slot: the list `free` came from may be stale.
const slot = slotOf(await startInFreeTab(laneId, body));
onRunsChanged();
if (laneRef.current !== laneId) return;
pending.current.set(slot, activeRuns);
change((l) => moveInLayout(l, slot, target, edge));
} catch (err) {
toast.error(err);
}
};
const requested = attachRequest ? [slotOf(attachRequest.runId)] : [];
// No layout yet (a first visit, a request not yet applied, or no run at
// all): one pane, so the console always has its single render site.
const fallback = requested[0] ?? 0;
const view: PaneLayout = layout.root ? layout : { root: pane(fallback), focus: fallback };
const viewGroups: PaneGroups = groups.groups.length ? groups : { groups: [view], active: 0 };
// A change to the tab on screen (`PaneGrid`, the split menus). Tabs never
// saved are the one-pane `view`: change that.
const change: LayoutUpdate = (fn) =>
changeGroups((g) => updateActive(g.groups.length ? g : viewGroups, fn));
const shown = leaves(view.root);
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b);
const focusSlot = view.focus;
// Alt+1–4 focus a pane, Alt+\ splits the focused one right with a new
// terminal, Alt+W takes it out into a tab of its own. Session view only, and never from inside a
// terminal or a field, whose keys are their own (Ctrl+M lets Tab out of a
// terminal first). Read through a ref: one listener, always current.
const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined);
const areaRef = useRef<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}
onStop={(slot) => void stopPane(slot)}
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 } = useWorkLanes();
const laneParam = searchParams.get("lane");
// `?run=` — a live session picked in the sidebar; the location key makes a
// second pick of the same session a new request.
const location = useLocation();
const runParam = searchParams.get("run");
// `?session=` — an ENDED session picked in the sidebar or Recents: its
// conversation, with a prompt box whose message resumes it (SessionReplay).
const sessionParam = runParam ? null : searchParams.get("session");
const navigate = useNavigate();
// A Ctrl/Cmd+click (tab or sidebar) opens the run in a split;
// it rides on the navigation's state, never in the URL.
const navState = location.state as { split?: unknown; focusOnly?: unknown } | null;
const splitOpen = navState?.split === true;
const focusOnly = navState?.focusOnly === true;
const attachRequest = useMemo(
() => (runParam ? { runId: runParam, nonce: location.key, split: splitOpen, focusOnly } : null),
[runParam, location.key, splitOpen, focusOnly]
);
const paramId = laneParam === null ? null : Number(laneParam);
const fallbackId = (() => {
if (!lanesLoaded) return null;
let stored: number | null = null;
try {
stored = Number(window.localStorage.getItem(SELECTED_KEY));
} catch {
/* disabled storage */
}
return lanes.find((l) => l.id === stored)?.id ?? lanes[0]?.id ?? null;
})();
const selectedLaneId =
paramId !== null && lanes.some((l) => l.id === paramId) ? paramId : fallbackId;
const setSelectedLaneId = useCallback(
(id: number | null) =>
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
// A run belongs to its lane: another lane opens on its home.
if (next.get("lane") !== String(id)) {
next.delete("run");
next.delete("session");
}
if (id === null) next.delete("lane");
else next.set("lane", String(id));
return next;
},
{ replace: true }
),
[setSearchParams]
);
// Name the fallback in the URL, so the sidebar highlights it and F5 keeps it.
// Only a URL naming NO lane is rewritten: one naming a lane the list has not
// caught up with yet (just created) must survive until the refetch lands.
useEffect(() => {
if (!lanesLoaded || selectedLaneId === null) return;
try {
window.localStorage.setItem(SELECTED_KEY, String(selectedLaneId));
} catch {
/* disabled storage */
}
if (paramId === null) setSelectedLaneId(selectedLaneId);
}, [lanesLoaded, selectedLaneId, paramId, setSelectedLaneId]);
// A lane action's failure, as a toast (it stays until dismissed).
const setLaneActionError = (message: string | null) => {
if (message) toast.error(tLanes("actionError", { message }));
};
const [destructive, setDestructive] = useState<{
lane: Lane;
action: "reset" | "remove";
} | null>(null);
const [addLaneOpen, setAddLaneOpen] = useState(false);
// Fields the Add-lane modal opens with when it was opened from a lane's git
// bar ("new lane here"): one provisioning path for every lane, prefilled.
const [addLanePrefill, setAddLanePrefill] = useState<{
sourceRepo?: string;
branch?: string;
} | null>(null);
const [viewedFeatureSlug, setViewedFeatureSlug] = useState<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);
// 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(() => {
// Hidden tab: skip; the visibility handler below re-reads on show.
if (document.visibilityState === "visible") refreshList();
}, 5000);
return () => clearInterval(tick);
}, [refreshList]);
// Refresh whenever the tab regains focus / visibility - typical when the
// user comes back from running `claude` in a terminal and wants to see the
// current state of every run without waiting for the next poll.
useEffect(() => {
const onFocus = () => refreshList();
const onVis = () => {
if (document.visibilityState === "visible") refreshList();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVis);
return () => {
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVis);
};
}, [refreshList]);
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
// Only claude lanes carry a pipeline (codex and antigravity emit no hooks),
// and only while a template is installed — none ships, so it is dormant.
const noPipeline =
!!currentLane && (currentLane.tool !== "claude" || currentLane.pipeline_nodes.length === 0);
// Feature list follows the selected lane, resets the viewer on lane switch.
useEffect(() => {
setViewedFeatureSlug(null);
setViewedFeature(null);
if (currentLane === null || currentLane === undefined) {
setFeatures([]);
return;
}
api.lanes.features
.list(currentLane.id)
.then((data) => setFeatures(data.features))
.catch(() => setFeatures([]));
}, [currentLane?.id]);
// Fetch the archived snapshot when the picker selects one — read-only, never
// touches the live lane.
useEffect(() => {
if (!currentLane || !viewedFeatureSlug) {
setViewedFeature(null);
return;
}
let cancelled = false;
api.lanes.features
.show(currentLane.id, viewedFeatureSlug)
.then((data) => {
if (!cancelled) setViewedFeature(data.feature);
})
.catch(() => {
if (!cancelled) setViewedFeature(null);
});
return () => {
cancelled = true;
};
}, [currentLane?.id, viewedFeatureSlug]);
// Proof gallery follows the selected lane.
useEffect(() => {
if (!currentLane) {
setProofFeatures([]);
return;
}
api.lanes.proof
.list(currentLane.id)
.then((data) => setProofFeatures(data.features))
.catch(() => setProofFeatures([]));
}, [currentLane?.id, viewedFeatureSlug]);
// Resolve the active feature slug: either the user-selected one, or the lane's active_feature_id
const activeFeatureSlug =
viewedFeatureSlug ??
(currentLane?.active_feature_id
? features.find((f) => f.id === currentLane?.active_feature_id)?.slug
: null);
const proofFeature = activeFeatureSlug
? proofFeatures.find((f) => f.slug === activeFeatureSlug)
: null;
// Viewport locked only when a live run is showing (TerminalView needs locked
// viewport for chat scrolling). The config-card screen needs normal page flow
// so the form is fully reachable on short windows. A full-width Changes panel
// hides that form anyway, and without a locked height it shrinks to its
// content — the editor inside it collapsed to a few lines (no live run).
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
// the console, panes and Changes panel, which the home only hides (CSS), so
// a live terminal never unmounts behind it.
const home = !!currentLane && !runParam && !sessionParam;
const replaying = !!currentLane && !!sessionParam;
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
// or the one the opened pane holds.
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
// The session view (console or replay) fills the page edge to edge, like an
// editor: no page padding, no cards, dividers between its regions.
const sessionView = !!currentLane && !home;
const viewportLocked =
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
const handleLaneAction = async (id: number, action: string, body?: Record<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 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"}`}
>
{/* Header: the title, Analyst, then the resize control — 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>
<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}
full={changesFull}
wide={changesPreset !== "compact"}
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}
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>
);
}