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
@@ -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",