diff --git a/packages/views/agents/components/create-agent-dialog.tsx b/packages/views/agents/components/create-agent-dialog.tsx index 62a9a33a91..137e46e773 100644 --- a/packages/views/agents/components/create-agent-dialog.tsx +++ b/packages/views/agents/components/create-agent-dialog.tsx @@ -1,20 +1,16 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { useState } from "react"; import { ArrowLeft, - ChevronDown, - Cloud, FileText, Globe, - Loader2, Lock, PenLine, } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; -import { ProviderLogo } from "../../runtimes/components/provider-logo"; -import { ActorAvatar } from "../../common/actor-avatar"; import { ModelDropdown } from "./model-dropdown"; +import { RuntimePicker, isRuntimeUsableForUser } from "./runtime-picker"; import { TemplatePicker } from "./template-picker"; import { TemplateDetail } from "./template-detail"; import { InstructionsEditor } from "./instructions-editor"; @@ -41,11 +37,6 @@ import { DialogTitle, DialogDescription, } from "@multica/ui/components/ui/dialog"; -import { - Popover, - PopoverTrigger, - PopoverContent, -} from "@multica/ui/components/ui/popover"; import { Button } from "@multica/ui/components/ui/button"; import { Input } from "@multica/ui/components/ui/input"; import { Label } from "@multica/ui/components/ui/label"; @@ -59,8 +50,6 @@ import { import { CharCounter } from "./char-counter"; import { useT } from "../../i18n"; -type RuntimeFilter = "mine" | "all"; - // State machine encoded as a discriminated union. // // chooser → "Start blank" or "From template" cards @@ -182,85 +171,30 @@ export function CreateAgentDialog({ ); const [creating, setCreating] = useState(false); const [failedURLs, setFailedURLs] = useState(null); - const [runtimeOpen, setRuntimeOpen] = useState(false); - const [runtimeFilter, setRuntimeFilter] = useState("mine"); - 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); - - // A runtime is disabled for the caller when it's owned by someone else - // AND its visibility is not "public". Older backends that haven't shipped - // MUL-2062 leave visibility undefined; we treat anything other than the - // literal string "public" as private so the strict default holds (the - // backend will reject the create anyway). - const isRuntimeDisabledForUser = (r: RuntimeDevice): boolean => { - if (!currentUserId) return false; - if (r.owner_id === currentUserId) return false; - return r.visibility !== "public"; - }; - - const filteredRuntimes = useMemo(() => { - const filtered = runtimeFilter === "mine" && currentUserId - ? runtimes.filter((r) => r.owner_id === currentUserId) - : runtimes; - return [...filtered].sort((a, b) => { - // Caller's own runtimes first; among the rest, usable (public) ones - // come before unusable (private) ones so the picker doesn't lead - // with greyed-out rows. - const aMine = a.owner_id === currentUserId; - const bMine = b.owner_id === currentUserId; - if (aMine && !bMine) return -1; - if (!aMine && bMine) return 1; - const aDisabled = isRuntimeDisabledForUser(a); - const bDisabled = isRuntimeDisabledForUser(b); - if (!aDisabled && bDisabled) return -1; - if (aDisabled && !bDisabled) return 1; - return 0; - }); - // currentUserId is the only external dep of isRuntimeDisabledForUser; - // listing it in the deps array is enough. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [runtimes, runtimeFilter, currentUserId]); - - // When duplicating, default to the template's runtime so the clone - // lands on the same machine — caller can still switch in the picker. - // But never seed with a runtime the caller can't actually use (locked - // by visibility); otherwise the dialog opens with a selected row the - // user can't submit, and Create falls through to a backend 403. Falling - // back to the first usable runtime is friendlier than the locked - // pre-fill. Computed inside the initializer so the find-walk only runs - // on first mount, not on every render. + // 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 + // 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 && !isRuntimeDisabledForUser(templateRuntime)) { + if (templateRuntime && isRuntimeUsableForUser(templateRuntime, currentUserId)) { return templateRuntime.id; } - return filteredRuntimes.find((r) => !isRuntimeDisabledForUser(r))?.id ?? ""; + return ""; }); - useEffect(() => { - if (!selectedRuntimeId) { - const firstUsable = filteredRuntimes.find( - (r) => !isRuntimeDisabledForUser(r), - ); - if (firstUsable) setSelectedRuntimeId(firstUsable.id); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filteredRuntimes, selectedRuntimeId]); - const selectedRuntime = runtimes.find((d) => d.id === selectedRuntimeId) ?? null; // Defense-in-depth: even if a locked runtime somehow ends up selected // (e.g. duplicate of an agent whose template runtime is now locked, and // the workspace has no usable fallback), gate Create on it so we don't // submit a request the backend will reject with 403. const selectedRuntimeLocked = - selectedRuntime != null && isRuntimeDisabledForUser(selectedRuntime); + selectedRuntime != null && + !isRuntimeUsableForUser(selectedRuntime, currentUserId); // Transition helpers. Each centralises the form-field initialisation // for its target step, so transitions can't leave stale state behind. @@ -300,6 +234,7 @@ export function CreateAgentDialog({ template_slug: tmpl.slug, name: candidate, runtime_id: selectedRuntime.id, + model: model.trim() || undefined, visibility: "workspace", }); if (wsId) { @@ -555,6 +490,16 @@ export function CreateAgentDialog({ onUse={quickCreateFromTemplate} creating={creating} failedURLs={failedURLs} + runtimes={runtimes} + runtimesLoading={runtimesLoading} + members={members} + currentUserId={currentUserId} + selectedRuntimeId={selectedRuntimeId} + onRuntimeSelect={setSelectedRuntimeId} + selectedRuntime={selectedRuntime} + model={model} + onModelChange={setModel} + useDisabled={!selectedRuntime || selectedRuntimeLocked} /> )} @@ -652,131 +597,14 @@ export function CreateAgentDialog({ -
-
- - {hasOtherRuntimes && ( -
- - -
- )} -
- - - {runtimesLoading ? ( - - ) : selectedRuntime ? ( - - ) : ( - - )} -
-
- - {runtimesLoading ? t(($) => $.create_dialog.runtime_loading) : (selectedRuntime?.name ?? t(($) => $.create_dialog.runtime_none))} - - {selectedRuntime?.runtime_mode === "cloud" && ( - - {t(($) => $.create_dialog.runtime_cloud_badge)} - - )} -
-
- {selectedRuntime - ? (getOwnerMember(selectedRuntime.owner_id)?.name ?? selectedRuntime.device_info) - : t(($) => $.create_dialog.runtime_register_first)} -
-
- -
- - {filteredRuntimes.map((device) => { - const ownerMember = getOwnerMember(device.owner_id); - const disabled = isRuntimeDisabledForUser(device); - const disabledTitle = disabled - ? t(($) => $.create_dialog.runtime_private_locked_tooltip) - : undefined; - return ( - - ); - })} - -
-
+ - +
+
+ +
@@ -113,8 +115,8 @@ export function ModelDropdown({ } return ( -
-
+
+
{modelsQuery.isError && ( {t(($) => $.model_dropdown.discovery_failed)} @@ -127,8 +129,11 @@ export function ModelDropdown({ >
-
- {triggerLabel} + {/* Wrapped in flex to mirror RuntimePicker's trigger DOM. The + two pickers sit side-by-side; inline-in-flex vs block-line- + box height calc would otherwise leave them ~1px misaligned. */} +
+ {triggerLabel}
{value && (
diff --git a/packages/views/agents/components/runtime-picker.tsx b/packages/views/agents/components/runtime-picker.tsx new file mode 100644 index 0000000000..3ca4e7adc7 --- /dev/null +++ b/packages/views/agents/components/runtime-picker.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { ChevronDown, Cloud, Loader2, Lock } from "lucide-react"; +import { ProviderLogo } from "../../runtimes/components/provider-logo"; +import { ActorAvatar } from "../../common/actor-avatar"; +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"; + +export type RuntimeFilter = "mine" | "all"; + +// Pulled out of create-agent-dialog so the template-detail step can reuse the +// same picker without duplicating the ~90-line popover. Internal UI state +// (popover open, filter toggle) lives here; selection lives in the parent +// because both the form and the template-create call read selectedRuntime. +export function RuntimePicker({ + runtimes, + runtimesLoading, + members, + currentUserId, + selectedRuntimeId, + onSelect, +}: { + runtimes: RuntimeDevice[]; + runtimesLoading?: boolean; + members: MemberWithUser[]; + currentUserId: string | null; + selectedRuntimeId: string; + onSelect: (id: string) => void; +}) { + const { t } = useT("agents"); + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState("mine"); + + 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); + + const filteredRuntimes = useMemo( + () => computeFilteredRuntimes(runtimes, filter, currentUserId), + [runtimes, filter, currentUserId], + ); + + 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 && ( +
+ + +
+ )} +
+ + + {runtimesLoading ? ( + + ) : selectedRuntime ? ( + + ) : ( + + )} +
+
+ + {runtimesLoading + ? t(($) => $.create_dialog.runtime_loading) + : (selectedRuntime?.name ?? + t(($) => $.create_dialog.runtime_none))} + + {selectedRuntime?.runtime_mode === "cloud" && ( + + {t(($) => $.create_dialog.runtime_cloud_badge)} + + )} +
+ {selectedRuntime && ( +
+ {getOwnerMember(selectedRuntime.owner_id)?.name ?? + selectedRuntime.device_info} +
+ )} +
+ +
+ + {filteredRuntimes.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].sort((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; + }); +} diff --git a/packages/views/agents/components/template-detail.tsx b/packages/views/agents/components/template-detail.tsx index ba9bad8055..dc64287fca 100644 --- a/packages/views/agents/components/template-detail.tsx +++ b/packages/views/agents/components/template-detail.tsx @@ -3,11 +3,17 @@ import { Check, ChevronRight, Loader2 } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { agentTemplateDetailOptions } from "@multica/core/agents/queries"; -import type { AgentTemplateSummary } from "@multica/core/types"; +import type { + AgentTemplateSummary, + MemberWithUser, + RuntimeDevice, +} from "@multica/core/types"; import { Button } from "@multica/ui/components/ui/button"; import { cn } from "@multica/ui/lib/utils"; import { useT } from "../../i18n"; import { getAccentClass, getTemplateIcon } from "./template-picker"; +import { ModelDropdown } from "./model-dropdown"; +import { RuntimePicker } from "./runtime-picker"; interface TemplateDetailProps { template: AgentTemplateSummary; @@ -23,6 +29,19 @@ interface TemplateDetailProps { * this banner can render — `quickCreateFromTemplate` fires from here * and never advances to a different step on failure. */ failedURLs?: readonly string[] | null; + // Runtime/model state lives in the parent so `quickCreateFromTemplate` + // can read the latest selection without lifting child state up at click + // time. + runtimes: RuntimeDevice[]; + runtimesLoading?: boolean; + members: MemberWithUser[]; + currentUserId: string | null; + selectedRuntimeId: string; + onRuntimeSelect: (id: string) => void; + selectedRuntime: RuntimeDevice | null; + model: string; + onModelChange: (model: string) => void; + useDisabled?: boolean; } /** @@ -42,6 +61,16 @@ export function TemplateDetail({ onUse, creating = false, failedURLs, + runtimes, + runtimesLoading, + members, + currentUserId, + selectedRuntimeId, + onRuntimeSelect, + selectedRuntime, + model, + onModelChange, + useDisabled, }: TemplateDetailProps) { const { t } = useT("agents"); const { data: detail, isLoading, error } = useQuery( @@ -93,6 +122,29 @@ export function TemplateDetail({
+ {/* Runtime + Model — picked here so the user knows where the + new agent will run and which model it uses. Defaults seeded + by the parent (first usable runtime, empty model = runtime + default); user can override before clicking Use. Two columns + with `items-stretch` keep both pickers visually balanced. */} +
+ + +
+ {/* Skill list — always visible (summary has cached descriptions) */}

@@ -152,7 +204,7 @@ export function TemplateDetail({