From 796bcabe6e25220b6872e2351168bb0f723e4e59 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 27 Jun 2026 11:48:26 +0100 Subject: [PATCH 1/8] Polish agent runtime cards --- .../src-tauri/src/managed_agents/personas.rs | 2 +- .../src/managed_agents/personas/tests.rs | 3 + desktop/src/features/agents/hooks.ts | 11 + .../features/agents/ui/AgentIdentityCard.tsx | 61 +-- .../agents/ui/AgentRuntimeAvatarControl.tsx | 192 ++++++++ .../src/features/agents/ui/AgentsScreen.tsx | 9 +- desktop/src/features/agents/ui/AgentsView.tsx | 23 +- .../src/features/agents/ui/TeamsSection.tsx | 2 +- .../agents/ui/UnifiedAgentsSection.tsx | 223 ++++----- .../agents/ui/useManagedAgentActions.ts | 95 ++++ .../src/features/profile/ui/AvatarUpload.tsx | 35 +- .../profile/ui/MaskedAvatarBadgeFrame.tsx | 432 ++++++++++++++++++ .../profile/ui/UserProfilePanelSections.tsx | 54 ++- .../settings/ui/ProfileSettingsCard.tsx | 188 ++++---- .../sidebar/ui/SidebarProfileCard.tsx | 40 +- .../shared/context/ProfilePanelContext.tsx | 13 +- desktop/src/testing/e2eBridge.ts | 9 +- desktop/tests/helpers/bridge.ts | 1 + 18 files changed, 1087 insertions(+), 306 deletions(-) create mode 100644 desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx create mode 100644 desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 95f58878b..008922d5c 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -50,7 +50,7 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[BuiltInPersona { "Orchard", "Buzz", ], model: None, - runtime: None, + runtime: Some("goose"), }]; const RETIRED_PERSONAS: &[(&str, &str)] = &[ diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 5d5d5e6b4..eff83c737 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -34,6 +34,9 @@ fn merge_personas_adds_missing_built_ins() { assert_eq!(records.len(), BUILT_IN_PERSONAS.len()); assert!(records.iter().all(|record| record.is_builtin)); assert!(records.iter().all(|record| record.is_active)); + assert!(records + .iter() + .any(|record| record.id == "builtin:fizz" && record.runtime.as_deref() == Some("goose"))); let display_names: Vec<&str> = records .iter() .map(|record| record.display_name.as_str()) diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index ed4680978..6cada5b15 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -361,6 +361,17 @@ export function useStartManagedAgentMutation() { return useMutation({ mutationFn: (pubkey: string) => startManagedAgent(pubkey), + onSuccess: (updated) => { + queryClient.setQueryData( + managedAgentsQueryKey, + (current) => { + if (!current) return current; + return current.map((agent) => + agent.pubkey === updated.pubkey ? updated : agent, + ); + }, + ); + }, onSettled: () => { invalidateManagedAgentQueriesInBackground(queryClient); }, diff --git a/desktop/src/features/agents/ui/AgentIdentityCard.tsx b/desktop/src/features/agents/ui/AgentIdentityCard.tsx index 5c27d5451..128f310a1 100644 --- a/desktop/src/features/agents/ui/AgentIdentityCard.tsx +++ b/desktop/src/features/agents/ui/AgentIdentityCard.tsx @@ -7,27 +7,23 @@ import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar"; type AgentIdentityCardProps = { actions?: ReactNode; ariaLabel: string; + avatar?: ReactNode; avatarUrl?: string | null; dataTestId: string; label: string; - errorLabel?: string | null; - modelControl?: ReactNode; - modelLabel: string; + modelLabel?: string | null; onClick: () => void; - status?: ReactNode; }; export function AgentIdentityCard({ actions, ariaLabel, + avatar, avatarUrl, dataTestId, - errorLabel, label, - modelControl, modelLabel, onClick, - status, }: AgentIdentityCardProps) { const trimmedAvatarUrl = avatarUrl?.trim() || null; @@ -40,50 +36,43 @@ export function AgentIdentityCard({ > + {actions ? (
{actions}
) : null} - {status ? ( -
- {status} -
- ) : null} - -
+
{label} - {modelControl ?? ( + {modelLabel ? ( {modelLabel} - )} - {errorLabel ? ( - - {errorLabel} - ) : null}
diff --git a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx new file mode 100644 index 000000000..0bf1a7bc4 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx @@ -0,0 +1,192 @@ +import { CircleAlert, Play } from "lucide-react"; +import { useReducedMotion } from "motion/react"; + +import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; +import { + type AvatarBadgeCurve, + MaskedAvatarBadgeFrame, +} from "@/features/profile/ui/MaskedAvatarBadgeFrame"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { cn } from "@/shared/lib/cn"; +import { Spinner } from "@/shared/ui/spinner"; +import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar"; + +type AgentRuntimeAvatarControlProps = { + activeTestId: string; + avatarUrl?: string | null; + errorLabel?: string | null; + errorTestId?: string; + isActive: boolean; + isStarting: boolean; + label: string; + startTestId: string; + onOpenError?: () => void; + onStart: () => void; +}; + +const TAILWIND_SPACING = { + "1": 4, + "2": 8, + "2.5": 10, + "6": 24, + "11": 44, + "24": 96, +} as const; + +const AGENT_AVATAR_SIZE = TAILWIND_SPACING["24"]; +const ACTION_BADGE_SIZE = TAILWIND_SPACING["11"]; +const ACTIVE_BADGE_SIZE = TAILWIND_SPACING["6"]; +const ACTION_BADGE_OFFSET = TAILWIND_SPACING["2.5"]; +const ACTIVE_BADGE_INSET = TAILWIND_SPACING["1"]; +const ACTIVE_DOT_CLASS_NAME = "h-4 w-4"; +const PROFILE_STATUS_CUTOUT_RATIO = 1.25; + +function getBadgeCenter(badgeSize: number, outwardOffset: number) { + return AGENT_AVATAR_SIZE + outwardOffset - badgeSize / 2; +} + +function getActionBadge(offset: number) { + return { + cutout: { + cx: getBadgeCenter(ACTION_BADGE_SIZE, offset), + cy: getBadgeCenter(ACTION_BADGE_SIZE, offset), + r: ACTION_BADGE_SIZE / 2, + }, + shell: { + bottom: -offset, + height: ACTION_BADGE_SIZE, + right: -offset, + width: ACTION_BADGE_SIZE, + }, + } as const; +} + +function getActiveBadge(inset: number) { + return { + cutout: { + cx: getBadgeCenter(ACTIVE_BADGE_SIZE, -inset), + cy: getBadgeCenter(ACTIVE_BADGE_SIZE, -inset), + r: (ACTIVE_BADGE_SIZE / 2) * PROFILE_STATUS_CUTOUT_RATIO, + }, + shell: { + bottom: inset, + height: ACTIVE_BADGE_SIZE, + right: inset, + width: ACTIVE_BADGE_SIZE, + }, + } as const; +} + +const ACTION_MASK_CURVE = { + avatarRoundingAngle: 0.16, + cutoutRoundingLength: ACTION_BADGE_SIZE * 0.18, + cutoutRoundingMinAngle: 0.34, + cutoutRoundingMaxAngle: 0.52, + handleDistanceRatio: 0.58, + handleLengthRatio: 0.26, +} satisfies AvatarBadgeCurve; + +const ACTION_BADGE = getActionBadge(ACTION_BADGE_OFFSET); +const ACTIVE_BADGE = getActiveBadge(ACTIVE_BADGE_INSET); + +const MASK_TRANSITION = { + duration: 0.22, + ease: [0.23, 1, 0.32, 1], +} as const; + +export function AgentRuntimeAvatarControl({ + activeTestId, + avatarUrl, + errorLabel, + errorTestId, + isActive, + isStarting, + label, + startTestId, + onOpenError, + onStart, +}: AgentRuntimeAvatarControlProps) { + const shouldReduceMotion = useReducedMotion(); + const trimmedAvatarUrl = avatarUrl?.trim() || null; + const actionLabel = isStarting ? `Starting ${label}` : `Start ${label}`; + const hasError = !isActive && !isStarting && Boolean(errorLabel); + const errorActionLabel = `${label} has a runtime error. Open runtime details.`; + const transition = shouldReduceMotion ? { duration: 0 } : MASK_TRANSITION; + const badge = isActive ? ACTIVE_BADGE : ACTION_BADGE; + + return ( + + {isActive ? ( + + + + ) : ( + + )} + + } + badgeBox={badge.shell} + className="h-24 w-24" + curve={isActive ? undefined : ACTION_MASK_CURVE} + cutout={badge.cutout} + maskTransition={transition} + size={AGENT_AVATAR_SIZE} + > + {trimmedAvatarUrl ? ( + + ) : ( + + )} + + ); +} diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index d52ac7923..361199c50 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -14,7 +14,10 @@ import { } from "@/features/profile/ui/UserProfilePanelUtils"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { AgentPersona } from "@/shared/api/types"; -import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; +import { + type ProfilePanelOpenOptions, + ProfilePanelProvider, +} from "@/shared/context/ProfilePanelContext"; import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; @@ -64,11 +67,11 @@ export function AgentsScreen() { const { goChannel } = useAppNavigation(); const handleOpenProfilePanel = React.useCallback( - (pubkey: string) => { + (pubkey: string, options?: ProfilePanelOpenOptions) => { applyPatch({ profile: pubkey, profilePersona: null, - profileTab: null, + profileTab: options?.tab === "info" ? null : (options?.tab ?? null), profileView: null, }); }, diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 7305a9987..af37080a1 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -59,15 +59,13 @@ export function AgentsView() { return ( <> -
-
-
+
+
+
{ void agents.handleBulkRemoveStopped(); }} @@ -87,12 +84,18 @@ export function AgentsView() { onCreateAgent={() => { agents.setIsCreateOpen(true); }} - onOpenAgentProfile={(pubkey) => { - openProfilePanel?.(pubkey); + onOpenAgentProfile={(pubkey, options) => { + openProfilePanel?.(pubkey, options); }} onOpenPersonaProfile={(persona) => { openPersonaProfilePanel?.(persona); }} + onStartAgent={(pubkey) => { + void agents.handleStart(pubkey); + }} + onStartPersona={(persona) => { + void agents.handleStartPersona(persona); + }} // Persona props canChooseCatalog={personas.catalogPersonas.length > 0} personas={personas.libraryPersonas} diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index 095f8d0ef..09b8ed21a 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -94,7 +94,7 @@ export function TeamsSection({ className={`${TEAM_CARD_COLUMN_CLASS} flex items-center justify-between gap-3`} >
-

My teams

+

Teams

Saved groups from My Agents that you can add to a channel together.

diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 5fe114a30..88792eb8c 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -7,21 +7,14 @@ import { Trash2, } from "lucide-react"; -import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { formatAgentModelLabel } from "@/features/agents/lib/formatAgentModelLabel"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; -import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; -import { ModelPicker } from "@/features/agents/ui/ModelPicker"; import { useUserProfileQuery } from "@/features/profile/hooks"; -import type { - AgentPersona, - ManagedAgent, - PresenceLookup, -} from "@/shared/api/types"; +import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; +import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; -import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -32,6 +25,7 @@ import { } from "@/shared/ui/dropdown-menu"; import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton"; import { AgentIdentityCard } from "./AgentIdentityCard"; +import { AgentRuntimeAvatarControl } from "./AgentRuntimeAvatarControl"; import { CreateIdentityCard } from "./CreateIdentityCard"; import { buildUnifiedGroups, pickProfileAgent } from "./unifiedAgentGroups"; @@ -39,19 +33,21 @@ type UnifiedAgentsSectionProps = { actionErrorMessage: string | null; actionNoticeMessage: string | null; agents: ManagedAgent[]; - channelIdToName: Record; - channelsByPubkey: Record; agentsError: Error | null; isActionPending: boolean; isAgentsLoading: boolean; - personaLabelsById: Record; - presenceLoaded: boolean; - presenceLookup: PresenceLookup; + startingAgentPubkey: string | null; + startingPersonaId: string | null; onBulkRemoveStopped: () => void; onBulkStopRunning: () => void; onCreateAgent: () => void; - onOpenAgentProfile: (pubkey: string) => void; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; onOpenPersonaProfile: (persona: AgentPersona) => void; + onStartAgent: (pubkey: string) => void; + onStartPersona: (persona: AgentPersona) => void; canChooseCatalog: boolean; personas: AgentPersona[]; personasError: Error | null; @@ -75,13 +71,15 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { agentsError, isActionPending, isAgentsLoading, - presenceLoaded, - presenceLookup, + startingAgentPubkey, + startingPersonaId, onBulkRemoveStopped, onBulkStopRunning, onCreateAgent, onOpenAgentProfile, onOpenPersonaProfile, + onStartAgent, + onStartPersona, canChooseCatalog, personas, personasError, @@ -175,10 +173,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { agent={profileAgent} key={group.persona.id} persona={group.persona} - presenceLoaded={presenceLoaded} - presenceLookup={presenceLookup} + startingAgentPubkey={startingAgentPubkey} + startingPersonaId={startingPersonaId} onOpenAgentProfile={onOpenAgentProfile} onOpenPersonaProfile={onOpenPersonaProfile} + onStartAgent={onStartAgent} + onStartPersona={onStartPersona} /> ); })} @@ -198,10 +198,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { collapsed={collapsed} groupKey="__additional_persona_agents__" label="Additional agent instances" - presenceLoaded={presenceLoaded} - presenceLookup={presenceLookup} + startingAgentPubkey={startingAgentPubkey} onToggle={toggle} onOpenAgentProfile={onOpenAgentProfile} + onStartAgent={onStartAgent} /> ) : null} {unknown.length > 0 ? ( @@ -210,10 +210,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { collapsed={collapsed} groupKey="__unknown__" label="Unknown Persona" - presenceLoaded={presenceLoaded} - presenceLookup={presenceLookup} + startingAgentPubkey={startingAgentPubkey} onToggle={toggle} onOpenAgentProfile={onOpenAgentProfile} + onStartAgent={onStartAgent} /> ) : null} {ungrouped.length > 0 ? ( @@ -222,35 +222,15 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { collapsed={collapsed} groupKey="__ungrouped__" label="Custom Agents" - presenceLoaded={presenceLoaded} - presenceLookup={presenceLookup} + startingAgentPubkey={startingAgentPubkey} onToggle={toggle} onOpenAgentProfile={onOpenAgentProfile} + onStartAgent={onStartAgent} /> ) : null}
) : null} - {!isLoading && stoppedCount > 0 ? ( -
-

- {stoppedCount} stopped {stoppedCount === 1 ? "agent" : "agents"} -

- -
- ) : null} - {agentsError ? (

void; + startingAgentPubkey: string | null; + startingPersonaId: string | null; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; onOpenPersonaProfile: (persona: AgentPersona) => void; + onStartAgent: (pubkey: string) => void; + onStartPersona: (persona: AgentPersona) => void; }) { const title = persona.displayName; const modelLabel = formatAgentModelLabel(agent?.model ?? persona.model); + const isActive = agent ? isManagedAgentActive(agent) : false; const profileQuery = useUserProfileQuery(agent?.pubkey); const avatarUrl = agent ? firstAvatarUrl(profileQuery.data?.avatarUrl, persona.avatarUrl) @@ -293,92 +281,106 @@ function AgentPersonaCard({ const friendlyError = agent ? friendlyAgentLastError(agent.lastError)?.copy : null; + const opensRuntimeTab = Boolean(agent && friendlyError && !isActive); return ( { + onOpenAgentProfile(agent.pubkey, { tab: "runtime" }); + }} + onStart={() => onStartAgent(agent.pubkey)} + /> + ) : ( + onStartPersona(persona)} + /> + ) + } avatarUrl={avatarUrl} dataTestId={`persona-agent-row-${persona.id}`} - errorLabel={friendlyError} label={title} - modelControl={agent ? : undefined} - modelLabel={modelLabel} + modelLabel={agent && isActive ? modelLabel : null} onClick={() => { if (agent) { - onOpenAgentProfile(agent.pubkey); + onOpenAgentProfile( + agent.pubkey, + opensRuntimeTab ? { tab: "runtime" } : undefined, + ); return; } onOpenPersonaProfile(persona); }} - status={ - agent ? ( - - ) : null - } /> ); } function StandaloneAgentCard({ agent, - presenceLoaded, - presenceLookup, + startingAgentPubkey, onOpenAgentProfile, + onStartAgent, }: { agent: ManagedAgent; - presenceLoaded: boolean; - presenceLookup: PresenceLookup; - onOpenAgentProfile: (pubkey: string) => void; + startingAgentPubkey: string | null; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; + onStartAgent: (pubkey: string) => void; }) { const title = agent.name; const profileQuery = useUserProfileQuery(agent.pubkey); const friendlyError = friendlyAgentLastError(agent.lastError)?.copy; + const isActive = isManagedAgentActive(agent); + const opensRuntimeTab = Boolean(friendlyError && !isActive); return ( { + onOpenAgentProfile(agent.pubkey, { tab: "runtime" }); + }} + onStart={() => onStartAgent(agent.pubkey)} + /> + } avatarUrl={profileQuery.data?.avatarUrl} dataTestId={`managed-agent-${agent.pubkey}`} - errorLabel={friendlyError} label={title} - modelControl={} - modelLabel={formatAgentModelLabel(agent.model)} + modelLabel={isActive ? formatAgentModelLabel(agent.model) : null} onClick={() => { - onOpenAgentProfile(agent.pubkey); + onOpenAgentProfile( + agent.pubkey, + opensRuntimeTab ? { tab: "runtime" } : undefined, + ); }} - status={ - - } - /> - ); -} - -function AgentCardStatus({ - agent, - presenceLoaded, - presenceLookup, -}: { - agent: ManagedAgent; - presenceLoaded: boolean; - presenceLookup: PresenceLookup; -}) { - const activeTurns = useActiveAgentTurns(agent.pubkey); - const presenceStatus = presenceLookup[normalizePubkey(agent.pubkey)]; - - return ( - 0} - presenceLoaded={presenceLoaded} - presenceStatus={presenceStatus} - status={agent.status} /> ); } @@ -417,7 +419,7 @@ function SectionHeader({ className={`${AGENT_CARD_COLUMN_CLASS} flex items-center justify-between gap-3`} >

-

Your Agents

+

Agents

Agents in this workspace.

@@ -545,19 +547,22 @@ function CollapsibleAgentGroup({ label, agents, collapsed, - presenceLoaded, - presenceLookup, + startingAgentPubkey, onToggle, onOpenAgentProfile, + onStartAgent, }: { groupKey: string; label: string; agents: ManagedAgent[]; collapsed: ReadonlySet; - presenceLoaded: boolean; - presenceLookup: PresenceLookup; + startingAgentPubkey: string | null; onToggle: (key: string) => void; - onOpenAgentProfile: (pubkey: string) => void; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; + onStartAgent: (pubkey: string) => void; }) { const isCollapsed = collapsed.has(groupKey); return ( @@ -581,9 +586,9 @@ function CollapsibleAgentGroup({ ))}
diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 35010e8c1..a6cffbfb6 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -2,6 +2,8 @@ import * as React from "react"; import { type AttachManagedAgentToChannelResult, + useAvailableAcpRuntimes, + useCreateManagedAgentMutation, useManagedAgentLogQuery, useManagedAgentsQuery, useRelayAgentsQuery, @@ -15,7 +17,11 @@ import { usePresenceQuery } from "@/features/presence/hooks"; import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore"; import type { + AcpRuntime, + AcpRuntimeCatalogEntry, + AgentPersona, Channel, + CreateManagedAgentInput, CreateManagedAgentResponse, ManagedAgent, } from "@/shared/api/types"; @@ -27,6 +33,7 @@ import { startManagedAgentWithRules, stopManagedAgentWithRules, } from "../lib/managedAgentControlActions"; +import { resolvePersonaRuntime } from "../lib/resolvePersonaRuntime"; export function useManagedAgentActions() { const relayAgentsQuery = useRelayAgentsQuery(); @@ -36,6 +43,8 @@ export function useManagedAgentActions() { const startMutation = useStartManagedAgentMutation(); const stopMutation = useStopManagedAgentMutation(); const deleteMutation = useDeleteManagedAgentMutation(); + const createAgentMutation = useCreateManagedAgentMutation(); + const availableRuntimesQuery = useAvailableAcpRuntimes(); const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation(); const [isCreateOpen, setIsCreateOpen] = React.useState(false); const [agentToAddToChannel, setAgentToAddToChannel] = @@ -159,6 +168,71 @@ export function useManagedAgentActions() { } } + async function getAvailableRuntimesForStart() { + if (availableRuntimesQuery.isFetched) { + return availableRuntimesQuery.data ?? []; + } + + const result = await availableRuntimesQuery.refetch(); + return filterAvailableRuntimes(result.data); + } + + async function handleStartPersona(persona: AgentPersona) { + clearFeedback(); + try { + const runtimes = await getAvailableRuntimesForStart(); + const defaultRuntime = runtimes[0] ?? null; + const { runtime, warnings } = resolvePersonaRuntime( + persona.runtime, + runtimes, + defaultRuntime, + ); + + if (!runtime) { + throw new Error("No available runtime found for this agent."); + } + + const input: CreateManagedAgentInput = { + name: persona.displayName, + acpCommand: "buzz-acp", + agentCommand: runtime.command, + agentArgs: runtime.defaultArgs, + mcpCommand: runtime.mcpCommand ?? "", + personaId: persona.id, + systemPrompt: persona.systemPrompt, + avatarUrl: persona.avatarUrl ?? undefined, + model: persona.model ?? undefined, + envVars: persona.envVars, + spawnAfterCreate: true, + startOnAppLaunch: true, + backend: { type: "local" }, + }; + + const created = await createAgentMutation.mutateAsync(input); + const notices = [...warnings]; + + if (created.spawnError) { + setActionErrorMessage(created.spawnError); + } else { + notices.push(`Started ${created.agent.name}.`); + } + + if (created.profileSyncError) { + notices.push(created.profileSyncError); + } + if (notices.length > 0) { + setActionNoticeMessage(notices.join(" ")); + } + + void managedAgentsQuery.refetch(); + void relayAgentsQuery.refetch(); + } catch (error) { + setActionErrorMessage( + error instanceof Error ? error.message : "Failed to start agent.", + ); + } + } + async function getChannelsForAction() { if (channelsQuery.data) { return channelsQuery.data; @@ -345,10 +419,20 @@ export function useManagedAgentActions() { } const isPending = + createAgentMutation.isPending || startMutation.isPending || stopMutation.isPending || startOnLaunchMutation.isPending || deleteMutation.isPending; + const startingAgentPubkey = + startMutation.isPending && typeof startMutation.variables === "string" + ? startMutation.variables + : null; + const startingPersonaId = + createAgentMutation.isPending && + typeof createAgentMutation.variables?.personaId === "string" + ? createAgentMutation.variables.personaId + : null; return { relayAgentsQuery, @@ -372,7 +456,10 @@ export function useManagedAgentActions() { setActionNoticeMessage, actionErrorMessage, setActionErrorMessage, + startingAgentPubkey, + startingPersonaId, handleStart, + handleStartPersona, handleStop, handleDelete, handleToggleStartOnAppLaunch, @@ -383,3 +470,11 @@ export function useManagedAgentActions() { refetchRelayAgents: () => void relayAgentsQuery.refetch(), }; } + +function filterAvailableRuntimes( + runtimes: readonly AcpRuntimeCatalogEntry[] | undefined, +): AcpRuntime[] { + return (runtimes ?? []).filter( + (runtime): runtime is AcpRuntime => runtime.availability === "available", + ); +} diff --git a/desktop/src/features/profile/ui/AvatarUpload.tsx b/desktop/src/features/profile/ui/AvatarUpload.tsx index dd371a398..384c78927 100644 --- a/desktop/src/features/profile/ui/AvatarUpload.tsx +++ b/desktop/src/features/profile/ui/AvatarUpload.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { Camera, Link2, Upload, X } from "lucide-react"; +import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { useAvatarUpload } from "@/features/profile/useAvatarUpload"; import { Input } from "@/shared/ui/input"; @@ -76,13 +77,27 @@ export function AvatarUpload({

Add a profile photo

- + + +
+ ) + } + badgeBox={{ bottom: -4, height: 32, right: -4, width: 32 }} + className="h-20 w-20" + cutout={{ cx: 68, cy: 68, r: 20 }} + size={80} + > + + {showClear && onClear ? ( - ) : ( -
- -
- )} + ) : null}
+
+ ) + } + badgeBox={{ + bottom: 0, + height: 54, + right: 0, + width: 54, + }} + className="h-48 w-48" + cutout={{ cx: 165, cy: 165, r: 30 }} + size={192} >
- {shouldShowAnimatedPreview ? null : emojiAvatarPreview ? ( + className="relative h-full w-full" + data-testid="profile-avatar-preview-clip" + >
- 0 && "buzz-avatar-squish", - )} - data-testid="profile-avatar-preview-emoji" - key={avatarSquishKey} - > - {emojiAvatarPreview.emoji} - -
- ) : ( - - )} -
- -
- -
+
+
diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx index 759ef4775..198f45536 100644 --- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -4,6 +4,7 @@ import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { useSelfProfileCache } from "@/features/profile/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfilePopover } from "@/features/profile/ui/ProfilePopover"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import type { Workspace } from "@/features/workspaces/types"; @@ -102,22 +103,31 @@ export function SidebarProfileCard({ }} type="button" > - - + + + } + badgeBox={{ bottom: -2, height: 14, right: -2, width: 14 }} + className="h-8 w-8" + cutout={{ cx: 28, cy: 28, r: 7.5 }} + size={32} > - - + +
diff --git a/desktop/src/shared/context/ProfilePanelContext.tsx b/desktop/src/shared/context/ProfilePanelContext.tsx index eea5f354b..3f47364c5 100644 --- a/desktop/src/shared/context/ProfilePanelContext.tsx +++ b/desktop/src/shared/context/ProfilePanelContext.tsx @@ -2,8 +2,14 @@ import * as React from "react"; import type { AgentPersona } from "@/shared/api/types"; +export type ProfilePanelOpenOptions = { + tab?: "info" | "runtime" | "channels" | "memories"; +}; + type ProfilePanelContextValue = { - openProfilePanel: ((pubkey: string) => void) | null; + openProfilePanel: + | ((pubkey: string, options?: ProfilePanelOpenOptions) => void) + | null; openPersonaProfilePanel: ((persona: AgentPersona) => void) | null; }; @@ -18,7 +24,10 @@ export function ProfilePanelProvider({ onOpenPersonaProfilePanel, }: { children: React.ReactNode; - onOpenProfilePanel: (pubkey: string) => void; + onOpenProfilePanel: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; onOpenPersonaProfilePanel?: (persona: AgentPersona) => void; }) { const value = React.useMemo( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 8c9d82558..197abe21b 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -44,6 +44,7 @@ type MockManagedAgentSeed = { channelNames?: string[]; channelIds?: string[]; backend?: RawManagedAgent["backend"]; + lastError?: string | null; respondTo?: RawManagedAgent["respond_to"]; respondToAllowlist?: string[]; }; @@ -448,6 +449,10 @@ type RawPersona = { display_name: string; avatar_url: string | null; system_prompt: string; + runtime?: string | null; + model?: string | null; + provider?: string | null; + name_pool?: string[]; is_builtin: boolean; is_active: boolean; env_vars?: Record; @@ -1027,7 +1032,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { last_started_at: status === "running" ? now : null, last_stopped_at: status === "stopped" ? now : null, last_exit_code: null, - last_error: null, + last_error: seed.lastError ?? null, log_path: `/tmp/mock-agent-${seed.pubkey}.log`, start_on_app_launch: true, backend: seed.backend ?? { type: "local" }, @@ -1112,6 +1117,8 @@ function resetMockPersonas(config?: E2eConfig) { display_name: "Fizz", avatar_url: null, system_prompt: "You are Fizz.", + runtime: "goose", + model: null, is_builtin: true, is_active: activePersonaIds.has("builtin:fizz"), created_at: now, diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 8d317d7fe..258b11506 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -52,6 +52,7 @@ type MockManagedAgentSeed = { backend?: | { type: "local" } | { type: "provider"; id: string; config: Record }; + lastError?: string | null; respondTo?: "owner-only" | "allowlist" | "anyone"; respondToAllowlist?: string[]; }; From 0bd7b6a400f54c21a26ef47809b6d4d1c6e19aa2 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 27 Jun 2026 11:54:17 +0100 Subject: [PATCH 2/8] Soften status avatar badge masks --- .../features/agents/ui/AgentRuntimeAvatarControl.tsx | 5 +++-- .../src/features/profile/ui/MaskedAvatarBadgeFrame.tsx | 9 +++++++++ .../features/profile/ui/UserProfilePanelSections.tsx | 10 ++++++++-- desktop/src/features/sidebar/ui/SidebarProfileCard.tsx | 6 +++++- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx index 0bf1a7bc4..462476ae8 100644 --- a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx +++ b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx @@ -5,6 +5,7 @@ import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { type AvatarBadgeCurve, MaskedAvatarBadgeFrame, + STATUS_DOT_MASK_CURVE, } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { cn } from "@/shared/lib/cn"; @@ -38,7 +39,7 @@ const ACTION_BADGE_SIZE = TAILWIND_SPACING["11"]; const ACTIVE_BADGE_SIZE = TAILWIND_SPACING["6"]; const ACTION_BADGE_OFFSET = TAILWIND_SPACING["2.5"]; const ACTIVE_BADGE_INSET = TAILWIND_SPACING["1"]; -const ACTIVE_DOT_CLASS_NAME = "h-4 w-4"; +const ACTIVE_DOT_CLASS_NAME = "h-[1.125rem] w-[1.125rem]"; const PROFILE_STATUS_CUTOUT_RATIO = 1.25; function getBadgeCenter(badgeSize: number, outwardOffset: number) { @@ -168,7 +169,7 @@ export function AgentRuntimeAvatarControl({ } badgeBox={badge.shell} className="h-24 w-24" - curve={isActive ? undefined : ACTION_MASK_CURVE} + curve={isActive ? STATUS_DOT_MASK_CURVE : ACTION_MASK_CURVE} cutout={badge.cutout} maskTransition={transition} size={AGENT_AVATAR_SIZE} diff --git a/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx b/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx index acaf2f0cd..3c2080ce8 100644 --- a/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx +++ b/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx @@ -69,6 +69,15 @@ const DEFAULT_AVATAR_BADGE_CURVE = { handleLengthRatio: 0.14, } as const; +export const STATUS_DOT_MASK_CURVE = { + avatarRoundingAngle: 0.11, + cutoutRoundingLength: 6.5, + cutoutRoundingMinAngle: 0.28, + cutoutRoundingMaxAngle: 0.44, + handleDistanceRatio: 0.5, + handleLengthRatio: 0.2, +} satisfies AvatarBadgeCurve; + function getCircleIntersections( avatar: AvatarBadgeCircle, cutout: AvatarBadgeCircle, diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 1479b6b75..ce4611a45 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -38,7 +38,10 @@ import { ProfileRuntimeTabContent, ProfileTabBar, } from "@/features/profile/ui/UserProfilePanelTabs"; -import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; +import { + MaskedAvatarBadgeFrame, + STATUS_DOT_MASK_CURVE, +} from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; @@ -480,7 +483,9 @@ function ProfileHero({ profile: ProfileSummaryViewProps["profile"]; userStatus: ProfileSummaryViewProps["userStatus"]; }) { - const presenceDotClassName = isBot ? "h-4 w-4" : "h-3.5 w-3.5"; + const presenceDotClassName = isBot + ? "h-[1.125rem] w-[1.125rem]" + : "h-3.5 w-3.5"; return (
@@ -502,6 +507,7 @@ function ProfileHero({ } badgeBox={PROFILE_HERO_PRESENCE_BADGE.shell} className="h-20 w-20" + curve={STATUS_DOT_MASK_CURVE} cutout={PROFILE_HERO_PRESENCE_BADGE.cutout} size={80} > diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx index 198f45536..885dd7e9a 100644 --- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -4,7 +4,10 @@ import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { useSelfProfileCache } from "@/features/profile/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; +import { + MaskedAvatarBadgeFrame, + STATUS_DOT_MASK_CURVE, +} from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfilePopover } from "@/features/profile/ui/ProfilePopover"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import type { Workspace } from "@/features/workspaces/types"; @@ -116,6 +119,7 @@ export function SidebarProfileCard({ } badgeBox={{ bottom: -2, height: 14, right: -2, width: 14 }} className="h-8 w-8" + curve={STATUS_DOT_MASK_CURVE} cutout={{ cx: 28, cy: 28, r: 7.5 }} size={32} > From 65d5b280d8c59eca3e31edd01443ec3719987532 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 27 Jun 2026 12:48:16 +0100 Subject: [PATCH 3/8] Address agent card review feedback --- desktop/scripts/check-px-text.mjs | 2 +- .../agents/ui/AgentRuntimeAvatarControl.tsx | 2 +- .../features/agents/ui/useManagedAgentActions.ts | 4 +++- .../profile/ui/MaskedAvatarBadgeFrame.tsx | 3 +++ .../profile/ui/UserProfilePanelSections.tsx | 4 +--- .../features/settings/ui/ProfileSettingsCard.tsx | 6 ++---- desktop/src/shared/api/types.ts | 7 +++---- desktop/tailwind.config.js | 3 +++ desktop/tests/e2e/active-turn-resilience.spec.ts | 16 ++++++++++------ desktop/tests/e2e/mesh-compute.spec.ts | 16 ++++++++++------ desktop/tests/e2e/profile.spec.ts | 2 +- 11 files changed, 38 insertions(+), 27 deletions(-) diff --git a/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs index f0d4fd1f4..8182d18cd 100644 --- a/desktop/scripts/check-px-text.mjs +++ b/desktop/scripts/check-px-text.mjs @@ -23,7 +23,7 @@ const rules = [ // glyph is a fixed display size sized to its avatar box (not readable message // text), so it stays as the lone documented `text-[6rem]` literal. const overrides = new Set([ - "src/features/settings/ui/ProfileSettingsCard.tsx:572", + "src/features/settings/ui/ProfileSettingsCard.tsx:584", "src/features/onboarding/ui/AvatarStep.tsx:89", ]); diff --git a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx index 462476ae8..6f34ffba7 100644 --- a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx +++ b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx @@ -39,7 +39,7 @@ const ACTION_BADGE_SIZE = TAILWIND_SPACING["11"]; const ACTIVE_BADGE_SIZE = TAILWIND_SPACING["6"]; const ACTION_BADGE_OFFSET = TAILWIND_SPACING["2.5"]; const ACTIVE_BADGE_INSET = TAILWIND_SPACING["1"]; -const ACTIVE_DOT_CLASS_NAME = "h-[1.125rem] w-[1.125rem]"; +const ACTIVE_DOT_CLASS_NAME = "h-4.5 w-4.5"; const PROFILE_STATUS_CUTOUT_RATIO = 1.25; function getBadgeCenter(badgeSize: number, outwardOffset: number) { diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index a6cffbfb6..456a0f0d8 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -182,7 +182,7 @@ export function useManagedAgentActions() { try { const runtimes = await getAvailableRuntimesForStart(); const defaultRuntime = runtimes[0] ?? null; - const { runtime, warnings } = resolvePersonaRuntime( + const { runtime, warnings, isOverridden } = resolvePersonaRuntime( persona.runtime, runtimes, defaultRuntime, @@ -206,9 +206,11 @@ export function useManagedAgentActions() { spawnAfterCreate: true, startOnAppLaunch: true, backend: { type: "local" }, + harnessOverride: isOverridden, }; const created = await createAgentMutation.mutateAsync(input); + setCreatedAgent(created); const notices = [...warnings]; if (created.spawnError) { diff --git a/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx b/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx index 3c2080ce8..426e22660 100644 --- a/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx +++ b/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx @@ -45,6 +45,7 @@ type MaskedAvatarBadgeFrameProps = { badgeBox?: AvatarBadgeBox; children: React.ReactNode; className?: string; + clipTestId?: string; curve?: AvatarBadgeCurve; cutout?: AvatarBadgeCircle; maskTransition?: React.ComponentProps["transition"]; @@ -386,6 +387,7 @@ export function MaskedAvatarBadgeFrame({ badgeBox, children, className, + clipTestId, curve, cutout, maskTransition, @@ -417,6 +419,7 @@ export function MaskedAvatarBadgeFrame({ diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index b9dc8635f..262c39f2a 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -559,13 +559,11 @@ export function ProfileSettingsCard({ width: 54, }} className="h-48 w-48" + clipTestId="profile-avatar-preview-clip" cutout={{ cx: 165, cy: 165, r: 30 }} size={192} > -
+
{ }, ]); - const paulRow = page.getByTestId(`managed-agent-${AGENT_PAUL}`); - const duncanRow = page.getByTestId(`managed-agent-${AGENT_DUNCAN}`); - await expect(paulRow).toContainText("Working", { timeout: 5_000 }); - await expect(duncanRow).toContainText("Working", { timeout: 5_000 }); + const paulRuntimeBadge = page.getByTestId( + `agent-runtime-active-${AGENT_PAUL}`, + ); + const duncanRuntimeBadge = page.getByTestId( + `agent-runtime-active-${AGENT_DUNCAN}`, + ); + await expect(paulRuntimeBadge).toBeVisible({ timeout: 5_000 }); + await expect(duncanRuntimeBadge).toBeVisible({ timeout: 5_000 }); // Simulate the all-at-once relay drop: no further frames, advance the clock // past both thresholds. This fires several real prune ticks; shouldPausePrune @@ -119,7 +123,7 @@ test.describe("active turn badge resilience", () => { // gone after the first tick past 25s. await page.clock.fastForward(FRAME_GAP_MS); - await expect(paulRow).toContainText("Working"); - await expect(duncanRow).toContainText("Working"); + await expect(paulRuntimeBadge).toBeVisible(); + await expect(duncanRuntimeBadge).toBeVisible(); }); }); diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts index aa7c89f43..774eea329 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -331,7 +331,9 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a const row = page.getByTestId(`managed-agent-${pubkey}`); await expect(row).toContainText("Saved relay mesh agent"); - await expect(row).toContainText("running"); + await expect( + page.getByTestId(`agent-runtime-active-${pubkey}`), + ).toBeVisible(); await page.getByRole("button", { name: "Done" }).click(); await expect(page.getByRole("dialog", { name: "Agent created" })).toHaveCount( 0, @@ -341,7 +343,7 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a await expect .poll(async () => await commands(page)) .toContain("stop_managed_agent"); - await expect(row).toContainText("stopped"); + await expect(page.getByTestId(`agent-runtime-start-${pubkey}`)).toBeVisible(); // With a live serve target for the model, manual restart goes through: // the backend preflight re-resolves the target and the agent starts. @@ -349,10 +351,12 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a await expect .poll(async () => await commands(page)) .toContain("start_managed_agent"); - await expect(row).toContainText("running"); + await expect( + page.getByTestId(`agent-runtime-active-${pubkey}`), + ).toBeVisible(); await triggerManagedAgentPrimaryAction(page, pubkey); - await expect(row).toContainText("stopped"); + await expect(page.getByTestId(`agent-runtime-start-${pubkey}`)).toBeVisible(); // Without a live serve target, the backend preflight rejects the start // with an actionable error, surfaced as a toast; the agent stays stopped. @@ -364,7 +368,7 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a .locator("[data-sonner-toast]") .filter({ hasText: "no live serve target is available" }), ).toBeVisible(); - await expect(row).toContainText("stopped"); + await expect(page.getByTestId(`agent-runtime-start-${pubkey}`)).toBeVisible(); await expect( page.evaluate(async (agentPubkey) => { @@ -383,5 +387,5 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a } }, pubkey), ).resolves.toContain("no live serve target is available"); - await expect(row).toContainText("stopped"); + await expect(page.getByTestId(`agent-runtime-start-${pubkey}`)).toBeVisible(); }); diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index e2bdb4de4..0ff0e8a79 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -301,7 +301,7 @@ test("nests the avatar edit button in a clipped notch", async ({ page }) => { await expect(page.getByTestId("profile-avatar-preview-clip")).toHaveCSS( "clip-path", - /url/, + /polygon/, ); const editShell = page.getByTestId("profile-avatar-edit-shell"); await expect(editShell).toHaveCSS("height", "54px"); From 4a68a0846b512469f45f02cbf3c117539739515e Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 27 Jun 2026 13:01:30 +0100 Subject: [PATCH 4/8] Block unavailable persona runtime quick-start --- desktop/src/features/agents/ui/useManagedAgentActions.ts | 7 ++++++- desktop/src/shared/api/types.ts | 7 ++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 456a0f0d8..a7eb332eb 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -191,6 +191,12 @@ export function useManagedAgentActions() { if (!runtime) { throw new Error("No available runtime found for this agent."); } + if (isOverridden) { + throw new Error( + warnings[0] ?? + "This agent's configured runtime is not available. Install the runtime or edit the agent before starting it.", + ); + } const input: CreateManagedAgentInput = { name: persona.displayName, @@ -206,7 +212,6 @@ export function useManagedAgentActions() { spawnAfterCreate: true, startOnAppLaunch: true, backend: { type: "local" }, - harnessOverride: isOverridden, }; const created = await createAgentMutation.mutateAsync(input); diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 0c3ffb109..ef59385aa 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -364,9 +364,10 @@ export type CreateManagedAgentInput = { acpCommand?: string; agentCommand?: string; /** - * True when `agentCommand` is a runtime the caller intentionally wants to - * preserve instead of inheriting the linked persona's runtime. Omit/false for - * persona-less creates and persona-backed creates that should inherit. + * True when `agentCommand` is a runtime the user deliberately picked to + * override the linked persona (a deploy-dialog runtime selector). Lets the + * backend distinguish a real pin from a missing-runtime fallback. Omit/false + * for persona-less creates and fallback divergence — both inherit. */ harnessOverride?: boolean; agentArgs?: string[]; From ea6f44177aa7a3d60e44e554b77b571769547c1e Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 27 Jun 2026 13:14:35 +0100 Subject: [PATCH 5/8] Preserve discovered persona runtime command --- desktop/src/features/agents/ui/useManagedAgentActions.ts | 1 + desktop/src/shared/api/types.ts | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index a7eb332eb..90482330e 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -212,6 +212,7 @@ export function useManagedAgentActions() { spawnAfterCreate: true, startOnAppLaunch: true, backend: { type: "local" }, + harnessOverride: persona.runtime === runtime.id, }; const created = await createAgentMutation.mutateAsync(input); diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index ef59385aa..b706eb89a 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -364,10 +364,11 @@ export type CreateManagedAgentInput = { acpCommand?: string; agentCommand?: string; /** - * True when `agentCommand` is a runtime the user deliberately picked to - * override the linked persona (a deploy-dialog runtime selector). Lets the - * backend distinguish a real pin from a missing-runtime fallback. Omit/false - * for persona-less creates and fallback divergence — both inherit. + * True when `agentCommand` is a runtime the caller deliberately wants to + * preserve instead of inheriting the linked persona command (for example a + * deploy-dialog runtime selector, or a discovered command for the same + * persona runtime id). Omit/false for persona-less creates and fallback + * divergence — both inherit. */ harnessOverride?: boolean; agentArgs?: string[]; From 4173c04b77a5f70590f7e23954d13dc85b11e214 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 29 Jun 2026 15:19:47 +0100 Subject: [PATCH 6/8] Address agent runtime card review feedback --- .../agents/ui/useManagedAgentActions.ts | 2 +- .../tests/e2e/active-turn-resilience.spec.ts | 31 ++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 90482330e..5f6436083 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -212,7 +212,7 @@ export function useManagedAgentActions() { spawnAfterCreate: true, startOnAppLaunch: true, backend: { type: "local" }, - harnessOverride: persona.runtime === runtime.id, + harnessOverride: !persona.runtime || persona.runtime === runtime.id, }; const created = await createAgentMutation.mutateAsync(input); diff --git a/desktop/tests/e2e/active-turn-resilience.spec.ts b/desktop/tests/e2e/active-turn-resilience.spec.ts index 6382b77e6..f8e3c39ae 100644 --- a/desktop/tests/e2e/active-turn-resilience.spec.ts +++ b/desktop/tests/e2e/active-turn-resilience.spec.ts @@ -59,6 +59,16 @@ async function seedTurns( }, turns); } +async function openAgentProfile( + page: import("@playwright/test").Page, + pubkey: string, +) { + await page.getByTestId(`managed-agent-${pubkey}`).click(); + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible({ timeout: 5_000 }); + return panel; +} + test.describe("active turn badge resilience", () => { test.use({ viewport: { width: 1280, height: 720 } }); @@ -107,23 +117,20 @@ test.describe("active turn badge resilience", () => { }, ]); - const paulRuntimeBadge = page.getByTestId( - `agent-runtime-active-${AGENT_PAUL}`, - ); - const duncanRuntimeBadge = page.getByTestId( - `agent-runtime-active-${AGENT_DUNCAN}`, - ); - await expect(paulRuntimeBadge).toBeVisible({ timeout: 5_000 }); - await expect(duncanRuntimeBadge).toBeVisible({ timeout: 5_000 }); + const paulPanel = await openAgentProfile(page, AGENT_PAUL); + await expect(paulPanel).toContainText("Working in #general", { + timeout: 5_000, + }); + await expect(paulPanel).toContainText("Working in #engineering"); // Simulate the all-at-once relay drop: no further frames, advance the clock // past both thresholds. This fires several real prune ticks; shouldPausePrune // sees every turn's lastActivityAt stuck at T0 (gap > 20s) and pauses the - // prune, so the badges survive. Under the pre-fix code every badge would be - // gone after the first tick past 25s. + // prune, so the active-turn-driven working badges survive. Under the + // pre-fix code every badge would be gone after the first tick past 25s. await page.clock.fastForward(FRAME_GAP_MS); - await expect(paulRuntimeBadge).toBeVisible(); - await expect(duncanRuntimeBadge).toBeVisible(); + await expect(paulPanel).toContainText("Working in #general"); + await expect(paulPanel).toContainText("Working in #engineering"); }); }); From 4d946e0d51302484955140c628861592f2a68bbe Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 29 Jun 2026 15:28:58 +0100 Subject: [PATCH 7/8] Move channel pane props to type module --- .../src/features/channels/ui/ChannelPane.tsx | 136 +----------------- .../features/channels/ui/ChannelPane.types.ts | 129 +++++++++++++++++ 2 files changed, 133 insertions(+), 132 deletions(-) create mode 100644 desktop/src/features/channels/ui/ChannelPane.types.ts diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 588f19ae3..556180830 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -11,7 +11,6 @@ import { MessageTimeline, type MessageTimelineHandle, } from "@/features/messages/ui/MessageTimeline"; -import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay"; import { getDmHuddleMemberPubkeys, @@ -23,20 +22,12 @@ import { } from "@/features/messages/lib/videoReviewContext"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; -import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; -import { - type ProfilePanelTab, - type ProfilePanelView, - UserProfilePanel, -} from "@/features/profile/ui/UserProfilePanel"; +import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; -import { - BotActivityComposerAction, - type BotActivityAgent, -} from "@/features/channels/ui/BotActivityBar"; +import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; import { containsWelcomePersonaMention, WelcomeComposerBanner, @@ -52,136 +43,17 @@ import { isWelcomeSetupSystemMessage, mentionsKnownAgent, } from "@/features/channels/ui/ChannelPane.helpers"; +import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; -import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions"; import { Button } from "@/shared/ui/button"; -import type { useChannelFind } from "@/features/search/useChannelFind"; -import { - buildMainTimelineEntries, - type MainTimelineEntry, -} from "@/features/messages/lib/threadPanel"; +import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { isWelcomeChannel } from "@/features/onboarding/welcome"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; -import type { Channel } from "@/shared/api/types"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; -type ChannelPaneProps = { - activeChannel: Channel | null; - activityAgents?: BotActivityAgent[]; - agentPubkeys?: ReadonlySet; - agentPubkeysPending?: boolean; - agentSessionAgents: ChannelAgentSessionAgent[]; - botTypingEntries: TypingIndicatorEntry[]; - channelFind: ReturnType; - channelManagementOpen?: boolean; - currentPubkey?: string; - editTarget?: { - author: string; - body: string; - id: string; - imetaMedia?: ImetaMedia[]; - } | null; - fetchOlder?: () => Promise; - header?: React.ReactNode; - hasOlderMessages?: boolean; - isFetchingOlder?: boolean; - isJoining?: boolean; - isSinglePanelView?: boolean; - isSending: boolean; - isTimelineLoading: boolean; - messages: TimelineMessage[]; - firstUnreadMessageId?: string | null; - unreadCount?: number; - canResetThreadPanelWidth: boolean; - onCancelEdit?: () => void; - onCancelThreadReply: () => void; - onCloseAgentSession: () => void; - onCloseChannelManagement?: () => void; - onChannelManagementDeleted?: () => void; - onCloseProfilePanel: () => void; - onAddAgent?: () => void; - onCreateChannel?: () => void; - onCloseThread: () => void; - onDelete?: (message: TimelineMessage) => void; - onEdit?: (message: TimelineMessage) => void; - onEditSave?: (content: string, mediaTags?: string[][]) => Promise; - onMarkUnread?: (message: TimelineMessage) => void; - onMarkRead?: (message: TimelineMessage) => void; - onExpandThreadReplies: (message: TimelineMessage) => void; - onJoinChannel?: () => Promise; - onOpenAgentSession: (pubkey: string) => void; - onOpenDm?: (pubkeys: string[]) => Promise | void; - onOpenMembers?: () => void; - onOpenProfilePanel: (pubkey: string) => void; - onOpenThread: (message: TimelineMessage) => void; - onResetThreadPanelWidth: () => void; - onSelectThreadReplyTarget: (message: TimelineMessage) => void; - onSendMessage: ( - content: string, - mentionPubkeys: string[], - mediaTags?: string[][], - ) => Promise; - onSendVideoReviewComment?: ( - message: TimelineMessage, - content: string, - mentionPubkeys: string[], - mediaTags?: string[][], - parentEventId?: string, - ) => Promise; - onSendThreadReply: ( - content: string, - mentionPubkeys: string[], - mediaTags?: string[][], - ) => Promise; - onTargetReached?: (messageId: string) => void; - onToggleReaction?: ( - message: TimelineMessage, - emoji: string, - remove: boolean, - ) => Promise; - onThreadScrollTargetResolved: () => void; - onThreadPanelResizeStart: ( - event: React.PointerEvent, - ) => void; - personaLookup?: Map; - profiles?: UserProfileLookup; - openThreadHeadId: string | null; - shouldShowThreadSkeleton: boolean; - openAgentSessionPubkey: string | null; - onProfilePanelViewChange: ( - view: ProfilePanelView, - options?: { replace?: boolean }, - ) => void; - onProfilePanelTabChange: ( - tab: ProfilePanelTab, - options?: { replace?: boolean }, - ) => void; - profilePanelPubkey?: string | null; - profilePanelTab: ProfilePanelTab; - profilePanelView: ProfilePanelView; - threadHeadMessage: TimelineMessage | null; - threadMessages: MainTimelineEntry[]; - threadPanelWidthPx: number; - threadTypingPubkeys: string[]; - threadReplyTargetMessage: TimelineMessage | null; - threadScrollTargetId: string | null; - threadUnreadCounts?: ReadonlyMap; - threadReplyUnreadCounts?: ReadonlyMap; - threadFirstUnreadReplyId?: string | null; - targetMessageId: string | null; - typingPubkeys: string[]; - isFollowingThread?: boolean; - onFollowThread?: () => void; - onUnfollowThread?: () => void; - followThreadById?: (rootId: string) => void; - unfollowThreadById?: (rootId: string) => void; - isFollowingThreadById?: (rootId: string) => boolean; - isMessageUnreadById?: (messageId: string) => boolean; -}; export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentPubkeys, diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts new file mode 100644 index 000000000..d3da1f6ad --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -0,0 +1,129 @@ +import type * as React from "react"; +import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { + ProfilePanelTab, + ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanel"; +import type { useChannelFind } from "@/features/search/useChannelFind"; +import type { Channel } from "@/shared/api/types"; + +import type { BotActivityAgent } from "./BotActivityBar"; + +export type ChannelPaneProps = { + activeChannel: Channel | null; + activityAgents?: BotActivityAgent[]; + agentPubkeys?: ReadonlySet; + agentPubkeysPending?: boolean; + agentSessionAgents: ChannelAgentSessionAgent[]; + botTypingEntries: TypingIndicatorEntry[]; + channelFind: ReturnType; + channelManagementOpen?: boolean; + currentPubkey?: string; + editTarget?: { + author: string; + body: string; + id: string; + imetaMedia?: ImetaMedia[]; + } | null; + fetchOlder?: () => Promise; + header?: React.ReactNode; + hasOlderMessages?: boolean; + isFetchingOlder?: boolean; + isJoining?: boolean; + isSinglePanelView?: boolean; + isSending: boolean; + isTimelineLoading: boolean; + messages: TimelineMessage[]; + firstUnreadMessageId?: string | null; + unreadCount?: number; + canResetThreadPanelWidth: boolean; + onCancelEdit?: () => void; + onCancelThreadReply: () => void; + onCloseAgentSession: () => void; + onCloseChannelManagement?: () => void; + onChannelManagementDeleted?: () => void; + onCloseProfilePanel: () => void; + onAddAgent?: () => void; + onCreateChannel?: () => void; + onCloseThread: () => void; + onDelete?: (message: TimelineMessage) => void; + onEdit?: (message: TimelineMessage) => void; + onEditSave?: (content: string, mediaTags?: string[][]) => Promise; + onMarkUnread?: (message: TimelineMessage) => void; + onMarkRead?: (message: TimelineMessage) => void; + onExpandThreadReplies: (message: TimelineMessage) => void; + onJoinChannel?: () => Promise; + onOpenAgentSession: (pubkey: string) => void; + onOpenDm?: (pubkeys: string[]) => Promise | void; + onOpenMembers?: () => void; + onOpenProfilePanel: (pubkey: string) => void; + onOpenThread: (message: TimelineMessage) => void; + onResetThreadPanelWidth: () => void; + onSelectThreadReplyTarget: (message: TimelineMessage) => void; + onSendMessage: ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => Promise; + onSendVideoReviewComment?: ( + message: TimelineMessage, + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + parentEventId?: string, + ) => Promise; + onSendThreadReply: ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => Promise; + onTargetReached?: (messageId: string) => void; + onToggleReaction?: ( + message: TimelineMessage, + emoji: string, + remove: boolean, + ) => Promise; + onThreadScrollTargetResolved: () => void; + onThreadPanelResizeStart: ( + event: React.PointerEvent, + ) => void; + personaLookup?: Map; + profiles?: UserProfileLookup; + openThreadHeadId: string | null; + shouldShowThreadSkeleton: boolean; + openAgentSessionPubkey: string | null; + onProfilePanelViewChange: ( + view: ProfilePanelView, + options?: { replace?: boolean }, + ) => void; + onProfilePanelTabChange: ( + tab: ProfilePanelTab, + options?: { replace?: boolean }, + ) => void; + profilePanelPubkey?: string | null; + profilePanelTab: ProfilePanelTab; + profilePanelView: ProfilePanelView; + threadHeadMessage: TimelineMessage | null; + threadMessages: MainTimelineEntry[]; + threadPanelWidthPx: number; + threadTypingPubkeys: string[]; + threadReplyTargetMessage: TimelineMessage | null; + threadScrollTargetId: string | null; + threadUnreadCounts?: ReadonlyMap; + threadReplyUnreadCounts?: ReadonlyMap; + threadFirstUnreadReplyId?: string | null; + targetMessageId: string | null; + typingPubkeys: string[]; + isFollowingThread?: boolean; + onFollowThread?: () => void; + onUnfollowThread?: () => void; + followThreadById?: (rootId: string) => void; + unfollowThreadById?: (rootId: string) => void; + isFollowingThreadById?: (rootId: string) => boolean; + isMessageUnreadById?: (messageId: string) => boolean; +}; From f8b2d1ce2815a6843972e719709972ccd11d1f71 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 29 Jun 2026 15:45:52 +0100 Subject: [PATCH 8/8] Move agent model edits into edit dialog --- .../features/agents/ui/EditAgentDialog.tsx | 152 +++++++++++++++++ .../src/features/agents/ui/ModelPicker.tsx | 159 ------------------ 2 files changed, 152 insertions(+), 159 deletions(-) delete mode 100644 desktop/src/features/agents/ui/ModelPicker.tsx diff --git a/desktop/src/features/agents/ui/EditAgentDialog.tsx b/desktop/src/features/agents/ui/EditAgentDialog.tsx index 5d47f5b2b..ed6635218 100644 --- a/desktop/src/features/agents/ui/EditAgentDialog.tsx +++ b/desktop/src/features/agents/ui/EditAgentDialog.tsx @@ -5,10 +5,12 @@ import { useUpdateManagedAgentMutation, } from "@/features/agents/hooks"; import type { + AgentModelsResponse, ManagedAgent, RespondToMode, UpdateManagedAgentInput, } from "@/shared/api/types"; +import { getAgentModels } from "@/shared/api/tauri"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -17,6 +19,7 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; import { CreateAgentBasicsFields, CreateAgentRuntimeFields, @@ -24,6 +27,9 @@ import { import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; import { CreateAgentRespondToField } from "./RespondToField"; +const AUTO_MODEL_VALUE = "__auto_model__"; +const CUSTOM_MODEL_VALUE = "__custom_model__"; + export function EditAgentDialog({ agent, open, @@ -59,6 +65,11 @@ export function EditAgentDialog({ const [systemPrompt, setSystemPrompt] = React.useState( agent.systemPrompt ?? "", ); + const [model, setModel] = React.useState(agent.model ?? ""); + const [modelsData, setModelsData] = + React.useState(null); + const [modelsLoading, setModelsLoading] = React.useState(false); + const [modelsError, setModelsError] = React.useState(null); const [envVars, setEnvVars] = React.useState(agent.envVars); const personasQuery = usePersonasQuery(); const linkedPersona = React.useMemo( @@ -96,6 +107,7 @@ export function EditAgentDialog({ setTurnTimeoutSeconds(String(agent.turnTimeoutSeconds)); setParallelism(String(agent.parallelism)); setSystemPrompt(agent.systemPrompt ?? ""); + setModel(agent.model ?? ""); setEnvVars(agent.envVars); setRespondTo(agent.respondTo); setRespondToAllowlist(agent.respondToAllowlist); @@ -103,6 +115,40 @@ export function EditAgentDialog({ } }, [open, agent.pubkey]); + React.useEffect(() => { + if (!open) { + return; + } + + let cancelled = false; + setModelsData(null); + setModelsError(null); + setModelsLoading(true); + + getAgentModels(agent.pubkey) + .then((data) => { + if (!cancelled) { + setModelsData(data); + } + }) + .catch((error) => { + if (!cancelled) { + setModelsError( + error instanceof Error ? error.message : String(error), + ); + } + }) + .finally(() => { + if (!cancelled) { + setModelsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [open, agent.pubkey]); + function handleOpenChange(next: boolean) { onOpenChange(next); } @@ -138,6 +184,7 @@ export function EditAgentDialog({ .split(",") .map((v) => v.trim()) .filter((v) => v.length > 0); + const normalizedModel = model.trim() || null; // Harness pin resolution. The backend treats an empty string as the // "inherit from persona" sentinel (clears the override) and any concrete @@ -188,6 +235,10 @@ export function EditAgentDialog({ (systemPrompt.trim() || null) !== agent.systemPrompt ? systemPrompt.trim() || null : undefined, + model: + normalizedModel !== (agent.model ?? null) + ? normalizedModel + : undefined, envVars: envVarsChanged(envVars, agent.envVars) ? envVars : undefined, respondTo: respondTo !== agent.respondTo ? respondTo : undefined, // The allowlist is preserved across mode toggles in local UI state @@ -237,6 +288,15 @@ export function EditAgentDialog({ onModeChange={setRespondTo} /> + + {linkedPersona ? (