"use client"; import { useEffect, useMemo, useState } from "react"; import { ChevronDown, Cloud, Loader2, Lock, Search } from "lucide-react"; import { ProviderLogo } from "../../runtimes/components/provider-logo"; import { ActorAvatar } from "../../common/actor-avatar"; import { runtimeDisplayName } from "@multica/core/runtimes"; import type { MemberWithUser, RuntimeDevice } from "@multica/core/types"; import { Popover, PopoverTrigger, PopoverContent, } from "@multica/ui/components/ui/popover"; import { Label } from "@multica/ui/components/ui/label"; import { useT } from "../../i18n"; import { buildRuntimeMachines, filterRuntimeMachines, runtimeRowLabel, } from "../../runtimes/components/runtime-machines"; export type RuntimeFilter = "mine" | "all"; // Above this many runtimes the flat list becomes hard to scan, so we surface // a search box. Machine grouping kicks in independently whenever more than one // machine is present. const SEARCH_THRESHOLD = 6; export function RuntimePicker({ runtimes, runtimesLoading, members, currentUserId, selectedRuntimeId, onSelect, disabled = false, }: { runtimes: RuntimeDevice[]; runtimesLoading?: boolean; members: MemberWithUser[]; currentUserId: string | null; selectedRuntimeId: string; onSelect: (id: string) => void; /** Blocks opening the picker while the selection cannot be honoured yet * (e.g. a builder reply or a runtime rebind is in flight). */ disabled?: boolean; }) { const { t } = useT("agents"); const [open, setOpen] = useState(false); const [filter, setFilter] = useState("mine"); const [search, setSearch] = useState(""); const getOwnerMember = (ownerId: string | null) => { if (!ownerId) return null; return members.find((m) => m.user_id === ownerId) ?? null; }; const hasOtherRuntimes = runtimes.some((r) => r.owner_id !== currentUserId); // Base list honours the mine/all toggle and drives auto-selection; it is // intentionally independent of the search box so typing never changes the // seeded selection. const filteredRuntimes = useMemo( () => computeFilteredRuntimes(runtimes, filter, currentUserId), [runtimes, filter, currentUserId], ); // Group the (searched) base list by machine so 20+ runtimes read as a // handful of named machines, online-first, current machine first. const machines = useMemo(() => { const all = buildRuntimeMachines(filteredRuntimes, { now: Date.now(), currentUserId, }); return filterRuntimeMachines(all, search, "all"); }, [filteredRuntimes, search, currentUserId]); const showSearch = runtimes.length > SEARCH_THRESHOLD; const selectedRuntime = runtimes.find((d) => d.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 // 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), ); if (firstUsable) onSelect(firstUsable.id); }, [filteredRuntimes, 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). const handleFilterChange = (next: RuntimeFilter) => { if (next === filter) return; setFilter(next); const nextList = computeFilteredRuntimes(runtimes, next, currentUserId); const firstUsable = nextList.find((r) => isRuntimeUsableForUser(r, currentUserId), ); onSelect(firstUsable?.id ?? ""); }; return (
{hasOtherRuntimes && ( // These are not just a view filter: changing tab re-selects the first // usable runtime in the new list, so they are a second way to fire // onSelect and must honour `disabled` alongside the trigger.
)}
{ if (disabled) return; setOpen(next); if (!next) setSearch(""); }} > {runtimesLoading ? ( ) : selectedRuntime ? ( ) : ( )}
{runtimesLoading ? t(($) => $.create_dialog.runtime_loading) : selectedRuntime ? runtimeDisplayName(selectedRuntime) : t(($) => $.create_dialog.runtime_none)} {selectedRuntime?.runtime_mode === "cloud" && ( {t(($) => $.create_dialog.runtime_cloud_badge)} )}
{selectedRuntime && (
{getOwnerMember(selectedRuntime.owner_id)?.name ?? selectedRuntime.device_info}
)}
{showSearch && (
setSearch(e.target.value)} placeholder={t(($) => $.create_dialog.runtime_search_placeholder)} className="w-full rounded-md border border-border bg-background py-1.5 pl-8 pr-2 text-sm outline-none focus:ring-1 focus:ring-ring" />
)}
{machines.length === 0 ? (
{t(($) => $.create_dialog.runtime_no_results)}
) : ( machines.map((machine) => (
{/* Always show the machine header — even when a search or a single-machine workspace narrows it to one group — so the grouping stays consistent instead of collapsing to a flat list. */}
{machine.title} {t(($) => $.create_dialog.runtime_group_online, { online: machine.onlineCount, total: machine.runtimes.length, })}
{machine.runtimes.map((device) => { const ownerMember = getOwnerMember(device.owner_id); const disabled = !isRuntimeUsableForUser( device, currentUserId, ); const disabledTitle = disabled ? t(($) => $.create_dialog.runtime_private_locked_tooltip) : undefined; return ( ); })}
)) )}
); } // 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( r: RuntimeDevice, currentUserId: string | null, ): boolean { if (!currentUserId) return true; if (r.owner_id === currentUserId) return true; return r.visibility === "public"; } function computeFilteredRuntimes( runtimes: RuntimeDevice[], filter: RuntimeFilter, 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; }); }