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
+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>