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:
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user