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