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:
Lambda
2026-07-11 23:33:41 +08:00
parent 5541819f98
commit ecffafa167
8 changed files with 528 additions and 103 deletions

View File

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

View File

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

View File

@@ -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(

View File

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

View File

@@ -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": "ランタイムで管理",

View File

@@ -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": "런타임에서 관리",

View File

@@ -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": "由运行时管理",

View File

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