1b561c22d9
- Changes card beside lane detail with five modes: Files tree, overview, Review (whole diff, per-file collapse), single file, commit history. - LaneDiffView: unified / side-by-side with independent scroll on both axes and mirrored scrollLeft, expand/collapse context, intra-line emphasis. - Staged-only commit box; git actions (fetch/pull/push/stage/unstage/ discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js. - Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind. - Add lane: new/existing branch, current branch, worktree list, prefill from the git bar's "New lane here"; /api/run/cwds gains a repo kind. - GlobalTooltip upgrades every native title into a themed anchored bubble; chart/info popovers use theme tokens.
1078 lines
45 KiB
TypeScript
1078 lines
45 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 (its first tile creates a lane), 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 { useSearchParams } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Plus,
|
|
Square,
|
|
Columns2,
|
|
Grid2x2,
|
|
Pencil,
|
|
Layers,
|
|
PanelLeftClose,
|
|
ChevronsLeftRight,
|
|
PanelRightClose,
|
|
} from "lucide-react";
|
|
import { api } from "../lib/api";
|
|
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
|
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
|
|
import { eventBus } from "../lib/eventBus";
|
|
import { keepIfSame } from "../lib/keepIfSame";
|
|
import {
|
|
readLaneOrder,
|
|
writeLaneOrder,
|
|
sortByStoredOrder,
|
|
moveLane,
|
|
} from "../lib/laneOrderStorage";
|
|
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
|
import PipelineMap from "../components/lanes/PipelineMap";
|
|
import LaneCard, {
|
|
since,
|
|
LIVENESS_DOT,
|
|
LaneToolsMenu,
|
|
LaneRouterRow,
|
|
} from "../components/lanes/LaneCard";
|
|
import LaneStripCard from "../components/lanes/LaneStripCard";
|
|
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
|
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
|
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
|
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
|
|
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
|
|
|
/** 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";
|
|
|
|
/** 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
|
|
* narrow → 50/50 → 70/30 → closed, rather than a width button beside a
|
|
* collapse button that both live in the panel's tightest corner. */
|
|
const CHANGES_SIZES = ["compact", "half", "wide"] as const;
|
|
type ChangesSize = (typeof CHANGES_SIZES)[number];
|
|
const CHANGES_WIDTH: Record<ChangesSize, string> = {
|
|
compact: "w-[28rem] xl:w-[34rem]",
|
|
half: "w-1/2",
|
|
wide: "w-[70%]",
|
|
};
|
|
/** The icon the one resize control shows AT each size: what the next click
|
|
* gives you — an even split, a wider panel, then closed. */
|
|
const CHANGES_NEXT_ICON: Record<ChangesSize, typeof Columns2> = {
|
|
compact: Columns2,
|
|
half: ChevronsLeftRight,
|
|
wide: PanelRightClose,
|
|
};
|
|
|
|
// ── Page ──────────────────────────────────────────────────────────────
|
|
|
|
/** One pane, two side by side, four in a grid — the conventional icons for
|
|
* each, so the toggle reads at a glance without spending three words on it. */
|
|
const LAYOUT_CHOICES = [
|
|
{ n: 1 as const, Icon: Square },
|
|
{ n: 2 as const, Icon: Columns2 },
|
|
{ n: 4 as const, Icon: Grid2x2 },
|
|
];
|
|
|
|
function SplitLayoutToggle({
|
|
splitView,
|
|
setLayout,
|
|
}: {
|
|
splitView: SplitViewState;
|
|
setLayout: (layout: SplitLayout) => void;
|
|
}) {
|
|
const { t: tLanes } = useTranslation("lanes");
|
|
|
|
return (
|
|
<div className="flex items-center gap-1.5">
|
|
{LAYOUT_CHOICES.map(({ n, Icon }) => {
|
|
// The count still reaches assistive tech and the tooltip — only the
|
|
// rendered word is gone, and the icon says the same thing in the
|
|
// shape every other app uses for it.
|
|
const label = tLanes("splitView.paneCount", { count: n });
|
|
return (
|
|
<button
|
|
key={n}
|
|
type="button"
|
|
aria-pressed={splitView.layout === n}
|
|
aria-label={label}
|
|
title={label}
|
|
onClick={() => setLayout(n)}
|
|
className={`rounded border p-1.5 ${
|
|
splitView.layout === n
|
|
? "border-accent bg-accent/15 text-accent"
|
|
: "border-border text-fg-secondary hover:border-border-light"
|
|
}`}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" />
|
|
</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);
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
// Lane state
|
|
const [lanes, setLanes] = useState<Lane[]>([]);
|
|
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
|
|
const [laneActionError, setLaneActionError] = useState<string | 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[]>([]);
|
|
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
|
|
// Inline rename of the selected lane's title, in the detail header.
|
|
const [renamingLaneId, setRenamingLaneId] = useState<number | null>(null);
|
|
const [renameDraft, setRenameDraft] = useState("");
|
|
|
|
// Run state kept at page level: shared across every pane, or drives the
|
|
// lane strip itself rather than any one pane's form.
|
|
const [activeRuns, setActiveRuns] = useState<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);
|
|
// 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]);
|
|
const [changesSize, setChangesSize] = useState<ChangesSize>(() => {
|
|
try {
|
|
const saved = window.localStorage.getItem(CHANGES_SIZE_KEY) as ChangesSize | null;
|
|
return saved && CHANGES_SIZES.includes(saved) ? saved : "compact";
|
|
} catch {
|
|
return "compact";
|
|
}
|
|
});
|
|
useEffect(() => {
|
|
try {
|
|
window.localStorage.setItem(CHANGES_SIZE_KEY, changesSize);
|
|
} catch {
|
|
/* same as above: a forgotten width, not an error */
|
|
}
|
|
}, [changesSize]);
|
|
const nextChangesSize =
|
|
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesSize) + 1) % CHANGES_SIZES.length]!;
|
|
const changesNextLabel =
|
|
changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]
|
|
? tLanes("changes.collapse")
|
|
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
|
|
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();
|
|
// The `lanes` state IS the display order: the server answers in id order,
|
|
// the viewer's stored strip order is applied once here, and the in-place
|
|
// lane_update merge below preserves whatever order this produced.
|
|
setLanes(keepIfSame(sortByStoredOrder(r.lanes, readLaneOrder())));
|
|
setSelectedLaneId((cur) =>
|
|
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
|
);
|
|
} catch {
|
|
// Silent fail for lanes load
|
|
}
|
|
}, []);
|
|
|
|
// Deep link from a notification click (`/workspace?lane=<id>`, see
|
|
// useNotifications' laneUrlForSession): once that lane is in the loaded
|
|
// list, select it and drop the param so a later manual lane switch sticks.
|
|
useEffect(() => {
|
|
const laneParam = searchParams.get("lane");
|
|
if (laneParam === null || lanes.length === 0) return;
|
|
const paramId = Number(laneParam);
|
|
if (lanes.some((l) => l.id === paramId)) {
|
|
setSelectedLaneId(paramId);
|
|
}
|
|
setSearchParams(
|
|
(prev) => {
|
|
const next = new URLSearchParams(prev);
|
|
next.delete("lane");
|
|
return next;
|
|
},
|
|
{ replace: true }
|
|
);
|
|
}, [lanes, searchParams, setSearchParams]);
|
|
|
|
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"));
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Move a lane card within the strip. Purely a viewer preference — it writes
|
|
* localStorage, never the lane row, so nothing the CLI or another session
|
|
* reads changes.
|
|
*/
|
|
const handleLaneReorder = (from: number, to: number) => {
|
|
if (from === to || from < 0 || to < 0 || from >= lanes.length || to >= lanes.length) return;
|
|
const ids = moveLane(
|
|
lanes.map((l) => l.id),
|
|
from,
|
|
to
|
|
);
|
|
writeLaneOrder(ids);
|
|
setLanes(sortByStoredOrder(lanes, ids));
|
|
};
|
|
|
|
/** Commit an inline lane rename. An empty name clears the title, which puts
|
|
* the cwd back in its place (`lane.title || lane.cwd` everywhere). */
|
|
const commitRename = async (id: number) => {
|
|
const title = renameDraft.trim();
|
|
setRenamingLaneId(null);
|
|
const lane = lanes.find((l) => l.id === id);
|
|
if (!lane || title === (lane.title || "")) return;
|
|
try {
|
|
await api.lanes.update(id, { title });
|
|
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"
|
|
}
|
|
>
|
|
{/* 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">
|
|
{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 lanes 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>
|
|
) : (
|
|
<div
|
|
data-testid="lane-strip"
|
|
className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
|
>
|
|
{/* "Add lane" is a tile in the strip rather than a header button:
|
|
creating a lane is the same kind of act as picking one, so it
|
|
reads as the empty slot at the head of the row. Dashed border and
|
|
no liveness dot keep it from being mistaken for a lane. */}
|
|
<button
|
|
type="button"
|
|
data-testid="lane-tile-add"
|
|
onClick={() => setAddLaneOpen(true)}
|
|
title={tLanes("addLane")}
|
|
className="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{tLanes("add")}
|
|
</button>
|
|
{lanes.map((l, i) => (
|
|
<LaneStripCard
|
|
key={l.id}
|
|
lane={l}
|
|
index={i}
|
|
selected={selectedLaneId === l.id}
|
|
onSelect={() => setSelectedLaneId(l.id)}
|
|
onReorder={handleLaneReorder}
|
|
/>
|
|
))}
|
|
</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. */}
|
|
{/* 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. */}
|
|
<div className="flex min-h-0 flex-1 gap-4">
|
|
<section
|
|
data-testid="lane-detail"
|
|
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}`}
|
|
>
|
|
{/* One header row, not two. The pipeline picker, the stage badges,
|
|
the feature picker and the router select used to sit on a second
|
|
row below this one; every one of them is a per-lane control that
|
|
belongs beside the lane's own name, and stacking them cost a whole
|
|
row of height on a panel whose point is the pipeline map further
|
|
down. The pipeline's NAME is no longer interpolated into
|
|
`laneHeader` either — the picker right next to it already says it.
|
|
They stay gated on `showInfo`, same as before the merge. */}
|
|
{currentLane && (
|
|
<div className="mb-3 flex flex-wrap 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>
|
|
{/* The lane's name, and the one place it can be set. No "Lane 23 ·"
|
|
prefix any more: the id is on the strip tile, where the CLI's
|
|
`--lane` reader looks for it, and repeating it here only ate
|
|
the width the name needed. Submitting an empty name clears
|
|
`title`, which puts the cwd back in its place. */}
|
|
{renamingLaneId === currentLane.id ? (
|
|
<input
|
|
data-testid="lane-rename-input"
|
|
aria-label={tLanes("renameLabel")}
|
|
autoFocus
|
|
value={renameDraft}
|
|
onChange={(e) => setRenameDraft(e.target.value)}
|
|
// Blur CANCELS, it does not save — same convention as the
|
|
// rename field in CcConfig. Committing here too would fire
|
|
// the PATCH twice for one Enter: the commit unmounts the
|
|
// focused input, and removing a focused element raises a
|
|
// native blur before the refetch has moved `lanes`, so the
|
|
// "unchanged title" guard would not catch the second call.
|
|
onBlur={() => setRenamingLaneId(null)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") void commitRename(currentLane.id);
|
|
if (e.key === "Escape") setRenamingLaneId(null);
|
|
}}
|
|
maxLength={200}
|
|
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-0.5 text-sm font-semibold text-fg-primary"
|
|
/>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
data-testid="lane-rename"
|
|
onClick={() => {
|
|
setRenameDraft(currentLane.title || "");
|
|
setRenamingLaneId(currentLane.id);
|
|
}}
|
|
title={`${currentLane.cwd}\n${tLanes("renameLabel")}`}
|
|
className="group flex min-w-0 items-center gap-1 text-sm font-semibold text-fg-primary"
|
|
>
|
|
<span className="min-w-0 truncate">{currentLane.title || currentLane.cwd}</span>
|
|
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
|
|
</button>
|
|
)}
|
|
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
|
{since(currentLane.stage_seconds)}
|
|
</span>
|
|
{showInfo && (
|
|
<>
|
|
<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>
|
|
<LaneRouterRow lane={currentLane} />
|
|
{/* 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="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>
|
|
)}
|
|
</>
|
|
)}
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{/* Per-lane actions (agents install, mcp sync, sync-base check)
|
|
belong beside the lane they act on, not in a page header. */}
|
|
<LaneToolsMenu lane={currentLane} />
|
|
<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} />
|
|
{showInfo && (
|
|
<button
|
|
type="button"
|
|
data-testid="lane-action-remove"
|
|
onClick={() => setRemoveConfirmOpen(true)}
|
|
className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15"
|
|
>
|
|
{/* One label for both kinds: removal forgets the lane's row
|
|
and never deletes a worktree, a branch or a database. */}
|
|
{tLanes("action.forget")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{currentLane && showInfo && (
|
|
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
|
<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}
|
|
onNewLaneFromRepo={(repo, branch) => {
|
|
setAddLanePrefill({ sourceRepo: repo, branch });
|
|
setAddLaneOpen(true);
|
|
}}
|
|
/>
|
|
</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>
|
|
|
|
{currentLane &&
|
|
(changesOpen ? (
|
|
<aside
|
|
id="lane-changes-panel"
|
|
data-testid="lane-changes-card"
|
|
className={`card flex shrink-0 flex-col p-3 ${CHANGES_WIDTH[changesSize]}`}
|
|
>
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<span className="text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
|
|
{tLanes("changes.title")}
|
|
</span>
|
|
<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`.
|
|
if (changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]) {
|
|
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[changesSize];
|
|
return <Next className="h-4 w-4" />;
|
|
})()}
|
|
</button>
|
|
</div>
|
|
<LaneChangesPanel laneId={currentLane.id} />
|
|
</aside>
|
|
) : (
|
|
<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="card flex w-9 shrink-0 flex-col items-center gap-2 py-3 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>
|
|
))}
|
|
</div>
|
|
</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}
|
|
prefill={addLanePrefill}
|
|
onClose={() => {
|
|
setAddLaneOpen(false);
|
|
setAddLanePrefill(null);
|
|
}}
|
|
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>
|
|
);
|
|
}
|