feat: AI Router per session; Settings at the sidebar's foot; shipped tool icons

AI Router is a session's choice, not a workspace's:
- an AI Router select beside the model picker in the workspace home and a
  pane's run form (RouterPicker.tsx; hidden off Claude/Codex or with only
  Native; last pick remembered in agenthub.session.router)
- `router` on POST /api/lanes/:id/start and POST /api/run; Claude gets
  --settings=<data dir>/router-settings/<name>.json (0600, outranks project
  and user settings, key never in argv), Codex -p <name> + key via tmux -e
- dashboard_runs.router; a resume naming none goes back to it
- profile PATCH rewrites the session files, DELETE removes them
- gone: POST /api/lanes/:id/router/apply, LaneRouterRow, the Add-workspace
  router radio, the claude-/codex-home `routers` answers; clearLaneRouters
  takes the old keys out of each workspace's settings.local.json at start

Sidebar: Settings pinned to the foot, the collapse toggle beside it (stacked
on the rail) instead of in the brand's corner.

Icons: shipped SVG marks for claude, codex, openclaw and hermes (a
black/white pair swapped by theme) in client/public/ai-tools/, used by
AiToolIcon before the server's favicon.
This commit is contained in:
2026-10-02 14:19:02 +07:00
parent 5084334d81
commit c507d37baf
55 changed files with 851 additions and 1992 deletions
+2 -167
View File
@@ -24,7 +24,7 @@ import { ConfirmModal } from "../ConfirmModal";
import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
import { api } from "../../lib/api";
import type { AiRouter, CwdSuggestion } from "../../lib/api";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
@@ -106,10 +106,6 @@ export function AddLaneModal({
// repo's worktrees, hence the busy markers below.
const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
const [base, setBase] = useState("");
// "native" is the built-in profile, not a null state — see server/lib/ai-routers.js.
const [router, setRouter] = useState("native");
const [routers, setRouters] = useState<AiRouter[]>([]);
const [routersLoaded, setRoutersLoaded] = useState(false);
const [branchesError, setBranchesError] = useState<string | null>(null);
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
const [pickedTool, setPickedTool] = useState<string | null>(null);
@@ -123,20 +119,15 @@ export function AddLaneModal({
const [toolListOpen, setToolListOpen] = useState(false);
const pickTool = (id: string) => {
setPickedTool(id === defaultTool ? null : id);
// A tool with no endpoint to rewrite runs native only (see router-apply.js).
if (!aiTools.byId(id)?.routerCapable) setRouter("native");
setToolListOpen(false);
};
const noTools = aiTools.loaded && aiTools.defaultId === null;
const toolRouterCapable =
aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [browseOpen, setBrowseOpen] = useState(false);
const [setupResult, setSetupResult] = useState<{
profile: "scaffolded" | "skipped" | "failed";
mcp: "ok" | "failed";
router: "ok" | "skipped" | "failed";
} | null>(null);
const reset = () => {
@@ -148,8 +139,6 @@ export function AddLaneModal({
setWorktrees([]);
setBranchMode("new");
setBase("");
setRouter("native");
setRoutersLoaded(false);
setPickedTool(null);
setBranchesError(null);
setError(null);
@@ -169,36 +158,6 @@ export function AddLaneModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// A session that boots on the vendor's API keeps talking to it for its whole
// life, so the router has to be picked here, before the lane exists.
useEffect(() => {
if (!open) return;
let cancelled = false;
api.aiRouters
.list()
.then((r) => {
if (cancelled) return;
setRouters(r.items);
// A profile deleted in another tab while this modal sat open would
// otherwise stay selected in state while the select showed no matching
// option — and submit would send a name the server no longer knows.
setRouter((current) =>
current === "native" || r.items.some((item) => item.name === current) ? current : "native"
);
})
.catch(() => {
// Quiet: with no list the radio simply offers Native, and the lane is
// still created — a router is an optional extra, never a precondition.
if (!cancelled) setRouters([]);
})
.finally(() => {
if (!cancelled) setRoutersLoaded(true);
});
return () => {
cancelled = true;
};
}, [open]);
// Look up the repo's branches once the path settles - debounced so every
// keystroke while typing a path doesn't fire a request against a path that
// isn't finished yet. Worktree mode only: "Repo" mode adopts as-is and
@@ -236,10 +195,6 @@ export function AddLaneModal({
return () => window.clearTimeout(timer);
}, [mode, sourceRepo, t]);
// Native is the built-in profile, so this is one call either way — there is
// no "no router" branch to keep in step with the real one.
const applyRouter = (laneId: number) => api.lanes.routerApply(laneId, router);
const submit = async () => {
const repo = sourceRepo.trim();
const name = title.trim();
@@ -274,18 +229,6 @@ export function AddLaneModal({
setBusy(false);
return;
}
// A session that booted on the vendor's API would keep talking to it
// for its whole life, so the router is applied before anything runs.
try {
await applyRouter(result.lane.id);
} catch (routerErr) {
// The lane exists and is already listed — name what did not finish
// instead of closing on a half-set-up lane.
setError(routerErr instanceof Error ? routerErr.message : String(routerErr));
setBusy(false);
return;
}
reset();
onClose();
} catch (err) {
@@ -322,10 +265,9 @@ export function AddLaneModal({
return;
}
const [profileOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([
const [profileOutcome, mcpOutcome] = await Promise.allSettled([
api.lanes.profileInit(result.lane.id),
api.lanes.mcpSync(result.lane.id),
applyRouter(result.lane.id),
]);
const outcome = {
profile:
@@ -335,8 +277,6 @@ export function AddLaneModal({
: "skipped"
: "failed",
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
router:
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
} as const;
const anyFailed = Object.values(outcome).includes("failed");
setSetupResult(outcome);
@@ -346,9 +286,6 @@ export function AddLaneModal({
profile:
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
// Never the profile itself: it would put an endpoint and a lane id in
// the browser console. (The key is never sent here at all.)
router: routerOutcome.status,
});
}
@@ -690,103 +627,6 @@ export function AddLaneModal({
</div>
)}
{/* The router is the one per-lane extra this form still asks for, so it
sits in the form itself — a disclosure around a single field only
hid it. The pipeline picker is gone entirely: every lane is created
on `default` (the server's own default) and the template is changed
from the lane's own header afterwards. */}
<div className="rounded-md border border-border-light bg-surface-0 p-2">
<div>
<fieldset>
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
<div className="flex gap-2">
{(
[
{
value: "native",
label: t("addLaneRouterNativeTool", { tool: aiTools.label(tool) }),
},
{ value: "router", label: t("addLaneRouterCustom") },
] as const
).map((option) => {
const active =
option.value === "native" ? router === "native" : router !== "native";
// Clicking "AI Router" before the list lands would resolve back to
// Native and read as a dead control.
const disabled =
option.value === "router" && (!routersLoaded || !toolRouterCapable);
return (
<label
key={option.value}
title={
option.value === "router" && !toolRouterCapable
? t("router.nativeOnly", { tool: aiTools.label(tool) })
: undefined
}
// No radio dot: the card itself lights up when selected. That
// leaves the DISABLED state with nothing to show it, since the
// greyed-out dot was carrying it — hence the explicit branch.
// The input only goes visually hidden, so keyboard and screen
// readers still get a real radio group, and the card carries
// its focus ring.
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
disabled
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
: active
? "cursor-pointer border-accent bg-accent/10 text-accent"
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
}`}
>
<input
type="radio"
name="add-lane-router"
className="sr-only"
disabled={disabled}
checked={active}
onChange={() =>
// Switching to "AI Router" lands on the first saved
// profile; with none saved the radio stays on native and
// the select below explains why.
setRouter(
option.value === "native"
? "native"
: routers.find((r) => !r.builtin)?.name || "native"
)
}
/>
{option.label}
</label>
);
})}
</div>
{router !== "native" && (
<select
aria-label={t("addLaneRouterProfileLabel")}
value={router}
onChange={(e) => setRouter(e.target.value)}
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
>
{routers
.filter((r) => !r.builtin)
.map((r) => (
<option key={r.name} value={r.name}>
{r.label}
</option>
))}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">
{!routersLoaded
? t("addLaneRouterLoading")
: routers.some((r) => !r.builtin)
? t("addLaneRouterHint")
: t("addLaneRouterEmptyHint")}
</p>
</fieldset>
</div>
</div>
{setupResult && (
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
@@ -798,11 +638,6 @@ export function AddLaneModal({
skipped: setupResult.profile === "skipped",
},
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
{
label: t("addLaneSetupRouter"),
ok: setupResult.router !== "failed",
skipped: setupResult.router === "skipped",
},
] as const
).map((row) => (
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
+1 -171
View File
@@ -10,8 +10,6 @@
* progress: the pipeline map directly below this card draws the same number
* as named, coloured nodes, so a bar and a percent beside it were a worse
* second telling of it. The repo
* card also carries the lane's AI Router picker — the Add-lane modal is the
* only other place one is chosen, and a lane outlives its creation. The repo
* tooling dropdown (`LaneToolsMenu`, exported from this file — agents
* install / MCP sync / dev-sync check / create-update readme) and the
* remove-lane button live in Workspace's page header / pipeline-picker row
@@ -22,15 +20,13 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DestructiveLaneModal } from "./DestructiveLaneModal";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { keepIfSame } from "../../lib/keepIfSame";
import type { AiRouter } from "../../lib/api";
import type { Lane, LaneRuntime, NamedLock } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
/** How often a mounted card re-probes its stack. Faster than the Changes panel's
* git refresh because a stack dying is exactly what the user needs to see, and slower than
@@ -106,172 +102,6 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
return locks;
}
/**
* 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
* outlives the moment it was created.
*
* Applying writes the lane's own config on disk as well as the DB column, so it
* goes through `POST /lanes/:id/router/apply` and never a PATCH. A failure puts
* the select back: the row must not claim a router the lane did not get.
*
* Rendered only when there is something to choose. A user who has never opened
* the AI Router tab has exactly the built-in `native` profile, and a select
* with one option is a control that cannot do anything.
*/
export function LaneRouterRow({ lane }: { lane: Lane }) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const toolLabel = aiTools.label(lane.tool || "claude");
// Only Claude and Codex have an endpoint/model override to write; every
// other tool is offered native alone (the server refuses the rest anyway).
const routerCapable =
aiTools.byId(lane.tool || "claude")?.routerCapable ??
(lane.tool === "claude" || lane.tool === "codex" || !lane.tool);
const [routers, setRouters] = useState<AiRouter[] | null>(null);
const [current, setCurrent] = useState(lane.router_profile || "native");
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ ok: boolean; text: string; runId?: string } | null>(null);
// The polled lane list is the source of truth; a switch elsewhere (the lane
// was reset to native by a profile delete, say) has to land here too.
useEffect(() => {
setCurrent(lane.router_profile || "native");
setNote(null);
}, [lane.id, lane.router_profile]);
// One instance of this row serves every lane — it lives in the Workspace's
// lane-detail header, and switching lanes swaps its `lane` prop rather than
// remounting it. So an apply that is still in flight when the user moves on
// would land its rollback and its note on whatever lane is showing by then:
// lane B's failed switch putting lane B's old selection into lane A's picker.
const shownLane = useRef(lane.id);
useEffect(() => {
shownLane.current = lane.id;
}, [lane.id]);
useEffect(() => {
let alive = true;
api.aiRouters
.list()
.then((r) => {
if (alive) setRouters(r.items);
})
.catch(() => {
// Silent, same contract as the runtime poller above: a card that
// cannot list routers simply shows no router row.
if (alive) setRouters([]);
});
return () => {
alive = false;
};
}, []);
if (!routers || routers.length <= 1) return null;
const change = async (name: string) => {
const laneId = lane.id;
const previous = current;
setCurrent(name);
setBusy(true);
setNote(null);
try {
const res = await api.lanes.routerApply(laneId, name);
if (shownLane.current !== laneId) return;
// A live pane adopted the new base URL/key but kept the model names it
// resolved at startup, so it would send the OLD router's model to the NEW
// endpoint and fail with "model not found". Only that case gets the
// warning and the restart button.
setNote(
res.restart_required && res.run_id
? { ok: false, text: t("router.appliedRestart"), runId: res.run_id }
: { ok: true, text: t("router.applied") }
);
} catch (err) {
if (shownLane.current !== laneId) return;
setCurrent(previous);
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
} finally {
// `busy` belongs to the control on screen, not to the request, so it is
// cleared either way — a lane switch must never leave the picker locked.
setBusy(false);
}
};
/**
* Kill the pane and open a fresh one for this lane. Started with the lane id
* alone on purpose: the previous run's `--model` flag names a model of the
* router the user just left, so replaying it would re-create the exact
* mismatch this button exists to clear.
*/
const restart = async (runId: string) => {
const laneId = lane.id;
setBusy(true);
try {
await api.run.kill(runId);
await api.run.start({ laneId });
if (shownLane.current !== laneId) return;
setNote({ ok: true, text: t("router.restarted") });
} catch (err) {
if (shownLane.current !== laneId) return;
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(false);
}
};
return (
<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}
disabled={!r.builtin && !routerCapable}
title={
!r.builtin && !routerCapable ? t("router.nativeOnly", { tool: toolLabel }) : undefined
}
>
{r.builtin ? t("router.native", { tool: toolLabel }) : 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 && (
<span className={note.ok ? "text-fg-muted" : "text-status-danger"} title={note.text}>
{note.ok ? "✓" : "⚠"}
</span>
)}
{/* The one case with an action attached: restarting the pane is what
actually makes the switch take effect, so it is a button and not
another sentence the user has to act on somewhere else. */}
{note?.runId && (
<button
type="button"
disabled={busy}
onClick={() => void restart(note.runId as string)}
className="rounded border border-border px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
>
{t("router.restart")}
</button>
)}
</label>
);
}
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
idle: "bg-surface-4",
@@ -39,6 +39,7 @@ import { fmtCost, timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelPicker, PromptEditor } from "../run/RunSetup";
import { RouterPicker, routerArg, useSessionRouter } from "../run/RouterPicker";
import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
@@ -107,6 +108,7 @@ function HomeBody({
// Claude Desktop's worktree toggle: the new session gets a git worktree of
// its own (`claude --worktree`), made and cleaned up by Claude Code.
const [inWorktree, setInWorktree] = useState(false);
const [router, setRouter] = useSessionRouter();
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
@@ -231,6 +233,7 @@ function HomeBody({
...(model ? { model } : {}),
...(claude ? { permissionMode: mode } : {}),
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
...routerArg(aiTools.byId(lane.tool)?.routerCapable, router),
});
setPrompt("");
const runId = r.run_id ?? paneRunId(lane.id, slot);
@@ -341,6 +344,7 @@ function HomeBody({
<div className="w-56">
<ModelPicker value={model} onChange={setModel} tool={lane.tool} />
</div>
<RouterPicker tool={lane.tool} value={router} onChange={setRouter} />
{claude && (
<div className="w-44">
<Select<PermissionMode>
@@ -2,8 +2,7 @@
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
* the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Start/Stop (`POST /api/lanes/:id/start` slot 0 / `stop`),
* Rename (the row's inline field), the AI Router select (`LaneRouterRow`,
* router-capable tools only), Create/update readme (claude only, the same run
* Rename (the row's inline field), Create/update readme (claude only, the same run
* the lane Tools menu starts), New workspace here (the Add modal prefilled),
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane. The server's guards are the real ones; the menu only hides
@@ -16,7 +15,6 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { LaneRouterRow } from "./LaneCard";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
@@ -93,9 +91,6 @@ export function WorkspaceRowMenu({
<button type="button" role="menuitem" className={item} onClick={() => onAction("rename")}>
{t("menu.rename")}
</button>
<div className="px-2 py-1">
<LaneRouterRow lane={lane} />
</div>
{lane.tool === "claude" && (
<button
type="button"
@@ -29,7 +29,6 @@ vi.mock("../../../lib/api", () => ({
get: vi.fn(),
profileInit: vi.fn(),
mcpSync: vi.fn(),
routerApply: vi.fn(),
},
aiRouters: { list: vi.fn() },
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
@@ -157,53 +156,9 @@ beforeEach(() => {
.mockReset()
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
vi.mocked(api.lanes.routerApply).mockReset().mockResolvedValue({
tool: "claude",
file: null,
restart_required: false,
run_id: null,
profile: "native",
});
// Only the built-in profile by default, so the router radio stays on Native
// and no test has to opt out of it.
vi.mocked(api.aiRouters.list)
.mockReset()
.mockResolvedValue({ items: [nativeRouter()] });
vi.mocked(api.aiRouters.list).mockReset();
});
/** The built-in profile, exactly as `GET /api/ai-routers` reports it. */
function nativeRouter() {
return {
name: "native",
label: "Native",
builtin: true,
baseUrl: "",
hasKey: false,
model: "claude-sonnet-5",
models: {
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
},
};
}
/** A saved router profile, for the tests that need the radio to have a target. */
function savedRouter(over: Record<string, unknown> = {}) {
return {
...nativeRouter(),
name: "9router",
label: "9Router",
builtin: false,
hasKey: true,
baseUrl: "http://localhost:20128/v1",
model: "Highest",
...over,
};
}
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
const withWorktrees = {
branches: ["main", "feat/other", "feat/held"],
@@ -644,134 +599,12 @@ describe("AddLaneModal — create/update readme", () => {
});
});
describe("AddLaneModal — AI Router", () => {
it("applies native without asking, so a lane always has a declared endpoint", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "native"));
});
it("sends the picked profile once AI Router is chosen", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "9router"));
});
it("keeps the radio on Native when no profile is saved, and says why", async () => {
// Choosing "AI Router" with nothing to point at would create a lane whose
// declared endpoint does not exist.
renderModal();
const user = userEvent.setup();
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
expect(screen.getByRole("radio", { name: /^Native/ })).toBeChecked();
expect(screen.getByText(/No router profile saved yet/)).toBeInTheDocument();
});
it("creates the lane anyway when the profile list cannot be fetched", async () => {
vi.mocked(api.aiRouters.list).mockRejectedValue(new Error("offline"));
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.ensure).toHaveBeenCalled());
});
});
describe("AddLaneModal — a created lane is never lost to a setup failure", () => {
it("reports the adopted lane even when applying the router fails", async () => {
// `ensure` already created it server-side; skipping onAdded would hide a
// real lane from the list until some unrelated refetch found it.
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("no router profile named 'gone'"));
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
const onAdded = vi.fn();
renderModal({ onAdded });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
// …and the failure is still surfaced, on a modal left open.
expect(await screen.findByText(/no router profile named/)).toBeInTheDocument();
});
});
describe("AddLaneModal — a router profile that disappeared", () => {
it("falls back to Native when the saved profile is gone by the time the list loads", async () => {
// Deleted in another tab while this modal sat open: the select would show
// no matching option while state still held the dead name.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
const { rerender } = renderModal();
const user = userEvent.setup();
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
expect(screen.getByRole("radio", { name: "AI Router" })).toBeChecked();
// Re-open with the profile gone from the server's answer.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter()] });
rerender(<AddLaneModal open={false} onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
rerender(<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
await waitFor(() => expect(screen.getByRole("radio", { name: /^Native/ })).toBeChecked());
});
it("does not offer AI Router until the profile list has arrived", async () => {
// Clicking it early resolved back to Native with no feedback, which reads
// as a dead control.
let resolveList: (v: { items: ReturnType<typeof nativeRouter>[] }) => void = () => {};
vi.mocked(api.aiRouters.list).mockReturnValue(
new Promise((resolve) => {
resolveList = resolve;
}) as never
);
renderModal();
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
resolveList({ items: [nativeRouter(), savedRouter()] });
await waitFor(() => expect(screen.getByRole("radio", { name: "AI Router" })).toBeEnabled());
describe("AddLaneModal — no router here", () => {
it("asks for no AI Router: a new session picks its own", async () => {
renderModal({});
await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(screen.queryByRole("radio", { name: "AI Router" })).toBeNull();
expect(api.aiRouters.list).not.toHaveBeenCalled();
});
});
@@ -811,15 +644,6 @@ describe("AddLaneModal — tool selection", () => {
);
});
it("offers only Native for a tool that cannot use an AI Router", async () => {
renderModal({});
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /Claude Code.*Default/ }));
await user.click(screen.getByRole("button", { name: "Gemini" }));
expect(screen.getByRole("radio", { name: "Native (Gemini)" })).toBeChecked();
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
});
it("says so, with a link to Settings, when no tool is installed and enabled", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue({ default: null, tools: [] });
renderModal({});
@@ -9,9 +9,9 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
import LaneCard, { LaneToolsMenu } from "../LaneCard";
import type { Lane, LaneRuntime } from "../../../lib/types";
import { api } from "../../../lib/api";
@@ -25,7 +25,6 @@ vi.mock("../../../lib/api", () => ({
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
mcpSync: vi.fn(),
syncBaseCheck: vi.fn(),
routerApply: vi.fn(),
},
locks: {
list: vi.fn(),
@@ -374,168 +373,3 @@ describe("LaneToolsMenu", () => {
expect(container).toBeEmptyDOMElement();
});
});
describe("LaneRouterRow — the Model picker", () => {
const native = {
name: "native",
label: "Native",
builtin: true,
baseUrl: "",
hasKey: false,
model: "claude-sonnet-5",
models: {
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
},
};
const nineRouter = { ...native, name: "9router", label: "9Router", builtin: false };
it("stays out of the card when native is the only profile", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] });
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(<LaneRouterRow lane={makeLane({ router_profile: "9router" })} />);
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
it("offers only native to a tool that cannot use an AI Router", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<LaneRouterRow lane={makeLane({ tool: "gemini" })} />);
const select = await screen.findByDisplayValue("Native (gemini)");
const router = Array.from((select as HTMLSelectElement).options).find(
(o) => o.value === "9router"
);
expect(router?.disabled).toBe(true);
});
it("applies a switch through the router route, not a lane PATCH", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/demo/.claude/settings.local.json",
profile: "9router",
restart_required: false,
run_id: null,
});
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(1, "9router"));
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
it("offers a restart when a live pane still holds the old router's models", async () => {
// Claude Code re-applies `env` to a running session but keeps the model it
// resolved at startup, so the switch is only half-applied until the pane is
// restarted — the server says so and the row must act on it.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/demo/.claude/settings.local.json",
profile: "9router",
restart_required: true,
run_id: "agenthub-lane-1",
});
vi.mocked(api.run.kill).mockResolvedValue({ ok: true });
vi.mocked(api.run.start).mockResolvedValue({} as never);
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
const restart = await screen.findByRole("button", { name: "Restart" });
await userEvent.click(restart);
await waitFor(() => expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1"));
// Started by lane id alone: replaying the old run's --model would name a
// model of the router the user just left.
expect(api.run.start).toHaveBeenCalledWith({ laneId: 1 });
});
it("leaves the restart button out when no pane is running", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/demo/.claude/settings.local.json",
profile: "9router",
restart_required: false,
run_id: null,
});
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
});
it("does not carry a pick made on one lane back to the lane selected before it", async () => {
// One LaneRouterRow instance serves every lane (it lives in the Workspace's
// lane-detail header), so its selected value is component state that
// outlives a lane switch unless the sync effect puts the new lane's own
// profile back.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/b/.claude/settings.local.json",
profile: "9router",
restart_required: false,
run_id: null,
});
const laneA = makeLane({ id: 1, router_profile: null });
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneA} />);
await screen.findByDisplayValue("Native (Claude)");
rerender(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
});
it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => {
// The row is one instance shared by every lane, so an apply still in flight
// when the user moves on would paint its rollback and its error on whoever
// is on screen by then — lane B's failure changing lane A's picker.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
let reject: (e: Error) => void = () => {};
vi.mocked(api.lanes.routerApply).mockImplementation(
() => new Promise((_, r) => (reject = r)) as ReturnType<typeof api.lanes.routerApply>
);
const laneA = makeLane({ id: 1, router_profile: null });
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
await act(async () => {
reject(new Error("EBADJSON"));
await Promise.resolve();
});
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
expect(screen.queryByTitle("EBADJSON")).toBeNull();
// And the picker must not be left locked by a request for another lane.
expect(screen.getByRole("combobox")).not.toBeDisabled();
});
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(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
});
});