feat(cc-config,lanes): per-profile quota, and three fixes that made a write look lost

Closes #19, #20, #21, #27.

#19 — a saved auth profile now reports how much of its account's quota is
already spent, which is the only number that decides which one is worth
switching to. Nothing under CLAUDE_HOME records it, so it comes from the same
endpoint the CLI's own /usage reads, answered for whichever account's OAuth
token is presented. It is a POST and a button, never a poll: every profile that
is not the active one is normally expired, so answering means refreshing it
first, and a refresh ROTATES the stored refresh token. The rotated pair is
written straight back — dropping it would leave the profile holding a
credential Anthropic has already retired, i.e. an account needing a fresh
login. The token is read, put in an Authorization header, and never returned,
logged or echoed; what leaves the server is two percentages and two reset
times.

#20 — a settings patch that changes nothing wrote nothing but still took a
timestamped backup, so opening the config panel quietly grew a backup
directory inside every lane. patchSettings now compares the rendered output
with what is already on disk and returns early, fixed once where all callers
route through. A lane's git exclude also picks up .claude/.cc-config-backups/
beside settings.local.json.

#21 — the router apply route was the only mutating lane route that never
broadcast its lane. The write reached disk and the column, but the client's
cached row kept the old router_profile forever: the picker looked right until
the user switched lanes and back, at which point the effect re-read the stale
row and flipped the selection to the previous profile. One broadcastLane, plus
a lane-switch guard so an apply that resolves after the user has moved on no
longer paints its result onto whichever lane is now on screen.

