feat(ai-tools): Settings → AI Tools, Default/Other lane picker, per-run bypass switch (#40 A1)
Settings → Dashboard gains an AI Tools section (installed tools with Enabled/Disabled and Set default, available ones with a homepage link, Refresh). useAiTools shares one catalog copy across the app and replaces LANE_TOOL_LABEL. The Add-lane modal picks Default (<tool>) or Other…; the router row reads Native (<tool>) and disables AI Router profiles for tools that cannot use one. The run form gives catalog tools resume and a red per-run Bypass permissions switch (Codex too). Claude Code / Codex settings tabs hide while that tool is disabled. The Workspace probes each lane tool's CLI fresh. Docs: CLAUDE.md tool/bypass rule, AGENTS.md, LANES.md, API.md, CLI.md, server/README.md, OpenAPI fragment + regenerated openapi.yaml.
This commit is contained in:
@@ -25,7 +25,8 @@ import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||||
import { api } from "../../lib/api";
|
||||
import type { AiRouter, CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneTool, LaneWorktree } from "../../lib/types";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||
@@ -109,7 +110,15 @@ export function AddLaneModal({
|
||||
const [routers, setRouters] = useState<AiRouter[]>([]);
|
||||
const [routersLoaded, setRoutersLoaded] = useState(false);
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
const [tool, setTool] = useState<LaneTool>("claude");
|
||||
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
|
||||
const [pickedTool, setPickedTool] = useState<string | null>(null);
|
||||
const aiTools = useAiTools();
|
||||
const defaultTool = aiTools.defaultId ?? "claude";
|
||||
const tool = pickedTool ?? defaultTool;
|
||||
const otherTools = aiTools.tools.filter((x) => x.installed && x.enabled && x.id !== defaultTool);
|
||||
const noTools = aiTools.loaded && aiTools.defaultId === null;
|
||||
const toolRouterCapable =
|
||||
aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [browseOpen, setBrowseOpen] = useState(false);
|
||||
@@ -130,7 +139,7 @@ export function AddLaneModal({
|
||||
setBase("");
|
||||
setRouter("native");
|
||||
setRoutersLoaded(false);
|
||||
setTool("claude");
|
||||
setPickedTool(null);
|
||||
setBranchesError(null);
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
@@ -414,27 +423,49 @@ export function AddLaneModal({
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={tool === "claude"}
|
||||
label={t("addLaneToolClaude")}
|
||||
onClick={() => setTool("claude")}
|
||||
/>
|
||||
<Seg
|
||||
active={tool === "codex"}
|
||||
label={t("addLaneToolCodex")}
|
||||
onClick={() => setTool("codex")}
|
||||
/>
|
||||
<Seg
|
||||
active={tool === "antigravity"}
|
||||
label={t("addLaneToolAntigravity")}
|
||||
onClick={() => {
|
||||
setTool("antigravity");
|
||||
// `agy` cannot run behind an AI Router (see router-apply.js).
|
||||
setRouter("native");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{noTools ? (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
||||
{t("addLaneNoTools")}{" "}
|
||||
<a href="/settings#ai-tools" className="underline">
|
||||
{t("addLaneNoToolsLink")}
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={tool === defaultTool}
|
||||
label={t("addLaneToolDefault", { tool: aiTools.label(defaultTool) })}
|
||||
onClick={() => setPickedTool(null)}
|
||||
/>
|
||||
</div>
|
||||
{otherTools.length > 0 && (
|
||||
<select
|
||||
aria-label={t("addLaneToolOther")}
|
||||
value={tool === defaultTool ? "" : tool}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value || null;
|
||||
setPickedTool(next);
|
||||
// A tool with no endpoint to rewrite runs native only
|
||||
// (see router-apply.js).
|
||||
if (next && !aiTools.byId(next)?.routerCapable) setRouter("native");
|
||||
}}
|
||||
className={`rounded-md border bg-surface-2 px-2 py-1 text-xs ${
|
||||
tool !== defaultTool
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<option value="">{t("addLaneToolOther")}</option>
|
||||
{otherTools.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -640,17 +671,17 @@ export function AddLaneModal({
|
||||
hid it. The pipeline picker is gone entirely: every lane is created
|
||||
on `default` (the server's own default) and the template is changed
|
||||
from the lane's own header afterwards. */}
|
||||
<div
|
||||
className="rounded-md border border-border-light bg-surface-0 p-2"
|
||||
hidden={tool === "antigravity"}
|
||||
>
|
||||
<div className="rounded-md border border-border-light bg-surface-0 p-2">
|
||||
<div>
|
||||
<fieldset>
|
||||
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
|
||||
<div className="flex gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: "native", label: t("addLaneRouterNative") },
|
||||
{
|
||||
value: "native",
|
||||
label: t("addLaneRouterNativeTool", { tool: aiTools.label(tool) }),
|
||||
},
|
||||
{ value: "router", label: t("addLaneRouterCustom") },
|
||||
] as const
|
||||
).map((option) => {
|
||||
@@ -658,10 +689,16 @@ export function AddLaneModal({
|
||||
option.value === "native" ? router === "native" : router !== "native";
|
||||
// Clicking "AI Router" before the list lands would resolve back to
|
||||
// Native and read as a dead control.
|
||||
const disabled = option.value === "router" && !routersLoaded;
|
||||
const disabled =
|
||||
option.value === "router" && (!routersLoaded || !toolRouterCapable);
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
title={
|
||||
option.value === "router" && !toolRouterCapable
|
||||
? t("router.nativeOnly", { tool: aiTools.label(tool) })
|
||||
: undefined
|
||||
}
|
||||
// No radio dot: the card itself lights up when selected. That
|
||||
// leaves the DISABLED state with nothing to show it, since the
|
||||
// greyed-out dot was carrying it — hence the explicit branch.
|
||||
|
||||
@@ -30,6 +30,7 @@ import { eventBus } from "../../lib/eventBus";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { AiRouter } from "../../lib/api";
|
||||
import type { Lane, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
/** How often a mounted card re-probes its stack. Faster than the Changes panel's
|
||||
* git refresh because a stack dying is exactly what the user needs to see, and slower than
|
||||
@@ -120,6 +121,13 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
*/
|
||||
export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const aiTools = useAiTools();
|
||||
const toolLabel = aiTools.label(lane.tool || "claude");
|
||||
// Only Claude and Codex have an endpoint/model override to write; every
|
||||
// other tool is offered native alone (the server refuses the rest anyway).
|
||||
const routerCapable =
|
||||
aiTools.byId(lane.tool || "claude")?.routerCapable ??
|
||||
(lane.tool === "claude" || lane.tool === "codex" || !lane.tool);
|
||||
const [routers, setRouters] = useState<AiRouter[] | null>(null);
|
||||
const [current, setCurrent] = useState(lane.router_profile || "native");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -225,8 +233,15 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
className="max-w-40 truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-fg-secondary disabled:opacity-50"
|
||||
>
|
||||
{routers.map((r) => (
|
||||
<option key={r.name} value={r.name}>
|
||||
{r.label}
|
||||
<option
|
||||
key={r.name}
|
||||
value={r.name}
|
||||
disabled={!r.builtin && !routerCapable}
|
||||
title={
|
||||
!r.builtin && !routerCapable ? t("router.nativeOnly", { tool: toolLabel }) : undefined
|
||||
}
|
||||
>
|
||||
{r.builtin ? t("router.native", { tool: toolLabel }) : r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { LANE_TOOL_LABEL } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
active: "bg-status-success",
|
||||
@@ -65,6 +65,7 @@ export default function LaneStripCard({
|
||||
onReorder: (from: number, to: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const { label: toolLabel } = useAiTools();
|
||||
const [dropTarget, setDropTarget] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -120,7 +121,7 @@ export default function LaneStripCard({
|
||||
installed; the others show their CLI's tag in place of the stage chip. */}
|
||||
{lane.tool !== "claude" || lane.pipeline_nodes.length === 0 ? (
|
||||
<span className="shrink-0 rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
|
||||
{LANE_TOOL_LABEL[lane.tool]}
|
||||
{toolLabel(lane.tool)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
||||
|
||||
@@ -14,7 +14,8 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AddLaneModal } from "../AddLaneModal";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { CwdSuggestion } from "../../../lib/api";
|
||||
import type { AiTool, AiToolsView, CwdSuggestion } from "../../../lib/api";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
@@ -31,10 +32,38 @@ vi.mock("../../../lib/api", () => ({
|
||||
routerApply: vi.fn(),
|
||||
},
|
||||
aiRouters: { list: vi.fn() },
|
||||
aiTools: { list: vi.fn() },
|
||||
run: { start: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool => ({
|
||||
id,
|
||||
label,
|
||||
command: id,
|
||||
homepageUrl: null,
|
||||
installed: true,
|
||||
path: `/usr/bin/${id}`,
|
||||
enabled: true,
|
||||
source: "builtin",
|
||||
launcher: null,
|
||||
prompt: { mode: "argv" },
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
...over,
|
||||
});
|
||||
const TOOLS: AiToolsView = {
|
||||
default: "claude",
|
||||
tools: [
|
||||
tool("claude", "Claude Code", { launcher: "claude", routerCapable: true, prompt: null }),
|
||||
tool("codex", "Codex", { launcher: "codex", routerCapable: true, prompt: null }),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
],
|
||||
};
|
||||
|
||||
function laneFixture(over: Partial<Lane> = {}): Lane {
|
||||
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
|
||||
// `T | undefined`, which no longer satisfies Lane's required fields. The
|
||||
@@ -107,6 +136,8 @@ async function fillWorktreeForm(
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetAiTools();
|
||||
vi.mocked(api.aiTools.list).mockReset().mockResolvedValue(TOOLS);
|
||||
vi.mocked(api.lanes.branches).mockReset();
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
@@ -138,7 +169,7 @@ beforeEach(() => {
|
||||
function nativeRouter() {
|
||||
return {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
label: "Native",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
@@ -649,7 +680,7 @@ describe("AddLaneModal — AI Router", () => {
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
|
||||
|
||||
expect(screen.getByRole("radio", { name: "Native" })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: /^Native/ })).toBeChecked();
|
||||
expect(screen.getByText(/No router profile saved yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -716,7 +747,7 @@ describe("AddLaneModal — a router profile that disappeared", () => {
|
||||
rerender(<AddLaneModal open={false} onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
|
||||
rerender(<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("radio", { name: "Native" })).toBeChecked());
|
||||
await waitFor(() => expect(screen.getByRole("radio", { name: /^Native/ })).toBeChecked());
|
||||
});
|
||||
|
||||
it("does not offer AI Router until the profile list has arrived", async () => {
|
||||
@@ -751,7 +782,14 @@ describe("AddLaneModal — tool selection", () => {
|
||||
const dirField = screen.getByLabelText("Directory");
|
||||
await focusField(user, dirField);
|
||||
await user.type(dirField, "/tmp/demo");
|
||||
await user.click(screen.getByRole("button", { name: /codex/i }));
|
||||
const other = await screen.findByRole("combobox", { name: "Other…" });
|
||||
// Installed + enabled only, and never the default itself.
|
||||
expect(Array.from((other as HTMLSelectElement).options).map((o) => o.value)).toEqual([
|
||||
"",
|
||||
"codex",
|
||||
"gemini",
|
||||
]);
|
||||
await user.selectOptions(other, "codex");
|
||||
await user.click(screen.getByRole("button", { name: /add lane|create/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -759,6 +797,24 @@ describe("AddLaneModal — tool selection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("offers only Native for a tool that cannot use an AI Router", async () => {
|
||||
renderModal({});
|
||||
const user = userEvent.setup();
|
||||
await user.selectOptions(await screen.findByRole("combobox", { name: "Other…" }), "gemini");
|
||||
expect(screen.getByRole("radio", { name: "Native (Gemini)" })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("says so, with a link to Settings, when no tool is installed and enabled", async () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({ default: null, tools: [] });
|
||||
renderModal({});
|
||||
expect(await screen.findByText(/No AI tool is installed and enabled/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /AI Tools/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings#ai-tools"
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to claude code", async () => {
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ id: 24, kind: "adopted", status: "idle" }),
|
||||
|
||||
@@ -378,7 +378,7 @@ describe("LaneToolsMenu", () => {
|
||||
describe("LaneRouterRow — the Model picker", () => {
|
||||
const native = {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
label: "Native",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
@@ -406,6 +406,16 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers only native to a tool that cannot use an AI Router", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
render(<LaneRouterRow lane={makeLane({ tool: "gemini" })} />);
|
||||
const select = await screen.findByDisplayValue("Native (gemini)");
|
||||
const router = Array.from((select as HTMLSelectElement).options).find(
|
||||
(o) => o.value === "9router"
|
||||
);
|
||||
expect(router?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("applies a switch through the router route, not a lane PATCH", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
@@ -417,9 +427,7 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
});
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(1, "9router"));
|
||||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||||
});
|
||||
@@ -440,9 +448,7 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
vi.mocked(api.run.start).mockResolvedValue({} as never);
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
|
||||
|
||||
const restart = await screen.findByRole("button", { name: "Restart" });
|
||||
await userEvent.click(restart);
|
||||
@@ -464,9 +470,7 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
});
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
|
||||
});
|
||||
@@ -487,14 +491,14 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
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)");
|
||||
await screen.findByDisplayValue("Native (Claude)");
|
||||
|
||||
rerender(<LaneRouterRow lane={laneB} />);
|
||||
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
|
||||
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude)"), ["9router"]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
|
||||
rerender(<LaneRouterRow lane={laneA} />);
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => {
|
||||
@@ -510,9 +514,7 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
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 userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
|
||||
rerender(<LaneRouterRow lane={laneA} />);
|
||||
@@ -521,7 +523,7 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Native (Claude)")).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();
|
||||
@@ -532,10 +534,8 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
|
||||
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user