Files
AgentHub/client/src/pages/Workspace.tsx
T
nntrivi2001 17d56d0e01 refactor!: rebrand CCAM to AgentHub
Clean break, no compatibility alias. Renames the product name, the CLI
binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` ->
`AGENTHUB_`), the Prometheus metric namespace (`ccam_*` ->
`agenthub_*`), the tmux session prefix (`ccam-lane-<id>` ->
`agenthub-lane-<id>`), the per-repo runtime profile directory
(`.ccam/profile` -> `.agenthub/profile`), the lanes state root
(`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled
plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash
commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` ->
`/agenthub:*`).

Because there is no alias, an existing install has to be updated by
hand: rename its `.ccam/profile` directory and any `CCAM_*` variables,
and reinstall the plugin under its new name.

Two other name families are deliberately left alone here, since they are
not just strings: the `Claude-Code-Monitor` repository/marketplace name,
and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard`
runtime directory, which holds the live database and needs a migration
rather than a rename.

Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across
tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
2026-09-07 12:09:15 +07:00

815 lines
33 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). Displays a horizontal
* lane strip at the top with counters, the selected lane's pipeline map, and run
* configuration/console/history below. 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. Polls GET
* /api/sessions?status=active alongside the run list so Claude Code sessions
* started outside the dashboard also appear in the active-runs list.
*
* @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.
* - `../lib/eventBus` — WebSocket subscription.
* - `../components/lanes/` — LaneCard, PipelineMap, DestructiveLaneModal.
* - `../components/run/` — RunConsole, RunSetup, RunHistory, ActiveRunsSwitcher.
*
* ## 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 { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunListResponse } from "../lib/api";
import type {
Lane,
LaneFeature,
LaneCounts,
ProofFeature,
Session,
WSMessage,
LaneTool,
} from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { keepIfSame } from "../lib/keepIfSame";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard";
import LaneStripCard from "../components/lanes/LaneStripCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
// ── Page ──────────────────────────────────────────────────────────────
function SplitLayoutToggle({
splitView,
setLayout,
}: {
splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void;
}) {
const { t: tLanes } = useTranslation("lanes");
return (
<div className="flex items-center gap-1.5">
{([1, 2, 4] as const).map((n) => (
<button
key={n}
type="button"
aria-pressed={splitView.layout === n}
onClick={() => setLayout(n)}
className={`rounded border px-2 py-1 text-xs ${
splitView.layout === n
? "border-accent bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:border-border-light"
}`}
>
{tLanes("splitView.paneCount", { count: n })}
</button>
))}
</div>
);
}
function ConsoleArea({
lanes,
selectedLaneId,
splitView,
setLayout,
setPaneLaneId,
binaryStatus,
cwdSuggestions,
activeRuns,
externalSessions,
wsConnected,
defaultCwd,
onHasActiveRunChange,
onLaneCreated,
onLaneIdChange,
showToggle = true,
}: {
lanes: Lane[];
selectedLaneId: number | null;
splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void;
setPaneLaneId: (index: number, id: number) => void;
binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
externalSessions: Session[];
wsConnected: boolean;
defaultCwd: string;
onHasActiveRunChange: (val: boolean) => void;
onLaneCreated: (lane: Lane) => void;
onLaneIdChange: (id: number | null) => void;
showToggle?: boolean;
}) {
return (
<>
{showToggle && <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />}
{splitView.layout === 1 ? (
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
showLaneSelector={false}
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={onHasActiveRunChange}
/>
) : (
<div
className={`grid flex-1 min-h-0 gap-3 ${
splitView.layout === 2 ? "grid-cols-2" : "grid-cols-2 grid-rows-2"
}`}
>
{splitView.paneLaneIds.map((id, i) => (
<LaneConsolePane
key={i}
lanes={lanes}
laneId={id}
showLaneSelector
onLaneIdChange={(newId) => setPaneLaneId(i, newId)}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
/>
))}
</div>
)}
</>
);
}
export function Workspace() {
const { t: tLanes } = useTranslation("lanes");
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
// Lane state
const [lanes, setLanes] = useState<Lane[]>([]);
const [counts, setCounts] = useState<LaneCounts>({ total: 0, running: 0, needs_you: 0, dead: 0 });
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
const [laneActionError, setLaneActionError] = useState<string | null>(null);
const [addLaneOpen, setAddLaneOpen] = useState(false);
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[]>([]);
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
// 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);
// Claude Code sessions running outside the dashboard (a `claude` the user
// started in a terminal tab). They are not dashboard runs, so the run list
// would otherwise show nothing while two agents are working.
const [externalSessions, setExternalSessions] = useState<Session[]>([]);
const [binaryStatus, setBinaryStatus] = useState<Record<
LaneTool,
{ found: boolean; path: string | null }
> | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [defaultCwd, setDefaultCwd] = useState<string>("");
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
// Split view state
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
// Info block (pickers, LaneCard, PipelineMap, proof gallery) starts shown in
// single-pane view and collapsed in split view, but the toggle below can
// override that in either mode — the user decides, not the layout.
const [infoExpanded, setInfoExpanded] = useState(() => splitView.layout === 1);
const showInfo = infoExpanded;
const setLayout = useCallback((layout: SplitLayout) => {
setSplitView((prev) => {
const paneLaneIds = Array.from({ length: layout }, (_, i) => prev.paneLaneIds[i] ?? null);
const next = { layout, paneLaneIds };
writeSplitViewState(next);
return next;
});
}, []);
const setPaneLaneId = useCallback((index: number, id: number) => {
setSplitView((prev) => {
const paneLaneIds = [...prev.paneLaneIds];
paneLaneIds[index] = id;
const next = { ...prev, paneLaneIds };
writeSplitViewState(next);
return next;
});
}, []);
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
const refreshLanes = useCallback(async () => {
try {
const r = await api.lanes.list();
setLanes(keepIfSame(r.lanes));
setCounts(keepIfSame(r.counts));
setSelectedLaneId((cur) =>
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
);
} catch {
// Silent fail for lanes load
}
}, []);
useEffect(() => {
Promise.all([api.run.binary("claude"), api.run.binary("codex")])
.then(([claude, codex]) => setBinaryStatus({ claude, codex }))
// Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
// `claude` or `codex` is missing from PATH — leave the probe unresolved rather than
// showing a misleading "binary missing" banner for an unrelated fault.
.catch(() => undefined);
api.lanes
.pipelines()
.then((r) => setPipelineTemplates(r.pipelines))
.catch(() => undefined);
void refreshLanes();
api.run
.cwds()
.then((r) => {
setCwdSuggestions(r.items);
// Pre-fill cwd with the user's home directory — a neutral default.
// Spawning in the dashboard's own cwd would make ad-hoc runs inherit
// this repo's project context (.claude/agents, skills, rules,
// CLAUDE.md, .mcp.json), which is almost never what an ad-hoc run
// wants and can bloat the initial request (issue #202). Fall back to
// the dashboard cwd when no home suggestion exists. The user can
// change it; we just don't want an invisible default.
const home = r.items.find((s) => s.kind === "home");
const dashboard = r.items.find((s) => s.kind === "dashboard");
const preferred = home || dashboard;
if (preferred) {
setDefaultCwd(preferred.path);
}
})
.catch(() => undefined);
// Subscribe to lane updates from the event bus
return eventBus.subscribe((msg: WSMessage) => {
if (msg.type !== "lane_update") return;
const payload = msg.data as { lane?: Lane; removed?: number };
if (payload.removed !== undefined) {
void refreshLanes();
return;
}
const lane = payload.lane;
if (!lane) return;
setLanes((cur) => {
const i = cur.findIndex((l) => l.id === lane.id);
if (i === -1) {
void refreshLanes();
return cur;
}
// The server re-broadcasts a lane on hook activity that often changed
// nothing this page renders; replacing the array anyway would re-render
// the strip and the console on every such event.
if (JSON.stringify(cur[i]) === JSON.stringify(lane)) return cur;
const next = [...cur];
next[i] = lane;
return next;
});
});
}, [refreshLanes]);
// A reconnect (e.g. the dashboard server restarting) resumes the WS but does
// not replay missed lane_update diffs, so a lane whose fields changed while
// disconnected — status, needs_action — would keep showing its pre-restart
// badges forever with no further server-side change to broadcast. Refetch
// the full list whenever the socket comes back up.
useEffect(
() =>
eventBus.onConnection((isConnected) => {
if (isConnected) void refreshLanes();
}),
[refreshLanes]
);
// Clean up stale pane lane IDs when lanes load
useEffect(() => {
if (!lanes.length) return;
setSplitView((prev) => {
const paneLaneIds = prev.paneLaneIds.map((id) =>
id !== null && lanes.some((l) => l.id === id) ? id : null
);
if (paneLaneIds.every((id, i) => id === prev.paneLaneIds[i])) return prev;
const next = { ...prev, paneLaneIds };
writeSplitViewState(next);
return next;
});
}, [lanes]);
// Both setters go through `keepIfSame`: these fire 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);
api.sessions
.list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true })
.then((r) => setExternalSessions(keepIfSame(r.sessions)))
.catch(() => undefined);
}, []);
// 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;
// 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.
const viewportLocked = paneHasActiveRun;
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={
viewportLocked
? "h-[calc(100vh-2.5rem)] lg:h-[calc(100vh-3rem)] flex flex-col gap-5"
: "space-y-5"
}
>
{/* Page header: what this screen is, and the four numbers that say
whether anything needs a human right now. */}
<div className="flex flex-wrap items-center gap-3 border-b border-border pb-3">
<h2 className="text-base font-semibold tracking-tight text-fg-primary">
{tLanes("title")}
</h2>
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<span
data-testid="count-total"
className="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
>
{counts.total} {tLanes("countTotal")}
</span>
<span
data-testid="count-running"
className="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
>
{counts.running} {tLanes("countRunning")}
</span>
{counts.needs_you > 0 && (
<span
data-testid="count-needs-you"
className="rounded-full bg-status-warning/20 px-2.5 py-0.5 text-status-warning"
>
{counts.needs_you} {tLanes("countNeedsYou")}
</span>
)}
{counts.dead > 0 && (
<span
data-testid="count-dead"
className="rounded-full bg-status-danger/20 px-2.5 py-0.5 text-status-danger"
>
{counts.dead} {tLanes("countDead")}
</span>
)}
</div>
<div className="ml-auto flex items-center gap-2">
{currentLane && <LaneToolsMenu lane={currentLane} />}
<button
onClick={() => setAddLaneOpen(true)}
className="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
title={tLanes("addLane")}
>
<Plus className="h-3.5 w-3.5" />
{tLanes("add")}
</button>
</div>
</div>
{/* Lane strip + detail stack vertically: the strip is a horizontally
scrolling row (holds any number of lanes), the detail/console area
below it gets the full width and never competes for it. */}
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div data-testid="lane-strip" className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1">
{lanes.map((l) => (
<LaneStripCard
key={l.id}
lane={l}
selected={selectedLaneId === l.id}
onSelect={() => setSelectedLaneId(l.id)}
/>
))}
{!lanes.length && (
<p className="text-sm text-fg-muted">
{tLanes("emptyState")} <code>agenthub lanes add --cwd $(pwd)</code>
</p>
)}
</div>
{/* 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. */}
<section
data-testid="lane-detail"
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}`}
>
{currentLane && (
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
{currentLane.liveness === "dead"
? tLanes("statusDead")
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
</span>
<span className="truncate text-sm font-semibold text-fg-primary">
{tLanes("laneHeader", {
id: currentLane.id,
title: currentLane.title || currentLane.cwd,
pipeline: currentLane.pipeline_name,
})}
</span>
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{since(currentLane.stage_seconds)}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
data-testid="lane-detail-toggle"
aria-expanded={infoExpanded}
onClick={() => setInfoExpanded((v) => !v)}
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
>
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
</button>
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
</div>
</div>
)}
{currentLane && showInfo && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
<div className="mb-3 flex flex-wrap items-baseline gap-2">
<select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
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>
{/* `stage` defaults to the DB sentinel "idle" until the driving
session ever calls `agenthub stage` — that string collides with
`status`'s own "idle"/"running" vocabulary, so a lane that is
actively running but has never declared a stage read as if it
were sitting idle. Show a distinct label instead of the raw
sentinel whenever it doesn't match any node this pipeline
actually has. */}
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
? currentLane.stage
: tLanes("stageUndeclared")}
</span>
{currentLane.detected_stage && (
<span
data-testid="detail-auto-stage"
title={currentLane.detected_signal || undefined}
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
>
{tLanes("autoStage", { stage: currentLane.detected_stage })}
</span>
)}
{features.length > 0 && (
<select
data-testid="feature-picker"
className="rounded border border-border bg-surface-1 px-2 py-0.5 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>
)}
<button
type="button"
data-testid="lane-action-remove"
onClick={() => setRemoveConfirmOpen(true)}
className="ml-auto rounded px-2 py-0.5 text-status-danger transition-colors hover:bg-status-danger/15"
>
{currentLane.kind === "adopted"
? tLanes("action.forget")
: tLanes("action.remove")}
</button>
</div>
<div className="mb-3">
<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>
<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 gap-2 ${
currentLane ? "border-t border-border pt-3" : ""
}`}
>
{/* `showToggle`: the lane header carries its own split toggle; with
no lane there is no header, so the console shows one instead. */}
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
setPaneLaneId={setPaneLaneId}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
onLaneIdChange={setSelectedLaneId}
showToggle={!currentLane}
/>
</div>
</section>
</div>
{currentLane && removeConfirmOpen && (
<DestructiveLaneModal
lane={currentLane}
action="remove"
open
onClose={() => setRemoveConfirmOpen(false)}
onConfirm={(body) => {
setRemoveConfirmOpen(false);
void handleLaneAction(currentLane.id, "remove", body);
}}
/>
)}
<AddLaneModal
open={addLaneOpen}
cwdSuggestions={cwdSuggestions}
onClose={() => setAddLaneOpen(false)}
onAdded={(lane) => {
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]));
setSelectedLaneId(lane.id);
void refreshLanes();
}}
/>
{laneActionError && (
<p
role="alert"
className="rounded border border-status-danger bg-status-danger/40 px-3 py-2 text-sm text-status-danger"
>
{tLanes("actionError", { message: laneActionError })}
</p>
)}
</div>
);
}