diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index f3cef21a27..dfe813e70b 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -707,7 +707,9 @@ export function AppShell() { onRemoveWorkspace={workspacesHook.removeWorkspace} onSwitchWorkspace={workspacesHook.switchWorkspace} onCreateAgent={() => - void goAgents().then(requestOpenCreateAgent) + void goAgents().then(() => { + requestOpenCreateAgent(); + }) } selfPresenceStatus={presenceSession.currentStatus} workspaces={workspacesHook.workspaces} diff --git a/desktop/src/app/routes/agents.tsx b/desktop/src/app/routes/agents.tsx index b32dc5a996..6300a7a8fc 100644 --- a/desktop/src/app/routes/agents.tsx +++ b/desktop/src/app/routes/agents.tsx @@ -11,7 +11,6 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type AgentsRouteSearch = { profile?: string; - profilePersona?: string; profileTab?: ProfilePanelTab; profileView?: ProfilePanelView; }; @@ -25,7 +24,6 @@ function validateAgentsSearch( ): AgentsRouteSearch { return { profile: nonEmptyString(search.profile), - profilePersona: nonEmptyString(search.profilePersona), profileTab: parseProfilePanelTab(search.profileTab) ?? undefined, profileView: parseProfilePanelView(search.profileView) ?? undefined, }; diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 567c5b5767..a452e1ed30 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -30,7 +30,11 @@ import { setPersonaActive, updatePersona, } from "@/shared/api/tauriPersonas"; -import { setManagedAgentStartOnAppLaunch } from "@/shared/api/tauriManagedAgents"; +import { + exportAgentToJson, + listAgentTemplates, + setManagedAgentStartOnAppLaunch, +} from "@/shared/api/tauriManagedAgents"; import { createTeam, deleteTeam, @@ -75,6 +79,7 @@ export const managedAgentsQueryKey = ["managed-agents"] as const; export const personasQueryKey = ["personas"] as const; export const teamsQueryKey = ["teams"] as const; export const acpRuntimesQueryKey = ["acp-runtimes"] as const; +export const agentTemplatesQueryKey = ["agent-templates"] as const; export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const; export const backendProvidersQueryKey = ["backend-providers"] as const; @@ -175,6 +180,22 @@ export function usePersonasQuery() { }); } +export function useAgentTemplatesQuery(options?: { enabled?: boolean }) { + return useQuery({ + enabled: options?.enabled ?? true, + queryKey: agentTemplatesQueryKey, + queryFn: listAgentTemplates, + // Static built-in data — never refetch. + staleTime: Number.POSITIVE_INFINITY, + }); +} + +export function useExportAgentJsonMutation() { + return useMutation({ + mutationFn: (pubkey: string) => exportAgentToJson(pubkey), + }); +} + export function useManagedAgentPrereqsQuery( acpCommand: string, mcpCommand: string, diff --git a/desktop/src/features/agents/lib/catalog.test.mjs b/desktop/src/features/agents/lib/catalog.test.mjs deleted file mode 100644 index 7fa72f4f3e..0000000000 --- a/desktop/src/features/agents/lib/catalog.test.mjs +++ /dev/null @@ -1,159 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - getCatalogPersonas, - getCatalogSelectionState, - getLibraryPersonas, - getPersonaLabelsById, - getPersonaLibraryState, - isCatalogPersonaSelected, -} from "./catalog.ts"; - -function createPersona(id, displayName, overrides = {}) { - return { - id, - displayName, - avatarUrl: overrides.avatarUrl ?? null, - systemPrompt: overrides.systemPrompt ?? `${displayName} prompt`, - runtime: overrides.runtime ?? null, - model: overrides.model ?? null, - isBuiltIn: overrides.isBuiltIn ?? false, - isActive: overrides.isActive ?? true, - createdAt: overrides.createdAt ?? "2026-01-01T00:00:00Z", - updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00Z", - }; -} - -test("getCatalogPersonas keeps built-ins visible whether selected or not", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }), - createPersona("custom:builder", "Builder"), - ]; - - assert.deepEqual( - getCatalogPersonas(personas).map((persona) => persona.id), - ["builtin:fizz"], - ); -}); - -test("getCatalogSelectionState keeps built-in selection rules in one place", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("custom:builder", "Builder"), - ]; - - const state = getCatalogSelectionState(personas); - - assert.deepEqual( - state.catalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], - ); - assert.deepEqual( - state.selectedCatalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], - ); - assert.deepEqual( - state.unselectedCatalogPersonas.map((persona) => persona.id), - [], - ); -}); - -test("getCatalogPersonas keeps chooser order stable when selection changes", () => { - const inactive = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }), - createPersona("builtin:reviewer", "Reviewer", { - isBuiltIn: true, - isActive: true, - }), - ]; - const active = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("builtin:reviewer", "Reviewer", { - isBuiltIn: true, - isActive: false, - }), - ]; - - assert.deepEqual( - getCatalogPersonas(inactive).map((persona) => persona.id), - getCatalogPersonas(active).map((persona) => persona.id), - ); -}); - -test("isCatalogPersonaSelected treats active catalog personas as selected", () => { - assert.equal( - isCatalogPersonaSelected( - createPersona("builtin:fizz", "Fizz", { - isBuiltIn: true, - isActive: true, - }), - ), - true, - ); - assert.equal( - isCatalogPersonaSelected( - createPersona("builtin:fizz", "Fizz", { - isBuiltIn: true, - isActive: false, - }), - ), - false, - ); - assert.equal( - isCatalogPersonaSelected(createPersona("custom:builder", "Builder")), - true, - ); -}); - -test("getPersonaLabelsById keeps every returned persona addressable", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }), - createPersona("custom:builder", "Builder"), - ]; - - assert.deepEqual(getPersonaLabelsById(personas), { - "builtin:fizz": "Fizz", - "custom:builder": "Builder", - }); -}); - -test("getPersonaLibraryState keeps the working library and full catalog in one place", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("custom:builder", "Builder"), - ]; - - const state = getPersonaLibraryState(personas); - - assert.deepEqual( - state.libraryPersonas.map((persona) => persona.id), - ["builtin:fizz", "custom:builder"], - ); - assert.deepEqual( - state.catalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], - ); - assert.equal(state.personaLabelsById["builtin:fizz"], "Fizz"); -}); - -test("getLibraryPersonas keeps active custom personas even when catalog entries are similar", () => { - const avatarUrl = "https://example.test/coordinator.png"; - const personas = [ - createPersona("builtin:work-coordinator", "Work Coordinator", { - avatarUrl, - isBuiltIn: true, - isActive: false, - }), - createPersona("custom:work-coordinator", "Work Coordinator", { - avatarUrl, - isActive: true, - }), - createPersona("custom:builder", "Builder", { isActive: true }), - ]; - - assert.deepEqual( - getLibraryPersonas(personas).map((persona) => persona.id), - ["custom:work-coordinator", "custom:builder"], - ); -}); diff --git a/desktop/src/features/agents/lib/catalog.ts b/desktop/src/features/agents/lib/catalog.ts deleted file mode 100644 index 226aafca5e..0000000000 --- a/desktop/src/features/agents/lib/catalog.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { AgentPersona } from "@/shared/api/types"; - -export type CatalogSelectionState = { - catalogPersonas: AgentPersona[]; - selectedCatalogPersonas: AgentPersona[]; - unselectedCatalogPersonas: AgentPersona[]; -}; - -export type PersonaLibraryState = { - catalogPersonas: AgentPersona[]; - libraryPersonas: AgentPersona[]; - personaLabelsById: Record; -}; - -export function isPersonaActive(persona: AgentPersona) { - return persona.isActive; -} - -export function getActivePersonas(personas: readonly AgentPersona[]) { - return personas.filter(isPersonaActive); -} - -export function getLibraryPersonas(personas: readonly AgentPersona[]) { - return getActivePersonas(personas); -} - -export function isPersonaVisibleInCatalog( - persona: AgentPersona, - sharedCatalogPersonaIds: ReadonlySet = new Set(), -) { - return persona.isBuiltIn || sharedCatalogPersonaIds.has(persona.id); -} - -export function getCatalogPersonas( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -) { - return personas - .filter((persona) => - isPersonaVisibleInCatalog(persona, sharedCatalogPersonaIds), - ) - .sort((left, right) => left.displayName.localeCompare(right.displayName)); -} - -export function isCatalogPersonaSelected(persona: AgentPersona) { - return persona.isActive; -} - -export function getCatalogSelectionState( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -): CatalogSelectionState { - const catalogPersonas = getCatalogPersonas(personas, sharedCatalogPersonaIds); - - return { - catalogPersonas, - selectedCatalogPersonas: catalogPersonas.filter(isCatalogPersonaSelected), - unselectedCatalogPersonas: catalogPersonas.filter( - (persona) => !isCatalogPersonaSelected(persona), - ), - }; -} - -export function getPersonaLabelsById(personas: readonly AgentPersona[]) { - return Object.fromEntries( - personas.map((persona) => [persona.id, persona.displayName]), - ); -} - -export function getPersonaLibraryState( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -): PersonaLibraryState { - const libraryPersonas = getLibraryPersonas(personas); - const { catalogPersonas } = getCatalogSelectionState( - personas, - sharedCatalogPersonaIds, - ); - - return { - catalogPersonas, - libraryPersonas, - personaLabelsById: getPersonaLabelsById(personas), - }; -} diff --git a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts index be0e936898..f59f963abb 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts @@ -73,7 +73,7 @@ export function resolvePersonaRuntime( return { runtime: defaultRuntime, warnings: [ - `Persona is configured for runtime "${personaRuntimeId}" but it is not available. Using ${defaultRuntime.label} instead.`, + `This agent is configured for runtime "${personaRuntimeId}" but it is not available. Using ${defaultRuntime.label} instead.`, ], isOverridden: true, }; @@ -82,7 +82,7 @@ export function resolvePersonaRuntime( return { runtime: null, warnings: [ - `Persona is configured for runtime "${personaRuntimeId}" but it is not available, and no other runtimes were found.`, + `This agent is configured for runtime "${personaRuntimeId}" but it is not available, and no other runtimes were found.`, ], isOverridden: false, }; diff --git a/desktop/src/features/agents/lib/teamMembers.ts b/desktop/src/features/agents/lib/teamMembers.ts new file mode 100644 index 0000000000..05288980b8 --- /dev/null +++ b/desktop/src/features/agents/lib/teamMembers.ts @@ -0,0 +1,149 @@ +import type { AgentPersona, AgentTeam, ManagedAgent } from "@/shared/api/types"; + +/** + * Unified team-member model. A team member is either a managed agent + * (referenced by pubkey — the primary membership for user teams) or a pack + * persona (referenced by persona id — pack-installed teams only). + */ +export type TeamMemberKind = "agent" | "persona"; + +export type TeamCardMember = { + key: string; + kind: TeamMemberKind; + /** Agent pubkey or persona id. */ + id: string; + displayName: string; + avatarUrl: string | null; + model: string | null; +}; + +export function agentMemberKey(pubkey: string) { + return `agent:${pubkey}`; +} + +export function personaMemberKey(personaId: string) { + return `persona:${personaId}`; +} + +export function memberFromAgent(agent: ManagedAgent): TeamCardMember { + return { + key: agentMemberKey(agent.pubkey), + kind: "agent", + id: agent.pubkey, + displayName: agent.name, + avatarUrl: agent.avatarUrl, + model: agent.model, + }; +} + +export function memberFromPersona(persona: AgentPersona): TeamCardMember { + return { + key: personaMemberKey(persona.id), + kind: "persona", + id: persona.id, + displayName: persona.displayName, + avatarUrl: persona.avatarUrl, + model: persona.model, + }; +} + +/** Split a selected member-key list back into the wire fields. */ +export function splitMemberKeys(keys: readonly string[]): { + personaIds: string[]; + agentPubkeys: string[]; +} { + const personaIds: string[] = []; + const agentPubkeys: string[] = []; + for (const key of keys) { + if (key.startsWith("agent:")) { + agentPubkeys.push(key.slice("agent:".length)); + } else if (key.startsWith("persona:")) { + personaIds.push(key.slice("persona:".length)); + } + } + return { personaIds, agentPubkeys }; +} + +export function teamMemberKeys( + team: Pick, +): string[] { + return [ + ...team.personaIds.map(personaMemberKey), + ...team.agentPubkeys.map(agentMemberKey), + ]; +} + +export type ResolvedTeamMembers = { + hasMissingMembers: boolean; + isComplete: boolean; + isUsable: boolean; + memberCount: number; + missingMemberCount: number; + resolvedMembers: TeamCardMember[]; + resolvedPersonas: AgentPersona[]; + resolvedAgents: ManagedAgent[]; +}; + +/** + * Resolve a team's membership against the local persona and agent stores. + * Missing members (an agent that only exists on another device, or a persona + * that was removed) are counted but not resolved. + */ +export function resolveTeamMembers( + team: Pick, + personas: AgentPersona[], + agents: ManagedAgent[], +): ResolvedTeamMembers { + const personasById = new Map( + personas.map((persona) => [persona.id, persona]), + ); + const agentsByPubkey = new Map(agents.map((agent) => [agent.pubkey, agent])); + + const resolvedMembers: TeamCardMember[] = []; + const resolvedPersonas: AgentPersona[] = []; + const resolvedAgents: ManagedAgent[] = []; + let missingMemberCount = 0; + + for (const personaId of team.personaIds) { + const persona = personasById.get(personaId); + if (persona) { + resolvedPersonas.push(persona); + resolvedMembers.push(memberFromPersona(persona)); + } else { + missingMemberCount += 1; + } + } + + for (const pubkey of team.agentPubkeys) { + const agent = agentsByPubkey.get(pubkey); + if (agent) { + resolvedAgents.push(agent); + resolvedMembers.push(memberFromAgent(agent)); + } else { + missingMemberCount += 1; + } + } + + const memberCount = team.personaIds.length + team.agentPubkeys.length; + + return { + hasMissingMembers: missingMemberCount > 0, + isComplete: missingMemberCount === 0, + isUsable: missingMemberCount === 0 && resolvedMembers.length > 0, + memberCount, + missingMemberCount, + resolvedMembers, + resolvedPersonas, + resolvedAgents, + }; +} + +export function getUsableTeams( + teams: readonly AgentTeam[], + personas: AgentPersona[], + agents: ManagedAgent[], +) { + return teams.filter( + (team) => resolveTeamMembers(team, personas, agents).isUsable, + ); +} diff --git a/desktop/src/features/agents/lib/teamPersonas.test.mjs b/desktop/src/features/agents/lib/teamPersonas.test.mjs deleted file mode 100644 index 34d2246e98..0000000000 --- a/desktop/src/features/agents/lib/teamPersonas.test.mjs +++ /dev/null @@ -1,94 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - emptyResolvedTeamPersonas, - getUsableTeams, - resolveTeamPersonas, -} from "./teamPersonas.ts"; - -function createPersona(id, displayName) { - return { - id, - displayName, - avatarUrl: null, - systemPrompt: `${displayName} prompt`, - runtime: null, - model: null, - isBuiltIn: false, - isActive: true, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - }; -} - -function createTeam(id, personaIds) { - return { - id, - name: `Team ${id}`, - description: null, - personaIds, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - }; -} - -test("emptyResolvedTeamPersonas starts empty but complete", () => { - assert.deepEqual(emptyResolvedTeamPersonas(), { - hasMissingPersonas: false, - isComplete: true, - isUsable: false, - missingPersonaCount: 0, - missingPersonaIds: [], - resolvedPersonaIds: [], - resolvedPersonas: [], - }); -}); - -test("resolveTeamPersonas preserves team order and marks complete teams usable", () => { - const personas = [ - createPersona("persona-1", "Solo"), - createPersona("persona-2", "Kit"), - ]; - const resolution = resolveTeamPersonas( - createTeam("team-1", ["persona-2", "persona-1"]), - personas, - ); - - assert.equal(resolution.isComplete, true); - assert.equal(resolution.isUsable, true); - assert.deepEqual(resolution.resolvedPersonaIds, ["persona-2", "persona-1"]); - assert.deepEqual( - resolution.resolvedPersonas.map((persona) => persona.displayName), - ["Kit", "Solo"], - ); -}); - -test("resolveTeamPersonas surfaces missing persona ids without dropping the resolved ones", () => { - const personas = [createPersona("persona-1", "Solo")]; - const resolution = resolveTeamPersonas( - createTeam("team-2", ["persona-1", "missing"]), - personas, - ); - - assert.equal(resolution.hasMissingPersonas, true); - assert.equal(resolution.isComplete, false); - assert.equal(resolution.isUsable, false); - assert.equal(resolution.missingPersonaCount, 1); - assert.deepEqual(resolution.missingPersonaIds, ["missing"]); - assert.deepEqual(resolution.resolvedPersonaIds, ["persona-1"]); -}); - -test("getUsableTeams keeps only fully-resolved teams with at least one persona", () => { - const personas = [createPersona("persona-1", "Solo")]; - const teams = [ - createTeam("team-empty", []), - createTeam("team-missing", ["missing"]), - createTeam("team-ready", ["persona-1"]), - ]; - - assert.deepEqual( - getUsableTeams(teams, personas).map((team) => team.id), - ["team-ready"], - ); -}); diff --git a/desktop/src/features/agents/lib/teamPersonas.ts b/desktop/src/features/agents/lib/teamPersonas.ts deleted file mode 100644 index fc08790e36..0000000000 --- a/desktop/src/features/agents/lib/teamPersonas.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { AgentPersona, AgentTeam } from "@/shared/api/types"; - -export type ResolvedTeamPersonas = { - hasMissingPersonas: boolean; - isComplete: boolean; - isUsable: boolean; - missingPersonaCount: number; - missingPersonaIds: string[]; - resolvedPersonaIds: string[]; - resolvedPersonas: AgentPersona[]; -}; - -export function emptyResolvedTeamPersonas(): ResolvedTeamPersonas { - return { - hasMissingPersonas: false, - isComplete: true, - isUsable: false, - missingPersonaCount: 0, - missingPersonaIds: [], - resolvedPersonaIds: [], - resolvedPersonas: [], - }; -} - -export function isResolvedTeamUsable( - resolution: Pick, -) { - return resolution.isComplete && resolution.resolvedPersonaIds.length > 0; -} - -export function getUsableTeams( - teams: readonly AgentTeam[], - personas: AgentPersona[], -) { - return teams.filter((team) => - isResolvedTeamUsable(resolveTeamPersonas(team, personas)), - ); -} - -export function resolveTeamPersonas( - team: Pick, - personas: AgentPersona[], -): ResolvedTeamPersonas { - const personasById = new Map( - personas.map((persona) => [persona.id, persona]), - ); - const resolvedPersonas: AgentPersona[] = []; - const resolvedPersonaIds: string[] = []; - const missingPersonaIds: string[] = []; - - for (const personaId of team.personaIds) { - const persona = personasById.get(personaId); - - if (persona) { - resolvedPersonas.push(persona); - resolvedPersonaIds.push(persona.id); - continue; - } - - missingPersonaIds.push(personaId); - } - - const missingPersonaCount = missingPersonaIds.length; - - return { - hasMissingPersonas: missingPersonaCount > 0, - isComplete: missingPersonaCount === 0, - isUsable: missingPersonaCount === 0 && resolvedPersonaIds.length > 0, - missingPersonaCount, - missingPersonaIds, - resolvedPersonaIds, - resolvedPersonas, - }; -} diff --git a/desktop/src/features/agents/lib/useBotRecents.test.mjs b/desktop/src/features/agents/lib/useBotRecents.test.mjs deleted file mode 100644 index 11d2e9fb4c..0000000000 --- a/desktop/src/features/agents/lib/useBotRecents.test.mjs +++ /dev/null @@ -1,63 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { pickQuickBotPersonas } from "./useBotRecents.ts"; - -function createPersona(id, displayName) { - return { - id, - displayName, - avatarUrl: null, - systemPrompt: `${displayName} prompt`, - runtime: null, - model: null, - isBuiltIn: true, - isActive: true, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - }; -} - -test("pickQuickBotPersonas prefers recents before defaults", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz"), - createPersona("builtin:reviewer", "Reviewer"), - ]; - - assert.deepEqual( - pickQuickBotPersonas(personas, ["builtin:reviewer"]).map( - (persona) => persona.id, - ), - ["builtin:reviewer", "builtin:fizz"], - ); -}); - -test("pickQuickBotPersonas falls back to any active personas when defaults are missing", () => { - const personas = [ - createPersona("builtin:reviewer", "Reviewer"), - createPersona("custom:planner", "Planner"), - createPersona("custom:writer", "Writer"), - ]; - - assert.deepEqual( - pickQuickBotPersonas(personas, []).map((persona) => persona.id), - ["builtin:reviewer", "custom:planner", "custom:writer"], - ); -}); - -test("pickQuickBotPersonas skips duplicate and missing recents", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz"), - createPersona("custom:honey", "Honey"), - ]; - - assert.deepEqual( - pickQuickBotPersonas(personas, [ - "builtin:fizz", - "missing", - "builtin:fizz", - "custom:honey", - ]).map((persona) => persona.id), - ["builtin:fizz", "custom:honey"], - ); -}); diff --git a/desktop/src/features/agents/lib/useBotRecents.ts b/desktop/src/features/agents/lib/useBotRecents.ts deleted file mode 100644 index cd5e1693de..0000000000 --- a/desktop/src/features/agents/lib/useBotRecents.ts +++ /dev/null @@ -1,91 +0,0 @@ -import * as React from "react"; - -import type { AgentPersona } from "@/shared/api/types"; - -const STORAGE_KEY = "buzz:bot-recents"; -const MAX_RECENTS = 8; - -// Default persona display names to seed the list when empty. -// These are resolved to IDs by the consumer. -export const DEFAULT_PERSONA_NAMES = ["Fizz"] as const; - -export function pickQuickBotPersonas( - personas: readonly AgentPersona[], - recentIds: readonly string[], - maxCount = 3, -) { - if (personas.length === 0) { - return []; - } - - const resolved: AgentPersona[] = []; - - const addPersona = (persona: AgentPersona | undefined) => { - if (!persona || resolved.some((candidate) => candidate.id === persona.id)) { - return; - } - - resolved.push(persona); - }; - - for (const id of recentIds) { - if (resolved.length >= maxCount) { - break; - } - - addPersona(personas.find((persona) => persona.id === id)); - } - - for (const name of DEFAULT_PERSONA_NAMES) { - if (resolved.length >= maxCount) { - break; - } - - addPersona( - personas.find( - (persona) => persona.displayName.toLowerCase() === name.toLowerCase(), - ), - ); - } - - for (const persona of personas) { - if (resolved.length >= maxCount) { - break; - } - - addPersona(persona); - } - - return resolved; -} - -export function useBotRecents(): { - recentIds: string[]; - pushRecent: (personaId: string) => void; -} { - const [recentIds, setRecentIds] = React.useState(() => { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? (JSON.parse(raw) as string[]) : []; - } catch { - return []; - } - }); - - const pushRecent = React.useCallback((personaId: string) => { - setRecentIds((prev) => { - const next = [personaId, ...prev.filter((id) => id !== personaId)].slice( - 0, - MAX_RECENTS, - ); - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); - } catch { - // localStorage full — ignore - } - return next; - }); - }, []); - - return { recentIds, pushRecent }; -} diff --git a/desktop/src/features/agents/openCreateAgentEvent.ts b/desktop/src/features/agents/openCreateAgentEvent.ts index 69c7eac6b9..a237b7c208 100644 --- a/desktop/src/features/agents/openCreateAgentEvent.ts +++ b/desktop/src/features/agents/openCreateAgentEvent.ts @@ -1,25 +1,37 @@ +import type { AgentDraft } from "@/features/agents/ui/agentDraft"; + const OPEN_CREATE_AGENT_EVENT = "buzz:open-create-agent"; -let pendingOpenCreateAgent = false; +type PendingOpenCreateAgent = { + draft: AgentDraft | null; +}; -export function requestOpenCreateAgent() { - pendingOpenCreateAgent = true; +let pendingOpenCreateAgent: PendingOpenCreateAgent | null = null; + +/** + * Ask the Agents view to open the create-agent flow. Without a draft it opens + * the wizard's start step; with a draft (e.g. "duplicate this agent" from a + * profile panel) it jumps straight to the prefilled details step. The request + * is retained until the Agents view mounts and consumes it. + */ +export function requestOpenCreateAgent(draft?: AgentDraft) { + pendingOpenCreateAgent = { draft: draft ?? null }; window.dispatchEvent(new Event(OPEN_CREATE_AGENT_EVENT)); } -export function consumePendingOpenCreateAgent() { - if (!pendingOpenCreateAgent) { - return false; - } - - pendingOpenCreateAgent = false; - return true; +export function consumePendingOpenCreateAgent(): PendingOpenCreateAgent | null { + const pending = pendingOpenCreateAgent; + pendingOpenCreateAgent = null; + return pending; } -export function subscribeOpenCreateAgent(handler: () => void) { +export function subscribeOpenCreateAgent( + handler: (pending: PendingOpenCreateAgent) => void, +) { function handleOpenCreateAgent() { - pendingOpenCreateAgent = false; - handler(); + const pending = pendingOpenCreateAgent ?? { draft: null }; + pendingOpenCreateAgent = null; + handler(pending); } window.addEventListener(OPEN_CREATE_AGENT_EVENT, handleOpenCreateAgent); diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index c6f9f5eb7d..2223f414f6 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -1,26 +1,27 @@ import { AlertTriangle } from "lucide-react"; import * as React from "react"; +import { useAvailableAcpRuntimes } from "@/features/agents/hooks"; import { - useAvailableAcpRuntimes, - useCreateChannelManagedAgentsMutation, -} from "@/features/agents/hooks"; -import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents"; -import { - emptyResolvedTeamPersonas, - resolveTeamPersonas, -} from "@/features/agents/lib/teamPersonas"; + attachManagedAgentToChannel, + createChannelManagedAgents, + type CreateChannelManagedAgentInput, + type CreateChannelManagedAgentsResult, +} from "@/features/agents/channelAgents"; +import { resolveTeamMembers } from "@/features/agents/lib/teamMembers"; import { collectRuntimeWarnings, resolvePersonaRuntime, } from "@/features/agents/lib/resolvePersonaRuntime"; import { useChannelsQuery } from "@/features/channels/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { AgentPersona, AgentTeam, Channel, ChannelRole, + ManagedAgent, } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { @@ -34,6 +35,7 @@ import { type AddTeamToChannelDialogProps = { team: AgentTeam | null; personas: AgentPersona[]; + agents: ManagedAgent[]; open: boolean; onOpenChange: (open: boolean) => void; onDeployed: ( @@ -45,17 +47,64 @@ type AddTeamToChannelDialogProps = { export function AddTeamToChannelDialog({ team, personas, + agents, open, onOpenChange, onDeployed, }: AddTeamToChannelDialogProps) { const channelsQuery = useChannelsQuery(); const providersQuery = useAvailableAcpRuntimes(); + const queryClient = useQueryClient(); const [channelId, setChannelId] = React.useState(""); const [role, setRole] = React.useState>("bot"); - const deployMutation = useCreateChannelManagedAgentsMutation( - channelId || null, - ); + const deployMutation = useMutation({ + mutationFn: async (input: { + channelId: string; + personaInputs: CreateChannelManagedAgentInput[]; + memberAgents: ManagedAgent[]; + }): Promise => { + // Existing agent members attach directly; pack persona members go + // through the persona create/reuse path. + const result = + input.personaInputs.length > 0 + ? await createChannelManagedAgents( + input.channelId, + input.personaInputs, + ) + : { successes: [], failures: [] }; + + for (const agent of input.memberAgents) { + try { + const attached = await attachManagedAgentToChannel(input.channelId, { + agent, + role, + }); + result.successes.push({ + ...attached, + created: false, + runtimeId: "", + }); + } catch (error) { + result.failures.push({ + kind: "generic", + name: agent.name, + personaId: null, + error: + error instanceof Error ? error.message : "Failed to add agent.", + }); + } + } + + return result; + }, + onSettled: (_data, _error, variables) => { + void queryClient.invalidateQueries({ queryKey: ["managed-agents"] }); + void queryClient.invalidateQueries({ queryKey: ["relay-agents"] }); + void queryClient.invalidateQueries({ + queryKey: ["channels", variables?.channelId, "members"], + }); + }, + }); const channels = React.useMemo( () => @@ -68,20 +117,28 @@ export function AddTeamToChannelDialog({ const providers = providersQuery.data ?? []; const defaultProvider = providers[0] ?? null; - const teamPersonaResolution = React.useMemo( + const resolution = React.useMemo( () => - team ? resolveTeamPersonas(team, personas) : emptyResolvedTeamPersonas(), - [team, personas], + team + ? resolveTeamMembers(team, personas, agents) + : resolveTeamMembers( + { personaIds: [], agentPubkeys: [] }, + personas, + agents, + ), + [team, personas, agents], ); - const resolved = teamPersonaResolution.resolvedPersonas; - const missingPersonaCount = teamPersonaResolution.missingPersonaCount; + const resolvedPersonas = resolution.resolvedPersonas; + const resolvedAgents = resolution.resolvedAgents; + const resolvedMembers = resolution.resolvedMembers; + const missingMemberCount = resolution.missingMemberCount; - // Surface warnings when a persona's preferred runtime is unavailable. + // Surface warnings when a pack persona's preferred runtime is unavailable. // This dialog has no runtime selector, so the fallback is always // `defaultProvider` (the first available runtime). const runtimeWarnings = React.useMemo( - () => collectRuntimeWarnings(resolved, providers, defaultProvider), - [resolved, providers, defaultProvider], + () => collectRuntimeWarnings(resolvedPersonas, providers, defaultProvider), + [resolvedPersonas, providers, defaultProvider], ); function reset() { @@ -110,22 +167,28 @@ export function AddTeamToChannelDialog({ channels.find((channel) => channel.id === channelId) ?? null; async function handleDeploy() { - if (!team || !selectedChannel || !defaultProvider) { + if (!team || !selectedChannel) { + return; + } + if (resolvedPersonas.length > 0 && !defaultProvider) { return; } try { - // Resolve each persona's preferred runtime. This dialog has no - // runtime selector, so the fallback is `defaultProvider` (first - // available runtime). Warnings are computed separately via the - // `runtimeWarnings` memo and rendered as inline alerts above. - const inputs = resolved.map((persona) => { + // Pack persona members: resolve each persona's preferred runtime. + // This dialog has no runtime selector, so the fallback is + // `defaultProvider` (first available runtime). Warnings are computed + // separately via the `runtimeWarnings` memo above. + const personaInputs = resolvedPersonas.map((persona) => { const { runtime: personaRuntime } = resolvePersonaRuntime( persona.runtime, providers, defaultProvider, ); const runtimeToUse = personaRuntime ?? defaultProvider; + if (!runtimeToUse) { + throw new Error("No available runtime found for this team."); + } return { runtime: { id: runtimeToUse.id, @@ -143,7 +206,11 @@ export function AddTeamToChannelDialog({ }; }); - const result = await deployMutation.mutateAsync(inputs); + const result = await deployMutation.mutateAsync({ + channelId: selectedChannel.id, + personaInputs, + memberAgents: resolvedAgents, + }); onDeployed(selectedChannel, result); handleOpenChange(false); } catch { @@ -158,31 +225,30 @@ export function AddTeamToChannelDialog({ Deploy team to channel - Create and attach one agent per persona in{" "} - {team?.name ?? "this team"} to the selected - channel. + Add every agent in {team?.name ?? "this team"} to + the selected channel.
- {resolved.length > 0 ? ( + {resolvedMembers.length > 0 ? (
- Personas ({resolved.length}) + Agents ({resolvedMembers.length})
- {resolved.map((persona) => ( + {resolvedMembers.map((member) => (
- {persona.displayName} + {member.displayName}
))} @@ -235,16 +301,18 @@ export function AddTeamToChannelDialog({
- {missingPersonaCount > 0 ? ( + {missingMemberCount > 0 ? (

- This team references {missingPersonaCount} persona - {missingPersonaCount === 1 ? "" : "s"} that{" "} - {missingPersonaCount === 1 ? "is" : "are"} no longer in My - Agents. Add them back or edit the team before deploying. + This team references {missingMemberCount} agent + {missingMemberCount === 1 ? "" : "s"} that{" "} + {missingMemberCount === 1 ? "is" : "are"} no longer available on + this device. Edit the team before deploying.

) : null} - {!defaultProvider && !providersQuery.isLoading ? ( + {resolvedPersonas.length > 0 && + !defaultProvider && + !providersQuery.isLoading ? (

No ACP runtimes found. Make sure an agent runtime (e.g. Goose) is installed. @@ -289,9 +357,9 @@ export function AddTeamToChannelDialog({ disabled={ !team || !selectedChannel || - !defaultProvider || - resolved.length === 0 || - missingPersonaCount > 0 || + (resolvedPersonas.length > 0 && !defaultProvider) || + resolvedMembers.length === 0 || + missingMemberCount > 0 || channelsQuery.isLoading || providersQuery.isLoading || deployMutation.isPending @@ -302,7 +370,7 @@ export function AddTeamToChannelDialog({ > {deployMutation.isPending ? "Deploying..." - : `Deploy ${resolved.length} ${resolved.length === 1 ? "agent" : "agents"}`} + : `Deploy ${resolvedMembers.length} ${resolvedMembers.length === 1 ? "agent" : "agents"}`}

diff --git a/desktop/src/features/agents/ui/AgentActionsMenu.tsx b/desktop/src/features/agents/ui/AgentActionsMenu.tsx new file mode 100644 index 0000000000..52e7b8c684 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentActionsMenu.tsx @@ -0,0 +1,85 @@ +import { + CopyPlus, + Ellipsis, + FileDown, + MessageSquarePlus, + Pencil, + Trash2, +} from "lucide-react"; + +import type { ManagedAgent } from "@/shared/api/types"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +export function AgentActionsMenu({ + agent, + disabled, + onAddToChannel, + onDelete, + onDuplicate, + onEdit, + onExport, +}: { + agent: ManagedAgent; + disabled: boolean; + onAddToChannel: (agent: ManagedAgent) => void; + onDelete: (agent: ManagedAgent) => void; + onDuplicate: (agent: ManagedAgent) => void; + onEdit: (agent: ManagedAgent) => void; + onExport: (agent: ManagedAgent) => void; +}) { + return ( + + + + + event.preventDefault()} + > + onEdit(agent)}> + + Edit + + onDuplicate(agent)} + > + + Duplicate + + onAddToChannel(agent)} + > + + Add to channel + + onExport(agent)}> + + Export + + + onDelete(agent)} + > + + Remove + + + + ); +} diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index 7492d78937..a8a8cdc055 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -115,7 +115,7 @@ function provenanceSentence( case "buzzExplicit": return "Set in Buzz"; case "personaDefault": - return "Inherited from persona"; + return "Inherited from agent profile"; case "runtimeOverride": return "Live override (this session only)"; case "harnessConstraint": diff --git a/desktop/src/features/agents/ui/AgentGroupRows.tsx b/desktop/src/features/agents/ui/AgentGroupRows.tsx deleted file mode 100644 index a8131906cb..0000000000 --- a/desktop/src/features/agents/ui/AgentGroupRows.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { ManagedAgent, PresenceLookup } from "@/shared/api/types"; -import { normalizePubkey } from "@/shared/lib/pubkey"; -import { ManagedAgentRow } from "./ManagedAgentRow"; - -export type AgentGroupRowsProps = { - agents: ManagedAgent[]; - channelIdToName: Record; - channelsByPubkey: Record; - logContent: string | null; - logError: Error | null; - logLoading: boolean; - personaLabelsById: Record; - presenceLoaded: boolean; - presenceLookup: PresenceLookup; - selectedLogAgentPubkey: string | null; - onOpenProfile: (pubkey: string) => void; - onSelectLogAgent: (pubkey: string | null) => void; -}; - -export function AgentGroupRows({ - agents, - channelIdToName, - channelsByPubkey, - logContent, - logError, - logLoading, - personaLabelsById, - presenceLoaded, - presenceLookup, - selectedLogAgentPubkey, - onOpenProfile, - onSelectLogAgent, -}: AgentGroupRowsProps) { - return ( -
- {agents.map((agent) => ( - - ))} -
- ); -} diff --git a/desktop/src/features/agents/ui/AgentModelProviderFields.tsx b/desktop/src/features/agents/ui/AgentModelProviderFields.tsx new file mode 100644 index 0000000000..9f32498000 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentModelProviderFields.tsx @@ -0,0 +1,342 @@ +import * as React from "react"; +import { AnimatePresence } from "motion/react"; + +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Input } from "@/shared/ui/input"; +import type { EnvVarsValue } from "./EnvVarsEditor"; +import { PersonaDropdownField } from "./PersonaDropdownField"; +import { PersonaModelField } from "./PersonaModelField"; +import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField"; +import { + AUTO_MODEL_DROPDOWN_VALUE, + AUTO_PROVIDER_DROPDOWN_VALUE, + CUSTOM_MODEL_DROPDOWN_VALUE, + CUSTOM_PROVIDER_DROPDOWN_VALUE, + getDefaultLlmProviderLabel, + getModelSelectValue, + getPersonaModelOptions, + getPersonaProviderOptions, + getProviderApiKeyConfig, + getProviderApiKeyEnvVar, + getRuntimePersonaModelOptions, + hasPersonaModelOption, + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, + PERSONA_LABEL_OPTIONAL_CLASS, + providerRequiresExplicitModel, + runtimeSupportsLlmProviderSelection, + shouldClearKnownModelForSelectionScope, + type PersonaDropdownOption, +} from "./personaDialogPickers"; +import { + MODEL_DISCOVERY_LOADING_VALUE, + usePersonaModelDiscovery, +} from "./usePersonaModelDiscovery"; + +const FIELD_TRANSITION = { duration: 0.18, ease: [0.23, 1, 0.32, 1] } as const; + +/** + * LLM provider + model pickers (with live model discovery) and the managed + * provider API-key field, for a given ACP runtime. Controlled: the parent + * owns `model`, `provider`, and `envVars`. + */ +export function AgentModelProviderFields({ + disabled, + envVars, + model, + open, + provider, + runtimeId, + selectedRuntime, + onEnvVarsChange, + onModelChange, + onProviderChange, +}: { + disabled: boolean; + envVars: EnvVarsValue; + model: string; + open: boolean; + provider: string; + runtimeId: string; + selectedRuntime: AcpRuntimeCatalogEntry | undefined; + onEnvVarsChange: (next: EnvVarsValue) => void; + onModelChange: (next: string) => void; + onProviderChange: (next: string) => void; +}) { + const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); + const [isCustomProviderEditing, setIsCustomProviderEditing] = + React.useState(false); + + const trimmedRuntime = runtimeId.trim(); + const llmProviderFieldVisible = + trimmedRuntime.length > 0 && + runtimeSupportsLlmProviderSelection(trimmedRuntime); + const modelFieldVisible = trimmedRuntime.length > 0; + const providerForModelScope = llmProviderFieldVisible ? provider : ""; + const trimmedProvider = provider.trim(); + const providerApiKeyConfig = + llmProviderFieldVisible && !isCustomProviderEditing + ? getProviderApiKeyConfig(trimmedProvider) + : null; + const providerApiKeyValue = providerApiKeyConfig + ? (envVars[providerApiKeyConfig.envVar] ?? "") + : ""; + const isExplicitModelRequired = + modelFieldVisible && providerRequiresExplicitModel(providerForModelScope); + + const { + discoveredModelOptions, + modelDiscoveryLoading, + modelDiscoveryStatus, + } = usePersonaModelDiscovery({ + envVars, + isCustomProviderEditing, + modelFieldVisible, + open, + provider: providerForModelScope, + selectedRuntime, + }); + + const staticModelOptions = getPersonaModelOptions( + trimmedRuntime, + providerForModelScope, + ); + const runtimeModelOptions = getRuntimePersonaModelOptions(trimmedRuntime); + const modelOptions = discoveredModelOptions ?? staticModelOptions; + const isModelCustom = !hasPersonaModelOption( + discoveredModelOptions ?? runtimeModelOptions, + model, + ); + const modelSelectValue = getModelSelectValue({ + isCustomModelEditing, + isModelCustom, + model, + }); + const showCustomModelInput = + modelFieldVisible && (isCustomModelEditing || isModelCustom); + const providerOptions = getPersonaProviderOptions( + providerForModelScope, + trimmedRuntime, + ); + const providerSelectValue = isCustomProviderEditing + ? CUSTOM_PROVIDER_DROPDOWN_VALUE + : trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE; + const providerDropdownOptions: PersonaDropdownOption[] = [ + ...providerOptions.map((option) => ({ + label: option.label, + value: option.id || AUTO_PROVIDER_DROPDOWN_VALUE, + })), + { label: "Custom provider...", value: CUSTOM_PROVIDER_DROPDOWN_VALUE }, + ]; + const modelDropdownOptions: PersonaDropdownOption[] = [ + ...modelOptions.map((option) => ({ + label: option.label, + value: option.id || AUTO_MODEL_DROPDOWN_VALUE, + })), + ...(modelDiscoveryLoading && discoveredModelOptions === null + ? [ + { + disabled: true, + label: "Loading models...", + value: MODEL_DISCOVERY_LOADING_VALUE, + }, + ] + : []), + { label: "Custom model...", value: CUSTOM_MODEL_DROPDOWN_VALUE }, + ]; + + React.useEffect(() => { + if ( + !open || + !modelFieldVisible || + isCustomModelEditing || + !shouldClearKnownModelForSelectionScope({ + model, + provider: providerForModelScope, + runtime: trimmedRuntime, + }) + ) { + return; + } + + onModelChange(""); + setIsCustomModelEditing(false); + }, [ + isCustomModelEditing, + model, + modelFieldVisible, + onModelChange, + open, + providerForModelScope, + trimmedRuntime, + ]); + + // Reset custom-editing state and clear a provider that the new runtime + // can't choose whenever the runtime changes underneath us. + const previousRuntimeRef = React.useRef(trimmedRuntime); + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally runs only on runtime change + React.useEffect(() => { + if (previousRuntimeRef.current === trimmedRuntime) { + return; + } + previousRuntimeRef.current = trimmedRuntime; + if (!llmProviderFieldVisible && trimmedProvider.length > 0) { + clearManagedProviderApiKeyWhenLeaving(trimmedProvider, ""); + setIsCustomProviderEditing(false); + onProviderChange(""); + } + setIsCustomModelEditing(false); + }, [trimmedRuntime]); + + function updateProviderApiKey(envKey: string, value: string) { + const current = envVars[envKey] ?? ""; + if (current === value) { + return; + } + const next = { ...envVars }; + if (value.length > 0) { + next[envKey] = value; + } else { + delete next[envKey]; + } + onEnvVarsChange(next); + } + + function clearManagedProviderApiKeyWhenLeaving( + previousProvider: string, + nextProvider: string, + ) { + const previousEnvVar = getProviderApiKeyEnvVar(previousProvider); + const nextEnvVar = getProviderApiKeyEnvVar(nextProvider); + if (previousEnvVar && previousEnvVar !== nextEnvVar) { + if (previousEnvVar in envVars) { + const next = { ...envVars }; + delete next[previousEnvVar]; + onEnvVarsChange(next); + } + } + } + + function handleProviderDropdownChange(nextValue: string) { + if (nextValue === CUSTOM_PROVIDER_DROPDOWN_VALUE) { + clearManagedProviderApiKeyWhenLeaving(provider, ""); + setIsCustomProviderEditing(true); + onProviderChange(""); + return; + } + + const nextProvider = + nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue; + clearManagedProviderApiKeyWhenLeaving(provider, nextProvider); + setIsCustomProviderEditing(false); + onProviderChange(nextProvider); + const requiredEnvVar = getProviderApiKeyEnvVar(nextProvider); + if (requiredEnvVar && !envVars[requiredEnvVar]?.trim()) { + onModelChange(""); + setIsCustomModelEditing(false); + return; + } + if ( + !isCustomModelEditing && + shouldClearKnownModelForSelectionScope({ + model, + provider: nextProvider, + runtime: trimmedRuntime, + }) + ) { + onModelChange(""); + setIsCustomModelEditing(false); + } + } + + function handleModelDropdownChange(nextValue: string) { + if (nextValue === CUSTOM_MODEL_DROPDOWN_VALUE) { + setIsCustomModelEditing(true); + if (!isModelCustom) { + onModelChange(""); + } + return; + } + + setIsCustomModelEditing(false); + onModelChange(nextValue === AUTO_MODEL_DROPDOWN_VALUE ? "" : nextValue); + } + + if (!modelFieldVisible && !llmProviderFieldVisible) { + return null; + } + + return ( + <> + {llmProviderFieldVisible ? ( +
+ + + {llmProviderFieldVisible && isCustomProviderEditing ? ( +
+ onProviderChange(event.target.value)} + placeholder="Custom provider ID" + value={provider} + /> +
+ ) : null} + {providerApiKeyConfig ? ( + + updateProviderApiKey(providerApiKeyConfig.envVar, value) + } + value={providerApiKeyValue} + /> + ) : null} +
+ ) : null} + + + {modelFieldVisible ? ( + + ) : null} + + + ); +} diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 361199c50d..65413b6d35 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { usePersonasQuery } from "@/features/agents/hooks"; import { useOpenDmMutation } from "@/features/channels/hooks"; import { type ProfilePanelTab, @@ -13,7 +12,6 @@ import { profilePanelViewFromSearch, } from "@/features/profile/ui/UserProfilePanelUtils"; import { useIdentityQuery } from "@/shared/api/hooks"; -import type { AgentPersona } from "@/shared/api/types"; import { type ProfilePanelOpenOptions, ProfilePanelProvider, @@ -27,41 +25,20 @@ const AgentsView = React.lazy(async () => { return { default: module.AgentsView }; }); -type ProfilePanelTarget = - | { kind: "pubkey"; pubkey: string } - | { kind: "persona"; persona: AgentPersona }; - const AGENTS_PROFILE_SEARCH_KEYS = [ "profile", - "profilePersona", "profileTab", "profileView", ] as const; export function AgentsScreen() { const identityQuery = useIdentityQuery(); - const personasQuery = usePersonasQuery(); const { applyPatch, values } = useHistorySearchState( AGENTS_PROFILE_SEARCH_KEYS, ); const profilePanelTab = profilePanelTabFromSearch(values.profileTab); const profilePanelView = profilePanelViewFromSearch(values.profileView); - const profilePanelTarget = React.useMemo(() => { - if (values.profile) { - return { kind: "pubkey", pubkey: values.profile }; - } - - if (values.profilePersona) { - const persona = personasQuery.data?.find( - (candidate) => candidate.id === values.profilePersona, - ); - if (persona) { - return { kind: "persona", persona }; - } - } - - return null; - }, [personasQuery.data, values.profile, values.profilePersona]); + const profilePubkey = values.profile || null; const threadPanelWidth = useThreadPanelWidth(); const openDmMutation = useOpenDmMutation(); const { goChannel } = useAppNavigation(); @@ -70,7 +47,6 @@ export function AgentsScreen() { (pubkey: string, options?: ProfilePanelOpenOptions) => { applyPatch({ profile: pubkey, - profilePersona: null, profileTab: options?.tab === "info" ? null : (options?.tab ?? null), profileView: null, }); @@ -78,21 +54,9 @@ export function AgentsScreen() { [applyPatch], ); - const handleOpenPersonaProfilePanel = React.useCallback( - (persona: AgentPersona) => { - applyPatch({ - profile: null, - profilePersona: persona.id, - profileTab: null, - profileView: null, - }); - }, - [applyPatch], - ); const handleCloseProfilePanel = React.useCallback(() => { applyPatch({ profile: null, - profilePersona: null, profileTab: null, profileView: null, }); @@ -117,16 +81,13 @@ export function AgentsScreen() { ); return ( - +
}> - {profilePanelTarget ? ( + {profilePubkey ? ( ( + null, + ); const teamActions = useTeamActions( { setActionNoticeMessage: agents.setActionNoticeMessage, @@ -40,23 +42,32 @@ export function AgentsView() { }, ); + const personas = personasQuery.data ?? []; const isActionPending = agents.isPending || - personas.isPending || teamActions.exportTeamJsonMutation.isPending || teamActions.createTeamMutation.isPending || teamActions.updateTeamMutation.isPending || teamActions.deleteTeamMutation.isPending; + // biome-ignore lint/correctness/useExhaustiveDependencies: subscribe once on mount React.useEffect(() => { - if (consumePendingOpenCreateAgent()) { - agents.setIsCreateOpen(true); + function handleRequest(pending: { draft: AgentDraft | null }) { + if (pending.draft) { + agents.setIsCreateStartOpen(false); + agents.setCreateDraft(pending.draft); + } else { + agents.openCreateStart(); + } } - return subscribeOpenCreateAgent(() => { - agents.setIsCreateOpen(true); - }); - }, [agents.setIsCreateOpen]); + const pending = consumePendingOpenCreateAgent(); + if (pending) { + handleRequest(pending); + } + + return subscribeOpenCreateAgent(handleRequest); + }, []); return ( <> @@ -75,63 +86,33 @@ export function AgentsView() { isActionPending={isActionPending} isAgentsLoading={agents.managedAgentsQuery.isLoading} startingAgentPubkey={agents.startingAgentPubkey} - startingPersonaIds={agents.startingPersonaIds} onBulkRemoveStopped={() => { void agents.handleBulkRemoveStopped(); }} onBulkStopRunning={() => { void agents.handleBulkStopRunning(); }} - onCreateAgent={() => { - agents.setIsCreateOpen(true); - }} + onCreateAgent={agents.openCreateStart} onOpenAgentProfile={(pubkey, options) => { openProfilePanel?.(pubkey, options); }} - onOpenPersonaProfile={(persona) => { - openPersonaProfilePanel?.(persona); - }} onStartAgent={(pubkey) => { void agents.handleStart(pubkey); }} - onStartPersona={(persona) => { - void agents.handleStartPersona(persona); + onAddAgentToChannel={agents.setAgentToAddToChannel} + onDeleteAgent={(agent) => { + void agents.handleDelete(agent.pubkey); }} - // Persona props - canChooseCatalog={personas.catalogPersonas.length > 0} - personas={personas.libraryPersonas} - personasError={ - personas.personasQuery.error instanceof Error - ? personas.personasQuery.error - : null - } - personaFeedbackErrorMessage={ - personas.personaFeedbackSurface === "library" - ? personas.personaErrorMessage - : null - } - personaFeedbackNoticeMessage={ - personas.personaFeedbackSurface === "library" - ? personas.personaNoticeMessage - : null - } - isPersonasLoading={personas.personasQuery.isLoading} - isPersonasPending={personas.isPending} - onCreatePersona={personas.openCreate} - onChooseCatalog={personas.openCatalog} - onDuplicatePersona={personas.openDuplicate} - onEditPersona={personas.openEdit} - onSharePersona={personas.openShare} - onDeactivatePersona={(persona) => { - void personas.handleSetActive(persona, false, "library"); - }} - onDeletePersona={personas.openDelete} - onImportPersonaFile={(fileBytes, fileName) => { - void personas.handleImportFile(fileBytes, fileName); + onDuplicateAgent={agents.openDuplicateAgent} + onEditAgent={setAgentToEdit} + onExportAgent={agents.handleExportAgent} + onImportAgentFile={(fileBytes, fileName) => { + void agents.handleImportAgentFile(fileBytes, fileName); }} /> @@ -171,14 +152,39 @@ export function AgentsView() {
- {agents.isCreateOpen ? ( + { + void agents.handleImportAgentFile(fileBytes, fileName); + }} + onOpenChange={agents.setIsCreateStartOpen} + onPickBlank={agents.handlePickBlank} + onPickTemplate={agents.handlePickTemplate} + open={agents.isCreateStartOpen} + /> + {agents.createDraft ? ( { agents.setLogAgentPubkey(result.agent.pubkey); agents.setCreatedAgent(result); }} - onOpenChange={agents.setIsCreateOpen} - open={agents.isCreateOpen} + onOpenChange={(open) => { + if (!open) { + agents.setCreateDraft(null); + } + }} + open={agents.createDraft !== null} + /> + ) : null} + {agentToEdit ? ( + { + if (!open) { + setAgentToEdit(null); + } + }} + open={agentToEdit !== null} /> ) : null} {agents.agentToAddToChannel ? ( @@ -203,122 +209,22 @@ export function AgentsView() { }} /> ) : null} - {personas.createdAgent ? ( - { if (!open) { - personas.setCreatedAgent(null); + agents.setBatchImportResult(null); } }} - /> - ) : null} - {personas.personaDialogState ? ( - { - if (!open) { - personas.setPersonaDialogState(null); - } - }} - onSubmit={personas.handleSubmit} - open={personas.personaDialogState !== null} - submitLabel={personas.personaDialogState.submitLabel} - title={personas.personaDialogState.title} - /> - ) : null} - {personas.personaToDelete ? ( - { - void personas.handleDelete(persona); - }} - onOpenChange={(open) => { - if (!open) { - personas.setPersonaToDelete(null); - } - }} - open={personas.personaToDelete !== null} - persona={personas.personaToDelete} - /> - ) : null} - {personas.personaToShare ? ( - { - if (personas.personaToShare) { - personas.setPersonaCatalogVisibility( - personas.personaToShare, - visible, - ); - } - }} - onExport={() => { - if (personas.personaToShare) { - personas.handleExport(personas.personaToShare); - } - }} - onOpenChange={(open) => { - if (!open) { - personas.setPersonaToShare(null); - } - }} - open={personas.personaToShare !== null} - persona={personas.personaToShare} - /> - ) : null} - {personas.isCatalogDialogOpen ? ( - { - personas.clearFeedback("catalog"); - }} - onOpenChange={personas.setIsCatalogDialogOpen} - onSelectPersona={(persona, active) => { - void personas.handleSetActive(persona, active, "catalog"); - }} - open={personas.isCatalogDialogOpen} - personas={personas.catalogPersonas} + open={agents.batchImportResult !== null} + result={agents.batchImportResult} /> ) : null} {teamActions.teamDialogState ? ( @@ -363,6 +269,7 @@ export function AgentsView() { ) : null} {teamActions.teamToAddToChannel ? ( { if (!open) { @@ -370,23 +277,10 @@ export function AgentsView() { } }} open={teamActions.teamToAddToChannel !== null} - personas={personas.libraryPersonas} + personas={personas} team={teamActions.teamToAddToChannel} /> ) : null} - {personas.batchImportResult ? ( - { - if (!open) { - personas.setBatchImportResult(null); - } - }} - open={personas.batchImportResult !== null} - result={personas.batchImportResult} - /> - ) : null} {teamActions.teamImportPreview ? ( ) : null} - {personas.personaImportActions.personaImportTarget ? ( - { - if (!open) { - personas.personaImportActions.closeImportUpdateDialog(); - } - }} - open={personas.personaImportActions.personaImportTarget !== null} - persona={personas.personaImportActions.personaImportTarget} - preview={ - personas.personaImportActions.personaImportTargetPreview?.preview ?? - null - } - /> - ) : null} ); } diff --git a/desktop/src/features/agents/ui/BatchImportDialog.tsx b/desktop/src/features/agents/ui/BatchImportDialog.tsx index 1644bd8e01..b2e1c8928e 100644 --- a/desktop/src/features/agents/ui/BatchImportDialog.tsx +++ b/desktop/src/features/agents/ui/BatchImportDialog.tsx @@ -7,8 +7,10 @@ import { } from "@/shared/ui/import-status-icon"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { useAvailableAcpRuntimes } from "@/features/agents/hooks"; +import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; +import { createManagedAgent } from "@/shared/api/tauri"; import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; -import { createPersona } from "@/shared/api/tauriPersonas"; import { Button } from "@/shared/ui/button"; import { Checkbox } from "@/shared/ui/checkbox"; import { @@ -18,9 +20,9 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { buildBatchImportPersonaInput } from "./batchImportPersonaInput"; +import { importedAvatarUrl } from "./agentDraft"; +import { buildBatchImportAgentInput } from "./batchImportPersonaInput"; import { resolveManagedAgentAvatarUrl } from "./managedAgentAvatar"; -import { importedAvatarUrl } from "./personaDialogState"; type BatchImportDialogProps = { fileName: string; @@ -37,6 +39,7 @@ export function BatchImportDialog({ onOpenChange, onComplete, }: BatchImportDialogProps) { + const runtimesQuery = useAvailableAcpRuntimes({ enabled: open }); const [selected, setSelected] = React.useState>(new Set()); const [status, setStatus] = React.useState< "idle" | "importing" | "done" | "error" @@ -69,6 +72,16 @@ export function BatchImportDialog({ return; } + const runtimes = runtimesQuery.data ?? []; + const defaultRuntime = runtimes[0] ?? null; + if (!defaultRuntime) { + setStatus("error"); + setErrorMessage( + "No available agent runtime found. Visit Settings > Doctor to set one up.", + ); + return; + } + setStatus("importing"); setErrorMessage(null); @@ -82,8 +95,8 @@ export function BatchImportDialog({ let completed = 0; for (const index of selected) { - const persona = result.personas[index]; - if (!persona) { + const preview = result.personas[index]; + if (!preview) { continue; } @@ -94,9 +107,34 @@ export function BatchImportDialog({ }); try { - const input = buildBatchImportPersonaInput(persona); - const avatarUrl = await resolveManagedAgentAvatarUrl(input.avatarUrl); - await createPersona({ ...input, avatarUrl }); + const input = buildBatchImportAgentInput(preview); + const resolved = resolvePersonaRuntime( + input.runtime ?? null, + runtimes, + defaultRuntime, + ); + const runtime = resolved.runtime ?? defaultRuntime; + const avatarUrl = await resolveManagedAgentAvatarUrl( + input.avatarUrl, + undefined, + runtime.avatarUrl, + ); + // Imported agents are created stopped so a large batch never spawns + // a fleet of processes at once. + await createManagedAgent({ + name: input.name, + acpCommand: "buzz-acp", + agentCommand: runtime.command, + agentArgs: runtime.defaultArgs, + mcpCommand: runtime.mcpCommand ?? "", + systemPrompt: input.systemPrompt || undefined, + avatarUrl: avatarUrl || undefined, + model: input.model, + provider: input.provider, + spawnAfterCreate: false, + startOnAppLaunch: false, + backend: { type: "local" }, + }); completed += 1; setImportedCount(completed); setItemStatuses((prev) => { @@ -112,7 +150,7 @@ export function BatchImportDialog({ }); setStatus("error"); setErrorMessage( - `Imported ${completed} of ${selected.size}. Failed on '${persona.displayName}': ${error instanceof Error ? error.message : String(error)}. Already-imported personas are saved.`, + `Imported ${completed} of ${selected.size}. Failed on '${preview.displayName}': ${error instanceof Error ? error.message : String(error)}. Already-imported agents are saved.`, ); return; } @@ -131,9 +169,9 @@ export function BatchImportDialog({
- Import Personas + Import agents - Found {personas.length} persona + Found {personas.length} agent {personas.length !== 1 ? "s" : ""} in {fileName || "archive"}. diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index de792e35dc..12bc817001 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -23,6 +23,10 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { Textarea } from "@/shared/ui/textarea"; +import type { AgentDraft } from "./agentDraft"; +import { AgentCreationPreview } from "./AgentCreationPreview"; +import { AgentModelProviderFields } from "./AgentModelProviderFields"; import { CreateAgentBasicsFields, CreateAgentOptionToggles, @@ -30,6 +34,7 @@ import { CreateAgentRuntimeFields, } from "./CreateAgentDialogSections"; import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; +import { providerRequiresExplicitModel } from "./personaDialogPickers"; import { coerceConfigValues, ProviderConfigFields, @@ -42,10 +47,13 @@ import { useLastRuntime } from "@/features/agents/lib/useLastRuntime"; export function CreateAgentDialog({ open, + draft, onCreated, onOpenChange, }: { open: boolean; + /** Prefilled starting point (blank / template / import / duplicate). */ + draft: AgentDraft | null; onCreated: (result: CreateManagedAgentResponse) => void; onOpenChange: (open: boolean) => void; }) { @@ -69,11 +77,17 @@ export function CreateAgentDialog({ const [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState("320"); const [parallelism, setParallelism] = React.useState("24"); const [systemPrompt, setSystemPrompt] = React.useState(""); + const [avatarUrl, setAvatarUrl] = React.useState(""); + const [isAvatarUploadPending, setIsAvatarUploadPending] = + React.useState(false); + const [model, setModel] = React.useState(""); + const [llmProvider, setLlmProvider] = React.useState(""); const [envVars, setEnvVars] = React.useState({}); const [selectedRuntimeId, setSelectedRuntimeId] = React.useState("custom"); const [hasSyncedProviderSelection, setHasSyncedProviderSelection] = React.useState(false); + const [hasAppliedDraft, setHasAppliedDraft] = React.useState(false); const [showAdvanced, setShowAdvanced] = React.useState(false); const [respondTo, setRespondTo] = React.useState("owner-only"); const [respondToAllowlist, setRespondToAllowlist] = React.useState( @@ -131,15 +145,39 @@ export function CreateAgentDialog({ prereqsQuery.isLoading || (prereqs !== null && !isSpawnSupported); const isDiscoveryPending = providersQuery.isLoading || prereqsQuery.isLoading; + // Apply the draft's simple fields once per open. Runtime selection happens + // separately below because it needs the runtime catalog to resolve. + React.useEffect(() => { + if (!open || !draft || hasAppliedDraft) { + return; + } + + setName(draft.name); + setSystemPrompt(draft.systemPrompt); + setAvatarUrl(draft.avatarUrl); + setModel(draft.model ?? ""); + setLlmProvider(draft.provider ?? ""); + setEnvVars({ ...draft.envVars }); + setHasAppliedDraft(true); + }, [draft, hasAppliedDraft, open]); + React.useEffect(() => { if (hasSyncedProviderSelection || providersQuery.isLoading) { return; } - // Prefer last-used runtime from localStorage - const remembered = lastRuntimeId - ? runtimes.find((runtime) => runtime.id === lastRuntimeId) + // Prefer the draft's runtime hint (an id like "goose" or a resolved + // command for duplicated agents), then the last-used runtime. + const draftRuntime = draft?.runtime + ? (runtimes.find((runtime) => runtime.id === draft.runtime) ?? + runtimes.find((runtime) => runtime.command === draft.runtime) ?? + null) : null; + const remembered = + draftRuntime ?? + (lastRuntimeId + ? runtimes.find((runtime) => runtime.id === lastRuntimeId) + : null); if (remembered) { setSelectedRuntimeId(remembered.id); setAgentCommand(remembered.command); @@ -155,6 +193,7 @@ export function CreateAgentDialog({ setHasSyncedProviderSelection(true); }, [ agentCommand, + draft, hasSyncedProviderSelection, lastRuntimeId, runtimes, @@ -241,9 +280,14 @@ export function CreateAgentDialog({ setTurnTimeoutSeconds("320"); setParallelism("24"); setSystemPrompt(""); + setAvatarUrl(""); + setIsAvatarUploadPending(false); + setModel(""); + setLlmProvider(""); setEnvVars({}); setSelectedRuntimeId("custom"); setHasSyncedProviderSelection(false); + setHasAppliedDraft(false); setShowAdvanced(false); setRunOn("local"); setProviderConfig({}); @@ -309,6 +353,15 @@ export function CreateAgentDialog({ const respondToValid = respondTo !== "allowlist" || respondToAllowlist.length > 0; + const showModelProviderFields = + !isProviderMode && !useMesh && selectedRuntimeId !== "custom"; + // Anthropic/OpenAI-style providers have no server-side default model — an + // empty model would crash the harness at startup. + const modelRequirementSatisfied = + !showModelProviderFields || + !providerRequiresExplicitModel(llmProvider) || + model.trim().length > 0; + const canSubmit = name.trim().length > 0 && !isDiscoveryPending && @@ -325,6 +378,8 @@ export function CreateAgentDialog({ // Relay-mesh mode requires a concrete serve target, not just a model name. !(useMesh && (meshModelId.trim().length === 0 || meshTarget == null)) && respondToValid && + modelRequirementSatisfied && + !isAvatarUploadPending && !meshPreparing && !createMutation.isPending; @@ -373,6 +428,9 @@ export function CreateAgentDialog({ ? Number.parseInt(parallelism, 10) : undefined, systemPrompt: systemPrompt.trim() || undefined, + avatarUrl: avatarUrl.trim() || undefined, + model: model.trim() || undefined, + provider: llmProvider.trim() || undefined, envVars, spawnAfterCreate: true, startOnAppLaunch: false, // Remote agents don't auto-start with the desktop @@ -406,8 +464,12 @@ export function CreateAgentDialog({ ? Number.parseInt(parallelism, 10) : undefined, systemPrompt: systemPrompt.trim() || undefined, + avatarUrl: avatarUrl.trim() || undefined, envVars, - model: useMesh ? meshModelId.trim() || undefined : undefined, + model: useMesh + ? meshModelId.trim() || undefined + : model.trim() || undefined, + provider: useMesh ? undefined : llmProvider.trim() || undefined, relayMesh: useMesh ? { modelRef: meshModelId.trim() } : undefined, spawnAfterCreate, // Relay-mesh agents need a freshly selected serve target to start; @@ -426,237 +488,291 @@ export function CreateAgentDialog({ } return ( - + { + if (!nextOpen && (createMutation.isPending || isAvatarUploadPending)) { + return; + } + handleOpenChange(nextOpen); + }} + open={open} + >
- Create agent + {draft?.title ?? "Create agent"} - This creates a local agent identity, syncs its display name when - possible, and can spawn `buzz-acp` immediately. + {draft?.description ?? + "Set up a new agent and start it in this workspace."} -
- - - v.trim()) - .filter((v) => v.length > 0), - mcpCommand, - model: meshModelId || null, - envVars, - }} - modelId={meshModelId} - targetEndpointAddr={meshTarget?.endpointAddr ?? ""} - onModelIdChange={(nextId, patch) => { - setMeshModelId(nextId); - if (patch == null) return; - // Fan out the preset into the existing setters so the rest - // of the dialog (and the submit branch) see normal local-mode - // values — relay-mesh is a curated local agent. - setAcpCommand(patch.acpCommand); - setAgentCommand(patch.agentCommand); - setAgentArgs(patch.agentArgs.join(",")); - setMcpCommand(patch.mcpCommand); - setEnvVars(patch.envVars); - }} - onTargetChange={setMeshTarget} - onUseMeshChange={(next) => { - setUseMesh(next); - if (!next) { - // Clearing the toggle: drop the model selection so the - // submit guard doesn't fire on a stale value. The runtime - // fields keep whatever the user had — they can re-pick - // ACP runtime or stay with the preset values, their call. - setMeshModelId(""); - setMeshTarget(null); - } - }} - useMesh={useMesh} - /> - - {/* Run on selector — only shown when backend providers are discovered */} - {!useMesh && backendProviders.length > 0 ? ( -
- - -
- ) : null} - - {/* Provider mode: trust warning + config fields */} - {isProviderMode && selectedBackendProvider ? ( -
-
- -

- This provider at{" "} - - {selectedBackendProvider.binaryPath} - {" "} - will receive your agent's private key. Only use - providers from trusted sources. -

-
- - {probeError ? ( -

- Could not probe provider: {probeError} -

- ) : null} - - {probedProvider?.config_schema ? ( - - ) : null} -
- ) : null} - - {/* Local mode: show the ACP runtime selector */} - {!isProviderMode && !useMesh ? ( - +
+ setAvatarUrl("")} + onSelectAvatar={setAvatarUrl} + onUploadPendingChange={setIsAvatarUploadPending} /> - ) : null} - { - setStartOnAppLaunch((current) => !current); - }} - onToggleSpawnAfterCreate={() => { - if (!spawnToggleDisabled) { - setSpawnAfterCreate((current) => !current); - } - }} - prereqs={prereqs} - startOnAppLaunch={isProviderMode ? false : startOnAppLaunch} - startOnAppLaunchDisabled={isProviderMode} - spawnAfterCreate={isProviderMode ? true : spawnAfterCreate} - spawnToggleDisabled={isProviderMode || spawnToggleDisabled} - /> +
+ - - -
-