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:
@@ -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