Compare commits

...

3 Commits

Author SHA1 Message Date
Jiayuan
61684767c0 fix(agents): drop duplicate template thinking_level on runtime fallback (MUL-3772)
Remaining blocker on #4638: in duplicate mode the dialog initialized
`thinkingLevel` from the template even when the template runtime was
locked/missing and it fell back to a different-provider runtime. The
provider-change effect skips the initial establishment (to preserve genuine
pre-fill) and the catalog-based clear can't run when the fallback runtime is
offline / its catalog hasn't resolved, so the stale per-provider token could
be submitted and 400 (e.g. a Claude `max` on an offline Codex fallback).

Fix: only clone the template's `thinking_level` when the clone actually lands
on the template's own runtime — the same usability condition that governs the
runtime pre-fill, now factored into a shared `landsOnTemplateRuntime` helper.
On fallback to any other runtime the level starts blank, so it can never be
carried over regardless of provider, online state, or catalog timing.

Tests: locked template runtime → offline different-provider fallback drops the
level and omits it from the payload; landing on the template runtime (offline,
same provider) still preserves it.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-27 02:54:27 +08:00
Jiayuan
791c99b2b5 fix(agents): prevent stale/provider-invalid reasoning level on create (MUL-3772)
Addresses the blocking review on #4638: the create form held the chosen
`thinking_level` across runtime/model changes and could submit a token the
new provider rejects (the backend validates effort per provider and 400s an
invalid one, e.g. a Codex `none` on a Claude runtime).

Two guards:
- create-agent-dialog: clear the override synchronously when the selected
  runtime's provider changes (the backend validation axis), so a stale token
  can never be submitted — race-free, covering the window before the new
  runtime's model catalog has loaded. The initial provider establishment is
  skipped so duplicate-mode pre-fill survives until the user changes it.
- reasoning-picker: once the catalog for the current (runtime, model) has
  loaded, drop a value the model no longer advertises (also hides the row for
  non-reasoning models). Gated on loaded data so a duplicate pre-fill isn't
  wiped before its catalog arrives. Stricter than the inspector picker, which
  keeps orphans visible as a clear-only affordance.

Tests: switching the agent runtime to another provider clears the stale level
and omits it from the payload; a duplicate-mode orphan the model doesn't
advertise is dropped and not submitted. Per-runtime model-catalog test mock.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-27 02:45:46 +08:00
Jiayuan
6039b451ab feat(agents): split create-agent runtime into Machine + Agent runtime and add reasoning (MUL-3772)
REQ-1: the single "Runtime" dropdown (e.g. "Claude (host.local)") 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, so this is a pure frontend regrouping: the flat
`runtimes` array is grouped by daemon (cloud/daemon-less hosts fall back to
owner+device, then runtime id) and the resolved (machine, runtime) collapses
back to one `runtime_id`. The submit payload is unchanged. Single-runtime
machines render the agent-runtime selector read-only for a stable layout.
The Mine/All filter, private-runtime lock + Create gate, duplicate-mode
pre-fill, and late-WS seeding are all preserved on the new Machine selector.

REQ-2: models that expose reasoning levels now get a Reasoning (effort)
picker in the create form, gated identically to the inspector — hidden until
the selected model advertises `supported_levels`, "" = "Follow CLI config"
(the effort flag is omitted). Level resolution is shared with the inspector
via a new `thinking-levels` helper (no logic fork). `thinking_level` is added
to the create payload and pre-filled in duplicate mode.

No API/DB change. New i18n keys added across en/ja/ko/zh-Hans.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-27 02:31:56 +08:00
11 changed files with 1259 additions and 201 deletions

View File

@@ -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");
});
});

View File

@@ -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. */}

View File

@@ -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 ?? [];
}

View File

@@ -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];
}

View 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>
);
}

View 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);
});
});

View File

@@ -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;
}

View File

@@ -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...",

View File

@@ -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": "作成中...",

View File

@@ -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": "만드는 중...",

View File

@@ -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": "创建中...",