feat(lanes): reorder and rename lane cards, and show each file's line delta

The lane strip's tiles are now drag-reorderable (Alt+←/→ is the keyboard
path), and a lane's name can be set in place from the detail header. The
"Lane 23 ·" prefix is gone from both — the bare `#23` stays on the tile,
because that number is how the CLI addresses a lane.

Card order is a per-viewer preference, not a lane fact: it lives in
localStorage (`agenthub.workspace.laneOrder`) beside the split-view layout
and writes nothing server-side, so `GET /api/lanes` and `agenthub lanes`
keep answering in id order. Renaming is the opposite — it is PATCH
/api/lanes/:id with {title}, the one lane column a user now types into
directly.

That made a patch value reachable by hand for the first time, so
`bindable` now rejects any non-scalar for every PATCHABLE column with
EBADFIELD, and the route answers 400 for that and for the
SQLITE_CONSTRAINT family, rather than handing better-sqlite3 a datatype
throw and reporting 500 on plain bad input.

The uncommitted-files tooltip became a real panel. `GET /api/lanes/:id/git`
now returns `files` as {path, status, added, deleted} records — deltas from
one `git diff --numstat HEAD`, null where git has none to report (untracked,
binary, renamed) — and the card renders them portalled to the body, so the
Workspace's own scroll box cannot clip the list.
This commit is contained in:
2026-09-17 15:07:37 +07:00
parent b5ca531800
commit 5bd1c0323f
22 changed files with 1552 additions and 95 deletions
+89 -12
View File
@@ -42,12 +42,18 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Square, Columns2, Grid2x2 } from "lucide-react";
import { Plus, Square, Columns2, Grid2x2, Pencil } 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, {
@@ -206,6 +212,9 @@ 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("");
// Run state kept at page level: shared across every pane, or drives the
// lane strip itself rather than any one pane's form.
@@ -253,7 +262,10 @@ export function Workspace() {
const refreshLanes = useCallback(async () => {
try {
const r = await api.lanes.list();
setLanes(keepIfSame(r.lanes));
// 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)
);
@@ -472,6 +484,37 @@ 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.
@@ -524,12 +567,14 @@ export function Workspace() {
<Plus className="h-4 w-4" />
{tLanes("add")}
</button>
{lanes.map((l) => (
{lanes.map((l, i) => (
<LaneStripCard
key={l.id}
lane={l}
index={i}
selected={selectedLaneId === l.id}
onSelect={() => setSelectedLaneId(l.id)}
onReorder={handleLaneReorder}
/>
))}
{!lanes.length && (
@@ -568,15 +613,47 @@ export function Workspace() {
? tLanes("statusDead")
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
</span>
<span
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
title={currentLane.cwd}
>
{tLanes("laneHeader", {
id: currentLane.id,
title: currentLane.title || currentLane.cwd,
})}
</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>