mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
feat(agents): two-level runtime picker on agent settings — machine, then runtime (MUL-4421)
A machine-level rename stamps the same custom_name on every runtime of a daemon (MUL-4217), so the settings-page runtime dropdown rendered N indistinguishable machine-name rows. Split selection into two levels: pick the machine first, then a runtime on it, labelled by the runtime itself. Opening lands inside the selected runtime's machine, and the trigger now reads 'Claude · <machine>' instead of the bare machine name. Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import type { MemberWithUser, RuntimeDevice } from "@multica/core/types";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../../locales/en/common.json";
|
||||
import enAgents from "../../../locales/en/agents.json";
|
||||
import enIssues from "../../../locales/en/issues.json";
|
||||
|
||||
// ActorAvatar pulls workspace context this unit test doesn't provide.
|
||||
vi.mock("../../../common/actor-avatar", () => ({
|
||||
ActorAvatar: () => null,
|
||||
}));
|
||||
|
||||
// Provider logos are inline SVGs with no behavior under test.
|
||||
vi.mock("../../../runtimes/components/provider-logo", () => ({
|
||||
ProviderLogo: () => null,
|
||||
}));
|
||||
|
||||
import { RuntimePicker } from "./runtime-picker";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
en: { common: enCommon, agents: enAgents, issues: enIssues },
|
||||
};
|
||||
|
||||
const ME = "user-me";
|
||||
const OTHER = "user-other";
|
||||
|
||||
const MEMBERS = [
|
||||
{ user_id: ME, name: "Me", role: "member" },
|
||||
{ user_id: OTHER, name: "Other", role: "member" },
|
||||
] as unknown as MemberWithUser[];
|
||||
|
||||
function makeRuntime(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: "",
|
||||
status: "online",
|
||||
device_info: "host.local · macOS (arm64)",
|
||||
metadata: {},
|
||||
owner_id: ME,
|
||||
visibility: "private",
|
||||
last_seen_at: "2026-07-11T00:00:00Z",
|
||||
created_at: "2026-07-01T00:00:00Z",
|
||||
updated_at: "2026-07-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Machine "Jiayuan's MacBook Pro": a machine-level rename stamped the same
|
||||
// custom_name on both runtimes (MUL-4217) — the exact shape that made the
|
||||
// old flat list unreadable.
|
||||
const RT_CLAUDE = makeRuntime({
|
||||
id: "rt-claude",
|
||||
daemon_id: "daemon-1",
|
||||
name: "Claude (mbp.local)",
|
||||
custom_name: "Jiayuan's MacBook Pro",
|
||||
provider: "claude",
|
||||
});
|
||||
const RT_CODEX = makeRuntime({
|
||||
id: "rt-codex",
|
||||
daemon_id: "daemon-1",
|
||||
name: "Codex (mbp.local)",
|
||||
custom_name: "Jiayuan's MacBook Pro",
|
||||
provider: "codex",
|
||||
});
|
||||
|
||||
// Another member's public machine.
|
||||
const RT_OTHER_PUBLIC = makeRuntime({
|
||||
id: "rt-other-claude",
|
||||
daemon_id: "daemon-2",
|
||||
name: "Claude (other.local)",
|
||||
owner_id: OTHER,
|
||||
visibility: "public",
|
||||
});
|
||||
|
||||
// Another member's private machine — visible in All but locked.
|
||||
const RT_OTHER_PRIVATE = makeRuntime({
|
||||
id: "rt-other-private",
|
||||
daemon_id: "daemon-3",
|
||||
name: "Gemini (secret.local)",
|
||||
provider: "gemini",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
|
||||
const ALL_RUNTIMES = [RT_CLAUDE, RT_CODEX, RT_OTHER_PUBLIC, RT_OTHER_PRIVATE];
|
||||
|
||||
function renderPicker(
|
||||
props: Partial<React.ComponentProps<typeof RuntimePicker>> = {},
|
||||
) {
|
||||
const onChange = vi.fn();
|
||||
const utils = render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<RuntimePicker
|
||||
variant="field"
|
||||
showLabel={false}
|
||||
value="rt-claude"
|
||||
runtimes={ALL_RUNTIMES}
|
||||
members={MEMBERS}
|
||||
currentUserId={ME}
|
||||
canEdit
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
return { ...utils, onChange };
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Runtime · / }));
|
||||
}
|
||||
|
||||
describe("RuntimePicker (agent settings)", () => {
|
||||
beforeEach(() => cleanup());
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it("labels the trigger with the runtime, not just the machine", () => {
|
||||
renderPicker();
|
||||
// Runtime identity first ("Claude"), machine second — previously the
|
||||
// trigger was just the machine name.
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: /Runtime · Claude · Jiayuan's MacBook Pro · online/,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens inside the selected runtime's machine with runtime-labelled rows", () => {
|
||||
renderPicker();
|
||||
openPicker();
|
||||
|
||||
// Level 2: rows are labelled by runtime, not by the machine rename.
|
||||
expect(screen.getByRole("button", { name: /^Claude/ })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /^Codex/ })).toBeTruthy();
|
||||
// Back affordance carries the machine context.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Back to machines" }),
|
||||
).toBeTruthy();
|
||||
// Other machines are not mixed into this machine's list.
|
||||
expect(screen.queryByText("other.local")).toBeNull();
|
||||
});
|
||||
|
||||
it("navigates back to the machine list and scopes it with Mine/All", () => {
|
||||
renderPicker();
|
||||
openPicker();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back to machines" }));
|
||||
|
||||
// Mine scope: only my machine, with its online count.
|
||||
expect(
|
||||
screen.getByRole("button", { name: /^Jiayuan's MacBook Pro/ }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("2/2 online")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /^other\.local/ })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "All" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: /^other\.local/ }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /^secret\.local/ }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("drills into another machine and selects a runtime on it", async () => {
|
||||
const { onChange } = renderPicker();
|
||||
openPicker();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back to machines" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "All" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^other\.local/ }));
|
||||
|
||||
const row = await screen.findByRole("button", { name: /^Claude/ });
|
||||
fireEvent.click(row);
|
||||
expect(onChange).toHaveBeenCalledWith("rt-other-claude");
|
||||
});
|
||||
|
||||
it("widens to the All scope when the selection belongs to someone else", () => {
|
||||
renderPicker({ value: "rt-other-claude" });
|
||||
openPicker();
|
||||
|
||||
// Lands inside the other member's machine even though the picker
|
||||
// defaults to the Mine scope.
|
||||
expect(screen.getByRole("button", { name: /^Claude/ })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back to machines" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: /^Jiayuan's MacBook Pro/ }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /^other\.local/ }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps other members' private runtimes locked", () => {
|
||||
const { onChange } = renderPicker();
|
||||
openPicker();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back to machines" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "All" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^secret\.local/ }));
|
||||
|
||||
const locked = screen.getByRole("button", { name: /^Gemini/ });
|
||||
expect((locked as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(locked);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the machine list when nothing is selected and several machines exist", () => {
|
||||
renderPicker({
|
||||
value: "",
|
||||
runtimes: [
|
||||
RT_CLAUDE,
|
||||
RT_CODEX,
|
||||
makeRuntime({
|
||||
id: "rt-laptop",
|
||||
daemon_id: "daemon-4",
|
||||
name: "Claude (laptop.local)",
|
||||
}),
|
||||
],
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Runtime · none/ }));
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /^Jiayuan's MacBook Pro/ }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /^laptop\.local/ }),
|
||||
).toBeTruthy();
|
||||
// Level 1 lists machines only — no runtime rows yet.
|
||||
expect(screen.queryByRole("button", { name: /^Codex/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("skips the pointless single-machine list when nothing is selected", () => {
|
||||
renderPicker({ value: "", runtimes: [RT_CLAUDE, RT_CODEX] });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Runtime · none/ }));
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Claude/ })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /^Codex/ })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronDown, Cloud, Lock, Monitor } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Lock,
|
||||
Monitor,
|
||||
} from "lucide-react";
|
||||
import type { AgentRuntime, MemberWithUser } from "@multica/core/types";
|
||||
import { runtimeDisplayName } from "@multica/core/runtimes";
|
||||
import { ActorAvatar } from "../../../common/actor-avatar";
|
||||
import {
|
||||
PickerItem,
|
||||
PropertyPicker,
|
||||
} from "../../../issues/components/pickers";
|
||||
import { ProviderLogo } from "../../../runtimes/components/provider-logo";
|
||||
import {
|
||||
buildRuntimeMachines,
|
||||
runtimeRowLabel,
|
||||
type RuntimeMachine,
|
||||
} from "../../../runtimes/components/runtime-machines";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { CHIP_CLASS } from "./chip";
|
||||
import { useT } from "../../../i18n";
|
||||
|
||||
type Filter = "mine" | "all";
|
||||
|
||||
// How many provider logos a machine row previews before collapsing to "+N".
|
||||
const MACHINE_PROVIDER_PREVIEW = 4;
|
||||
|
||||
/**
|
||||
* Inline runtime picker for the agent inspector. Mirrors the runtime selector
|
||||
* the previous Settings tab embedded — same Mine/All filter, same provider
|
||||
* logos, same online dot — but renders inside the inspector's PropRow so
|
||||
* users don't have to leave the page to switch runtime.
|
||||
* Two-level runtime picker for the agent settings form. A machine-level
|
||||
* rename stamps the same custom name on every runtime of a daemon
|
||||
* (MUL-4217), so the previous flat list rendered N indistinguishable
|
||||
* "Jiayuan's MacBook Pro" rows. Level 1 lists machines; drilling in lists
|
||||
* that machine's runtimes labelled by what actually differs — the runtime
|
||||
* itself. Opening lands inside the selected runtime's machine so the common
|
||||
* case (switching runtime on the same machine) costs no extra click.
|
||||
*/
|
||||
export function RuntimePicker({
|
||||
value,
|
||||
@@ -45,46 +62,70 @@ export function RuntimePicker({
|
||||
const { t } = useT("agents");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState<Filter>("mine");
|
||||
// Level 2 target. `null` shows the machine list; a machine id shows that
|
||||
// machine's runtimes. Falls back to the machine list at render time when
|
||||
// the id no longer resolves (e.g. the daemon was GC'd over WS).
|
||||
const [machineId, setMachineId] = useState<string | null>(null);
|
||||
|
||||
const selected = runtimes.find((r) => r.id === value) ?? null;
|
||||
const Icon = selected?.runtime_mode === "cloud" ? Cloud : Monitor;
|
||||
|
||||
// Compute filtered list unconditionally — the early `!canEdit` return
|
||||
// below would otherwise re-order this hook across renders.
|
||||
const isDisabled = (r: AgentRuntime): boolean => {
|
||||
if (!currentUserId) return false;
|
||||
if (r.owner_id === currentUserId) return false;
|
||||
return r.visibility !== "public";
|
||||
};
|
||||
const filtered = useMemo(() => {
|
||||
const list =
|
||||
|
||||
// Machine grouping over the unfiltered list — resolves the selected
|
||||
// runtime's machine for the trigger label regardless of the Mine/All
|
||||
// scope, and is reused as-is for the list whenever the scope is "all".
|
||||
const allMachines = useMemo(
|
||||
() => buildRuntimeMachines(runtimes, { now: Date.now(), currentUserId }),
|
||||
[runtimes, currentUserId],
|
||||
);
|
||||
const machines = useMemo(
|
||||
() =>
|
||||
filter === "mine" && currentUserId
|
||||
? runtimes.filter((r) => r.owner_id === currentUserId)
|
||||
: runtimes;
|
||||
return list.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 aDisabled = isDisabled(a);
|
||||
const bDisabled = isDisabled(b);
|
||||
if (!aDisabled && bDisabled) return -1;
|
||||
if (aDisabled && !bDisabled) return 1;
|
||||
return 0;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runtimes, filter, currentUserId]);
|
||||
? buildRuntimeMachines(
|
||||
runtimes.filter((r) => r.owner_id === currentUserId),
|
||||
{ now: Date.now(), currentUserId },
|
||||
)
|
||||
: allMachines,
|
||||
[runtimes, filter, currentUserId, allMachines],
|
||||
);
|
||||
|
||||
const machineOf = (machineList: RuntimeMachine[], runtimeId: string) =>
|
||||
machineList.find((m) => m.runtimes.some((r) => r.id === runtimeId)) ?? null;
|
||||
|
||||
const selectedMachine = selected ? machineOf(allMachines, selected.id) : null;
|
||||
const selectedLabel = selected
|
||||
? runtimeRowLabel(selected, selectedMachine?.title ?? "")
|
||||
: null;
|
||||
// Combined "Claude · Jiayuan's MacBook Pro" string for compact surfaces
|
||||
// (chip, read-only field, tooltips). The dedupe guard covers runtimes
|
||||
// whose whole label already is the machine title (single unnamed cloud
|
||||
// workers and the like).
|
||||
const combinedLabel = selected
|
||||
? selectedMachine && selectedMachine.title !== selectedLabel
|
||||
? `${selectedLabel} · ${selectedMachine.title}`
|
||||
: (selectedLabel ?? "")
|
||||
: t(($) => $.pickers.runtime_none);
|
||||
|
||||
const isOnline = selected?.status === "online";
|
||||
|
||||
if (!canEdit) {
|
||||
const isOnline = selected?.status === "online";
|
||||
const valueLabel = selected
|
||||
? runtimeDisplayName(selected)
|
||||
: t(($) => $.pickers.runtime_none);
|
||||
const icon = selected ? (
|
||||
<ProviderLogo provider={selected.provider} className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<Monitor
|
||||
className="h-4 w-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
if (variant === "field") {
|
||||
const control = (
|
||||
<div className="flex min-h-10 items-center gap-2 rounded-lg border border-input bg-input/50 px-3 text-sm text-muted-foreground">
|
||||
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate">{valueLabel}</span>
|
||||
{icon}
|
||||
<span className="min-w-0 flex-1 truncate">{combinedLabel}</span>
|
||||
{selected ? (
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
@@ -105,10 +146,15 @@ export function RuntimePicker({
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
<span className="min-w-0 truncate font-mono">
|
||||
{selected ? runtimeDisplayName(selected) : t(($) => $.pickers.runtime_none)}
|
||||
</span>
|
||||
{selected ? (
|
||||
<ProviderLogo
|
||||
provider={selected.provider}
|
||||
className="h-3 w-3 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<Monitor className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 truncate font-mono">{combinedLabel}</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={`ml-auto h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||
@@ -119,18 +165,10 @@ export function RuntimePicker({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// The chip shows only the runtime name. `runtime.name` already comes back
|
||||
// from the back-end pre-formatted as e.g. "Claude (host.local)", so we
|
||||
// deliberately do NOT append `device_info` to the tooltip — that string
|
||||
// also leads with the host and would just repeat what's already in name,
|
||||
// producing the "Claude (host) (host · 2.1.121 (Claude Code))" mess.
|
||||
const triggerLabel = selected
|
||||
? runtimeDisplayName(selected)
|
||||
: t(($) => $.pickers.runtime_none);
|
||||
const isOnline = selected?.status === "online";
|
||||
|
||||
const triggerTitle = selected
|
||||
? t(($) => $.pickers.runtime_tooltip, {
|
||||
name: runtimeDisplayName(selected),
|
||||
name: combinedLabel,
|
||||
status: isOnline ? t(($) => $.pickers.runtime_online) : t(($) => $.pickers.runtime_offline),
|
||||
})
|
||||
: t(($) => $.pickers.runtime_tooltip_none);
|
||||
@@ -140,15 +178,66 @@ export function RuntimePicker({
|
||||
const getOwner = (id: string | null) =>
|
||||
id ? members.find((m) => m.user_id === id) ?? null : null;
|
||||
|
||||
// The single owner shared by every runtime on a machine, or null when the
|
||||
// machine merges runtimes from several owners (possible for legacy rows
|
||||
// grouped by device name instead of daemon id).
|
||||
const machineOwner = (machine: RuntimeMachine): MemberWithUser | null => {
|
||||
const ownerIds = new Set(
|
||||
machine.runtimes.map((r) => r.owner_id).filter(Boolean),
|
||||
);
|
||||
if (ownerIds.size !== 1) return null;
|
||||
return getOwner(
|
||||
machine.runtimes.find((r) => r.owner_id)?.owner_id ?? null,
|
||||
);
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (next) {
|
||||
// Land where the selection lives. Widen to the All scope first when
|
||||
// the selected runtime isn't ours — the Mine list can never contain
|
||||
// its machine. With no selection, a single-machine list is pure
|
||||
// friction, so skip straight into it.
|
||||
const nextFilter: Filter =
|
||||
selected && currentUserId && selected.owner_id !== currentUserId
|
||||
? "all"
|
||||
: filter;
|
||||
setFilter(nextFilter);
|
||||
const visible =
|
||||
nextFilter === "mine" && currentUserId
|
||||
? buildRuntimeMachines(
|
||||
runtimes.filter((r) => r.owner_id === currentUserId),
|
||||
{ now: Date.now(), currentUserId },
|
||||
)
|
||||
: allMachines;
|
||||
const landing = selected
|
||||
? machineOf(visible, selected.id)
|
||||
: visible.length === 1
|
||||
? visible[0]
|
||||
: null;
|
||||
setMachineId(landing?.id ?? null);
|
||||
}
|
||||
setOpen(next);
|
||||
};
|
||||
|
||||
const select = async (id: string) => {
|
||||
setOpen(false);
|
||||
if (id !== value) await onChange(id);
|
||||
};
|
||||
|
||||
const drilled = machineId
|
||||
? machines.find((m) => m.id === machineId) ?? null
|
||||
: null;
|
||||
|
||||
const onlineCountLabel = (machine: RuntimeMachine) =>
|
||||
t(($) => $.create_dialog.runtime_group_online, {
|
||||
online: machine.onlineCount,
|
||||
total: machine.runtimes.length,
|
||||
});
|
||||
|
||||
const picker = (
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
width={
|
||||
variant === "field"
|
||||
? "w-[var(--anchor-width)] min-w-[18rem] max-w-md"
|
||||
@@ -169,23 +258,44 @@ export function RuntimePicker({
|
||||
}
|
||||
trigger={
|
||||
<>
|
||||
<Icon
|
||||
className={
|
||||
variant === "field"
|
||||
? "h-4 w-4 shrink-0 text-muted-foreground"
|
||||
: "h-3 w-3 shrink-0 text-muted-foreground"
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
variant === "field"
|
||||
? "min-w-0 flex-1 truncate"
|
||||
: "min-w-0 truncate font-mono"
|
||||
}
|
||||
>
|
||||
{triggerLabel}
|
||||
</span>
|
||||
{selected ? (
|
||||
<ProviderLogo
|
||||
provider={selected.provider}
|
||||
className={
|
||||
variant === "field" ? "h-4 w-4 shrink-0" : "h-3 w-3 shrink-0"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Monitor
|
||||
className={
|
||||
variant === "field"
|
||||
? "h-4 w-4 shrink-0 text-muted-foreground"
|
||||
: "h-3 w-3 shrink-0 text-muted-foreground"
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{variant === "field" && selected ? (
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{selectedLabel}
|
||||
{selectedMachine && selectedMachine.title !== selectedLabel && (
|
||||
<span className="text-muted-foreground">
|
||||
{" · "}
|
||||
{selectedMachine.title}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
variant === "field"
|
||||
? "min-w-0 flex-1 truncate"
|
||||
: "min-w-0 truncate font-mono"
|
||||
}
|
||||
>
|
||||
{combinedLabel}
|
||||
</span>
|
||||
)}
|
||||
{selected && (
|
||||
<span
|
||||
className={`ml-auto h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||
@@ -204,7 +314,22 @@ export function RuntimePicker({
|
||||
</>
|
||||
}
|
||||
header={
|
||||
hasOtherRuntimes ? (
|
||||
drilled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMachineId(null)}
|
||||
aria-label={t(($) => $.pickers.runtime_back_to_machines)}
|
||||
className="flex w-full items-center gap-2 px-2 py-2 text-left text-sm transition-colors hover:bg-muted/60"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{drilled.title}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{onlineCountLabel(drilled)}
|
||||
</span>
|
||||
</button>
|
||||
) : hasOtherRuntimes ? (
|
||||
<div className="p-2">
|
||||
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
|
||||
<FilterButton
|
||||
@@ -224,17 +349,14 @@ export function RuntimePicker({
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.pickers.runtime_empty)}
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((rt) => {
|
||||
{drilled ? (
|
||||
drilled.runtimes.map((rt) => {
|
||||
const owner = getOwner(rt.owner_id);
|
||||
const rtOnline = rt.status === "online";
|
||||
const locked = isDisabled(rt);
|
||||
const label = runtimeRowLabel(rt, drilled.title);
|
||||
const tooltip = [
|
||||
runtimeDisplayName(rt),
|
||||
label,
|
||||
owner ? t(($) => $.pickers.runtime_owned_by, { name: owner.name }) : null,
|
||||
rtOnline ? t(($) => $.pickers.runtime_online) : t(($) => $.pickers.runtime_offline),
|
||||
locked ? t(($) => $.create_dialog.runtime_private_locked_tooltip) : null,
|
||||
@@ -256,22 +378,59 @@ export function RuntimePicker({
|
||||
provider={rt.provider}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{label}
|
||||
</span>
|
||||
{rt.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1 text-[10px] font-medium text-info">
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
</span>
|
||||
)}
|
||||
{locked && (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
{t(($) => $.create_dialog.runtime_private_badge)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||
rtOnline ? "bg-success" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
aria-label={rtOnline ? t(($) => $.pickers.runtime_online) : t(($) => $.pickers.runtime_offline)}
|
||||
/>
|
||||
</PickerItem>
|
||||
);
|
||||
})
|
||||
) : machines.length === 0 ? (
|
||||
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.pickers.runtime_empty)}
|
||||
</p>
|
||||
) : (
|
||||
machines.map((machine) => {
|
||||
const owner = machineOwner(machine);
|
||||
const containsSelection = machine.runtimes.some(
|
||||
(r) => r.id === value,
|
||||
);
|
||||
const extraProviders =
|
||||
machine.providerNames.length - MACHINE_PROVIDER_PREVIEW;
|
||||
return (
|
||||
<button
|
||||
key={machine.id}
|
||||
type="button"
|
||||
data-picker-item
|
||||
onClick={() => setMachineId(machine.id)}
|
||||
className="flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{runtimeDisplayName(rt)}
|
||||
{machine.title}
|
||||
</span>
|
||||
{rt.runtime_mode === "cloud" && (
|
||||
{machine.mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1 text-[10px] font-medium text-info">
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
</span>
|
||||
)}
|
||||
{locked && (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
{t(($) => $.create_dialog.runtime_private_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{owner && (
|
||||
@@ -284,23 +443,37 @@ export function RuntimePicker({
|
||||
<span className="truncate">{owner.name}</span>
|
||||
</span>
|
||||
)}
|
||||
{owner && rt.device_info && (
|
||||
{owner && machine.providerNames.length > 0 && (
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
)}
|
||||
{rt.device_info && (
|
||||
<span className="truncate font-mono text-[10px]">
|
||||
{rt.device_info}
|
||||
{machine.providerNames.length > 0 && (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{machine.providerNames
|
||||
.slice(0, MACHINE_PROVIDER_PREVIEW)
|
||||
.map((provider) => (
|
||||
<ProviderLogo
|
||||
key={provider}
|
||||
provider={provider}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
))}
|
||||
{extraProviders > 0 && (
|
||||
<span className="text-[10px] tabular-nums">
|
||||
+{extraProviders}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||
rtOnline ? "bg-success" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
aria-label={rtOnline ? t(($) => $.pickers.runtime_online) : t(($) => $.pickers.runtime_offline)}
|
||||
/>
|
||||
</PickerItem>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{onlineCountLabel(machine)}
|
||||
</span>
|
||||
{containsSelection && (
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useT } from "../../i18n";
|
||||
import {
|
||||
buildRuntimeMachines,
|
||||
filterRuntimeMachines,
|
||||
splitRuntimeName,
|
||||
runtimeRowLabel,
|
||||
} from "../../runtimes/components/runtime-machines";
|
||||
|
||||
export type RuntimeFilter = "mine" | "all";
|
||||
@@ -310,17 +310,6 @@ export function RuntimePicker({
|
||||
);
|
||||
}
|
||||
|
||||
// The per-row label inside a machine group. When the machine already carries a
|
||||
// name (its own custom name or device name in the header), repeating it on
|
||||
// every row is noise — so a row shows its provider base (e.g. "Claude"). An
|
||||
// individual per-runtime rename that differs from the machine name is shown
|
||||
// verbatim so it stays visible.
|
||||
function runtimeRowLabel(runtime: RuntimeDevice, machineTitle: string): string {
|
||||
const custom = runtime.custom_name?.trim();
|
||||
if (custom && custom !== machineTitle) return custom;
|
||||
return splitRuntimeName(runtime.name).base;
|
||||
}
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
"runtime_offline": "offline",
|
||||
"runtime_owned_by": "owned by {{name}}",
|
||||
"runtime_empty": "No runtimes",
|
||||
"runtime_back_to_machines": "Back to machines",
|
||||
"model_default": "Default",
|
||||
"model_tooltip": "Model · {{value}}",
|
||||
"model_managed_by_runtime": "Managed by runtime",
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"runtime_offline": "オフライン",
|
||||
"runtime_owned_by": "{{name}} が所有",
|
||||
"runtime_empty": "ランタイムなし",
|
||||
"runtime_back_to_machines": "マシン一覧に戻る",
|
||||
"model_default": "デフォルト",
|
||||
"model_tooltip": "モデル · {{value}}",
|
||||
"model_managed_by_runtime": "ランタイムで管理",
|
||||
|
||||
@@ -196,6 +196,7 @@
|
||||
"runtime_offline": "오프라인",
|
||||
"runtime_owned_by": "{{name}} 소유",
|
||||
"runtime_empty": "런타임 없음",
|
||||
"runtime_back_to_machines": "기기 목록으로 돌아가기",
|
||||
"model_default": "기본값",
|
||||
"model_tooltip": "모델 · {{value}}",
|
||||
"model_managed_by_runtime": "런타임에서 관리",
|
||||
|
||||
@@ -196,6 +196,7 @@
|
||||
"runtime_offline": "离线",
|
||||
"runtime_owned_by": "{{name}} 的",
|
||||
"runtime_empty": "暂无运行时",
|
||||
"runtime_back_to_machines": "返回机器列表",
|
||||
"model_default": "默认",
|
||||
"model_tooltip": "模型 · {{value}}",
|
||||
"model_managed_by_runtime": "由运行时管理",
|
||||
|
||||
@@ -77,6 +77,20 @@ export function splitRuntimeName(name: string): {
|
||||
return { base: m[1], hostname: m[2] };
|
||||
}
|
||||
|
||||
// The label for a runtime rendered under (or next to) its machine's name.
|
||||
// A machine-level rename stamps the same custom_name on every runtime of
|
||||
// the daemon (MUL-4217), so repeating it per runtime is noise — fall back
|
||||
// to the provider base (e.g. "Claude"). A one-off per-runtime rename that
|
||||
// differs from the machine name stays visible verbatim.
|
||||
export function runtimeRowLabel(
|
||||
runtime: AgentRuntime,
|
||||
machineTitle: string,
|
||||
): string {
|
||||
const custom = runtime.custom_name?.trim();
|
||||
if (custom && custom !== machineTitle) return custom;
|
||||
return splitRuntimeName(runtime.name).base;
|
||||
}
|
||||
|
||||
export function buildRuntimeMachines(
|
||||
runtimes: AgentRuntime[],
|
||||
options: RuntimeMachineOptions,
|
||||
|
||||
Reference in New Issue
Block a user