#27 — Add-lane in worktree mode left the dialog up after a clean setup with a
dead confirm button and no obvious way out. It closes itself when nothing
failed, and when something did, the summary stays and the confirm becomes a
real Close.
This commit is contained in:
2026-09-17 22:20:32 +07:00
parent b0e8368c1d
commit 5ba9e07d78
25 changed files with 1169 additions and 30 deletions
+22 -10
View File
@@ -297,7 +297,7 @@ export function AddLaneModal({
api.lanes.mcpSync(result.lane.id),
applyRouter(result.lane.id),
]);
setSetupResult({
const outcome = {
profile:
profileOutcome.status === "fulfilled"
? profileOutcome.value.scaffolded
@@ -308,7 +308,9 @@ export function AddLaneModal({
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
router:
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
});
} as const;
const anyFailed = Object.values(outcome).includes("failed");
setSetupResult(outcome);
if (writeDocs && tool === "claude") {
await startDocsRun(result.lane.id, provisionedLane.cwd);
@@ -326,10 +328,17 @@ export function AddLaneModal({
});
}
// Leave the modal open so the setup summary below stays on screen; the
// user dismisses it themselves (Cancel/X) once they've seen it, rather
// than racing a timer that can close before they've looked at it.
// A clean setup has nothing to tell anyone, so the lane is made and the
// dialog is done — leaving it up read as "still working" (the confirm
// button is dead from here, since the lane already exists) with no
// obvious way out. A summary is kept on screen only when a step FAILED,
// and then the confirm button becomes a plain close, because that is the
// one case where the user has something to read before dismissing it.
setBusy(false);
if (!anyFailed) {
reset();
onClose();
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
@@ -354,21 +363,24 @@ export function AddLaneModal({
onClose();
}, [onClose, browseOpen]);
// A summary on screen means the lane already exists and a step failed, so
// the confirm button has no second creation to run: it closes instead. That
// is the difference between a dialog that is finished and one that is stuck.
const done = !!setupResult;
const disabled =
!!setupResult ||
!sourceRepo.trim() ||
(mode === "worktree" && (!title.trim() || !branches || !branch.trim()));
!done &&
(!sourceRepo.trim() || (mode === "worktree" && (!title.trim() || !branches || !branch.trim())));
return (
<ConfirmModal
open={open}
title={t("addLane")}
confirmLabel={t("add")}
confirmLabel={done ? t("addLaneSetupClose") : t("add")}
cancelLabel={t("destructive.cancel")}
destructive={false}
busy={busy}
disabled={disabled}
onConfirm={submit}
onConfirm={done ? handleCancel : submit}
onCancel={handleCancel}
>
<div className="space-y-3">
+16 -1
View File
@@ -168,6 +168,16 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
setNote(null);
}, [lane.id, lane.router_profile]);
// One instance of this row serves every lane — it lives in the Workspace's
// lane-detail header, and switching lanes swaps its `lane` prop rather than
// remounting it. So an apply that is still in flight when the user moves on
// would land its rollback and its note on whatever lane is showing by then:
// lane B's failed switch putting lane B's old selection into lane A's picker.
const shownLane = useRef(lane.id);
useEffect(() => {
shownLane.current = lane.id;
}, [lane.id]);
useEffect(() => {
let alive = true;
api.aiRouters
@@ -188,17 +198,22 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
if (!routers || routers.length <= 1) return null;
const change = async (name: string) => {
const laneId = lane.id;
const previous = current;
setCurrent(name);
setBusy(true);
setNote(null);
try {
await api.lanes.routerApply(lane.id, name);
await api.lanes.routerApply(laneId, name);
if (shownLane.current !== laneId) return;
setNote({ ok: true, text: t("router.applied") });
} catch (err) {
if (shownLane.current !== laneId) return;
setCurrent(previous);
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
} finally {
// `busy` belongs to the control on screen, not to the request, so it is
// cleared either way — a lane switch must never leave the picker locked.
setBusy(false);
}
};
@@ -242,9 +242,10 @@ describe("AddLaneModal — worktree mode (default)", () => {
expect(onAdded).toHaveBeenCalledWith(
expect.objectContaining({ id: 9, status: "provisioning" })
);
// The modal stays open showing the setup summary until dismissed - it
// does not close itself just because the lane was added.
expect(onClose).not.toHaveBeenCalled();
// Setup came back clean, so there is nothing to report and the dialog is
// done. Leaving it up read as "still working" — its confirm button is dead
// once the lane exists — which is how it looked stuck.
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("shows a server error and leaves the modal open instead of closing silently", async () => {
@@ -305,11 +306,12 @@ describe("AddLaneModal — worktree mode (default)", () => {
expect(onClose).not.toHaveBeenCalled();
});
it("shows the setup summary and lets the user dismiss it manually", async () => {
it("keeps the summary up when a setup step FAILED, and closes on the button that replaces Add lane", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
});
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp"));
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
@@ -317,12 +319,13 @@ describe("AddLaneModal — worktree mode (default)", () => {
await fillWorktreeForm(user, { title: "demo3" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
// A failure is the one case worth reading before the dialog goes away.
expect(await screen.findByText("Setup")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" });
if (!dismissButton) throw new Error("Cancel button not found");
await user.click(dismissButton);
// And then the primary button must close, not sit there disabled: the lane
// already exists, so "Add lane" has nothing left to do.
expect(screen.queryByRole("button", { name: "Add lane" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalled();
});
@@ -9,7 +9,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
import type { Lane, LaneRuntime } from "../../../lib/types";
@@ -513,6 +513,60 @@ describe("LaneRouterRow — the Model picker", () => {
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
it("does not carry a pick made on one lane back to the lane selected before it", async () => {
// One LaneRouterRow instance serves every lane (it lives in the Workspace's
// lane-detail header), so its selected value is component state that
// outlives a lane switch unless the sync effect puts the new lane's own
// profile back.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/b/.claude/settings.local.json",
profile: "9router",
});
const laneA = makeLane({ id: 1, router_profile: null });
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneA} />);
await screen.findByDisplayValue("Native (Claude / Codex)");
rerender(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
});
it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => {
// The row is one instance shared by every lane, so an apply still in flight
// when the user moves on would paint its rollback and its error on whoever
// is on screen by then — lane B's failure changing lane A's picker.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
let reject: (e: Error) => void = () => {};
vi.mocked(api.lanes.routerApply).mockImplementation(
() => new Promise((_, r) => (reject = r)) as ReturnType<typeof api.lanes.routerApply>
);
const laneA = makeLane({ id: 1, router_profile: null });
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
await act(async () => {
reject(new Error("EBADJSON"));
await Promise.resolve();
});
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.queryByTitle("EBADJSON")).toBeNull();
// And the picker must not be left locked by a request for another lane.
expect(screen.getByRole("combobox")).not.toBeDisabled();
});
it("puts the selection back when the apply fails", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
+8
View File
@@ -235,6 +235,14 @@
"filterAll": "All types"
},
"profiles": {
"usageCheck": "Check quota",
"usageChecking": "Checking…",
"usageRefresh": "Refresh",
"usageRetry": "Try again",
"usageFiveHour": "5h",
"usageSevenDay": "7d",
"usageResets": "Resets {{time}}",
"usageHint": "Asks Anthropic what this account has spent. Refreshes this profile's stored login token to do it.",
"title": "Claude Code profiles",
"openButton": "Switch profile",
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
+1
View File
@@ -32,6 +32,7 @@
"addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
"addLaneRepoLabel": "Source repository",
"addLaneRepoLabelAdopt": "Directory",
"addLaneSetupClose": "Close",
"addLaneSetupAgents": "Agents",
"addLaneSetupMcp": "MCP servers",
"addLaneSetupProfile": "Profile",
+8
View File
@@ -230,6 +230,14 @@
"filterAll": "Tất cả loại"
},
"profiles": {
"usageCheck": "Kiểm tra quota",
"usageChecking": "Đang kiểm tra…",
"usageRefresh": "Làm mới",
"usageRetry": "Thử lại",
"usageFiveHour": "5h",
"usageSevenDay": "7 ngày",
"usageResets": "Reset lúc {{time}}",
"usageHint": "Hỏi Anthropic tài khoản này đã dùng bao nhiêu. Thao tác này làm mới token đăng nhập đã lưu của profile.",
"title": "Profile Claude Code",
"openButton": "Đổi profile",
"subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.",
+1
View File
@@ -32,6 +32,7 @@
"addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.",
"addLaneRepoLabel": "Repo nguồn",
"addLaneRepoLabelAdopt": "Thư mục",
"addLaneSetupClose": "Đóng",
"addLaneSetupAgents": "Agent",
"addLaneSetupMcp": "MCP server",
"addLaneSetupProfile": "Profile",
+34
View File
@@ -1584,6 +1584,21 @@ export const api = {
"/cc-config/profiles/activate",
{ method: "POST", body: JSON.stringify({ name }) }
),
/**
* POST /api/cc-config/profiles/usage - how much of this profile's account
* quota is already spent.
*
* A POST because answering it is a write: a saved profile's access token has
* almost always expired, so the server refreshes it against Anthropic first
* and the refresh rotates the credentials stored on disk. That is also why
* this is never called on a timer — only when the user asks for one profile.
* No token material crosses the boundary in either direction.
*/
profileUsage: (name: string) =>
request<CcProfileUsage>("/cc-config/profiles/usage", {
method: "POST",
body: JSON.stringify({ name }),
}),
/** DELETE /api/cc-config/profiles - forget a saved profile (never the active one). */
deleteProfile: (name: string) =>
request<{ ok: true; name: string }>("/cc-config/profiles", {
@@ -2656,6 +2671,25 @@ export interface CcProfile extends CcAccountIdentity {
active: boolean;
}
/** One quota window as POST /api/cc-config/profiles/usage reports it. */
export interface CcUsageWindow {
/** Percent of the window's allowance already spent, 0-100. */
utilization: number | null;
/** ISO timestamp the window rolls over at, when the account reports one. */
resetsAt: string | null;
}
/** An account's spent quota, for the profile switcher. Percentages and reset
* times only — the OAuth token stays on the server. */
export interface CcProfileUsage {
name: string;
/** True when the stored token had expired and was refreshed to answer this. */
refreshed: boolean;
fiveHour: CcUsageWindow | null;
sevenDay: CcUsageWindow | null;
checkedAt: string;
}
/** Scope filter accepted by most `ccConfig` list endpoints; "all" merges
* user + project scope in one response. */
export type CcScope = "user" | "project" | "all";
+111
View File
@@ -141,6 +141,8 @@ import type {
CcUpdateAllBatch,
CcProfile,
CcAccountIdentity,
CcProfileUsage,
CcUsageWindow,
CcScope,
CcSettingsSource,
CcStatusline,
@@ -4232,6 +4234,11 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
/** `name` of the profile whose label is being edited inline, plus its draft. */
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
/** Quota per profile, keyed by `name`. Empty until asked for: reading it
* refreshes that account's stored OAuth token, so it is never fetched with
* the list — the user asks per profile, one profile at a time. */
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
const [usageError, setUsageError] = useState<Record<string, string>>({});
const load = useCallback(async () => {
try {
@@ -4276,6 +4283,26 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
[load, onToast]
);
const checkUsage = useCallback(async (name: string) => {
setBusy(`usage:${name}`);
setUsageError((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
try {
const result = await api.ccConfig.profileUsage(name);
setUsage((prev) => ({ ...prev, [name]: result }));
} catch (err: unknown) {
setUsageError((prev) => ({
...prev,
[name]: err instanceof Error ? err.message : "unknown error",
}));
} finally {
setBusy(null);
}
}, []);
const activeSaved = items?.some((p) => p.active) ?? false;
/** How many saved profiles hold the same account as `p`. */
@@ -4441,6 +4468,13 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
)}
</div>
<IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={busy === `usage:${p.name}`}
disabled={busy !== null}
onCheck={() => void checkUsage(p.name)}
/>
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
@@ -4459,6 +4493,83 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
);
}
/**
* One profile's quota: a button until asked, then the two windows Anthropic
* reports — the 5-hour session allowance and the 7-day one.
*
* Deliberately not fetched with the profile list. Reading a saved profile's
* usage means refreshing its OAuth token, and that rotates the credentials on
* disk (see `server/lib/cc-usage.js`), so it happens once per click and never
* on a render or a poll.
*/
function ProfileUsageLine({
usage,
error,
busy,
disabled,
onCheck,
}: {
usage?: CcProfileUsage;
error?: string;
busy: boolean;
disabled: boolean;
onCheck: () => void;
}) {
const { t } = useTranslation("ccConfig");
const pct = (w: CcUsageWindow | null | undefined) =>
w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—";
const resetTitle = (w: CcUsageWindow | null | undefined) =>
w && w.resetsAt
? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() })
: undefined;
if (error) {
return (
<div className="mt-1 flex items-center gap-2 text-[10px]">
<span className="text-status-danger">{error}</span>
<button
onClick={onCheck}
disabled={disabled}
className="text-fg-muted hover:text-accent disabled:opacity-50"
>
{t("profiles.usageRetry")}
</button>
</div>
);
}
if (!usage) {
return (
<button
onClick={onCheck}
disabled={disabled}
title={t("profiles.usageHint")}
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
>
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
</button>
);
}
return (
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
<span title={resetTitle(usage.fiveHour)}>
{t("profiles.usageFiveHour")}{" "}
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
</span>
<span title={resetTitle(usage.sevenDay)}>
{t("profiles.usageSevenDay")}{" "}
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
</span>
<button
onClick={onCheck}
disabled={disabled}
className="hover:text-accent disabled:opacity-50"
>
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
</button>
</div>
);
}
/** Org / seat / plan line shared by the active card and each profile row. */
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
@@ -0,0 +1,223 @@
/**
* @file Workspace.laneRouter.test.tsx
* @description The Model (AI Router) picker in the Workspace lane-detail
* header, exercised through the page rather than through `LaneRouterRow` on its
* own — one row instance serves every lane, so the bug this pins is a lane
* switch carrying the previous lane's selection with it: pick a router on one
* lane, come back to a lane that is on native, and see the router still
* selected there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, act, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
type LaneFixture = {
id: number;
cwd: string;
title: string;
pipeline: string;
pipeline_name: string;
pipeline_nodes: never[];
detected_signal: string | null;
run_id: string | null;
router_profile: string | null;
};
const lane = (id: number, title: string): LaneFixture => ({
id,
cwd: `/workspace/${id}`,
title,
pipeline: "default",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
router_profile: null,
});
const h = vi.hoisted(() => {
const nativeProfile = {
name: "native",
label: "Native (Claude / Codex)",
builtin: true,
baseUrl: "",
hasKey: false,
model: "claude-sonnet-5",
models: {
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
},
};
return {
lanes: [] as { id: number; router_profile: string | null }[],
routerApply: vi.fn(),
profiles: [
nativeProfile,
{ ...nativeProfile, name: "9router", label: "9Router", builtin: false },
],
};
});
const routerApply = h.routerApply;
let lanesToReturn: LaneFixture[] = [];
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
list: vi.fn().mockImplementation(async () => ({
lanes: h.lanes,
counts: { total: h.lanes.length, running: 0, needs_you: 0, dead: 0 },
})),
ensure: vi.fn(),
action: vi.fn(),
git: vi.fn().mockResolvedValue({ available: false }),
runtime: vi.fn().mockResolvedValue({ available: false }),
stage: vi.fn(),
update: vi.fn(),
pipelines: vi
.fn()
.mockResolvedValue({ pipelines: [{ id: "default", name: "default", nodes: [] }] }),
// The route the picker must use — never a lane PATCH.
routerApply: vi.fn().mockImplementation(async (id: number, profile: string) => {
h.routerApply(id, profile);
// The row moving with the call is the server's broadcast, not a test
// convenience: the apply route broadcasts the lane so the cached row
// stops disagreeing with what was just written. Drop the broadcast
// server-side and the next lane switch re-reads a stale profile.
const row = h.lanes.find((l) => l.id === id);
if (row) row.router_profile = profile === "native" ? null : profile;
return { tool: "claude", file: `/workspace/${id}/.claude/settings.local.json`, profile };
}),
features: { list: vi.fn().mockResolvedValue({ features: [] }), show: vi.fn() },
proof: {
list: vi.fn().mockResolvedValue({ features: [] }),
imageUrl: vi.fn(),
delete: vi.fn(),
},
},
aiRouters: {
list: vi.fn().mockImplementation(async () => ({ items: h.profiles })),
},
run: {
list: vi.fn().mockResolvedValue({ runs: [], items: [] }),
history: vi.fn().mockResolvedValue({ items: [] }),
binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }),
cwds: vi.fn().mockResolvedValue({ items: [] }),
files: vi.fn().mockResolvedValue({ items: [] }),
start: vi.fn(),
get: vi.fn(),
send: vi.fn(),
kill: vi.fn(),
},
ccConfig: {
commands: vi.fn().mockResolvedValue({ items: [] }),
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
file: vi.fn().mockResolvedValue({ text: "", content: "" }),
},
sessions: {
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
transcript: vi.fn().mockResolvedValue({ messages: [] }),
},
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
vi.mock("../../components/run/TerminalView", () => ({
TerminalView: ({ runId }: { runId: string }) => (
<div data-testid="terminal-view" data-run-id={runId} />
),
}));
import { Workspace } from "../Workspace";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
beforeEach(() => {
routerApply.mockClear();
lanesToReturn = [lane(1, "Lane A"), lane(2, "Lane B")];
h.lanes = lanesToReturn as unknown as typeof h.lanes;
});
async function renderWorkspace() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
await settle();
return utils;
}
/** Click the strip tile for a lane, which is how a lane is selected. */
async function selectLane(user: ReturnType<typeof userEvent.setup>, title: string) {
const tile = screen.getAllByText(title)[0];
if (!tile) throw new Error(`no strip tile for ${title}`);
await user.click(tile);
await settle();
}
describe("Workspace — the lane Model picker across a lane switch", () => {
it("leaves a lane on native after another lane was pointed at a router", async () => {
await renderWorkspace();
const user = userEvent.setup();
await selectLane(user, "Lane A");
expect(await screen.findByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
await selectLane(user, "Lane B");
await user.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
await waitFor(() => expect(routerApply).toHaveBeenCalledWith(2, "9router"));
await settle();
expect(screen.getByDisplayValue("9Router")).toBeInTheDocument();
// Back to the lane that was never switched. It is still native server-side,
// so the picker must say so — the row is one instance shared by every lane.
await selectLane(user, "Lane A");
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
// And nothing may have been written to lane A on the way.
expect(routerApply).not.toHaveBeenCalledWith(1, expect.anything());
});
});