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);
}}
/>