refactor(lanes): compact the lane strip tile and the lane-detail header

The Workspace lane panel spent four rows saying what fits in two.

Strip tile: the stage badges move onto the id row and the progress bar
and percent go away — the detail panel's pipeline map right below shows
the same progress with the stage names attached.

Lane detail: the pipeline picker, stage badges, feature picker, router
select and the remove button merge into the single header row instead of
a second row under it, and the pipeline's name is no longer interpolated
into `laneHeader` — the picker beside it already says it. The git block
collapses from four lines to one and drops the cwd, which the header
above it already spells out.

The router select is relabelled Model, moves out of LaneCard into that
header row (`LaneRouterRow` is now exported), and reports its apply
outcome as a ✓/⚠ marker with the message in a tooltip rather than a
wrapped sentence that pushes every row below it down.

Drops the tracker / dev QC / CI wait badge row and everything feeding it
(`useLaneIntegrations`, `api.lanes.integration`, the `integrations.*`
strings): two of the three toggles have no consumer at all, and a badge
reporting a toggle nothing acts on costs a row of height to say nothing.
`agenthub lanes integration` and the API route are untouched.
This commit is contained in:
2026-09-17 09:41:03 +07:00
parent 5ab674f134
commit 27923db4e0
9 changed files with 192 additions and 273 deletions
+53 -97
View File
@@ -137,40 +137,6 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
return locks;
}
const INTEGRATION_NAMES = ["tracker", "dev_qc", "ci_wait"] as const;
function useLaneIntegrations(
laneId: number,
available: boolean
): Record<(typeof INTEGRATION_NAMES)[number], boolean> | null {
const [state, setState] = useState<Record<string, boolean> | null>(null);
useEffect(() => {
if (!available) {
setState(null);
return;
}
let alive = true;
Promise.all(INTEGRATION_NAMES.map((name) => api.lanes.integration(laneId, name)))
.then((results) => {
if (!alive) return;
const next: Record<string, boolean> = {};
INTEGRATION_NAMES.forEach((name, i) => {
next[name] = results[i]?.enabled ?? false;
});
setState(next);
})
.catch(() => {
if (alive) setState(null);
});
return () => {
alive = false;
};
}, [laneId, available]);
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
}
/**
* Which AI Router this lane runs against, and the one place it can be changed
* after the lane exists — the Add-lane modal is the only other one, and a lane
@@ -184,7 +150,7 @@ function useLaneIntegrations(
* the AI Router tab has exactly the built-in `native` profile, and a select
* with one option is a control that cannot do anything.
*/
function LaneRouterRow({ lane }: { lane: Lane }) {
export function LaneRouterRow({ lane }: { lane: Lane }) {
const { t } = useTranslation(["lanes"]);
const [routers, setRouters] = useState<AiRouter[] | null>(null);
const [current, setCurrent] = useState(lane.router_profile || "native");
@@ -234,26 +200,34 @@ function LaneRouterRow({ lane }: { lane: Lane }) {
};
return (
<div className="mb-2 text-[11px]" data-testid={`lane-router-${lane.id}`}>
<label className="flex items-center gap-1.5 text-fg-muted">
{t("router.label")}
<select
value={current}
disabled={busy}
onChange={(e) => void change(e.target.value)}
className="flex-1 truncate rounded border border-border-light bg-surface-2 px-1.5 py-0.5 text-fg-secondary disabled:opacity-50"
>
{routers.map((r) => (
<option key={r.name} value={r.name}>
{r.label}
</option>
))}
</select>
</label>
<label
className="flex items-center gap-1.5 text-xs text-fg-muted"
data-testid={`lane-router-${lane.id}`}
>
{t("router.label")}
<select
value={current}
disabled={busy}
onChange={(e) => void change(e.target.value)}
className="max-w-40 truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-fg-secondary disabled:opacity-50"
>
{routers.map((r) => (
<option key={r.name} value={r.name}>
{r.label}
</option>
))}
</select>
{/* The applied-note is a tooltip, not a line of its own: it fires on a
click the user just made, and a wrapped sentence under the control
would push every row below it down for the life of the selection.
A failure still gets visible colour, because that one is not
self-evident from the select. */}
{note && (
<p className={`mt-1 ${note.ok ? "text-fg-muted" : "text-status-danger"}`}>{note.text}</p>
<span className={note.ok ? "text-fg-muted" : "text-status-danger"} title={note.text}>
{note.ok ? "✓" : "⚠"}
</span>
)}
</div>
</label>
);
}
@@ -425,7 +399,6 @@ export default function LaneCard({
const git = useLaneGitFacts(lane.id);
const runtime = useLaneRuntime(lane.id, runtimeBump);
const locks = useLaneLocks(lane.slot);
const integrations = useLaneIntegrations(lane.id, runtime?.available === true);
/**
* Live boot feedback. A build can run for minutes, and a card showing only a
@@ -566,32 +539,33 @@ export default function LaneCard({
</div>
)}
<dl className="mb-2 space-y-0.5 font-mono text-[11px] text-fg-secondary">
{git?.available && (
<div data-testid="lane-git" className="space-y-1">
<div className="truncate">
⑂ {git.branch}
<span className="ml-2 text-fg-muted">{git.head}</span>
</div>
<div className="truncate text-fg-muted" title={git.subject}>
{git.subject}
</div>
{(git.dirty > 0 || git.untracked > 0) && (
<div className="text-status-warning/80">
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</div>
)}
</div>
)}
{/* The lane's own recorded branch, shown only when git could not be
read — otherwise it duplicates the live branch above. */}
{!git?.available && lane.branch && <div className="truncate">⑂ {lane.branch}</div>}
<div className="truncate text-fg-muted" title={lane.cwd}>
{lane.cwd}
{/* One line, because every part of it is short and the panel is
narrow: branch, head, subject, and the uncommitted counts read as a
sentence rather than a four-row block. The cwd is NOT here — the
lane-detail header above this card already spells it out. */}
{git?.available && (
<div
data-testid="lane-git"
className="mb-2 flex items-baseline gap-2 truncate font-mono text-[11px] text-fg-secondary"
title={git.subject}
>
<span className="shrink-0">⑂ {git.branch}</span>
<span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="truncate text-fg-muted">{git.subject}</span>
{(git.dirty > 0 || git.untracked > 0) && (
<span className="shrink-0 text-status-warning/80">
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</span>
)}
</div>
</dl>
<LaneRouterRow lane={lane} />
)}
{/* The lane's own recorded branch, shown only when git could not be
read — otherwise it duplicates the live branch above. */}
{!git?.available && lane.branch && (
<div className="mb-2 truncate font-mono text-[11px] text-fg-secondary">
⑂ {lane.branch}
</div>
)}
{childWorktrees && childWorktrees.length > 0 && (
<div
@@ -618,24 +592,6 @@ export default function LaneCard({
</div>
)}
{integrations && (
<div className="mb-1 flex items-center gap-1.5 text-[10px]">
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
<span
key={name}
data-testid={`lane-integration-${name}`}
className={`rounded-full px-2 py-0.5 ${
integrations[name]
? "bg-status-success/10 text-status-success"
: "bg-surface-2 text-fg-muted"
}`}
>
{t(`integrations.${name}`)}
</span>
))}
</div>
)}
{/* mt-auto pins the controls to the bottom so cards of differing height
in one grid row still line their buttons up. Repo tooling and the
remove button live in the Workspace page header/pipeline row now —