feat(ui): sidebar project tree, status bar and a tabbed workbench for workspaces

The Workspace page's lane strip moves into the sidebar as a project tree:
workspaces grouped by git repository (new GET /api/projects, cached in
memory, POST /api/projects/refresh), untracked worktrees with Adopt, inline
rename, drag/Alt+arrow ordering and a right-click row menu. The selected
workspace is the URL's ?lane=, one shared lane list feeds the tree, the new
bottom status bar and the page. The Changes panel gains an activity bar
(Files / Changes / Sessions, with resume), and split panes get a header.
This commit is contained in:
2026-10-01 16:33:25 +07:00
parent 5b3884b309
commit ba2c4e1914
39 changed files with 2431 additions and 1439 deletions
+278 -336
View File
@@ -1,9 +1,12 @@
/**
* @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
* (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 one-row header, pipeline map and run console (1, 2
* or 4 panes, each split pane with its own header), 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. Polls GET
* /api/sessions?status=active alongside the run list so Claude Code sessions
@@ -48,23 +51,23 @@ import {
Square,
Columns2,
Grid2x2,
Pencil,
Layers,
PanelLeftClose,
PanelRightClose,
Maximize2,
FolderTree,
GitCompare,
History,
X,
} from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage } from "../lib/types";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { keepIfSame } from "../lib/keepIfSame";
import {
readLaneOrder,
writeLaneOrder,
sortByStoredOrder,
moveLane,
} from "../lib/laneOrderStorage";
import { useLanes } from "../hooks/useLanes";
import { useAiTools } from "../hooks/useAiTools";
import { AiToolIcon } from "../components/AiToolIcon";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, {
@@ -73,10 +76,9 @@ import LaneCard, {
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 { WorkspaceSessionsTab } from "../components/lanes/WorkspaceSessionsTab";
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
@@ -84,6 +86,17 @@ import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
/** localStorage key for how wide the Changes panel is. */
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
/** localStorage key for the right panel's activity-bar tab. */
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
const SELECTED_KEY = "agenthub.workspace.selected";
const PANEL_TABS = [
{ id: "files", Icon: FolderTree },
{ id: "changes", Icon: GitCompare },
{ id: "sessions", Icon: History },
] as const;
type PanelTab = (typeof PANEL_TABS)[number]["id"];
/** The three widths the Changes panel cycles through, then closed. Reading a
* diff and watching a console want opposite amounts of room, and which one is
@@ -155,6 +168,53 @@ function SplitLayoutToggle({
);
}
/** A split pane's one-line header: which lane, which slot, the run's model,
* and — for slots 1–3 — a button that kills that pane's own tmux session
* (`DELETE /api/run/<runId>`). Slot 0 is the lane's run and has no close. */
function PaneHeader({
lane,
slot,
runId,
model,
}: {
lane: Lane | null;
slot: number;
runId: string | null;
model: string | null;
}) {
const { t } = useTranslation("lanes");
const tools = useAiTools();
return (
<div
data-testid={`pane-header-${slot}`}
className="flex h-6 shrink-0 items-center gap-1.5 rounded border border-border bg-surface-2 px-2 text-[11px] text-fg-muted"
>
{lane && (
<AiToolIcon
tool={{ id: lane.tool, label: tools.label(lane.tool) }}
className="h-3.5 w-3.5 !p-0"
/>
)}
<span className="min-w-0 truncate font-medium text-fg-secondary">
{lane ? lane.title || lane.cwd.split("/").pop() : ""}
</span>
<span className="shrink-0">{t("pane.slot", { n: slot })}</span>
{model && <span className="min-w-0 truncate">{model}</span>}
{slot > 0 && runId && (
<button
type="button"
onClick={() => void api.run.kill(runId).catch(() => undefined)}
aria-label={t("pane.close")}
title={t("pane.close")}
className="ml-auto shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-status-danger"
>
<X className="h-3 w-3" />
</button>
)}
</div>
);
}
function ConsoleArea({
lanes,
selectedLaneId,
@@ -170,6 +230,7 @@ function ConsoleArea({
onLaneCreated,
onLaneIdChange,
showToggle = true,
resumeRequest,
}: {
lanes: Lane[];
selectedLaneId: number | null;
@@ -185,6 +246,8 @@ function ConsoleArea({
onLaneCreated: (lane: Lane) => void;
onLaneIdChange: (id: number | null) => void;
showToggle?: boolean;
/** A session to resume in slot 0, from the Sessions tab. */
resumeRequest: { sessionId: string; model: string | null; nonce: number } | null;
}) {
// Every split pane shows the SELECTED lane, each in its own tmux session
// (slot = pane index). Tracking what each pane shows lets the others leave
@@ -228,6 +291,7 @@ function ConsoleArea({
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={onHasActiveRunChange}
resumeRequest={resumeRequest}
/>
) : (
<div
@@ -236,23 +300,31 @@ function ConsoleArea({
}`}
>
{Array.from({ length: splitView.layout }, (_, i) => (
<LaneConsolePane
key={i}
lanes={lanes}
laneId={selectedLaneId}
slot={i}
hiddenRunIds={hiddenFor[i]}
onHandleChange={paneHandlers[i]}
showLaneSelector={false}
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
/>
<div key={i} className="flex min-h-0 min-w-0 flex-col gap-1">
<PaneHeader
lane={lanes.find((l) => l.id === selectedLaneId) ?? null}
slot={i}
runId={paneRunIds[i] ?? null}
model={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.model ?? null}
/>
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
slot={i}
hiddenRunIds={hiddenFor[i]}
onHandleChange={paneHandlers[i]}
showLaneSelector={false}
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
resumeRequest={i === 0 ? resumeRequest : null}
/>
</div>
))}
</div>
)}
@@ -265,9 +337,49 @@ export function Workspace() {
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);
// Lane state: the list is the shared store the sidebar reads too; the
// selection is the URL's `?lane=` (the sidebar sets it, so does a
// notification click), falling back to the last one shown, then the first.
const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useLanes();
const laneParam = searchParams.get("lane");
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);
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]);
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
@@ -283,10 +395,11 @@ export function Workspace() {
>([]);
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("");
const [resumeRequest, setResumeRequest] = useState<{
sessionId: string;
model: string | null;
nonce: number;
} | null>(null);
// Run state kept at page level: shared across every pane, or drives the
// lane strip itself rather than any one pane's form.
@@ -305,10 +418,21 @@ export function Workspace() {
// 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 [panelTab, setPanelTab] = useState<PanelTab>(() => {
try {
const saved = window.localStorage.getItem(PANEL_TAB_KEY) as PanelTab | null;
return saved && PANEL_TABS.some((x) => x.id === saved) ? saved : "changes";
} catch {
return "changes";
}
});
useEffect(() => {
try {
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
} catch {
/* the panel just forgets its tab between visits */
}
}, [panelTab]);
// Whether the Changes panel is open. A viewer preference, so it lives in
// localStorage and never reaches the server — same rule as the lane strip's
// order (client/src/lib/laneOrderStorage.ts).
@@ -347,7 +471,6 @@ export function Workspace() {
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) => {
@@ -358,42 +481,6 @@ export function Workspace() {
});
}, []);
// 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]);
// 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.
@@ -417,7 +504,6 @@ export function Workspace() {
.pipelines()
.then((r) => setPipelineTemplates(r.pipelines))
.catch(() => undefined);
void refreshLanes();
api.run
.cwds()
.then((r) => {
@@ -437,46 +523,7 @@ export function Workspace() {
}
})
.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(() => {
@@ -625,37 +672,6 @@ export function Workspace() {
}
};
/**
* 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.
@@ -685,15 +701,14 @@ export function Workspace() {
<div
className={
viewportLocked
? "h-[calc(100vh-2.5rem)] lg:h-[calc(100vh-3rem)] flex flex-col gap-5"
? "h-[calc(100vh-4rem)] lg:h-[calc(100vh-4.5rem)] 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. */}
{/* 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">
{lanes.length === 0 ? (
{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
@@ -723,36 +738,6 @@ export function Workspace() {
{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
@@ -792,47 +777,14 @@ export function Workspace() {
? 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>
)}
{/* Renaming lives on the sidebar row (double-click); here the
name is only read. */}
<span
title={currentLane.cwd}
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
>
{currentLane.title || currentLane.cwd}
</span>
{/* A lane with no pipeline (non-claude, or no template
installed): no time-at-stage, no pipeline or feature
picker, no map. */}
@@ -841,93 +793,68 @@ export function Workspace() {
{since(currentLane.stage_seconds)}
</span>
)}
{showInfo && (
<>
{!noPipeline && (
<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>
)}
{/* `agy` has no endpoint to point at a router. */}
<LaneRouterRow lane={currentLane} />
{/* No stage chip and no "auto: <stage>" chip here: the
{!noPipeline && (
<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>
)}
{/* `agy` has no endpoint to point at a router. */}
<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. */}
{!noPipeline && 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>
)}
</>
{!noPipeline && 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 (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 && (
{currentLane && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
{/* No margin when the card rendered nothing (see LaneCard). */}
<div className="mb-3 has-[>div:empty]:mb-0">
@@ -1017,11 +944,10 @@ export function Workspace() {
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
onLaneCreated={() => void refreshLanes()}
onLaneIdChange={setSelectedLaneId}
showToggle={!currentLane}
resumeRequest={resumeRequest}
/>
</div>
</section>
@@ -1033,10 +959,26 @@ export function Workspace() {
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>
{/* Activity bar: Files · Changes · Sessions, then the resize control. */}
<div className="mb-2 flex items-center gap-1" role="toolbar">
{PANEL_TABS.map(({ id, Icon }) => (
<button
key={id}
type="button"
data-testid={`panel-tab-${id}`}
aria-pressed={panelTab === id}
onClick={() => setPanelTab(id)}
title={tLanes(`panel.${id}`)}
aria-label={tLanes(`panel.${id}`)}
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
panelTab === id
? "bg-accent/15 text-accent"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
}`}
>
<Icon className="h-4 w-4" />
</button>
))}
<button
type="button"
data-testid="lane-changes-resize"
@@ -1067,20 +1009,34 @@ export function Workspace() {
})()}
</button>
</div>
<LaneChangesPanel
laneId={currentLane.id}
full={changesSize === "full"}
// 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);
}}
/>
{panelTab === "sessions" ? (
<WorkspaceSessionsTab
lane={currentLane}
onResume={(session) =>
setResumeRequest({
sessionId: session.id,
model: session.model,
nonce: Date.now(),
})
}
/>
) : (
<LaneChangesPanel
laneId={currentLane.id}
tab={panelTab}
full={changesSize === "full"}
// 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);
}}
/>
)}
</aside>
) : (
<button
@@ -1102,19 +1058,6 @@ export function Workspace() {
</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}
@@ -1124,9 +1067,8 @@ export function Workspace() {
setAddLanePrefill(null);
}}
onAdded={(lane) => {
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]));
setSelectedLaneId(lane.id);
void refreshLanes();
setSelectedLaneId(lane.id);
}}
/>
@@ -9,7 +9,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { MemoryRouter, useSearchParams } from "react-router-dom";
import i18n from "i18next";
// Hoisted: `vi.mock`'s factory is lifted above the module body, so the
@@ -138,19 +138,35 @@ describe("Workspace — the console cwd follows the selected lane", () => {
expect(cwdInput().value).toBe(LANE_A.cwd);
});
it("swaps the cwd when another lane is selected in the strip", async () => {
it("swaps the cwd when another lane is selected in the sidebar", async () => {
// Stands in for the sidebar's project tree: picking sets `?lane=`.
function Picker() {
const [, setParams] = useSearchParams();
return (
<>
{[1, 2].map((id) => (
<button
key={id}
data-testid={`pick-${id}`}
onClick={() => setParams({ lane: String(id) })}
/>
))}
</>
);
}
render(
<MemoryRouter initialEntries={["/run"]}>
<Picker />
<Workspace />
</MemoryRouter>
);
await settle();
fireEvent.click(screen.getByTestId("lane-tile-2"));
fireEvent.click(screen.getByTestId("pick-2"));
await settle();
expect(cwdInput().value).toBe(LANE_B.cwd);
fireEvent.click(screen.getByTestId("lane-tile-1"));
fireEvent.click(screen.getByTestId("pick-1"));
await settle();
expect(cwdInput().value).toBe(LANE_A.cwd);
});
@@ -1,614 +0,0 @@
/**
* @file Workspace.laneReorderRename.test.tsx
* @description Integration tests for lane strip reordering, persistence to
* localStorage, and inline lane rename functionality. Covers the three paths
* to reorder (drag, Alt+Arrow, and refetch with stored order), and rename
* commit/cancel/error paths.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
const recordedCalls: Array<{ method: string; path: string; payload?: unknown }> = [];
type LaneFixture = {
id: number;
cwd: string;
title: string;
pipeline_name: string;
pipeline_nodes: never[];
detected_signal: string | null;
run_id: string | null;
};
let lanesToReturn: LaneFixture[] = [
{
id: 1,
cwd: "/workspace/a",
title: "Lane A",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
{
id: 2,
cwd: "/workspace/b",
title: "Lane B",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
{
id: 3,
cwd: "/workspace/c",
title: "Lane C",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
];
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const recordCall = (method: string, path: string, payload?: unknown) => {
recordedCalls.push({ method, path, payload });
};
return {
...actual,
api: {
lanes: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes");
return {
lanes: lanesToReturn,
counts: { total: lanesToReturn.length, running: 0, needs_you: 0, dead: 0 },
};
}),
ensure: vi.fn(),
action: vi.fn(),
git: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/git`);
return { available: false };
}),
runtime: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/runtime`);
return { available: false };
}),
stage: vi.fn(),
pipelines: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes/pipelines");
return { pipelines: [{ id: "default", name: "default", nodes: [] }] };
}),
update: vi.fn().mockImplementation(async (id: number, patch: Record<string, unknown>) => {
recordCall("PATCH", `/api/lanes/${id}`, patch);
const lane = lanesToReturn.find((l) => l.id === id);
const updated = { ...lane, ...patch } as LaneFixture;
// Update in place for subsequent reads
const idx = lanesToReturn.findIndex((l) => l.id === id);
if (idx >= 0) lanesToReturn[idx] = updated;
return { lane: updated };
}),
features: {
list: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/features`);
return { features: [] };
}),
show: vi.fn(),
},
proof: {
list: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/proof`);
return { features: [] };
}),
imageUrl: vi.fn(),
delete: vi.fn(),
},
},
aiRouters: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/ai-routers");
return { items: [] };
}),
},
run: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/list");
return { runs: [], items: [] };
}),
history: vi.fn().mockResolvedValue({ items: [] }),
binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }),
cwds: vi.fn().mockResolvedValue({ items: [] }),
files: vi.fn().mockResolvedValue({ items: [] }),
start: vi.fn(),
get: vi.fn(),
send: vi.fn(),
kill: vi.fn(),
},
ccConfig: {
commands: vi.fn().mockResolvedValue({ items: [] }),
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
file: vi.fn().mockResolvedValue({ text: "", content: "" }),
},
sessions: {
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
transcript: vi.fn().mockResolvedValue({ messages: [] }),
},
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
vi.mock("../../components/run/TerminalView", () => ({
TerminalView: ({ runId }: { runId: string }) => (
<div data-testid="terminal-view" data-run-id={runId} />
),
}));
import { Workspace } from "../Workspace";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
if (typeof globalThis !== "undefined") {
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
if (typeof Element !== "undefined") {
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
}
}
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
async function renderWorkspace() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
await settle();
return utils;
}
beforeEach(() => {
recordedCalls.length = 0;
lanesToReturn = [
{
id: 1,
cwd: "/workspace/a",
title: "Lane A",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
{
id: 2,
cwd: "/workspace/b",
title: "Lane B",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
{
id: 3,
cwd: "/workspace/c",
title: "Lane C",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
];
localStorage.clear();
vi.clearAllMocks();
});
afterEach(() => {
recordedCalls.length = 0;
localStorage.clear();
vi.clearAllMocks();
});
describe("Workspace — lane reordering", () => {
it("persists lane order to localStorage when dragging a tile", async () => {
await renderWorkspace();
// Verify initial order is A, B, C (strip includes "Add lane" prefix and ids)
expect(screen.getByTestId("lane-strip").textContent).toMatch(/Lane A.*Lane B.*Lane C/);
// Drag tile 3 (Lane C) to position 0
const tile1 = screen.getByTestId("lane-tile-1");
const tile3 = screen.getByTestId("lane-tile-3");
fireEvent.dragStart(tile3, { dataTransfer: { setData: vi.fn(), effectAllowed: "" } });
fireEvent.dragOver(tile1, { dataTransfer: { dropEffect: "", preventDefault: () => {} } });
fireEvent.drop(tile1, {
dataTransfer: { getData: vi.fn(() => "2"), preventDefault: () => {} },
});
await settle();
// Check localStorage has the new order
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
expect(stored).toBeDefined();
const order = JSON.parse(stored!);
expect(order).toEqual([3, 1, 2]);
});
it("applies stored order when lanes are fetched", async () => {
// Pre-populate localStorage with a custom order
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 3, 1]));
await renderWorkspace();
// The lane strip should reflect the stored order: B, C, A
const strip = screen.getByTestId("lane-strip");
const tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
const laneIds = tiles
.map((el) => el.getAttribute("data-testid"))
.filter((id) => id && id.match(/lane-tile-\d+/))
.map((id) => id!.replace("lane-tile-", ""));
expect(laneIds).toEqual(["2", "3", "1"]);
});
it("appends new lanes to the end, preserving user order", async () => {
// Start with a custom order
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 1]));
await renderWorkspace();
let strip = screen.getByTestId("lane-strip");
let tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
let laneIds = tiles
.map((el) => el.getAttribute("data-testid"))
.filter((id) => id && id.match(/lane-tile-\d+/))
.map((id) => id!.replace("lane-tile-", ""));
expect(laneIds).toEqual(["2", "1", "3"]); // 3 is new, appended at end
});
it("ignores stored ids for lanes that no longer exist", async () => {
// Store an order that includes a lane ID that will be removed
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 999, 1]));
// Remove lane 2 from the server response
lanesToReturn = lanesToReturn.filter((l) => l.id !== 2);
await renderWorkspace();
// The strip should show lane 1 and 3 in stored order, skipping 999
const strip = screen.getByTestId("lane-strip");
const tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
const laneIds = tiles
.map((el) => el.getAttribute("data-testid"))
.filter((id) => id && id.match(/lane-tile-\d+/))
.map((id) => id!.replace("lane-tile-", ""));
// Expected: skip 999 and 2, show 1, then 3 (the remaining fresh lane)
expect(laneIds).toEqual(["1", "3"]);
});
it("reorders with Alt+Arrow keyboard shortcut", async () => {
await renderWorkspace();
// Get tile 2 (Lane B) and move it right to position 2
const tile2 = screen.getByTestId("lane-tile-2");
fireEvent.keyDown(tile2, { key: "ArrowRight", altKey: true });
await settle();
// Check localStorage
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
const order = JSON.parse(stored!);
// Tile 2 was at index 1, moving right puts it at index 2
expect(order).toEqual([1, 3, 2]);
});
});
describe("Workspace — lane rename", () => {
it("enters rename mode when the pencil icon is clicked", async () => {
const user = userEvent.setup();
await renderWorkspace();
// Click the first lane to select it (so its detail panel shows)
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
// Find the rename button (displays the lane title with a pencil icon)
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
// The input should appear with the current title
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe("Lane A");
});
it("commits a rename when Enter is pressed", async () => {
const user = userEvent.setup();
await renderWorkspace();
// Select lane 1 and open rename
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
await user.clear(input);
await user.type(input, "New Lane Name");
fireEvent.keyDown(input, { key: "Enter" });
await settle();
// Should have called update with trimmed title
await waitFor(() => {
const updateCall = recordedCalls.find(
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
);
expect(updateCall).toBeDefined();
expect(updateCall?.payload).toEqual({ title: "New Lane Name" });
});
// Input should be gone, button should be back
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
expect(screen.getByTestId("lane-rename")).toBeInTheDocument();
});
it("cancels rename when Escape is pressed", async () => {
const user = userEvent.setup();
await renderWorkspace();
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
await user.type(input, "should not save");
fireEvent.keyDown(input, { key: "Escape" });
await settle();
// Input should be gone
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
// No API call should have been made
const updateCalls = recordedCalls.filter(
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
);
expect(updateCalls).toHaveLength(0);
});
it("exits rename mode when focus leaves (blur/tab away), attempting to commit", async () => {
const user = userEvent.setup();
await renderWorkspace();
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
expect(input).toBeInTheDocument();
// Move focus away (tab)
await user.tab();
await settle();
// Input should be gone (rename mode exited)
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
// The rename button should be back
expect(screen.getByTestId("lane-rename")).toBeInTheDocument();
});
it("trims whitespace from the renamed title", async () => {
const user = userEvent.setup();
await renderWorkspace();
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
await user.clear(input);
await user.type(input, " Trimmed ");
fireEvent.keyDown(input, { key: "Enter" });
await settle();
// Should send trimmed title
await waitFor(() => {
const updateCall = recordedCalls.find(
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
);
expect(updateCall?.payload).toEqual({ title: "Trimmed" });
});
});
it("clears the title (puts cwd in its place) when empty name is submitted", async () => {
const user = userEvent.setup();
await renderWorkspace();
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
await user.clear(input);
// Leave it empty (or with just whitespace)
await user.type(input, " ");
fireEvent.keyDown(input, { key: "Enter" });
await settle();
// Should send empty string
await waitFor(() => {
const updateCall = recordedCalls.find(
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
);
expect(updateCall?.payload).toEqual({ title: "" });
});
});
it("does not call the API if the title has not changed", async () => {
const user = userEvent.setup();
await renderWorkspace();
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
// Don't change the title, just press Enter
fireEvent.keyDown(input, { key: "Enter" });
await settle();
// Should NOT have called update (or at least not with a changed value)
const updateCalls = recordedCalls.filter(
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
);
expect(updateCalls).toHaveLength(0);
});
it("allows the user to retry after an error, starting fresh", async () => {
const user = userEvent.setup();
await renderWorkspace();
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
// Enter and exit rename mode twice to verify the state resets
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
let input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
expect(input.value).toBe("Lane A");
fireEvent.keyDown(input, { key: "Escape" });
await settle();
// Enter again to verify we can re-enter
await user.click(screen.getByTestId("lane-rename"));
await settle();
input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
expect(input.value).toBe("Lane A");
});
it("respects max length of 200 characters (enforced by server)", async () => {
const user = userEvent.setup();
await renderWorkspace();
const tile1 = screen.getByTestId("lane-tile-1");
await user.click(tile1);
await settle();
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
// HTML input has maxLength=200, so typing more should be capped
expect(input.maxLength).toBe(200);
});
});
describe("Workspace — lane order and rename interaction", () => {
it("preserves custom order after renaming a lane", async () => {
const user = userEvent.setup();
// Set custom order
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([3, 2, 1]));
await renderWorkspace();
// Verify order is C, B, A
let strip = screen.getByTestId("lane-strip");
let tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
let laneIds = tiles
.map((el) => el.getAttribute("data-testid"))
.filter((id) => id && id.match(/lane-tile-\d+/))
.map((id) => id!.replace("lane-tile-", ""));
expect(laneIds).toEqual(["3", "2", "1"]);
// Click lane 2 (which is now at position 1)
const tile2 = screen.getByTestId("lane-tile-2");
await user.click(tile2);
await settle();
// Rename it
const renameButton = screen.getByTestId("lane-rename");
await user.click(renameButton);
await settle();
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
await user.clear(input);
await user.type(input, "Renamed B");
fireEvent.keyDown(input, { key: "Enter" });
await settle();
// Wait for the refresh
await waitFor(() => {
const updateCall = recordedCalls.find(
(c) => c.path === "/api/lanes/2" && c.method === "PATCH"
);
expect(updateCall).toBeDefined();
});
// Order should still be preserved
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
const order = JSON.parse(stored!);
expect(order).toEqual([3, 2, 1]);
});
});
@@ -11,7 +11,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, act, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { MemoryRouter, useSearchParams } from "react-router-dom";
import userEvent from "@testing-library/user-event";
type LaneFixture = {
@@ -179,9 +179,24 @@ beforeEach(() => {
h.lanes = lanesToReturn as unknown as typeof h.lanes;
});
/** Stands in for the sidebar's project tree: picking a workspace sets `?lane=`. */
function Picker() {
const [, setParams] = useSearchParams();
return (
<>
{lanesToReturn.map((l) => (
<button key={l.id} onClick={() => setParams({ lane: String(l.id) })}>
{`pick ${l.title}`}
</button>
))}
</>
);
}
async function renderWorkspace() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Picker />
<Workspace />
</MemoryRouter>
);
@@ -191,11 +206,9 @@ async function renderWorkspace() {
return utils;
}
/** Click the strip tile for a lane, which is how a lane is selected. */
/** Pick a lane the way the sidebar does: by naming it in the URL. */
async function selectLane(user: ReturnType<typeof userEvent.setup>, title: string) {
const tile = screen.getAllByText(title)[0];
if (!tile) throw new Error(`no strip tile for ${title}`);
await user.click(tile);
await user.click(screen.getByRole("button", { name: `pick ${title}` }));
await settle();
}
+31 -27
View File
@@ -8,7 +8,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { MemoryRouter, useSearchParams } from "react-router-dom";
import userEvent from "@testing-library/user-event";
// Track all API calls made by recording the method and endpoint
@@ -284,9 +284,27 @@ async function settle() {
});
}
/** Stands in for the sidebar's project tree: picking a workspace sets `?lane=`. */
function Picker() {
const [params, setParams] = useSearchParams();
return (
<>
<span data-testid="lane-param">{params.get("lane")}</span>
{[1, 2].map((id) => (
<button
key={id}
data-testid={`pick-${id}`}
onClick={() => setParams({ lane: String(id) })}
/>
))}
</>
);
}
async function renderWorkspace() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Picker />
<Workspace />
</MemoryRouter>
);
@@ -329,11 +347,12 @@ afterEach(() => {
});
describe("Workspace page — lane integration", () => {
it("lane strip lists the lanes the API returned", async () => {
it("has no lane strip: it opens the first workspace and names it in the URL", async () => {
await renderWorkspace();
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
expect(screen.queryByTestId("lane-strip")).toBeNull();
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
expect(screen.getByTestId("lane-param").textContent).toBe("1");
});
it("selecting a lane switches the pipeline and run id", async () => {
@@ -345,20 +364,16 @@ describe("Workspace page — lane integration", () => {
await renderWorkspace();
const user = userEvent.setup();
// Selection is announced on the tile itself, not inferred from styling.
const laneATile = screen.getByTestId("lane-tile-1");
const laneBTile = screen.getByTestId("lane-tile-2");
expect(laneATile.getAttribute("aria-pressed")).toBe("true");
expect(laneBTile.getAttribute("aria-pressed")).toBe("false");
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
await user.click(laneBTile);
// The sidebar names the workspace in the URL; the page follows it.
await user.click(screen.getByTestId("pick-2"));
await settle();
expect(laneBTile.getAttribute("aria-pressed")).toBe("true");
expect(laneATile.getAttribute("aria-pressed")).toBe("false");
// And the detail panel follows: only the selected lane gets a full card.
// Only the selected lane gets a full card.
expect(screen.getByTestId("lane-card-2")).toBeInTheDocument();
expect(screen.queryByTestId("lane-card-1")).toBeNull();
expect(screen.queryByTestId("lane-card-1")).toBeNull();
});
it("starting a run requests /api/lanes/<id>/start and not /api/run/start", async () => {
@@ -490,27 +505,17 @@ describe("Workspace page — lane integration", () => {
});
describe("Workspace layout", () => {
it("opens the add-lane modal from the strip's first tile", async () => {
it("opens the add-workspace modal from the empty banner when there is none", async () => {
const user = userEvent.setup();
lanesToReturn = [];
await renderWorkspace();
// The tile leads the strip, so it is reachable without scrolling past lanes.
const strip = screen.getByTestId("lane-strip");
expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add"));
await user.click(screen.getByTestId("lane-tile-add"));
await user.click(screen.getByTestId("lane-empty"));
expect(
await screen.findByText("Create a workspace from a working directory")
).toBeInTheDocument();
});
it("gives every lane a tile in the carousel", async () => {
await renderWorkspace();
expect(screen.getByTestId("lane-strip")).toBeInTheDocument();
expect(screen.getByTestId("lane-tile-1")).toBeInTheDocument();
expect(screen.getByTestId("lane-tile-2")).toBeInTheDocument();
});
it("shows the full card only for the selected lane, in the detail panel", async () => {
await renderWorkspace();
// Lane 1 is selected by default. Its full card — controls, git facts — is
@@ -555,7 +560,6 @@ describe("Workspace — the console is its own section", () => {
// The console is a window onto a process, not a property of a card. Nesting
// it in one made the owning card span the row and left a hole when closed.
expect(screen.getByTestId("lane-card-1").contains(body)).toBe(false);
expect(screen.getByTestId("lane-strip").contains(body)).toBe(false);
});
it("has no header line of its own - the pipeline panel already names the lane", async () => {