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
+1 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select on `LaneCard`, and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
+33 -77
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,14 +200,16 @@ 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">
<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="flex-1 truncate rounded border border-border-light bg-surface-2 px-1.5 py-0.5 text-fg-secondary disabled:opacity-50"
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}>
@@ -249,11 +217,17 @@ function LaneRouterRow({ lane }: { lane: Lane }) {
</option>
))}
</select>
</label>
{/* 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">
{/* 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="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>
<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) && (
<div className="text-status-warning/80">
<span className="shrink-0 text-status-warning/80">
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</div>
</span>
)}
</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}
{!git?.available && lane.branch && (
<div className="mb-2 truncate font-mono text-[11px] text-fg-secondary">
⑂ {lane.branch}
</div>
</dl>
<LaneRouterRow lane={lane} />
)}
{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 —
+14 -24
View File
@@ -1,10 +1,12 @@
/**
* @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, how far — because the full card, its controls and its
* working-copy facts live in the detail panel below it. Keeping the tile
* small and fixed-width is what lets many lanes stay scannable in one
* scrolling row.
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -66,23 +68,11 @@ export default function LaneStripCard({
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
}`}
>
<div className="mb-1.5 flex items-center gap-1.5">
<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]}`} />
<span className="text-[10px] font-semibold uppercase tracking-widest text-fg-muted">
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-widest text-fg-muted">
{t("cardId", { id: lane.id })}
</span>
{lane.needs_action && (
<span className="ml-auto text-status-warning" title={lane.needs_action}>
⚠
</span>
)}
</div>
<div className="mb-2 truncate text-[13px] font-medium text-fg-primary">
{lane.title || lane.cwd}
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
{lane.stage}
</span>
@@ -100,16 +90,16 @@ export default function LaneStripCard({
{lane.detected_stage}
</span>
)}
{lane.progress > 0 && (
<span className="ml-auto shrink-0 tabular-nums text-fg-muted">{lane.progress}%</span>
{lane.needs_action && (
<span className="ml-auto shrink-0 text-status-warning" title={lane.needs_action}>
⚠
</span>
)}
</div>
{lane.progress > 0 && (
<div className="mt-1.5 h-0.5 overflow-hidden rounded-full bg-surface-4">
<div className="h-full rounded-full bg-accent" style={{ width: `${lane.progress}%` }} />
<div className="truncate text-[13px] font-medium text-fg-primary">
{lane.title || lane.cwd}
</div>
)}
</button>
);
}
@@ -11,7 +11,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LaneCard, { LaneToolsMenu } from "../LaneCard";
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
import type { Lane, LaneRuntime } from "../../../lib/types";
import { api } from "../../../lib/api";
@@ -23,7 +23,6 @@ vi.mock("../../../lib/api", () => ({
up: vi.fn(),
down: vi.fn(),
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
integration: vi.fn(),
agentsInstall: vi.fn(),
mcpSync: vi.fn(),
syncBaseCheck: vi.fn(),
@@ -45,8 +44,6 @@ beforeEach(() => {
// stack, so the runtime strip and its button stay absent unless a test opts in.
vi.mocked(api.lanes.runtime).mockReset();
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
vi.mocked(api.lanes.integration).mockReset();
vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false });
// Only the built-in profile by default: a user who never opened the AI Router
// tab has nothing to switch between, so the row stays out of every other test.
vi.mocked(api.aiRouters.list).mockReset();
@@ -348,40 +345,6 @@ describe("LaneCard — named locks", () => {
});
});
describe("LaneCard — integration badges", () => {
beforeEach(() => {
// Only the fields this describe block's assertions read; `as LaneRuntime`
// keeps the double from having to restate a shape the component never
// touches here.
vi.mocked(api.lanes.runtime).mockResolvedValue({
available: true as const,
provisioned: true as const,
up: false,
slot: 1,
profileDir: "/work/demo/.agenthub/profile",
ports: {},
} as unknown as LaneRuntime);
vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
Promise.resolve({ enabled: name === "tracker" })
);
});
it("shows integration badges reflecting each toggle's state", async () => {
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const tracker = await screen.findByTestId("lane-integration-tracker");
const devQc = await screen.findByTestId("lane-integration-dev_qc");
expect(tracker.className).toContain("status-success");
expect(devQc.className).not.toContain("status-success");
});
it("hides integration badges when the lane has no profile", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
});
});
describe("LaneToolsMenu", () => {
beforeEach(() => {
vi.mocked(api.lanes.runtime).mockResolvedValue({
@@ -449,7 +412,7 @@ describe("LaneToolsMenu", () => {
});
});
describe("LaneCard — the AI Router picker", () => {
describe("LaneRouterRow — the Model picker", () => {
const native = {
name: "native",
label: "Native (Claude / Codex)",
@@ -469,14 +432,14 @@ describe("LaneCard — the AI Router picker", () => {
it("stays out of the card when native is the only profile", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] });
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
render(<LaneRouterRow lane={makeLane()} />);
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
expect(screen.queryByTestId("lane-router-1")).toBeNull();
});
it("shows the lane's current profile selected", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<LaneCard lane={makeLane({ router_profile: "9router" })} onAction={vi.fn()} />);
render(<LaneRouterRow lane={makeLane({ router_profile: "9router" })} />);
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
@@ -487,7 +450,7 @@ describe("LaneCard — the AI Router picker", () => {
file: "/work/demo/.claude/settings.local.json",
profile: "9router",
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
@@ -499,12 +462,12 @@ describe("LaneCard — the AI Router picker", () => {
it("puts the selection back when the apply fails", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await waitFor(() => expect(screen.getByText("EBADJSON")).toBeInTheDocument());
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
});
});
+2 -5
View File
@@ -98,7 +98,7 @@
"kind.managed": "managed",
"laneDetail.hide": "Hide details",
"laneDetail.show": "Lane details",
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}",
"laneHeader": "Lane {{id}} · {{title}}",
"locks.held_one": "{{count}} lock held",
"locks.held_other": "{{count}} locks held",
"moreActions": "More actions",
@@ -116,9 +116,6 @@
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
"runtime.stop": "■ down",
"runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)",
"integrations.ci_wait": "CI wait",
"integrations.dev_qc": "dev QC",
"integrations.tracker": "tracker",
"stageUndeclared": "not declared",
"status.failed": "failed",
"status.idle": "idle",
@@ -143,6 +140,6 @@
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
"addLaneSetupRouter": "Model endpoint",
"addLaneRouterLoading": "Loading router profiles…",
"router.label": "AI Router",
"router.label": "Model",
"router.applied": "Applied. A session already running in this lane keeps the old one until it restarts."
}
+2 -5
View File
@@ -98,7 +98,7 @@
"kind.managed": "được quản lý",
"laneDetail.hide": "Ẩn chi tiết",
"laneDetail.show": "Chi tiết lane",
"laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}",
"laneHeader": "Làn đường {{id}} · {{title}}",
"locks.held_one": "Đang giữ {{count}} khóa",
"locks.held_other": "Đang giữ {{count}} khóa",
"moreActions": "Thêm hành động",
@@ -116,9 +116,6 @@
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
"runtime.stop": "■ dừng",
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của lane này (khác với phiên Claude của nó)",
"integrations.ci_wait": "CI wait",
"integrations.dev_qc": "dev QC",
"integrations.tracker": "tracker",
"stageUndeclared": "chưa khai báo",
"status.failed": "thất bại",
"status.idle": "rảnh",
@@ -143,6 +140,6 @@
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
"addLaneSetupRouter": "Endpoint model",
"addLaneRouterLoading": "Đang tải danh sách router…",
"router.label": "AI Router",
"router.label": "Model",
"router.applied": "Đã áp dụng. Session đang chạy trong lane này vẫn dùng router cũ cho tới khi khởi động lại."
}
-2
View File
@@ -2497,8 +2497,6 @@ export const api = {
`/lanes/${id}/profile/init`,
{ method: "POST", body: JSON.stringify({ force }) }
),
integration: (id: number, name: string) =>
request<{ enabled: boolean }>(`/lanes/${id}/integrations/${encodeURIComponent(name)}`),
syncBaseCheck: (id: number, branch?: string) =>
request<{
code: number;
+42 -24
View File
@@ -58,7 +58,12 @@ import { eventBus } from "../lib/eventBus";
import { keepIfSame } from "../lib/keepIfSame";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard";
import LaneCard, {
since,
LIVENESS_DOT,
LaneToolsMenu,
LaneRouterRow,
} from "../components/lanes/LaneCard";
import LaneStripCard from "../components/lanes/LaneStripCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
@@ -576,43 +581,36 @@ export function Workspace() {
data-testid="lane-detail"
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}`}
>
{/* One header row, not two. The pipeline picker, the stage badges,
the feature picker and the router select used to sit on a second
row below this one; every one of them is a per-lane control that
belongs beside the lane's own name, and stacking them cost a whole
row of height on a panel whose point is the pipeline map further
down. The pipeline's NAME is no longer interpolated into
`laneHeader` either — the picker right next to it already says it.
They stay gated on `showInfo`, same as before the merge. */}
{currentLane && (
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
{currentLane.liveness === "dead"
? tLanes("statusDead")
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
</span>
<span className="truncate text-sm font-semibold text-fg-primary">
<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,
pipeline: currentLane.pipeline_name,
})}
</span>
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{since(currentLane.stage_seconds)}
</span>
</div>
<div className="flex items-center gap-2">
<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} />
</div>
</div>
)}
{currentLane && showInfo && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
<div className="mb-3 flex flex-wrap items-baseline gap-2">
{showInfo && (
<>
<select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
@@ -635,6 +633,7 @@ export function Workspace() {
</option>
))}
</select>
<LaneRouterRow lane={currentLane} />
{/* `stage` defaults to the DB sentinel "idle" until the driving
session ever calls `agenthub stage` — that string collides with
`status`'s own "idle"/"running" vocabulary, so a lane that is
@@ -672,17 +671,36 @@ export function Workspace() {
))}
</select>
)}
</>
)}
<div className="ml-auto flex items-center gap-2">
<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="ml-auto rounded px-2 py-0.5 text-status-danger transition-colors hover:bg-status-danger/15"
className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15"
>
{currentLane.kind === "adopted"
? tLanes("action.forget")
: tLanes("action.remove")}
</button>
)}
</div>
</div>
)}
{currentLane && showInfo && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
<div className="mb-3">
<LaneCard
lane={currentLane}
+2 -2
View File
@@ -43,7 +43,7 @@ The modal has a segmented toggle mirroring the CLI's two modes:
- **Repo** — adopts the given directory as-is (maps to `agenthub lanes add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub lanes add --repo`). Unlike the CLI, the modal requires you to type the new branch's name yourself rather than deriving one from the title — the underlying route still derives one when `branch` is omitted, so the CLI's behavior is unchanged.
Under the *Pipeline template* select, a **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's card in Workspace carries an **AI Router** select showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the card claiming a router the lane did not get. A session already running in the pane keeps the endpoint it started with — Claude Code reads `settings.local.json` at startup — so the change takes effect on the next run. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
Under the *Pipeline template* select, a **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the lane did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane keeps the endpoint it started with — Claude Code reads `settings.local.json` at startup — so the change takes effect on the next run. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
Under the *Pipeline template* select, a **Create/update readme file** checkbox (Claude lanes only — Codex has no slash commands) starts the new lane's terminal run right after creation with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set. It requires the `sg-all` plugin to be installed in Claude Code; without it the session simply reports an unknown command. Leave it unticked to create the lane without starting a run.
@@ -1516,7 +1516,7 @@ A profile can declare `.agenthub/profile/integrations.env` with `TRACKER_ENABLED
agenthub lanes integration tracker # exit 0 = on, 1 = off
```
This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. The agent that would act on an enabled `dev_qc` toggle isn't built yet, and neither is a `tracker` ticketer — `ship-feature-lane`'s Stage 9 stays skipped and Stage 13's dev-QC half stays unavailable regardless of what those two toggles read. `ci_wait`'s consumer is `agenthub lanes ci status` (added — see the command reference below); Stage 13's CI-wait half now honors it for real.
This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. The agent that would act on an enabled `dev_qc` toggle isn't built yet, and neither is a `tracker` ticketer — `ship-feature-lane`'s Stage 9 stays skipped and Stage 13's dev-QC half stays unavailable regardless of what those two toggles read. `ci_wait`'s consumer is `agenthub lanes ci status` (added — see the command reference below); Stage 13's CI-wait half now honors it for real. The CLI command and `GET /api/lanes/:id/integrations/:name` are the only readouts: the lane card used to carry a row of three badges for these, and it no longer does — two of the three could never be anything but decoration, and a badge that reports a toggle nothing acts on costs a row of card height to say nothing.
### Post-merge CI status: agenthub lanes ci status