From ecffafa1679ef0f15420d988fc27474c5f6d58e4 Mon Sep 17 00:00:00 2001 From: Lambda Date: Sat, 11 Jul 2026 23:33:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(agents):=20two-level=20runtime=20picker=20?= =?UTF-8?q?on=20agent=20settings=20=E2=80=94=20machine,=20then=20runtime?= =?UTF-8?q?=20(MUL-4421)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 · ' instead of the bare machine name. Co-authored-by: multica-agent --- .../inspector/runtime-picker.test.tsx | 245 ++++++++++++ .../components/inspector/runtime-picker.tsx | 355 +++++++++++++----- .../agents/components/runtime-picker.tsx | 13 +- packages/views/locales/en/agents.json | 1 + packages/views/locales/ja/agents.json | 1 + packages/views/locales/ko/agents.json | 1 + packages/views/locales/zh-Hans/agents.json | 1 + .../runtimes/components/runtime-machines.ts | 14 + 8 files changed, 528 insertions(+), 103 deletions(-) create mode 100644 packages/views/agents/components/inspector/runtime-picker.test.tsx diff --git a/packages/views/agents/components/inspector/runtime-picker.test.tsx b/packages/views/agents/components/inspector/runtime-picker.test.tsx new file mode 100644 index 0000000000..db4f4bdc5a --- /dev/null +++ b/packages/views/agents/components/inspector/runtime-picker.test.tsx @@ -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 { + 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> = {}, +) { + const onChange = vi.fn(); + const utils = render( + + + , + ); + 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(); + }); +}); diff --git a/packages/views/agents/components/inspector/runtime-picker.tsx b/packages/views/agents/components/inspector/runtime-picker.tsx index 5dd2f2f3db..e55c26ecd7 100644 --- a/packages/views/agents/components/inspector/runtime-picker.tsx +++ b/packages/views/agents/components/inspector/runtime-picker.tsx @@ -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("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(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 ? ( + + ) : ( +