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
@@ -353,6 +353,7 @@ export function LaneConsolePane({
approval: args.approval,
resumeLast: args.resumeLast,
bypass: args.bypass,
router: args.router,
});
} catch (laneErr: unknown) {
// A code-only check: the server's message is human prose ("lane
@@ -0,0 +1,91 @@
/**
* @file RouterPicker.tsx
* @description The AI Router a NEW session starts on — a session's choice, not
* its workspace's (`router` on `POST /api/lanes/:id/start`, `server/lib/router-apply.js`).
* Shown only for a tool that can use one (Claude, Codex) and only when a
* profile beyond Native exists. The last pick is remembered for the next
* session (`agenthub.session.router`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { api, type AiRouter } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { Select } from "../Select";
const KEY = "agenthub.session.router";
/** The remembered pick, `"native"` by default. */
export function useSessionRouter(): [string, (v: string) => void] {
const [value, setValue] = useState(() => {
try {
return localStorage.getItem(KEY) || "native";
} catch {
return "native";
}
});
const set = (v: string) => {
setValue(v);
try {
localStorage.setItem(KEY, v);
} catch {
/* storage off — the pick still holds for this page */
}
};
return [value, set];
}
/** What to send as `router` for `tool`: nothing for native or a tool that has none. */
export function routerArg(routerCapable: boolean | undefined, router: string): { router?: string } {
return routerCapable && router && router !== "native" ? { router } : {};
}
export function RouterPicker({
tool,
value,
onChange,
wrap = (el) => el,
}: {
tool: string;
value: string;
onChange: (v: string) => void;
/** Puts the select in the caller's labelled field; not called while hidden. */
wrap?: (select: ReactNode) => ReactNode;
}) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const capable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
const [routers, setRouters] = useState<AiRouter[] | null>(null);
useEffect(() => {
let alive = true;
api.aiRouters
.list()
.then((r) => alive && setRouters(r.items))
.catch(() => alive && setRouters([]));
return () => {
alive = false;
};
}, []);
// A remembered profile that has since been deleted falls back to native,
// or the start would be refused for a router the user cannot see.
useEffect(() => {
if (routers && value !== "native" && !routers.some((r) => r.name === value)) onChange("native");
}, [routers, value, onChange]);
if (!capable || !routers || routers.length <= 1) return null;
return wrap(
<div className="w-44" data-testid="router-picker" title={t("router.label")}>
<Select<string>
value={value}
onChange={onChange}
options={routers.map((r) => ({
value: r.name,
label: r.builtin ? t("router.native", { tool: aiTools.label(tool) }) : r.label,
}))}
/>
</div>
);
}
+33 -4
View File
@@ -59,6 +59,7 @@ import type {
import type { Session, LaneTool } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { Select } from "../Select";
import { RouterPicker, routerArg, useSessionRouter } from "./RouterPicker";
export interface SlashCommand {
name: string;
@@ -409,6 +410,8 @@ export function RunSetup(props: RunSetupProps) {
const canResume = !catalogTool || !!entry?.resume;
// Per run, never remembered: the form starts unticked every time it mounts.
const [bypass, setBypass] = useState(false);
const [router, setRouter] = useSessionRouter();
const routerSent = routerArg(entry?.routerCapable, router).router;
const canBypass = props.tool !== "claude" && !!entry?.bypass;
// Claude and Codex always take a model; a catalog tool only when its file names the flag.
const hasModel = !catalogTool || !!entry?.modelFlag;
@@ -450,6 +453,15 @@ export function RunSetup(props: RunSetupProps) {
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field>
)}
{/* A resume goes back to the router its session ran on (server-side). */}
{!props.resumeSession && (
<RouterPicker
tool={props.tool}
value={router}
onChange={setRouter}
wrap={(select) => <Field label={t("lanes:router.label")}>{select}</Field>}
/>
)}
{catalogTool ? null : props.tool === "codex" ? (
<>
<Field label={t("fields.sandbox")}>
@@ -536,7 +548,7 @@ export function RunSetup(props: RunSetupProps) {
// update has not landed yet on this tick, so reading
// props.resumeSession here would resume nothing.
if (s && !props.busy)
handleStart(props, s, bypass, hasModel, entry?.defaultModel);
handleStart(props, s, bypass, hasModel, entry?.defaultModel, routerSent);
}}
// Fall back to the typed cwd when no lane is selected — otherwise
// an unfiltered picker lists every session from every repo.
@@ -564,7 +576,14 @@ export function RunSetup(props: RunSetupProps) {
value={props.prompt}
onChange={props.onPromptChange}
onSubmit={() =>
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel)
handleStart(
props,
undefined,
canBypass && bypass,
hasModel,
entry?.defaultModel,
routerSent
)
}
placeholder={t("fields.promptPlaceholderTerminal", { tool: aiTools.label(props.tool) })}
rows={5}
@@ -620,7 +639,14 @@ export function RunSetup(props: RunSetupProps) {
</div>
<button
onClick={() =>
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel)
handleStart(
props,
undefined,
canBypass && bypass,
hasModel,
entry?.defaultModel,
routerSent
)
}
disabled={
!props.binaryFound ||
@@ -655,7 +681,8 @@ function handleStart(
session?: Session,
bypass?: boolean,
hasModel?: boolean,
defaultModel?: string | null
defaultModel?: string | null,
router?: string
) {
const resume = session ?? props.resumeSession;
const codex = props.tool === "codex";
@@ -682,6 +709,8 @@ function handleStart(
// asked for, so a resume carries no prompt at all.
initialPrompt: resume ? undefined : props.prompt || undefined,
bypass: bypass || undefined,
// A resume names none: the server goes back to its session's own router.
router: resume ? undefined : router,
});
}
@@ -29,6 +29,7 @@ vi.mock("../../../lib/api", () => ({
action: vi.fn(),
list: vi.fn(),
},
aiRouters: { list: vi.fn().mockResolvedValue({ items: [] }) },
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
@@ -0,0 +1,83 @@
/**
* @file RouterPicker.test.tsx
* @description The per-session AI Router picker: hidden with only Native or on
* a tool that cannot use a router, listing the saved profiles otherwise,
* remembering the pick, and falling back to Native when the remembered
* profile is gone; plus `routerArg`, which never sends native.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { RouterPicker, routerArg, useSessionRouter } from "../RouterPicker";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
aiTools: {
list: vi.fn().mockResolvedValue({
default: "claude",
tools: [
{ id: "claude", label: "Claude Code", routerCapable: true },
{ id: "gemini", label: "Gemini", routerCapable: false },
].map((x) => ({ ...x, installed: true, enabled: true })),
}),
},
aiRouters: { list: vi.fn() },
},
};
});
const native = { name: "native", label: "Native", builtin: true };
const nine = { name: "9router", label: "9Router", builtin: false };
beforeEach(() => {
localStorage.clear();
vi.mocked(api.aiRouters.list)
.mockReset()
.mockResolvedValue({ items: [native, nine] } as never);
});
describe("RouterPicker", () => {
it("lists the saved profiles for a tool that can use one", async () => {
render(<RouterPicker tool="claude" value="9router" onChange={() => {}} />);
expect(await screen.findByText("9Router")).toBeInTheDocument();
});
it("is hidden with only Native saved", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] } as never);
render(<RouterPicker tool="claude" value="native" onChange={() => {}} />);
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
expect(screen.queryByTestId("router-picker")).toBeNull();
});
it("is hidden on a tool with no endpoint to point at a router", async () => {
render(<RouterPicker tool="gemini" value="native" onChange={() => {}} />);
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
expect(screen.queryByTestId("router-picker")).toBeNull();
});
it("falls back to Native when the remembered profile is gone", async () => {
const onChange = vi.fn();
render(<RouterPicker tool="claude" value="deleted" onChange={onChange} />);
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
});
});
describe("useSessionRouter / routerArg", () => {
it("remembers the last pick for the next session", () => {
const first = renderHook(() => useSessionRouter());
expect(first.result.current[0]).toBe("native");
act(() => first.result.current[1]("9router"));
expect(renderHook(() => useSessionRouter()).result.current[0]).toBe("9router");
});
it("sends a router only for a capable tool and never native", () => {
expect(routerArg(true, "9router")).toEqual({ router: "9router" });
expect(routerArg(true, "native")).toEqual({});
expect(routerArg(false, "9router")).toEqual({});
});
});
@@ -25,6 +25,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
return {
...actual,
api: {
aiRouters: { list: r({ items: [] }) },
aiTools: {
list: r({
default: "claude",