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
@@ -139,14 +139,7 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
api.settings.claudeHome.set(p).then((r) => {
void load();
onHomeChanged?.();
const missing = r.routers?.missing ?? [];
return {
value: r.claude_home,
envOwned: !!r.env_owned,
warning: missing.length
? t("claudeHome.routersMissing", { lanes: missing.join(", ") })
: null,
};
return { value: r.claude_home, envOwned: !!r.env_owned };
})
}
/>
@@ -244,10 +244,23 @@ describe("AiToolsSettings — install and update", () => {
describe("AiToolIcon", () => {
it("retries the icon when the same instance shows another tool", async () => {
const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render(<AiToolIcon tool={{ id: "claude", label: "Claude" }} />);
const { container, rerender } = render(<AiToolIcon tool={{ id: "gemini", label: "Gemini" }} />);
fireEvent.error(container.querySelector("img")!);
expect(screen.getByText("C")).toBeInTheDocument();
rerender(<AiToolIcon tool={{ id: "codex", label: "Codex" }} />);
expect(container.querySelector('img[src="/api/ai-tools/codex/icon"]')).toBeTruthy();
expect(screen.getByText("G")).toBeInTheDocument();
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
});
it("uses the shipped mark, and a light/dark pair for Hermes", async () => {
const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render(<AiToolIcon tool={{ id: "claude", label: "Claude" }} />);
expect(container.querySelector('img[src="/ai-tools/claude.svg"]')).toBeTruthy();
rerender(<AiToolIcon tool={{ id: "hermes", label: "Hermes" }} />);
expect(container.querySelector('img[src="/ai-tools/hermes-black.svg"]')?.className).toMatch(
/dark:hidden/
);
expect(container.querySelector('img[src="/ai-tools/hermes-white.svg"]')?.className).toMatch(
/hidden dark:block/
);
});
});
@@ -6,7 +6,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, within } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
@@ -62,7 +62,7 @@ beforeEach(() => {
.mockResolvedValue({ claude_home: "/home/u/.claude" });
vi.mocked(api.settings.claudeHome.set)
.mockReset()
.mockResolvedValue({ ok: true, claude_home: "/srv/claude", routers: { missing: [] } });
.mockResolvedValue({ ok: true, claude_home: "/srv/claude" });
vi.mocked(api.settings.resetPricing).mockReset();
vi.mocked(api.settings.reinstallHooks)
.mockReset()
@@ -97,12 +97,7 @@ describe("ClaudeAgentHubSettings", () => {
expect(api.settings.reinstallHooks).toHaveBeenCalledTimes(1);
});
it("re-reads hook status and tells the page after a CLAUDE_HOME save, naming lanes left without their router", async () => {
vi.mocked(api.settings.claudeHome.set).mockResolvedValue({
ok: true,
claude_home: "/srv/claude",
routers: { missing: ["7 (api): my-router"] },
});
it("re-reads hook status and tells the page after a CLAUDE_HOME save", async () => {
const onHomeChanged = vi.fn();
const user = userEvent.setup();
const { container } = render(<ClaudeAgentHubSettings onHomeChanged={onHomeChanged} />);
@@ -112,8 +107,7 @@ describe("ClaudeAgentHubSettings", () => {
const home = container.querySelector("section#claude-home") as HTMLElement;
const reads = vi.mocked(api.settings.info).mock.calls.length;
await user.click(within(home).getByRole("button"));
expect(await within(home).findByText(/7 \(api\): my-router/)).toBeInTheDocument();
expect(onHomeChanged).toHaveBeenCalledTimes(1);
await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
});