mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 18:39:17 +02:00
Compare commits
3 Commits
feature/co
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61684767c0 | ||
|
|
791c99b2b5 | ||
|
|
6039b451ab |
@@ -3,7 +3,12 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import type { Agent, MemberWithUser, RuntimeDevice } from "@multica/core/types";
|
||||
import type {
|
||||
Agent,
|
||||
MemberWithUser,
|
||||
RuntimeDevice,
|
||||
RuntimeModel,
|
||||
} from "@multica/core/types";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { NavigationProvider, type NavigationAdapter } from "../../navigation";
|
||||
@@ -26,11 +31,30 @@ vi.mock("@multica/core/hooks", () => ({
|
||||
}));
|
||||
|
||||
// ModelDropdown talks to the api; the create dialog only needs it as a
|
||||
// stand-in here, so swap it out.
|
||||
// stand-in here, so swap it out. The Reasoning picker, however, IS under
|
||||
// test, so we mock the runtime-models query layer it depends on with a
|
||||
// controllable catalog instead of mocking the component itself.
|
||||
vi.mock("./model-dropdown", () => ({
|
||||
ModelDropdown: () => null,
|
||||
}));
|
||||
|
||||
// Per-runtime model catalogs so a runtime/provider switch resolves a
|
||||
// different reasoning vocabulary, exercising the stale-token clearing.
|
||||
const modelsByRuntime: Record<
|
||||
string,
|
||||
{ models: RuntimeModel[]; supported: boolean }
|
||||
> = {};
|
||||
|
||||
vi.mock("@multica/core/runtimes", () => ({
|
||||
runtimeModelsOptions: (runtimeId: string | null | undefined) => ({
|
||||
queryKey: ["runtime-models", runtimeId ?? "none"],
|
||||
queryFn: async () =>
|
||||
modelsByRuntime[runtimeId ?? ""] ?? { models: [], supported: true },
|
||||
enabled: Boolean(runtimeId),
|
||||
retry: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Provider logos don't matter for these assertions but they pull in SVGs.
|
||||
vi.mock("../../runtimes/components/provider-logo", () => ({
|
||||
ProviderLogo: () => null,
|
||||
@@ -42,7 +66,7 @@ vi.mock("../../common/actor-avatar", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
import { CreateAgentDialog } from "./create-agent-dialog";
|
||||
@@ -78,10 +102,10 @@ function makeRuntime(overrides: Partial<RuntimeDevice>): RuntimeDevice {
|
||||
id: "rt",
|
||||
workspace_id: "ws-1",
|
||||
daemon_id: null,
|
||||
name: "Test Runtime",
|
||||
name: "Claude (host.local)",
|
||||
runtime_mode: "local",
|
||||
provider: "claude",
|
||||
launch_header: "",
|
||||
launch_header: "claude (stream-json)",
|
||||
status: "online",
|
||||
device_info: "host.local",
|
||||
metadata: {},
|
||||
@@ -94,11 +118,11 @@ function makeRuntime(overrides: Partial<RuntimeDevice>): RuntimeDevice {
|
||||
};
|
||||
}
|
||||
|
||||
function makeTemplate(runtimeId: string): Agent {
|
||||
function makeTemplate(overrides: Partial<Agent>): Agent {
|
||||
return {
|
||||
id: "agent-template",
|
||||
workspace_id: "ws-1",
|
||||
runtime_id: runtimeId,
|
||||
runtime_id: "rt",
|
||||
name: "Template Agent",
|
||||
description: "",
|
||||
instructions: "",
|
||||
@@ -116,6 +140,7 @@ function makeTemplate(runtimeId: string): Agent {
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,16 +154,16 @@ function renderDialog(runtimes: RuntimeDevice[], template?: Agent) {
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkspaceSlugProvider slug="test-ws">
|
||||
<NavigationProvider value={navigationStub}>
|
||||
<CreateAgentDialog
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={ME}
|
||||
template={template}
|
||||
onClose={onClose}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</NavigationProvider>
|
||||
<NavigationProvider value={navigationStub}>
|
||||
<CreateAgentDialog
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={ME}
|
||||
template={template}
|
||||
onClose={onClose}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</NavigationProvider>
|
||||
</WorkspaceSlugProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
@@ -146,138 +171,218 @@ function renderDialog(runtimes: RuntimeDevice[], template?: Agent) {
|
||||
return { onCreate, onClose };
|
||||
}
|
||||
|
||||
describe("CreateAgentDialog runtime visibility gate", () => {
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// A name is required before Create will submit; manual-create starts blank.
|
||||
function typeName(value = "My Agent") {
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. Deep Research Agent"), {
|
||||
target: { value },
|
||||
});
|
||||
}
|
||||
|
||||
const openMachinePicker = () =>
|
||||
fireEvent.click(screen.getByTestId("machine-picker-trigger"));
|
||||
const openAgentRuntimePicker = () =>
|
||||
fireEvent.click(screen.getByTestId("agent-runtime-trigger"));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
document.body.innerHTML = "";
|
||||
for (const key of Object.keys(modelsByRuntime)) delete modelsByRuntime[key];
|
||||
});
|
||||
|
||||
describe("CreateAgentDialog machine + agent-runtime cascade (MUL-3772)", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
// Base UI Dialog renders into a portal on document.body and leaves
|
||||
// focus-guard / inert wrapper divs around after the React tree unmounts.
|
||||
// The auto-cleanup from @testing-library/react drops the container but
|
||||
// not the portal residue, so two-tests-in-a-row queries see double
|
||||
// matches ("All", "My Runtime"). Force cleanup + wipe body between tests.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
document.body.innerHTML = "";
|
||||
|
||||
it("groups two CLIs on one daemon into a machine and cascades the runtime picker", async () => {
|
||||
const claude = makeRuntime({
|
||||
id: "rt-claude",
|
||||
daemon_id: "d1",
|
||||
name: "Claude (Workstation)",
|
||||
device_info: "Workstation",
|
||||
provider: "claude",
|
||||
});
|
||||
const codex = makeRuntime({
|
||||
id: "rt-codex",
|
||||
daemon_id: "d1",
|
||||
name: "Codex (Workstation)",
|
||||
device_info: "Workstation",
|
||||
provider: "codex",
|
||||
launch_header: "codex app-server",
|
||||
});
|
||||
const { onCreate } = renderDialog([claude, codex]);
|
||||
|
||||
// Machine box shows the host label; agent-runtime box seeds to the
|
||||
// provider-sorted first runtime (claude < codex).
|
||||
expect(
|
||||
screen.getByText("Workstation", { selector: "span.truncate" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Claude", { selector: "span.truncate" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Cascade: open the Agent runtime picker and switch to Codex.
|
||||
openAgentRuntimePicker();
|
||||
fireEvent.click(screen.getByText("Codex", { selector: "span.truncate" }));
|
||||
|
||||
typeName();
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
await tick();
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-codex");
|
||||
});
|
||||
|
||||
it("disables another member's private runtime in the picker", () => {
|
||||
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
|
||||
const othersPrivate = makeRuntime({
|
||||
id: "rt-others-private",
|
||||
name: "Others Private",
|
||||
it("renders the agent-runtime selector read-only for a single-runtime machine", () => {
|
||||
const only = makeRuntime({
|
||||
id: "rt-solo",
|
||||
daemon_id: "d-solo",
|
||||
name: "Claude (Solo)",
|
||||
device_info: "Solo",
|
||||
});
|
||||
renderDialog([only]);
|
||||
|
||||
// The agent-runtime title is present but it is NOT inside a button
|
||||
// (no popover trigger) — single-runtime machines have nothing to pick.
|
||||
const title = screen.getByText("Claude", { selector: "span.truncate" });
|
||||
expect(title.closest("button")).toBeNull();
|
||||
});
|
||||
|
||||
const mineAndOthersPrivate = (): RuntimeDevice[] => [
|
||||
makeRuntime({
|
||||
id: "rt-mine",
|
||||
daemon_id: "d-mine",
|
||||
name: "Claude (Mine)",
|
||||
device_info: "Mine",
|
||||
owner_id: ME,
|
||||
visibility: "private",
|
||||
}),
|
||||
makeRuntime({
|
||||
id: "rt-others",
|
||||
daemon_id: "d-other",
|
||||
name: "Claude (Theirs)",
|
||||
device_info: "Theirs",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
renderDialog([mine, othersPrivate]);
|
||||
}),
|
||||
];
|
||||
|
||||
// Flip to "All" so other-owned runtimes show.
|
||||
fireEvent.click(screen.getByText("All"));
|
||||
// Open the picker.
|
||||
fireEvent.click(
|
||||
screen.getByText("My Runtime", { selector: "span.truncate" }),
|
||||
);
|
||||
|
||||
const disabledRow = screen
|
||||
.getByText("Others Private")
|
||||
.closest("button") as HTMLButtonElement;
|
||||
expect(disabledRow).not.toBeNull();
|
||||
expect(disabledRow.disabled).toBe(true);
|
||||
expect(disabledRow.title).toMatch(/Private runtime/i);
|
||||
it("filters another member's machine out of the picker under Mine", () => {
|
||||
renderDialog(mineAndOthersPrivate());
|
||||
openMachinePicker();
|
||||
expect(screen.queryByText("Theirs")).toBeNull();
|
||||
});
|
||||
|
||||
it("lets a plain member pick another member's public runtime", () => {
|
||||
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
|
||||
const othersPublic = makeRuntime({
|
||||
id: "rt-others-public",
|
||||
name: "Others Public",
|
||||
owner_id: OTHER,
|
||||
visibility: "public",
|
||||
});
|
||||
renderDialog([mine, othersPublic]);
|
||||
|
||||
it("shows another member's private machine locked under All", () => {
|
||||
renderDialog(mineAndOthersPrivate());
|
||||
// hasOtherMachines surfaces the Mine/All toggle; flip to All before
|
||||
// opening so the other-owned machine is in scope.
|
||||
fireEvent.click(screen.getByText("All"));
|
||||
fireEvent.click(
|
||||
screen.getByText("My Runtime", { selector: "span.truncate" }),
|
||||
);
|
||||
openMachinePicker();
|
||||
|
||||
const publicRow = screen
|
||||
.getByText("Others Public")
|
||||
.closest("button") as HTMLButtonElement;
|
||||
expect(publicRow).not.toBeNull();
|
||||
expect(publicRow.disabled).toBe(false);
|
||||
const lockedRow = screen.getByText("Theirs").closest("button");
|
||||
expect(lockedRow).not.toBeNull();
|
||||
expect((lockedRow as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((lockedRow as HTMLButtonElement).title).toMatch(/Private runtime/i);
|
||||
});
|
||||
|
||||
it("defaults the selected runtime to a usable one, not a locked private", () => {
|
||||
it("seeds to a usable machine, not a locked private one that sorts first", () => {
|
||||
const othersPrivate = makeRuntime({
|
||||
id: "rt-others-private",
|
||||
name: "Others Private",
|
||||
id: "rt-others",
|
||||
daemon_id: "d-other",
|
||||
name: "Claude (Theirs)",
|
||||
device_info: "Theirs",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
const mine = makeRuntime({
|
||||
id: "rt-mine",
|
||||
name: "My Runtime",
|
||||
daemon_id: "d-mine",
|
||||
name: "Claude (Mine)",
|
||||
device_info: "Mine",
|
||||
owner_id: ME,
|
||||
visibility: "private",
|
||||
});
|
||||
renderDialog([othersPrivate, mine]);
|
||||
|
||||
// The trigger label shows the selected runtime name. The picker must
|
||||
// not seed with the other-owned private runtime even if it sorted
|
||||
// first in the input list.
|
||||
expect(screen.queryByText("Others Private", { selector: "span.truncate" })).toBeNull();
|
||||
expect(screen.getByText("My Runtime", { selector: "span.truncate" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Mine", { selector: "span.truncate" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Theirs", { selector: "span.truncate" })).toBeNull();
|
||||
});
|
||||
|
||||
it("in duplicate mode, does not pre-fill the template's runtime when it's now locked", async () => {
|
||||
// Template runtime is owned by someone else and now private — the
|
||||
// duplicate flow used to seed with it anyway, leaving the user with
|
||||
// a Create button that 403s server-side. Now we fall back to the
|
||||
// first usable runtime instead.
|
||||
it("treats a cloud runtime (no daemon) as its own machine with a Cloud badge", () => {
|
||||
const cloud = makeRuntime({
|
||||
id: "rt-cloud",
|
||||
daemon_id: null,
|
||||
runtime_mode: "cloud",
|
||||
name: "Codex cloud",
|
||||
device_info: "Cloud · us-west",
|
||||
provider: "codex",
|
||||
owner_id: null,
|
||||
visibility: "public",
|
||||
});
|
||||
renderDialog([cloud]);
|
||||
|
||||
// A workspace cloud runtime is owned by nobody, so it lives under "All",
|
||||
// not "Mine" — flip the filter to bring it into scope.
|
||||
fireEvent.click(screen.getByText("All"));
|
||||
|
||||
expect(
|
||||
screen.getByText("Cloud · us-west", { selector: "span.truncate" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Codex cloud", { selector: "span.truncate" }),
|
||||
).toBeInTheDocument();
|
||||
// Cloud badge is rendered in the machine trigger.
|
||||
expect(screen.getAllByText("Cloud").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateAgentDialog Create gate (MUL-3772)", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("in duplicate mode, falls back off a now-locked template runtime", async () => {
|
||||
const othersPrivate = makeRuntime({
|
||||
id: "rt-others-private",
|
||||
name: "Others Private",
|
||||
id: "rt-others",
|
||||
daemon_id: "d-other",
|
||||
name: "Claude (Theirs)",
|
||||
device_info: "Theirs",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
const mine = makeRuntime({
|
||||
id: "rt-mine",
|
||||
name: "My Runtime",
|
||||
daemon_id: "d-mine",
|
||||
name: "Claude (Mine)",
|
||||
device_info: "Mine",
|
||||
owner_id: ME,
|
||||
visibility: "private",
|
||||
});
|
||||
const template = makeTemplate("rt-others-private");
|
||||
const template = makeTemplate({ runtime_id: "rt-others" });
|
||||
const { onCreate } = renderDialog([othersPrivate, mine], template);
|
||||
|
||||
expect(
|
||||
screen.getByText("My Runtime", { selector: "span.truncate" }),
|
||||
screen.getByText("Mine", { selector: "span.truncate" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Others Private", { selector: "span.truncate" }),
|
||||
).toBeNull();
|
||||
|
||||
// Sanity check: with a usable selection seeded, Create should submit.
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await tick();
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-mine");
|
||||
});
|
||||
|
||||
it("disables Create when the selected runtime is locked (template + no usable fallback)", () => {
|
||||
// Edge case: template points at a locked runtime AND the workspace
|
||||
// has no usable alternatives in scope. The defense-in-depth gate on
|
||||
// the Create button must keep the user from submitting a 403.
|
||||
it("disables Create when the only runtime is locked", () => {
|
||||
const onlyOthersPrivate = makeRuntime({
|
||||
id: "rt-only-others-private",
|
||||
name: "Only Others Private",
|
||||
id: "rt-locked",
|
||||
daemon_id: "d-other",
|
||||
name: "Claude (Theirs)",
|
||||
device_info: "Theirs",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
// Flip the picker to "All" so the locked runtime is at least
|
||||
// visible — that's the scope where the selected-but-locked state
|
||||
// can persist after the initial seed search returns nothing.
|
||||
const template = makeTemplate("rt-only-others-private");
|
||||
const template = makeTemplate({ runtime_id: "rt-locked" });
|
||||
renderDialog([onlyOthersPrivate], template);
|
||||
|
||||
// The Create button is rendered by lucide-free CTA text "Create".
|
||||
const createBtn = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent === "Create");
|
||||
@@ -285,3 +390,226 @@ describe("CreateAgentDialog runtime visibility gate", () => {
|
||||
expect((createBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function reasoningModel(
|
||||
id: string,
|
||||
levels: { value: string; label: string }[],
|
||||
): RuntimeModel {
|
||||
return { id, label: id, default: true, thinking: { supported_levels: levels } };
|
||||
}
|
||||
|
||||
describe("CreateAgentDialog reasoning picker (MUL-3772 REQ-2)", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("hides the reasoning row when the model exposes no levels", async () => {
|
||||
modelsByRuntime["rt-1"] = {
|
||||
models: [{ id: "haiku", label: "Haiku", default: true }],
|
||||
supported: true,
|
||||
};
|
||||
renderDialog([makeRuntime({ id: "rt-1", daemon_id: "d1" })]);
|
||||
await tick();
|
||||
expect(screen.queryByText("Reasoning")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the row for a reasoning-capable model and submits the chosen level", async () => {
|
||||
modelsByRuntime["rt-1"] = {
|
||||
models: [
|
||||
reasoningModel("opus", [
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "high", label: "High" },
|
||||
]),
|
||||
],
|
||||
supported: true,
|
||||
};
|
||||
const { onCreate } = renderDialog([
|
||||
makeRuntime({ id: "rt-1", daemon_id: "d1" }),
|
||||
]);
|
||||
|
||||
// Row appears once the catalog query settles.
|
||||
await screen.findByText("Reasoning");
|
||||
|
||||
// Open the reasoning popover (trigger shows "Follow CLI config") and
|
||||
// pick "High".
|
||||
fireEvent.click(screen.getByText("Follow CLI config"));
|
||||
fireEvent.click(screen.getByText("High"));
|
||||
|
||||
typeName();
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
await tick();
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
expect(onCreate.mock.calls[0]?.[0].thinking_level).toBe("high");
|
||||
});
|
||||
|
||||
it("omits thinking_level when left on follow-CLI-config", async () => {
|
||||
modelsByRuntime["rt-1"] = {
|
||||
models: [reasoningModel("opus", [{ value: "high", label: "High" }])],
|
||||
supported: true,
|
||||
};
|
||||
const { onCreate } = renderDialog([
|
||||
makeRuntime({ id: "rt-1", daemon_id: "d1" }),
|
||||
]);
|
||||
await screen.findByText("Reasoning");
|
||||
|
||||
typeName();
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
await tick();
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
expect(onCreate.mock.calls[0]?.[0].thinking_level).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears a stale level when switching the agent runtime to another provider", async () => {
|
||||
// Two CLIs on one machine with disjoint reasoning vocabularies. Picking a
|
||||
// Claude-only level then switching to the Codex runtime must not carry the
|
||||
// now provider-invalid token into the payload (the backend 400s on it).
|
||||
modelsByRuntime["rt-claude"] = {
|
||||
models: [
|
||||
reasoningModel("opus", [
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "max", label: "Max" },
|
||||
]),
|
||||
],
|
||||
supported: true,
|
||||
};
|
||||
modelsByRuntime["rt-codex"] = {
|
||||
models: [
|
||||
reasoningModel("gpt5", [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "low", label: "Low" },
|
||||
]),
|
||||
],
|
||||
supported: true,
|
||||
};
|
||||
const { onCreate } = renderDialog([
|
||||
makeRuntime({
|
||||
id: "rt-claude",
|
||||
daemon_id: "d1",
|
||||
provider: "claude",
|
||||
name: "Claude (Mac)",
|
||||
device_info: "Mac",
|
||||
}),
|
||||
makeRuntime({
|
||||
id: "rt-codex",
|
||||
daemon_id: "d1",
|
||||
provider: "codex",
|
||||
name: "Codex (Mac)",
|
||||
device_info: "Mac",
|
||||
launch_header: "codex app-server",
|
||||
}),
|
||||
]);
|
||||
|
||||
// Seeds to Claude (provider-sorted); pick the Claude-only "Max".
|
||||
await screen.findByText("Reasoning");
|
||||
fireEvent.click(screen.getByText("Follow CLI config"));
|
||||
fireEvent.click(screen.getByText("Max"));
|
||||
|
||||
// Switch the agent runtime to Codex — a different provider.
|
||||
openAgentRuntimePicker();
|
||||
fireEvent.click(screen.getByText("Codex", { selector: "span.truncate" }));
|
||||
await tick();
|
||||
|
||||
// The stale "Max" is gone; the row reseeds to follow-CLI-config and the
|
||||
// payload omits the token entirely.
|
||||
expect(screen.queryByText("Max")).toBeNull();
|
||||
typeName();
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
await tick();
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-codex");
|
||||
expect(onCreate.mock.calls[0]?.[0].thinking_level).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops a duplicate-mode orphan level the current model does not advertise", async () => {
|
||||
// Duplicate clones thinking_level "high", but the target runtime's model
|
||||
// has no reasoning catalog — the orphan must not be submittable.
|
||||
modelsByRuntime["rt-1"] = {
|
||||
models: [{ id: "haiku", label: "Haiku", default: true }],
|
||||
supported: true,
|
||||
};
|
||||
const template = makeTemplate({
|
||||
runtime_id: "rt-1",
|
||||
thinking_level: "high",
|
||||
});
|
||||
const { onCreate } = renderDialog(
|
||||
[makeRuntime({ id: "rt-1", daemon_id: "d1" })],
|
||||
template,
|
||||
);
|
||||
|
||||
// Once the catalog loads, the orphan clears and the row disappears.
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.queryByText("Reasoning")).toBeNull(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
await tick();
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
expect(onCreate.mock.calls[0]?.[0].thinking_level).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops the template level when duplicate falls back off a locked template runtime to an offline different-provider runtime", async () => {
|
||||
// Template is a Claude agent with a Claude-only level, but its runtime is
|
||||
// now locked. The dialog falls back to the user's offline Codex runtime,
|
||||
// which has no model catalog — so neither the provider-change effect (it
|
||||
// skips the initial establishment) nor the catalog-based clear can run.
|
||||
// The template level must already be blank at init so it can't be sent.
|
||||
const lockedClaude = makeRuntime({
|
||||
id: "rt-claude-locked",
|
||||
daemon_id: "d-claude",
|
||||
provider: "claude",
|
||||
name: "Claude (Theirs)",
|
||||
device_info: "Theirs",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
const offlineCodex = makeRuntime({
|
||||
id: "rt-codex",
|
||||
daemon_id: "d-codex",
|
||||
provider: "codex",
|
||||
name: "Codex (Mine)",
|
||||
device_info: "Mine",
|
||||
owner_id: ME,
|
||||
status: "offline",
|
||||
launch_header: "codex app-server",
|
||||
});
|
||||
// Intentionally no catalog entry for rt-codex: offline → never queried.
|
||||
const template = makeTemplate({
|
||||
runtime_id: "rt-claude-locked",
|
||||
thinking_level: "max",
|
||||
model: "",
|
||||
});
|
||||
const { onCreate } = renderDialog([lockedClaude, offlineCodex], template);
|
||||
|
||||
// Fallback seeds the usable Codex runtime.
|
||||
await tick();
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
await tick();
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-codex");
|
||||
expect(onCreate.mock.calls[0]?.[0].thinking_level).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the template level when duplicate lands on the template runtime (offline, same provider)", async () => {
|
||||
// The complement of the regression above: when the clone DOES land on the
|
||||
// template's own runtime, the per-provider level is valid and must survive
|
||||
// even offline (where no catalog is available to re-confirm it).
|
||||
const claudeOffline = makeRuntime({
|
||||
id: "rt-claude",
|
||||
daemon_id: "d1",
|
||||
provider: "claude",
|
||||
owner_id: ME,
|
||||
status: "offline",
|
||||
});
|
||||
const template = makeTemplate({
|
||||
runtime_id: "rt-claude",
|
||||
thinking_level: "max",
|
||||
model: "",
|
||||
});
|
||||
const { onCreate } = renderDialog([claudeOffline], template);
|
||||
|
||||
await tick();
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
await tick();
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-claude");
|
||||
expect(onCreate.mock.calls[0]?.[0].thinking_level).toBe("max");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Globe, Lock } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ModelDropdown } from "./model-dropdown";
|
||||
import { RuntimePicker, isRuntimeUsableForUser } from "./runtime-picker";
|
||||
import { ReasoningPicker } from "./reasoning-picker";
|
||||
import { InstructionsEditor } from "./instructions-editor";
|
||||
import { SkillMultiSelect } from "./skill-multi-select";
|
||||
import { AvatarPicker } from "./avatar-picker";
|
||||
@@ -38,6 +39,24 @@ import {
|
||||
import { CharCounter } from "./char-counter";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// True when a duplicate would land on the template agent's own runtime — i.e.
|
||||
// that runtime still exists and is usable by the caller. When false the dialog
|
||||
// falls back to a different runtime, so template-runtime-scoped pre-fills (the
|
||||
// runtime itself and its per-provider `thinking_level`) must not be carried
|
||||
// over.
|
||||
function landsOnTemplateRuntime(
|
||||
template: Agent | null | undefined,
|
||||
runtimes: RuntimeDevice[],
|
||||
currentUserId: string | null,
|
||||
): boolean {
|
||||
if (!template?.runtime_id) return false;
|
||||
const templateRuntime = runtimes.find((r) => r.id === template.runtime_id);
|
||||
return (
|
||||
templateRuntime != null &&
|
||||
isRuntimeUsableForUser(templateRuntime, currentUserId)
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateAgentDialog({
|
||||
runtimes,
|
||||
runtimesLoading,
|
||||
@@ -87,6 +106,22 @@ export function CreateAgentDialog({
|
||||
template?.visibility ?? "workspace",
|
||||
);
|
||||
const [model, setModel] = useState(template?.model ?? "");
|
||||
// Reasoning/effort override (MUL-3772). "" = follow the local CLI config.
|
||||
// Duplicate mode clones the source agent's level — but ONLY when the clone
|
||||
// actually lands on the template's own runtime (same condition as the
|
||||
// runtime pre-fill below). The level is a per-provider token; if the
|
||||
// template runtime is locked/missing the dialog falls back to a different,
|
||||
// possibly different-provider runtime, where that token would be
|
||||
// provider-invalid. Starting blank there avoids carrying a value the
|
||||
// fallback runtime would reject — which the provider-change effect can't
|
||||
// catch (it skips the initial establishment) and the catalog-based clear
|
||||
// can't catch when the fallback runtime is offline / its catalog hasn't
|
||||
// resolved before submit.
|
||||
const [thinkingLevel, setThinkingLevel] = useState(() =>
|
||||
template?.thinking_level && landsOnTemplateRuntime(template, runtimes, currentUserId)
|
||||
? template.thinking_level
|
||||
: "",
|
||||
);
|
||||
const [instructions, setInstructions] = useState(template?.instructions ?? "");
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(template?.avatar_url ?? null);
|
||||
const [selectedSkillIds, setSelectedSkillIds] = useState<Set<string>>(
|
||||
@@ -96,18 +131,14 @@ export function CreateAgentDialog({
|
||||
|
||||
// Duplicate-mode pre-fill: clone lands on the source agent's runtime so
|
||||
// the user doesn't have to re-pick. Skipped when that runtime is now
|
||||
// locked for the caller (Create would 403). Empty fallback hands the
|
||||
// job to RuntimePicker — it owns filter state, so it's the only place
|
||||
// locked/missing for the caller (Create would 403). Empty fallback hands
|
||||
// the job to RuntimePicker — it owns filter state, so it's the only place
|
||||
// that knows which runtimes are visible right now.
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(() => {
|
||||
const templateRuntime = template?.runtime_id
|
||||
? runtimes.find((r) => r.id === template.runtime_id)
|
||||
: undefined;
|
||||
if (templateRuntime && isRuntimeUsableForUser(templateRuntime, currentUserId)) {
|
||||
return templateRuntime.id;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(() =>
|
||||
landsOnTemplateRuntime(template, runtimes, currentUserId)
|
||||
? (template?.runtime_id ?? "")
|
||||
: "",
|
||||
);
|
||||
|
||||
const selectedRuntime = runtimes.find((d) => d.id === selectedRuntimeId) ?? null;
|
||||
// Defense-in-depth: even if a locked runtime somehow ends up selected
|
||||
@@ -118,6 +149,27 @@ export function CreateAgentDialog({
|
||||
selectedRuntime != null &&
|
||||
!isRuntimeUsableForUser(selectedRuntime, currentUserId);
|
||||
|
||||
// Reasoning/effort tokens are validated per provider on the backend, so a
|
||||
// level chosen for one provider is invalid the moment the user switches the
|
||||
// selected runtime to a different provider (e.g. a Codex `none` would 400 on
|
||||
// a Claude runtime). Clear the override synchronously on a provider change so
|
||||
// a stale token can never be submitted — this also covers the brief window
|
||||
// before ReasoningPicker's catalog-based clear can run for a not-yet-cached
|
||||
// runtime. The initial establishment (undefined → first provider) is skipped
|
||||
// so a duplicate-mode pre-fill survives until the user actually changes it.
|
||||
const prevProviderRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
const provider = selectedRuntime?.provider;
|
||||
if (provider === undefined) return;
|
||||
if (
|
||||
prevProviderRef.current !== undefined &&
|
||||
prevProviderRef.current !== provider
|
||||
) {
|
||||
setThinkingLevel("");
|
||||
}
|
||||
prevProviderRef.current = provider;
|
||||
}, [selectedRuntime?.provider]);
|
||||
|
||||
// Shared squad-join follow-up. Returns nothing — the caller has
|
||||
// already shown its create-success toast; we only need to surface a
|
||||
// warning when the agent landed but the squad-join failed. Cache
|
||||
@@ -160,6 +212,9 @@ export function CreateAgentDialog({
|
||||
runtime_id: selectedRuntime.id,
|
||||
visibility,
|
||||
model: model.trim() || undefined,
|
||||
// Omit when "" so the backend leaves out the effort flag and the
|
||||
// local CLI config decides — matches the "follow CLI config" sentinel.
|
||||
thinking_level: thinkingLevel || undefined,
|
||||
instructions: trimmedInstructions || undefined,
|
||||
avatar_url: avatarUrl ?? undefined,
|
||||
};
|
||||
@@ -342,6 +397,14 @@ export function CreateAgentDialog({
|
||||
disabled={!selectedRuntime}
|
||||
/>
|
||||
|
||||
<ReasoningPicker
|
||||
runtimeId={selectedRuntime?.id ?? null}
|
||||
runtimeOnline={selectedRuntime?.status === "online"}
|
||||
model={model}
|
||||
value={thinkingLevel}
|
||||
onChange={setThinkingLevel}
|
||||
/>
|
||||
|
||||
{/* --- Optional sections (instructions / skills) ---
|
||||
Collapsed by default so quick-create stays fast.
|
||||
Duplicate pre-fills everything from the source agent. */}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
RuntimeModel,
|
||||
RuntimeModelThinkingLevel,
|
||||
} from "@multica/core/types";
|
||||
|
||||
// Shared, framework-free reasoning-level resolution. Both the inspector's
|
||||
// ThinkingPropRow and the create-dialog ReasoningPicker derive the level set
|
||||
// from the discovered model catalog the same way, so the logic lives here
|
||||
// once (no fork). MUL-2339 / MUL-3772.
|
||||
|
||||
/**
|
||||
* Resolve the catalog entry for the active model. When `model` is empty
|
||||
* (the agent runs the runtime's default), fall back to the catalog's
|
||||
* `default` flag, then the first discovered model — matching how the model
|
||||
* picker presents the implicit default.
|
||||
*/
|
||||
export function pickModelEntry(
|
||||
models: RuntimeModel[],
|
||||
model: string,
|
||||
): RuntimeModel | undefined {
|
||||
if (model) return models.find((m) => m.id === model);
|
||||
return models.find((m) => m.default) ?? models[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The reasoning/effort levels the active (runtime, model) pair exposes.
|
||||
* Empty when the model advertises no `thinking` catalog — the caller treats
|
||||
* that as "no reasoning picker for this model".
|
||||
*/
|
||||
export function resolveThinkingLevels(
|
||||
models: RuntimeModel[],
|
||||
model: string,
|
||||
): RuntimeModelThinkingLevel[] {
|
||||
return pickModelEntry(models, model)?.thinking?.supported_levels ?? [];
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { RuntimeModel } from "@multica/core/types";
|
||||
import { runtimeModelsOptions } from "@multica/core/runtimes";
|
||||
import { PropRow } from "../../../common/prop-row";
|
||||
import { useT } from "../../../i18n";
|
||||
import { ThinkingPicker } from "./thinking-picker";
|
||||
import { resolveThinkingLevels } from "./thinking-levels";
|
||||
|
||||
/**
|
||||
* Thinking row for the agent inspector. Hidden when the active model has
|
||||
@@ -46,8 +46,7 @@ export function ThinkingPropRow({
|
||||
);
|
||||
|
||||
const models = modelsQuery.data?.models ?? [];
|
||||
const entry = pickModelEntry(models, model);
|
||||
const levels = entry?.thinking?.supported_levels ?? [];
|
||||
const levels = resolveThinkingLevels(models, model);
|
||||
if (levels.length === 0 && !value) return null;
|
||||
|
||||
return (
|
||||
@@ -61,11 +60,3 @@ export function ThinkingPropRow({
|
||||
</PropRow>
|
||||
);
|
||||
}
|
||||
|
||||
function pickModelEntry(
|
||||
models: RuntimeModel[],
|
||||
model: string,
|
||||
): RuntimeModel | undefined {
|
||||
if (model) return models.find((m) => m.id === model);
|
||||
return models.find((m) => m.default) ?? models[0];
|
||||
}
|
||||
|
||||
166
packages/views/agents/components/reasoning-picker.tsx
Normal file
166
packages/views/agents/components/reasoning-picker.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, Check, Sparkles } from "lucide-react";
|
||||
import { runtimeModelsOptions } from "@multica/core/runtimes";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { useT } from "../../i18n";
|
||||
import { resolveThinkingLevels } from "./inspector/thinking-levels";
|
||||
|
||||
// ReasoningPicker — the create-dialog counterpart of the inspector's
|
||||
// ThinkingPropRow (MUL-3772 / REQ-2). It shares the level-resolution logic
|
||||
// (`resolveThinkingLevels`) and the empty-string "follow CLI config" sentinel
|
||||
// with the inspector, but wears the full-width form chrome the other create
|
||||
// fields use instead of the inspector's inline chip.
|
||||
//
|
||||
// Mounted unconditionally by the dialog; renders nothing until the selected
|
||||
// model advertises a reasoning catalog, so providers without reasoning never
|
||||
// show an empty row. An already-set value (duplicate pre-fill) also forces the
|
||||
// row so a stale token stays visible and clearable.
|
||||
export function ReasoningPicker({
|
||||
runtimeId,
|
||||
runtimeOnline,
|
||||
model,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
runtimeId: string | null;
|
||||
runtimeOnline: boolean;
|
||||
model: string;
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const modelsQuery = useQuery(
|
||||
runtimeModelsOptions(runtimeOnline ? runtimeId : null),
|
||||
);
|
||||
const models = modelsQuery.data?.models ?? [];
|
||||
const levels = resolveThinkingLevels(models, model);
|
||||
|
||||
// Create-flow safety net: once the catalog for the current (runtime, model)
|
||||
// has loaded, drop a non-empty value the model no longer advertises so it
|
||||
// can never be submitted. Without this, picking a level on one runtime and
|
||||
// switching to a model/provider that lacks it would carry the stale token
|
||||
// into the create payload — the backend validates effort tokens per provider
|
||||
// and 400s an invalid one (e.g. a Codex-only `none` on a Claude runtime).
|
||||
//
|
||||
// Gated on loaded data (not just the empty-while-loading default) so a
|
||||
// duplicate-mode pre-fill is never wiped before its catalog arrives. This is
|
||||
// deliberately stricter than the inspector's ThinkingPicker, which keeps an
|
||||
// orphan visible as a clear-only affordance: a saved agent legitimately owns
|
||||
// its token, but the create form must not forward an unsubmittable one.
|
||||
useEffect(() => {
|
||||
const data = modelsQuery.data;
|
||||
if (!data || !value) return;
|
||||
if (resolveThinkingLevels(data.models, model).some((l) => l.value === value))
|
||||
return;
|
||||
onChange("");
|
||||
}, [modelsQuery.data, model, value, onChange]);
|
||||
|
||||
// Hidden until the model exposes reasoning levels (or a value is already
|
||||
// persisted) — mirrors ThinkingPropRow's gate so behavior matches the
|
||||
// inspector exactly.
|
||||
if (levels.length === 0 && !value) return null;
|
||||
|
||||
const selected = value ? levels.find((l) => l.value === value) : undefined;
|
||||
// Unknown-but-set value (model swap that dropped the option): show the raw
|
||||
// token so the user can see and clear what is actually persisted.
|
||||
const triggerLabel = selected
|
||||
? selected.label
|
||||
: value || t(($) => $.pickers.thinking_default);
|
||||
|
||||
const select = (next: string) => {
|
||||
setOpen(false);
|
||||
if (next !== value) onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex h-6 items-center">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(($) => $.create_dialog.reasoning_label)}
|
||||
</Label>
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50">
|
||||
<Sparkles className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{triggerLabel}</span>
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{t(($) => $.create_dialog.reasoning_hint)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
|
||||
open ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--anchor-width)] p-1 max-h-72 overflow-y-auto"
|
||||
>
|
||||
{/* "Follow CLI config" (value "") is a first-class, selectable
|
||||
option — Multica omits the effort flag and the local CLI config
|
||||
decides. Mirrors the inspector picker's empty-sentinel meaning. */}
|
||||
<ReasoningOption
|
||||
label={t(($) => $.pickers.thinking_default)}
|
||||
selected={value === ""}
|
||||
onClick={() => select("")}
|
||||
/>
|
||||
{levels.map((level) => (
|
||||
<ReasoningOption
|
||||
key={level.value}
|
||||
label={level.label}
|
||||
description={level.description}
|
||||
selected={level.value === value}
|
||||
onClick={() => select(level.value)}
|
||||
/>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasoningOption({
|
||||
label,
|
||||
description,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
||||
selected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{label}</div>
|
||||
{description && (
|
||||
<div className="mt-0.5 text-xs leading-snug text-muted-foreground">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selected && <Check className="h-4 w-4 shrink-0 text-primary" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
95
packages/views/agents/components/runtime-picker.test.ts
Normal file
95
packages/views/agents/components/runtime-picker.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { RuntimeDevice } from "@multica/core/types";
|
||||
import {
|
||||
buildAgentRuntimeMachines,
|
||||
isRuntimeUsableForUser,
|
||||
} from "./runtime-picker";
|
||||
|
||||
function rt(overrides: Partial<RuntimeDevice>): RuntimeDevice {
|
||||
return {
|
||||
id: "rt",
|
||||
workspace_id: "ws-1",
|
||||
daemon_id: null,
|
||||
name: "Claude (host.local)",
|
||||
runtime_mode: "local",
|
||||
provider: "claude",
|
||||
launch_header: "claude (stream-json)",
|
||||
status: "online",
|
||||
device_info: "host.local",
|
||||
metadata: {},
|
||||
owner_id: "u1",
|
||||
visibility: "private",
|
||||
last_seen_at: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildAgentRuntimeMachines", () => {
|
||||
it("groups CLIs on the same daemon into one machine, sorted by provider", () => {
|
||||
const machines = buildAgentRuntimeMachines([
|
||||
rt({ id: "a", daemon_id: "d1", provider: "codex", name: "Codex (Mac)" }),
|
||||
rt({ id: "b", daemon_id: "d1", provider: "claude", name: "Claude (Mac)" }),
|
||||
]);
|
||||
expect(machines).toHaveLength(1);
|
||||
expect(machines[0]!.runtimes.map((r) => r.provider)).toEqual([
|
||||
"claude",
|
||||
"codex",
|
||||
]);
|
||||
expect(machines[0]!.label).toBe("Mac");
|
||||
});
|
||||
|
||||
it("keeps two members' identically-named daemon-less hosts separate", () => {
|
||||
const machines = buildAgentRuntimeMachines([
|
||||
rt({ id: "a", daemon_id: null, owner_id: "u1", name: "Claude (box)" }),
|
||||
rt({ id: "b", daemon_id: null, owner_id: "u2", name: "Claude (box)" }),
|
||||
]);
|
||||
expect(machines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("treats a cloud runtime as its own machine", () => {
|
||||
const machines = buildAgentRuntimeMachines([
|
||||
rt({
|
||||
id: "c",
|
||||
daemon_id: null,
|
||||
runtime_mode: "cloud",
|
||||
name: "Codex cloud",
|
||||
device_info: "Cloud · us-west",
|
||||
owner_id: null,
|
||||
}),
|
||||
]);
|
||||
expect(machines).toHaveLength(1);
|
||||
expect(machines[0]!.cloud).toBe(true);
|
||||
expect(machines[0]!.label).toBe("Cloud · us-west");
|
||||
expect(machines[0]!.ownerId).toBeNull();
|
||||
});
|
||||
|
||||
it("marks a machine online when any of its runtimes is online", () => {
|
||||
const machines = buildAgentRuntimeMachines([
|
||||
rt({ id: "a", daemon_id: "d1", provider: "claude", status: "offline" }),
|
||||
rt({ id: "b", daemon_id: "d1", provider: "codex", status: "online" }),
|
||||
]);
|
||||
expect(machines[0]!.online).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the runtime id when daemon and device are both missing", () => {
|
||||
const machines = buildAgentRuntimeMachines([
|
||||
rt({ id: "x", daemon_id: null, name: "Claude", device_info: "" }),
|
||||
]);
|
||||
expect(machines).toHaveLength(1);
|
||||
expect(machines[0]!.label).toBe("Claude");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRuntimeUsableForUser", () => {
|
||||
it("allows the owner, allows public, blocks another member's private", () => {
|
||||
expect(isRuntimeUsableForUser(rt({ owner_id: "u1" }), "u1")).toBe(true);
|
||||
expect(
|
||||
isRuntimeUsableForUser(rt({ owner_id: "u2", visibility: "public" }), "u1"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isRuntimeUsableForUser(rt({ owner_id: "u2", visibility: "private" }), "u1"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Cloud, Loader2, Lock } from "lucide-react";
|
||||
import { ChevronDown, Cloud, Loader2, Lock, Monitor } from "lucide-react";
|
||||
import { ProviderLogo } from "../../runtimes/components/provider-logo";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { splitRuntimeName } from "../../runtimes/components/runtime-machines";
|
||||
import type { MemberWithUser, RuntimeDevice } from "@multica/core/types";
|
||||
import {
|
||||
Popover,
|
||||
@@ -15,6 +16,30 @@ import { useT } from "../../i18n";
|
||||
|
||||
export type RuntimeFilter = "mine" | "all";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MUL-3772: the single "Runtime" dropdown is split into two cascading
|
||||
// selectors — Machine (the physical/cloud host) and Agent runtime (the CLI
|
||||
// backend on that host). A RuntimeDevice is already a (machine × CLI) pair;
|
||||
// the label the daemon builds is `<provider> (<deviceName>)`, so the split is
|
||||
// purely a frontend regrouping. The submit payload is unchanged: a resolved
|
||||
// (machine, runtime) collapses back to one `runtime_id`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A host grouping one-or-more agent-runtime (CLI) instances. */
|
||||
export interface AgentRuntimeMachine {
|
||||
/** Stable group key — daemon id, else owner+device, else runtime id. */
|
||||
key: string;
|
||||
/** Display label: hostname/device name. */
|
||||
label: string;
|
||||
/** Runtime owner (shared across a daemon's runtimes); null for cloud. */
|
||||
ownerId: string | null;
|
||||
/** True when any runtime on this machine is online. */
|
||||
online: boolean;
|
||||
/** True when this is a cloud-mode machine. */
|
||||
cloud: boolean;
|
||||
runtimes: RuntimeDevice[];
|
||||
}
|
||||
|
||||
export function RuntimePicker({
|
||||
runtimes,
|
||||
runtimesLoading,
|
||||
@@ -30,62 +55,136 @@ export function RuntimePicker({
|
||||
selectedRuntimeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState<RuntimeFilter>("mine");
|
||||
|
||||
const getOwnerMember = (ownerId: string | null) => {
|
||||
if (!ownerId) return null;
|
||||
return members.find((m) => m.user_id === ownerId) ?? null;
|
||||
};
|
||||
const machines = useMemo(
|
||||
() => buildAgentRuntimeMachines(runtimes),
|
||||
[runtimes],
|
||||
);
|
||||
const hasOtherMachines = machines.some((m) => m.ownerId !== currentUserId);
|
||||
|
||||
const hasOtherRuntimes = runtimes.some((r) => r.owner_id !== currentUserId);
|
||||
|
||||
const filteredRuntimes = useMemo(
|
||||
() => computeFilteredRuntimes(runtimes, filter, currentUserId),
|
||||
[runtimes, filter, currentUserId],
|
||||
const filteredMachines = useMemo(
|
||||
() => computeFilteredMachines(machines, filter, currentUserId),
|
||||
[machines, filter, currentUserId],
|
||||
);
|
||||
|
||||
const selectedRuntime =
|
||||
runtimes.find((d) => d.id === selectedRuntimeId) ?? null;
|
||||
const selectedMachine =
|
||||
machines.find((m) =>
|
||||
m.runtimes.some((r) => r.id === selectedRuntimeId),
|
||||
) ?? null;
|
||||
|
||||
// Sole source of truth for seeding the parent's selection when it's empty
|
||||
// — first mount with no template runtime, runtimes arriving later over
|
||||
// WS, or filter toggle clearing to a set with no usable item. Only fires
|
||||
// — first mount with no template runtime, runtimes arriving later over WS,
|
||||
// or a filter toggle clearing to a set with no usable item. Only fires
|
||||
// when `selectedRuntimeId === ""` so a duplicate-mode pre-fill (template
|
||||
// runtime) is never silently overwritten.
|
||||
useEffect(() => {
|
||||
if (selectedRuntimeId !== "") return;
|
||||
const firstUsable = filteredRuntimes.find((r) =>
|
||||
isRuntimeUsableForUser(r, currentUserId),
|
||||
);
|
||||
const firstUsable = firstUsableRuntime(filteredMachines, currentUserId);
|
||||
if (firstUsable) onSelect(firstUsable.id);
|
||||
}, [filteredRuntimes, selectedRuntimeId, currentUserId, onSelect]);
|
||||
}, [filteredMachines, selectedRuntimeId, currentUserId, onSelect]);
|
||||
|
||||
// On filter toggle, recompute the picker's selection to a usable item
|
||||
// in the new filter set. Pushes `""` when nothing matches; the seeding
|
||||
// effect above is a no-op in that case (correct: no usable item to pick).
|
||||
// On filter toggle, recompute the selection to a usable runtime in the new
|
||||
// machine set. Pushes `""` when nothing matches; the seeding effect above
|
||||
// is a no-op in that case (correct: no usable item to pick).
|
||||
const handleFilterChange = (next: RuntimeFilter) => {
|
||||
if (next === filter) return;
|
||||
setFilter(next);
|
||||
const nextList = computeFilteredRuntimes(runtimes, next, currentUserId);
|
||||
const firstUsable = nextList.find((r) =>
|
||||
const nextMachines = computeFilteredMachines(machines, next, currentUserId);
|
||||
const firstUsable = firstUsableRuntime(nextMachines, currentUserId);
|
||||
onSelect(firstUsable?.id ?? "");
|
||||
};
|
||||
|
||||
// Switching machine moves the selection to that machine's first usable
|
||||
// runtime (or its first runtime if all are locked — the Create gate still
|
||||
// blocks submit, but the user can see what's there).
|
||||
const handleMachineSelect = (machine: AgentRuntimeMachine) => {
|
||||
const usable = machine.runtimes.find((r) =>
|
||||
isRuntimeUsableForUser(r, currentUserId),
|
||||
);
|
||||
onSelect(firstUsable?.id ?? "");
|
||||
onSelect((usable ?? machine.runtimes[0])?.id ?? "");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MachinePicker
|
||||
machines={filteredMachines}
|
||||
selectedMachine={selectedMachine}
|
||||
members={members}
|
||||
currentUserId={currentUserId}
|
||||
runtimesLoading={runtimesLoading}
|
||||
runtimesEmpty={runtimes.length === 0}
|
||||
filter={filter}
|
||||
hasOtherMachines={hasOtherMachines}
|
||||
onFilterChange={handleFilterChange}
|
||||
onSelect={handleMachineSelect}
|
||||
/>
|
||||
<AgentRuntimePicker
|
||||
machine={selectedMachine}
|
||||
selectedRuntimeId={selectedRuntimeId}
|
||||
currentUserId={currentUserId}
|
||||
runtimesLoading={runtimesLoading}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machine selector — owns the Mine/All filter (relabelled but functionally
|
||||
// the runtime filter it replaced) and the online/owner/Cloud chrome.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MachinePicker({
|
||||
machines,
|
||||
selectedMachine,
|
||||
members,
|
||||
currentUserId,
|
||||
runtimesLoading,
|
||||
runtimesEmpty,
|
||||
filter,
|
||||
hasOtherMachines,
|
||||
onFilterChange,
|
||||
onSelect,
|
||||
}: {
|
||||
machines: AgentRuntimeMachine[];
|
||||
selectedMachine: AgentRuntimeMachine | null;
|
||||
members: MemberWithUser[];
|
||||
currentUserId: string | null;
|
||||
runtimesLoading?: boolean;
|
||||
runtimesEmpty: boolean;
|
||||
filter: RuntimeFilter;
|
||||
hasOtherMachines: boolean;
|
||||
onFilterChange: (next: RuntimeFilter) => void;
|
||||
onSelect: (machine: AgentRuntimeMachine) => void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const ownerName = (machine: AgentRuntimeMachine): string | null => {
|
||||
if (!machine.ownerId) return null;
|
||||
return members.find((m) => m.user_id === machine.ownerId)?.name ?? null;
|
||||
};
|
||||
|
||||
const machineSubtitle = (machine: AgentRuntimeMachine): string => {
|
||||
const owner = ownerName(machine);
|
||||
const count = t(($) => $.create_dialog.machine_runtime_count, {
|
||||
count: machine.runtimes.length,
|
||||
});
|
||||
return owner ? `${owner} · ${count}` : count;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex h-6 items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(($) => $.create_dialog.runtime_label)}
|
||||
{t(($) => $.create_dialog.machine_label)}
|
||||
</Label>
|
||||
{hasOtherRuntimes && (
|
||||
{hasOtherMachines && (
|
||||
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFilterChange("mine")}
|
||||
onClick={() => onFilterChange("mine")}
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
|
||||
filter === "mine"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
@@ -96,7 +195,7 @@ export function RuntimePicker({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFilterChange("all")}
|
||||
onClick={() => onFilterChange("all")}
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
|
||||
filter === "all"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
@@ -110,40 +209,44 @@ export function RuntimePicker({
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
disabled={runtimes.length === 0 && !runtimesLoading}
|
||||
data-testid="machine-picker-trigger"
|
||||
disabled={runtimesEmpty && !runtimesLoading}
|
||||
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{runtimesLoading ? (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : selectedRuntime ? (
|
||||
<ProviderLogo
|
||||
provider={selectedRuntime.provider}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
) : selectedMachine?.cloud ? (
|
||||
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{runtimesLoading
|
||||
? t(($) => $.create_dialog.runtime_loading)
|
||||
: (selectedRuntime?.name ??
|
||||
: (selectedMachine?.label ??
|
||||
t(($) => $.create_dialog.runtime_none))}
|
||||
</span>
|
||||
{selectedRuntime?.runtime_mode === "cloud" && (
|
||||
{selectedMachine?.cloud && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedRuntime && (
|
||||
{selectedMachine && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{getOwnerMember(selectedRuntime.owner_id)?.name ??
|
||||
selectedRuntime.device_info}
|
||||
{machineSubtitle(selectedMachine)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedMachine && (
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
selectedMachine.online ? "bg-success" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
|
||||
open ? "rotate-180" : ""
|
||||
@@ -154,39 +257,42 @@ export function RuntimePicker({
|
||||
align="start"
|
||||
className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto"
|
||||
>
|
||||
{filteredRuntimes.map((device) => {
|
||||
const ownerMember = getOwnerMember(device.owner_id);
|
||||
const disabled = !isRuntimeUsableForUser(device, currentUserId);
|
||||
{machines.map((machine) => {
|
||||
const disabled = !isMachineUsableForUser(machine, currentUserId);
|
||||
const disabledTitle = disabled
|
||||
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
|
||||
: undefined;
|
||||
const owner = ownerName(machine);
|
||||
return (
|
||||
<button
|
||||
key={device.id}
|
||||
key={machine.key}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={disabledTitle}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
onSelect(device.id);
|
||||
onSelect(machine);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: device.id === selectedRuntimeId
|
||||
: machine.key === selectedMachine?.key
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderLogo
|
||||
provider={device.provider}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
{machine.cloud ? (
|
||||
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{device.name}</span>
|
||||
{device.runtime_mode === "cloud" && (
|
||||
<span className="truncate font-medium">
|
||||
{machine.label}
|
||||
</span>
|
||||
{machine.cloud && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
</span>
|
||||
@@ -199,25 +305,27 @@ export function RuntimePicker({
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{ownerMember ? (
|
||||
{owner && machine.ownerId ? (
|
||||
<>
|
||||
<ActorAvatar
|
||||
actorType="member"
|
||||
actorId={ownerMember.user_id}
|
||||
actorId={machine.ownerId}
|
||||
size={14}
|
||||
/>
|
||||
<span className="truncate">{ownerMember.name}</span>
|
||||
<span className="truncate">
|
||||
{machineSubtitle(machine)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="truncate">{device.device_info}</span>
|
||||
<span className="truncate">
|
||||
{machineSubtitle(machine)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
device.status === "online"
|
||||
? "bg-success"
|
||||
: "bg-muted-foreground/40"
|
||||
machine.online ? "bg-success" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
@@ -229,6 +337,167 @@ export function RuntimePicker({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent-runtime selector — cascades off the selected machine. A machine with
|
||||
// a single runtime renders the selector read-only (stable layout, nothing to
|
||||
// choose) per the approved RFC default.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentRuntimePicker({
|
||||
machine,
|
||||
selectedRuntimeId,
|
||||
currentUserId,
|
||||
runtimesLoading,
|
||||
onSelect,
|
||||
}: {
|
||||
machine: AgentRuntimeMachine | null;
|
||||
selectedRuntimeId: string;
|
||||
currentUserId: string | null;
|
||||
runtimesLoading?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const runtimes = machine?.runtimes ?? [];
|
||||
const selectedRuntime =
|
||||
runtimes.find((r) => r.id === selectedRuntimeId) ?? null;
|
||||
const single = runtimes.length === 1;
|
||||
|
||||
const runtimeTitle = (runtime: RuntimeDevice): string =>
|
||||
splitRuntimeName(runtime.name).base;
|
||||
|
||||
const runtimeSubtitle = (runtime: RuntimeDevice): string => {
|
||||
const kind = runtime.profile_id
|
||||
? t(($) => $.create_dialog.runtime_kind_custom)
|
||||
: t(($) => $.create_dialog.runtime_kind_builtin);
|
||||
const header = runtime.launch_header?.trim();
|
||||
return header ? `${header} · ${kind}` : kind;
|
||||
};
|
||||
|
||||
const triggerContent = (
|
||||
<>
|
||||
{selectedRuntime ? (
|
||||
<ProviderLogo
|
||||
provider={selectedRuntime.provider}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{selectedRuntime
|
||||
? runtimeTitle(selectedRuntime)
|
||||
: t(($) => $.create_dialog.agent_runtime_none)}
|
||||
</span>
|
||||
</div>
|
||||
{selectedRuntime && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{runtimeSubtitle(selectedRuntime)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex h-6 items-center">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(($) => $.create_dialog.agent_runtime_label)}
|
||||
</Label>
|
||||
</div>
|
||||
{single ? (
|
||||
// Read-only: a single-runtime machine has nothing to pick. Render the
|
||||
// same chrome (minus the chevron / hover) so the layout doesn't jump
|
||||
// when switching to a multi-runtime machine.
|
||||
<div
|
||||
data-testid="agent-runtime-readonly"
|
||||
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2.5 mt-1.5 text-left text-sm"
|
||||
>
|
||||
{triggerContent}
|
||||
</div>
|
||||
) : (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
data-testid="agent-runtime-trigger"
|
||||
disabled={!machine || runtimesLoading}
|
||||
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{triggerContent}
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
|
||||
open ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto"
|
||||
>
|
||||
{runtimes.map((runtime) => {
|
||||
const disabled = !isRuntimeUsableForUser(runtime, currentUserId);
|
||||
const disabledTitle = disabled
|
||||
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
|
||||
: undefined;
|
||||
return (
|
||||
<button
|
||||
key={runtime.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={disabledTitle}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
onSelect(runtime.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: runtime.id === selectedRuntimeId
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderLogo
|
||||
provider={runtime.provider}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{runtimeTitle(runtime)}
|
||||
</span>
|
||||
{disabled && (
|
||||
<span className="shrink-0 inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t(($) => $.create_dialog.runtime_private_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{runtimeSubtitle(runtime)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
runtime.status === "online"
|
||||
? "bg-success"
|
||||
: "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Visibility gate exposed so the parent can defend Create against a locked
|
||||
// selection (e.g. duplicate of an agent whose runtime is now private).
|
||||
export function isRuntimeUsableForUser(
|
||||
@@ -240,24 +509,101 @@ export function isRuntimeUsableForUser(
|
||||
return r.visibility === "public";
|
||||
}
|
||||
|
||||
function computeFilteredRuntimes(
|
||||
runtimes: RuntimeDevice[],
|
||||
filter: RuntimeFilter,
|
||||
function isMachineUsableForUser(
|
||||
machine: AgentRuntimeMachine,
|
||||
currentUserId: string | null,
|
||||
): RuntimeDevice[] {
|
||||
const filtered =
|
||||
filter === "mine" && currentUserId
|
||||
? runtimes.filter((r) => r.owner_id === currentUserId)
|
||||
: runtimes;
|
||||
return filtered.toSorted((a, b) => {
|
||||
const aMine = a.owner_id === currentUserId;
|
||||
const bMine = b.owner_id === currentUserId;
|
||||
if (aMine && !bMine) return -1;
|
||||
if (!aMine && bMine) return 1;
|
||||
const aUsable = isRuntimeUsableForUser(a, currentUserId);
|
||||
const bUsable = isRuntimeUsableForUser(b, currentUserId);
|
||||
if (aUsable && !bUsable) return -1;
|
||||
if (!aUsable && bUsable) return 1;
|
||||
return 0;
|
||||
): boolean {
|
||||
return machine.runtimes.some((r) => isRuntimeUsableForUser(r, currentUserId));
|
||||
}
|
||||
|
||||
// Group the flat runtime list into machines. A daemon-backed runtime groups
|
||||
// by `daemon_id`; cloud / daemon-less runtimes (daemon_id: null) fall back to
|
||||
// owner+device so two members' identically-named hosts never collapse into
|
||||
// one row, then to the runtime id when even the device name is missing.
|
||||
export function buildAgentRuntimeMachines(
|
||||
runtimes: RuntimeDevice[],
|
||||
): AgentRuntimeMachine[] {
|
||||
const groups = new Map<string, RuntimeDevice[]>();
|
||||
const order: string[] = [];
|
||||
for (const runtime of runtimes) {
|
||||
const key = machineKey(runtime);
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.push(runtime);
|
||||
} else {
|
||||
groups.set(key, [runtime]);
|
||||
order.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return order.map((key) => {
|
||||
const group = groups
|
||||
.get(key)!
|
||||
.toSorted((a, b) => a.provider.localeCompare(b.provider));
|
||||
const first = group[0]!;
|
||||
return {
|
||||
key,
|
||||
label: machineLabel(group),
|
||||
ownerId: first.owner_id,
|
||||
online: group.some((r) => r.status === "online"),
|
||||
cloud: group.some((r) => r.runtime_mode === "cloud"),
|
||||
runtimes: group,
|
||||
} satisfies AgentRuntimeMachine;
|
||||
});
|
||||
}
|
||||
|
||||
function machineKey(runtime: RuntimeDevice): string {
|
||||
if (runtime.daemon_id) return `daemon:${runtime.daemon_id}`;
|
||||
const device =
|
||||
splitRuntimeName(runtime.name).hostname ??
|
||||
(runtime.device_info?.trim() || null);
|
||||
if (device) return `device:${runtime.owner_id ?? "_"}:${device}`;
|
||||
return `runtime:${runtime.id}`;
|
||||
}
|
||||
|
||||
function machineLabel(runtimes: RuntimeDevice[]): string {
|
||||
const first = runtimes[0]!;
|
||||
const host = splitRuntimeName(first.name).hostname;
|
||||
if (host) return host;
|
||||
const device = first.device_info?.trim();
|
||||
if (device) return device;
|
||||
return first.name;
|
||||
}
|
||||
|
||||
function computeFilteredMachines(
|
||||
machines: AgentRuntimeMachine[],
|
||||
filter: RuntimeFilter,
|
||||
currentUserId: string | null,
|
||||
): AgentRuntimeMachine[] {
|
||||
const filtered =
|
||||
filter === "mine" && currentUserId
|
||||
? machines.filter((m) => m.ownerId === currentUserId)
|
||||
: machines;
|
||||
return filtered.toSorted((a, b) => {
|
||||
const aMine = a.ownerId === currentUserId;
|
||||
const bMine = b.ownerId === currentUserId;
|
||||
if (aMine && !bMine) return -1;
|
||||
if (!aMine && bMine) return 1;
|
||||
const aUsable = isMachineUsableForUser(a, currentUserId);
|
||||
const bUsable = isMachineUsableForUser(b, currentUserId);
|
||||
if (aUsable && !bUsable) return -1;
|
||||
if (!aUsable && bUsable) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}
|
||||
|
||||
// First selectable runtime across the (already sorted) machine set — used to
|
||||
// seed and to re-seed on filter change. Honors the per-runtime visibility
|
||||
// gate so a locked private runtime is never auto-selected.
|
||||
function firstUsableRuntime(
|
||||
machines: AgentRuntimeMachine[],
|
||||
currentUserId: string | null,
|
||||
): RuntimeDevice | undefined {
|
||||
for (const machine of machines) {
|
||||
const usable = machine.runtimes.find((r) =>
|
||||
isRuntimeUsableForUser(r, currentUserId),
|
||||
);
|
||||
if (usable) return usable;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -241,6 +241,15 @@
|
||||
"runtime_cloud_badge": "Cloud",
|
||||
"runtime_private_badge": "Private",
|
||||
"runtime_private_locked_tooltip": "Private runtime — only its owner or a workspace admin can create agents on it. Ask the owner to switch it to Public to share.",
|
||||
"machine_label": "Machine",
|
||||
"machine_runtime_count_one": "{{count}} runtime",
|
||||
"machine_runtime_count_other": "{{count}} runtimes",
|
||||
"agent_runtime_label": "Agent runtime",
|
||||
"agent_runtime_none": "Select a machine first",
|
||||
"runtime_kind_builtin": "Built-in",
|
||||
"runtime_kind_custom": "Custom",
|
||||
"reasoning_label": "Reasoning",
|
||||
"reasoning_hint": "Effort level — discovered from the runtime",
|
||||
"duplicate_copy_suffix": " (Copy)",
|
||||
"create": "Create",
|
||||
"creating": "Creating...",
|
||||
|
||||
@@ -228,6 +228,14 @@
|
||||
"runtime_cloud_badge": "クラウド",
|
||||
"runtime_private_badge": "非公開",
|
||||
"runtime_private_locked_tooltip": "非公開ランタイムです。オーナーまたはワークスペースの admin のみがこのランタイム上にエージェントを作成できます。共有するには、オーナーに公開へ切り替えるよう依頼してください。",
|
||||
"machine_label": "マシン",
|
||||
"machine_runtime_count_other": "ランタイム {{count}} 件",
|
||||
"agent_runtime_label": "エージェントランタイム",
|
||||
"agent_runtime_none": "先にマシンを選択してください",
|
||||
"runtime_kind_builtin": "組み込み",
|
||||
"runtime_kind_custom": "カスタム",
|
||||
"reasoning_label": "思考",
|
||||
"reasoning_hint": "努力レベル — ランタイムから検出",
|
||||
"duplicate_copy_suffix": " (コピー)",
|
||||
"create": "作成",
|
||||
"creating": "作成中...",
|
||||
|
||||
@@ -241,6 +241,15 @@
|
||||
"runtime_cloud_badge": "클라우드",
|
||||
"runtime_private_badge": "비공개",
|
||||
"runtime_private_locked_tooltip": "비공개 런타임입니다. 소유자 또는 워크스페이스 관리자만 이 런타임에 에이전트를 만들 수 있습니다. 공유하려면 소유자에게 공개로 전환해 달라고 요청하세요.",
|
||||
"machine_label": "머신",
|
||||
"machine_runtime_count_one": "런타임 {{count}}개",
|
||||
"machine_runtime_count_other": "런타임 {{count}}개",
|
||||
"agent_runtime_label": "에이전트 런타임",
|
||||
"agent_runtime_none": "먼저 머신을 선택하세요",
|
||||
"runtime_kind_builtin": "기본 제공",
|
||||
"runtime_kind_custom": "사용자 지정",
|
||||
"reasoning_label": "추론",
|
||||
"reasoning_hint": "강도 수준 — 런타임에서 자동 감지",
|
||||
"duplicate_copy_suffix": " (사본)",
|
||||
"create": "만들기",
|
||||
"creating": "만드는 중...",
|
||||
|
||||
@@ -236,6 +236,14 @@
|
||||
"runtime_cloud_badge": "云端",
|
||||
"runtime_private_badge": "私有",
|
||||
"runtime_private_locked_tooltip": "私有运行时——只有 runtime 所有者或工作区管理员可在其上创建智能体。如果需要共享,请联系所有者切换为「公开」。",
|
||||
"machine_label": "机器",
|
||||
"machine_runtime_count_other": "{{count}} 个运行时",
|
||||
"agent_runtime_label": "Agent 运行时",
|
||||
"agent_runtime_none": "请先选择机器",
|
||||
"runtime_kind_builtin": "内置",
|
||||
"runtime_kind_custom": "自定义",
|
||||
"reasoning_label": "思考",
|
||||
"reasoning_hint": "强度等级——由运行时自动发现",
|
||||
"duplicate_copy_suffix": "(副本)",
|
||||
"create": "创建",
|
||||
"creating": "创建中...",
|
||||
|
||||
Reference in New Issue
Block a user