feat: v2.5.5 — AI Router renamed AI Proxy, reload onto new builds, update no longer spins

AI Proxy (was AI Router):
- server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at
  /api/ai-proxies and the old /api/ai-routers; `router` still accepted on the
  start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/
- dashboard_runs.router renamed in place to proxy: an older build on the same
  DB fails on that column, so dev and prod sharing one DB update together
- a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages);
  Test probes Claude /v1/messages and Codex /v1/responses
- Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker

Updates:
- every tab reloads onto the new UI build when its WebSocket reconnects to a
  server of another version (lib/newBuildReload.ts, once per version)
- Update now stops waiting on a plugin chain that failed or pulled nothing
  (the old server never goes down), and reloads as soon as a new version answers

Also:
- a pane whose command exits at once says so (EPANEEXITED) instead of
  "Command failed: tmux set-option … mouse on"
- the pane drag preview draws where the pane actually lands
This commit is contained in:
2026-10-06 22:48:09 +07:00
parent dd9b23106c
commit 5fe5d51b1b
82 changed files with 1792 additions and 1412 deletions
+2 -8
View File
@@ -14,7 +14,7 @@
* toasts. The `+` menu opens a new tab in the free slot the server picks
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
* or an installed, enabled AI tool started bare on its default model, no
* prompt (a prompt, router or worktree is still the workspace home's prompt
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
* workspace's home) at its left end, `actions` (the single pane's split menus)
@@ -51,13 +51,7 @@ import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import {
type PaneGroups,
detachSlot,
groupOf,
leaves,
reorderGroups,
} from "../../lib/paneLayout";
import { type PaneGroups, detachSlot, groupOf, leaves, reorderGroups } from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
type AiToolsState = ReturnType<typeof useAiTools>;
+25 -17
View File
@@ -106,20 +106,27 @@ export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate]
return [current.groups, update];
}
type Zone = Edge | "center";
export type Zone = Edge | "center";
const pct = (r: Rect) => ({
left: `${r.x * 100}%`,
top: `${r.y * 100}%`,
width: `${r.w * 100}%`,
height: `${r.h * 100}%`,
});
/** The half (or the whole) of a pane a drop zone would fill. */
function zoneRect(r: Rect, zone: Zone): Rect {
if (zone === "left") return { ...r, w: r.w / 2 };
if (zone === "right") return { ...r, x: r.x + r.w / 2, w: r.w / 2 };
if (zone === "top") return { ...r, h: r.h / 2 };
if (zone === "bottom") return { ...r, y: r.y + r.h / 2, h: r.h / 2 };
return r;
/** What dropping `src` on a pane's zone does: the center swaps, an edge
* splits. The drop applies it and the preview draws it, so they agree. */
export function applyDrop(l: PaneLayout, src: number, slot: number, zone: Zone): PaneLayout {
return zone === "center" ? swapInLayout(l, src, slot) : moveInLayout(l, src, slot, zone);
}
/** Where `src` would land, or null for a drop that changes nothing. A pane
* already shown leaves its place first, so its target grows before the
* split — the target's own half is not where it lands. */
export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone): Rect | null {
if (src === slot) return null;
const next = applyDrop(l, src, slot, zone);
if (next === l) return null;
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
}
/** A small anchored menu: a trigger button and a list that closes on a pick,
@@ -323,7 +330,7 @@ export function PaneGrid({
}) {
const { t } = useTranslation("lanes");
const gridRef = useRef<HTMLDivElement>(null);
const [drop, setDrop] = useState<{ slot: number; zone: Zone } | null>(null);
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
const { panes, dividers } = layoutRects(layout.root);
const shown = leaves(layout.root);
const rectOf = new Map(panes.map((p) => [p.slot, p]));
@@ -391,6 +398,8 @@ export function PaneGrid({
return { slot: p.slot, zone: dist < 0.25 ? edge : "center" };
};
const preview = drop && dropPreview(layout, drop.src, drop.slot, drop.zone);
const isPaneDrag = (e: React.DragEvent) =>
dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE);
@@ -404,7 +413,10 @@ export function PaneGrid({
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
const z = zoneAt(e);
setDrop((d) => (d?.slot === z?.slot && d?.zone === z?.zone ? d : z));
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
setDrop((d) =>
d?.src === next?.src && d?.slot === next?.slot && d?.zone === next?.zone ? d : next
);
}}
onDragLeave={(e) => {
if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null);
@@ -417,11 +429,7 @@ export function PaneGrid({
dragSlot = null;
setDrop(null);
if (!z || !Number.isInteger(src) || src === z.slot) return;
onChange((l) =>
z.zone === "center"
? swapInLayout(l, src, z.slot)
: moveInLayout(l, src, z.slot, z.zone as Edge)
);
onChange((l) => applyDrop(l, src, z.slot, z.zone));
}}
>
{mounted.map((slot) => {
@@ -561,11 +569,11 @@ export function PaneGrid({
}
/>
))}
{drop && rectOf.has(drop.slot) && (
{preview && (
<div
data-testid="pane-drop-preview"
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
style={pct(zoneRect(rectOf.get(drop.slot) as Rect, drop.zone))}
style={pct(preview)}
/>
)}
</div>
+104
View File
@@ -0,0 +1,104 @@
/**
* @file ProxyPicker.tsx
* @description The AI Proxy a NEW session starts on — a session's choice, not
* its workspace's (`proxy` on `POST /api/lanes/:id/start`, `server/lib/proxy-apply.js`):
* Native or one of the saved profiles (`useAiProxies`, which the caller also
* reads to list the proxy's models). A tool that cannot use a proxy (only
* Claude and Codex can) shows Native, disabled. The last pick is remembered
* for the next session (`agenthub.session.proxy`; the pre-rename
* `agenthub.session.router` is still read when that is unset).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type AiProxy } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { Select } from "../Select";
const KEY = "agenthub.session.proxy";
const LEGACY_KEY = "agenthub.session.router";
/** The remembered pick, `"native"` by default. */
export function useSessionProxy(): [string, (v: string) => void] {
const [value, setValue] = useState(() => {
try {
return localStorage.getItem(KEY) || localStorage.getItem(LEGACY_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];
}
/** The saved profiles (Native included), null until they arrive. */
export function useAiProxies(): AiProxy[] | null {
const [proxies, setProxies] = useState<AiProxy[] | null>(null);
useEffect(() => {
let alive = true;
api.aiProxies
.list()
.then((r) => alive && setProxies(r.items))
.catch(() => alive && setProxies([]));
return () => {
alive = false;
};
}, []);
return proxies;
}
/** What to send as `proxy` for `tool`: nothing for native or a tool that has none. */
export function proxyArg(proxyCapable: boolean | undefined, proxy: string): { proxy?: string } {
return proxyCapable && proxy && proxy !== "native" ? { proxy } : {};
}
export function ProxyPicker({
tool,
proxies,
value,
onChange,
variant,
}: {
variant?: "field" | "ghost";
tool: string;
/** From `useAiProxies`. */
proxies: AiProxy[] | null;
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const capable = aiTools.byId(tool)?.proxyCapable ?? (tool === "claude" || tool === "codex");
const native = t("proxy.native", { tool: aiTools.label(tool) });
// A remembered profile that has since been deleted falls back to native,
// or the start would be refused for a proxy the user cannot see.
useEffect(() => {
if (proxies && value !== "native" && !proxies.some((r) => r.name === value)) onChange("native");
}, [proxies, value, onChange]);
const options =
capable && proxies?.length
? proxies.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
: [{ value: "native", label: native }];
return (
<div data-testid="proxy-picker">
<Select<string>
value={capable ? value : "native"}
onChange={onChange}
options={options}
disabled={!capable}
variant={variant}
label={t("proxy.label")}
/>
</div>
);
}
-102
View File
@@ -1,102 +0,0 @@
/**
* @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`):
* Native or one of the saved profiles (`useAiRouters`, which the caller also
* reads to list the router's models). A tool that cannot use a router (only
* Claude and Codex can) shows Native, disabled. 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 } 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];
}
/** The saved profiles (Native included), null until they arrive. */
export function useAiRouters(): AiRouter[] | null {
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;
};
}, []);
return routers;
}
/** 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,
routers,
value,
onChange,
variant,
}: {
variant?: "field" | "ghost";
tool: string;
/** From `useAiRouters`. */
routers: AiRouter[] | null;
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const capable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
const native = t("router.native", { tool: aiTools.label(tool) });
// 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]);
const options =
capable && routers?.length
? routers.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
: [{ value: "native", label: native }];
return (
<div data-testid="router-picker">
<Select<string>
value={capable ? value : "native"}
onChange={onChange}
options={options}
disabled={!capable}
variant={variant}
label={t("router.label")}
/>
</div>
);
}
+4 -4
View File
@@ -10,7 +10,7 @@
* (`POST /api/run/drop-image`);
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
* or a fixed one (an AI Router's models), plus Custom…;
* or a fixed one (an AI Proxy's models), plus Custom…;
* - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's
* "Sonnet 5.5 · Medium": the main models with their hints, the rest under
* More models, Custom…, and the Effort slider (Faster → Smarter, a reset
@@ -671,7 +671,7 @@ const MODEL_CUSTOM = "__custom__";
/**
* The models `tool` can start on, the inherit entry (`""`) first: the tool's
* own list (Codex's models cache, Claude's aliases named after the newest
* version, a catalog tool's vendor rows), or `fixed` (a router's models);
* version, a catalog tool's vendor rows), or `fixed` (a proxy's models);
* Claude's static aliases only when the server cannot answer. `loaded` once
* the list is final.
*/
@@ -707,7 +707,7 @@ export function ModelPicker({
value: string;
onChange: (s: string) => void;
tool: LaneTool;
/** A fixed list instead of the tool's own — an AI Router's models. */
/** A fixed list instead of the tool's own — an AI Proxy's models. */
choices?: ModelChoice[];
variant?: "field" | "ghost";
}) {
@@ -781,7 +781,7 @@ export function ModelMenu({
value: string;
onChange: (s: string) => void;
tool: LaneTool;
/** A fixed list instead of the tool's own — an AI Router's models. */
/** A fixed list instead of the tool's own — an AI Proxy's models. */
choices?: ModelChoice[];
/** Omitted for a tool that takes no effort. */
effort?: EffortLevel;
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
action: vi.fn(),
list: vi.fn(),
},
aiRouters: { list: vi.fn().mockResolvedValue({ items: [] }) },
aiProxies: { 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,36 @@
/**
* @file PaneGrid.test.ts
* @description The pane drop preview: it draws where the dragged pane will
* actually land (`dropPreview` over `applyDrop`), not half of the target's
* current box, and nothing for a drop that changes nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { dropPreview } from "../PaneGrid";
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
describe("dropPreview", () => {
it("a shown pane leaves its place first: A onto B's top is the whole top half", () => {
expect(dropPreview(AB, 0, 1, "top")).toEqual({ slot: 0, x: 0, y: 0, w: 1, h: 0.5 });
});
it("a pane from another tab splits the target's current box", () => {
expect(dropPreview(AB, 2, 1, "bottom")).toEqual({ slot: 2, x: 0.5, y: 0.5, w: 0.5, h: 0.5 });
});
it("the center swaps into the target's box", () => {
expect(dropPreview(AB, 0, 1, "center")).toEqual({ slot: 0, x: 0.5, y: 0, w: 0.5, h: 1 });
});
it("nothing on its own pane or on a refused move", () => {
expect(dropPreview(AB, 0, 0, "left")).toBeNull();
const full: PaneLayout = {
root: split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3))),
focus: 0,
};
expect(dropPreview(full, 5, 0, "left")).toBeNull();
});
});
@@ -1,16 +1,16 @@
/**
* @file RouterPicker.test.tsx
* @description The per-session AI Router picker: Native plus the saved
* profiles, Native alone and disabled on a tool that cannot use a router,
* @file ProxyPicker.test.tsx
* @description The per-session AI Proxy picker: Native plus the saved
* profiles, Native alone and disabled on a tool that cannot use a proxy,
* remembering the pick, and falling back to Native when the remembered
* profile is gone; plus `routerArg`, which never sends native.
* profile is gone; plus `proxyArg`, 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 type { AiRouter } from "../../../lib/api";
import type { AiProxy } from "../../../lib/api";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../RouterPicker";
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../ProxyPicker";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
@@ -22,67 +22,72 @@ vi.mock("../../../lib/api", async (importOriginal) => {
list: vi.fn().mockResolvedValue({
default: "claude",
tools: [
{ id: "claude", label: "Claude Code", routerCapable: true },
{ id: "gemini", label: "Gemini", routerCapable: false },
{ id: "claude", label: "Claude Code", proxyCapable: true },
{ id: "gemini", label: "Gemini", proxyCapable: false },
].map((x) => ({ ...x, installed: true, enabled: true })),
}),
},
aiRouters: { list: vi.fn() },
aiProxies: { list: vi.fn() },
},
};
});
const native = { name: "native", label: "Native", builtin: true } as AiRouter;
const nine = { name: "9router", label: "9Router", builtin: false } as AiRouter;
const native = { name: "native", label: "Native", builtin: true } as AiProxy;
const nine = { name: "9router", label: "9Router", builtin: false } as AiProxy;
beforeEach(() => {
localStorage.clear();
vi.mocked(api.aiRouters.list)
vi.mocked(api.aiProxies.list)
.mockReset()
.mockResolvedValue({ items: [native, nine] } as never);
});
describe("RouterPicker", () => {
describe("ProxyPicker", () => {
it("lists the saved profiles for a tool that can use one", async () => {
render(
<RouterPicker tool="claude" routers={[native, nine]} value="9router" onChange={() => {}} />
<ProxyPicker tool="claude" proxies={[native, nine]} value="9router" onChange={() => {}} />
);
expect(await screen.findByText("9Router")).toBeInTheDocument();
});
it("shows Native, disabled, on a tool with no endpoint to point at a router", () => {
it("shows Native, disabled, on a tool with no endpoint to point at a proxy", () => {
render(
<RouterPicker tool="gemini" routers={[native, nine]} value="9router" onChange={() => {}} />
<ProxyPicker tool="gemini" proxies={[native, nine]} value="9router" onChange={() => {}} />
);
const picker = screen.getByTestId("router-picker");
const picker = screen.getByTestId("proxy-picker");
expect(picker.querySelector("button")).toBeDisabled();
expect(picker).not.toHaveTextContent("9Router");
});
it("falls back to Native when the remembered profile is gone", async () => {
const onChange = vi.fn();
render(<RouterPicker tool="claude" routers={[native]} value="deleted" onChange={onChange} />);
render(<ProxyPicker tool="claude" proxies={[native]} value="deleted" onChange={onChange} />);
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
});
it("useAiRouters reads the saved profiles once", async () => {
const { result } = renderHook(() => useAiRouters());
it("useAiProxies reads the saved profiles once", async () => {
const { result } = renderHook(() => useAiProxies());
await waitFor(() => expect(result.current).toEqual([native, nine]));
expect(api.aiRouters.list).toHaveBeenCalledTimes(1);
expect(api.aiProxies.list).toHaveBeenCalledTimes(1);
});
});
describe("useSessionRouter / routerArg", () => {
describe("useSessionProxy / proxyArg", () => {
it("remembers the last pick for the next session", () => {
const first = renderHook(() => useSessionRouter());
const first = renderHook(() => useSessionProxy());
expect(first.result.current[0]).toBe("native");
act(() => first.result.current[1]("9router"));
expect(renderHook(() => useSessionRouter()).result.current[0]).toBe("9router");
expect(renderHook(() => useSessionProxy()).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({});
it("still reads the pick remembered before the rename", () => {
localStorage.setItem("agenthub.session.router", "9router");
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
});
it("sends a proxy only for a capable tool and never native", () => {
expect(proxyArg(true, "9router")).toEqual({ proxy: "9router" });
expect(proxyArg(true, "native")).toEqual({});
expect(proxyArg(false, "9router")).toEqual({});
});
});
@@ -4,7 +4,7 @@
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
* list from the server, a fixed list (an AI Router's models) when given one,
* list from the server, a fixed list (an AI Proxy's models) when given one,
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
* same list as one chip: aliases up front, pinned versions under More models,
* plus the effort slider.
@@ -211,7 +211,7 @@ describe("ModelPicker", () => {
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
});
it("offers only a fixed list when given one (an AI Router's models)", async () => {
it("offers only a fixed list when given one (an AI Proxy's models)", async () => {
render(
<ModelPicker
value=""