5bd1c0323f
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.
148 lines
5.6 KiB
TypeScript
148 lines
5.6 KiB
TypeScript
/**
|
|
* @file The compact lane tile used in the Workspace's horizontal lane strip.
|
|
* It carries only what you need to pick a lane — which lane, is it alive,
|
|
* what stage — because the full card, its controls and its working-copy facts
|
|
* live in the detail panel below it. Stage badges ride on the id row rather
|
|
* than a line of their own, and progress (bar and percent) is deliberately
|
|
* absent: the detail panel's pipeline map below shows the same thing with the
|
|
* stage names attached. Keeping the tile small and fixed-width is what lets
|
|
* many lanes stay scannable in one scrolling row.
|
|
*
|
|
* The tile is also the strip's reorder handle: native HTML5 drag-and-drop, no
|
|
* dependency. Ceiling: desktop pointers only — there is no touch drag and no
|
|
* auto-scroll when the strip overflows, so Alt+Arrow moves the focused tile as
|
|
* the keyboard (and touch-free) path.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { Lane } from "../../lib/types";
|
|
|
|
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
|
active: "bg-status-success",
|
|
idle: "bg-surface-4",
|
|
dead: "bg-status-danger",
|
|
};
|
|
|
|
/**
|
|
* Whether the inferred stage sits ahead of the declared one in node order.
|
|
*
|
|
* The declaration is located by the node the server resolved it onto (`state
|
|
* === "current"`), not by `indexOf(lane.stage)`: a stage declared under an
|
|
* ALIAS (`tests`, `booting`, `ship` — most of what the ship-feature skill
|
|
* declares) is not a node id, so indexOf answers -1 and a detection sitting
|
|
* well BEHIND the declaration would still be badged as the live one.
|
|
*
|
|
* `>=`, not `>`: an undeclared lane is rendered AT its detected node by the
|
|
* server (see `inferredDisplayStage`), so the detection and the "current" node
|
|
* are the same one — and that lane is precisely the one whose stage the badge
|
|
* is the only readout for. A declared lane can never tie, because
|
|
* recordDetection refuses any detection that isn't strictly ahead.
|
|
*/
|
|
function detectionLeads(lane: Lane): boolean {
|
|
if (!lane.detected_stage) return false;
|
|
const nodes = lane.pipeline_nodes;
|
|
return (
|
|
nodes.findIndex((n) => n.id === lane.detected_stage) >=
|
|
nodes.findIndex((n) => n.state === "current")
|
|
);
|
|
}
|
|
|
|
export default function LaneStripCard({
|
|
lane,
|
|
index,
|
|
selected,
|
|
onSelect,
|
|
onReorder,
|
|
}: {
|
|
lane: Lane;
|
|
/** Position in the lane array — the unit both reorder paths speak in. */
|
|
index: number;
|
|
selected: boolean;
|
|
onSelect: () => void;
|
|
onReorder: (from: number, to: number) => void;
|
|
}) {
|
|
const { t } = useTranslation(["lanes"]);
|
|
const [dropTarget, setDropTarget] = useState(false);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-testid={`lane-tile-${lane.id}`}
|
|
data-selected={selected ? "true" : undefined}
|
|
data-drop-target={dropTarget ? "true" : undefined}
|
|
aria-pressed={selected}
|
|
onClick={onSelect}
|
|
draggable
|
|
onDragStart={(e) => {
|
|
// Firefox starts no drag at all without a setData call.
|
|
e.dataTransfer.setData("text/plain", String(index));
|
|
e.dataTransfer.effectAllowed = "move";
|
|
}}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
setDropTarget(true);
|
|
}}
|
|
onDragLeave={() => setDropTarget(false)}
|
|
onDragEnd={() => setDropTarget(false)}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
setDropTarget(false);
|
|
const from = Number(e.dataTransfer.getData("text/plain"));
|
|
if (Number.isInteger(from)) onReorder(from, index);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
|
|
e.preventDefault();
|
|
onReorder(index, index + (e.key === "ArrowLeft" ? -1 : 1));
|
|
}}
|
|
title={`${lane.cwd}\n${t("reorderHint")}`}
|
|
className={`w-56 shrink-0 cursor-grab rounded-lg border p-3 text-left shadow-sm transition-colors active:cursor-grabbing ${
|
|
dropTarget
|
|
? "border-accent bg-accent/20"
|
|
: selected
|
|
? "border-accent bg-accent/10"
|
|
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
|
}`}
|
|
>
|
|
<div className="mb-1.5 flex items-center gap-1.5 text-[11px]">
|
|
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
|
{/* The bare id, not "Lane 23": the word cost a third of the row's
|
|
width and said nothing, but the number itself is how the CLI
|
|
addresses a lane (`agenthub stage --lane 23`), so it stays. */}
|
|
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
|
|
#{lane.id}
|
|
</span>
|
|
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
|
{lane.stage}
|
|
</span>
|
|
{lane.tool === "codex" && (
|
|
<span className="shrink-0 rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
|
|
Codex
|
|
</span>
|
|
)}
|
|
{detectionLeads(lane) && (
|
|
<span
|
|
data-testid={`lane-tile-auto-${lane.id}`}
|
|
title={lane.detected_signal || undefined}
|
|
className="shrink-0 rounded border border-dashed border-status-warning/50 px-1 text-status-warning"
|
|
>
|
|
{lane.detected_stage}
|
|
</span>
|
|
)}
|
|
{lane.needs_action && (
|
|
<span className="ml-auto shrink-0 text-status-warning" title={lane.needs_action}>
|
|
⚠
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="truncate text-[13px] font-medium text-fg-primary">
|
|
{lane.title || lane.cwd}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|