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:
@@ -278,65 +278,21 @@ describe("/api/lanes", () => {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it("broadcasts lane_update on router apply", async () => {
|
||||
// Without this the client's cached row keeps the OLD router_profile: the
|
||||
// picker looks right until the user switches lanes and back, at which
|
||||
// point the effect re-reads the stale row and flips the selection.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-bcast-"));
|
||||
const created = await request("POST", "/api/lanes", { cwd, title: "Router Lane" });
|
||||
const id = created.body.lane.id;
|
||||
// Start somewhere other than native, so the broadcast has to carry a
|
||||
// CHANGED value rather than the one the row already held.
|
||||
require("../lib/lanes").setRouterProfile(id, "some-router");
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`);
|
||||
const messages = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.on("open", resolve);
|
||||
ws.on("error", reject);
|
||||
});
|
||||
ws.on("message", (msg) => {
|
||||
try {
|
||||
messages.push(JSON.parse(msg));
|
||||
} catch {
|
||||
/* parse error */
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const applied = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
|
||||
assert.equal(applied.status, 200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const msg = messages.find((m) => m.type === "lane_update" && m.data.lane?.id === id);
|
||||
assert.ok(msg, "router apply should broadcast lane_update");
|
||||
// `native` is stored as null — what matters is that the pushed row is the
|
||||
// post-apply one, not the "some-router" the client still has cached.
|
||||
assert.equal(msg.data.lane.router_profile, null);
|
||||
} finally {
|
||||
// A socket left open by a failed assertion keeps the whole run alive.
|
||||
ws.close();
|
||||
}
|
||||
|
||||
await request("DELETE", `/api/lanes/${id}`);
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("router apply asks for a restart only while the lane's pane is live", async () => {
|
||||
it("starts a session on the router it names, and refuses one the store lacks", async () => {
|
||||
const tmux = require("../lib/tmux");
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-live-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-start-"));
|
||||
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
||||
try {
|
||||
for (const live of [false, true]) {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session" && !live) throw new Error("no such session");
|
||||
return "";
|
||||
});
|
||||
const r = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.restart_required, live);
|
||||
assert.equal(r.body.run_id, live ? `agenthub-lane-${id}` : null);
|
||||
}
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") throw new Error("no such session");
|
||||
return "";
|
||||
});
|
||||
const bad = await request("POST", `/api/lanes/${id}/start`, { router: "nope" });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADROUTER");
|
||||
// The per-workspace route is gone: a router belongs to a session now.
|
||||
const gone = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
|
||||
assert.equal(gone.status, 404);
|
||||
} finally {
|
||||
tmux.__reset();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user